openreader/packages/database/src/index.ts
Richard R ee979205b3 fix(storage): unify workspace root resolution for docstore and migrations
Adopt a consistent findWorkspaceRoot helper across bootstrap, storage, and database modules to reliably locate the monorepo root. Update environment file loading and docstore path construction to use the workspace root, ensuring correct file resolution in multi-package setups. This addresses issues with relative paths and improves compatibility in monorepo and containerized environments.
2026-06-13 14:35:27 -06:00

128 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { sql } from 'drizzle-orm';
import path from 'path';
import fs from 'fs';
import * as schema from './schema';
import * as authSchemaSqlite from './schema_auth_sqlite';
import * as authSchemaPostgres from './schema_auth_postgres';
// Database driver modules are loaded lazily via require() inside getDrizzleDB()
// to avoid loading the unused driver (~15-20 MB each) in every serverless function.
// require() is used instead of dynamic import() because getDrizzleDB() must remain
// synchronous for the SQLite code path.
const UNCLAIMED_USER_ID = 'unclaimed';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let dbInstance: any = null;
let dbIsPostgres = false;
/** Track which system user IDs we have already ensured exist this process. */
const seededUserIds = new Set<string>();
/**
* Ensure a system/placeholder user row exists in the user table for `userId`.
* All user-facing tables now have userId foreign keys with ON DELETE CASCADE
* referencing the user table. The legacy first-enable-auth claim migration can
* still move rows from the historical 'unclaimed' userId (and namespaced
* variants like 'unclaimed::ns'), so those placeholder rows must exist before
* any migration transfer can run.
*
* This is safe to call repeatedly — it short-circuits via an in-memory Set
* and uses INSERT … ON CONFLICT/OR IGNORE at the SQL level.
*/
export function ensureSystemUserExists(userId: string) {
if (seededUserIds.has(userId)) return;
const drizzleDb = getDrizzleDB();
try {
if (dbIsPostgres) {
// Fire-and-forget: Postgres drizzle returns a Promise. We intentionally
// don't await (to keep this helper synchronous for SQLite compat), but we
// only mark the user as seeded once the insert actually resolves.
drizzleDb.execute(sql`
INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at, is_anonymous)
VALUES (${userId}, 'System User', ${userId + '@local'}, false, now(), now(), false)
ON CONFLICT (id) DO NOTHING
`).then(() => {
seededUserIds.add(userId);
}).catch(() => { /* table may not exist yet */ });
} else {
// better-sqlite3 driver is fully synchronous — no Promise returned.
drizzleDb.run(sql`
INSERT OR IGNORE INTO user (id, name, email, email_verified, created_at, updated_at, is_anonymous)
VALUES (${userId}, 'System User', ${userId + '@local'}, 0, ${Date.now()}, ${Date.now()}, 0)
`);
seededUserIds.add(userId);
}
} catch {
// Silently ignore the user table may not exist yet on first boot before migrations run.
}
}
function findWorkspaceRoot(startDir: string = process.cwd()): string {
let dir = startDir;
while (true) {
if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) {
break;
}
dir = parent;
}
return startDir;
}
function getDrizzleDB() {
if (dbInstance) return dbInstance;
dbIsPostgres = !!process.env.POSTGRES_URL;
if (dbIsPostgres) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { drizzle: drizzlePg } = require('drizzle-orm/node-postgres');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.POSTGRES_URL,
});
dbInstance = drizzlePg(pool, { schema: { ...schema, ...authSchemaPostgres } });
} else {
// Fallback to SQLite
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { drizzle: drizzleSqlite } = require('drizzle-orm/better-sqlite3');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const workspaceRoot = findWorkspaceRoot(process.cwd());
const dbPath = path.join(workspaceRoot, 'docstore', 'sqlite3.db');
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const sqlite = new Database(dbPath);
// WAL mode allows concurrent readers + writer without blocking each other.
// busy_timeout retries on SQLITE_BUSY instead of failing immediately,
// which prevents 500 errors under concurrent API requests (e.g. multiple
// Playwright browser projects hitting the server simultaneously).
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('busy_timeout = 5000');
dbInstance = drizzleSqlite(sqlite, { schema: { ...schema, ...authSchemaSqlite } });
}
ensureSystemUserExists(UNCLAIMED_USER_ID);
return dbInstance;
}
// Lazy proxy: the actual DB connection is only opened on first property access.
// This prevents side effects (e.g. creating an empty sqlite3.db) when modules
// import `db` but never use it, such as during Better Auth CLI schema generation.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const db: any = new Proxy({} as any, {
get(_target, prop, receiver) {
const instance = getDrizzleDB();
const value = Reflect.get(instance, prop, receiver);
return typeof value === 'function' ? value.bind(instance) : value;
},
});