UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

351 lines (303 loc) 11.1 kB
import { mkdir } from "node:fs/promises"; import { dirname } from "node:path"; import type { DatabaseSync } from "node:sqlite"; import { $atom, $env, $hook, $inject, $state, AlephaError, type Static, z, } from "alepha"; import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { BetterSQLiteSession } from "drizzle-orm/better-sqlite3/session"; import type { PgDatabase } from "drizzle-orm/pg-core"; import { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core/db"; import { SQLiteSyncDialect } from "drizzle-orm/sqlite-core/dialect"; import { databaseEnvSchema } from "../../schemas/databaseEnvSchema.ts"; import { SqliteModelBuilder } from "../../services/SqliteModelBuilder.ts"; import { DatabaseProvider, type SQLLike } from "./DatabaseProvider.ts"; // --------------------------------------------------------------------------------------------------------------------- // HACK - Hide ExperimentalWarning about SQLite from Node.js to avoid spamming logs // TODO: Remove when SQLite support is stable in Node.js (() => { if (process?.emit) { const originalEmit = process.emit; process.emit = (event: any, warning: any, ...args: any[]) => { if ( event === "warning" && warning?.name === "ExperimentalWarning" && warning?.message?.includes("SQLite") ) { return false; } return originalEmit.apply(process, [event, warning, ...args]); }; } })(); // --------------------------------------------------------------------------------------------------------------------- const envSchema = databaseEnvSchema; /** * Configuration options for the Node.js SQLite database provider. */ export const nodeSqliteOptions = $atom({ name: "alepha.postgres.node-sqlite.options", schema: z.object({ path: z .string() .describe( "Filepath or :memory:. If empty, provider will use DATABASE_URL from env.", ) .optional(), }), default: {}, }); export type NodeSqliteProviderOptions = Static<typeof nodeSqliteOptions.schema>; declare module "alepha" { interface State { [nodeSqliteOptions.key]: NodeSqliteProviderOptions; } } // --------------------------------------------------------------------------------------------------------------------- /** * Node.js SQLite provider using `node:sqlite` (DatabaseSync). * * Uses drizzle-orm's `BetterSQLiteSession` (sync driver) with a shimmed * `node:sqlite` DatabaseSync — no native `better-sqlite3` package required. * * The session and migrator sub-modules of `drizzle-orm/better-sqlite3` are * imported directly, bypassing `driver.cjs` which has a top-level * `require("better-sqlite3")`. */ export class NodeSqliteProvider extends DatabaseProvider { protected readonly env = $env(envSchema); protected readonly builder = $inject(SqliteModelBuilder); protected readonly options = $state(nodeSqliteOptions); protected sqlite!: DatabaseSync; protected drizzleDb!: any; public get name() { return "sqlite"; } public override readonly dialect = "sqlite"; public override get url(): string { const path = this.options.path ?? this.env.DATABASE_URL; if (path) { if (path.startsWith("postgres://")) { throw new AlephaError( "Postgres URL is not supported for SQLite provider.", ); } return path; } if (this.alepha.isTest() || this.alepha.isServerless()) { return ":memory:"; } else { return "node_modules/.alepha/sqlite.db"; } } public override get db(): PgDatabase<any> { return this.drizzleDb as unknown as PgDatabase<any>; } public override get nativeConnection(): unknown { return this.sqlite; } /** * SQLite transaction override. * * The base class uses `this.db.transaction()` which goes through drizzle's * better-sqlite3 driver. That driver wraps a synchronous `BEGIN`/`COMMIT` * around the callback, so async callbacks commit before the work finishes. * * This override uses direct `BEGIN`/`COMMIT`/`ROLLBACK` on the native * connection with proper `await`, making async transactions safe. */ public override async transactional<R>(fn: () => Promise<R>): Promise<R> { const existing = this.alepha.get("alepha.orm.tx"); if (existing) { return fn(); } // Set the tx marker to the drizzle db itself — SQLite transactions are // connection-scoped, so all operations on this connection participate. this.alepha.store.set("alepha.orm.tx", this.db as any, { skipEvents: true, }); this.sqlite.exec("BEGIN"); try { const result = await fn(); this.sqlite.exec("COMMIT"); return result; } catch (error) { this.sqlite.exec("ROLLBACK"); throw error; } finally { this.alepha.store.set("alepha.orm.tx", undefined, { skipEvents: true }); } } public override async execute( query: SQLLike, ): Promise<Array<Record<string, unknown>>> { return this.drizzleDb.all(query); } protected readonly onStart = $hook({ on: "start", handler: async () => { const { DatabaseSync } = await import("node:sqlite"); const filepath = this.url.replace("sqlite://", "").replace("sqlite:", ""); if (filepath !== ":memory:" && filepath !== "") { const dir = dirname(filepath); if (dir) { await mkdir(dir, { recursive: true }).catch(() => null); } } this.sqlite = new DatabaseSync(filepath); this.initDrizzle(); // Never migrate in serverless mode - migrations should be applied during deployment if (!this.alepha.isServerless()) { await this.migrate(); } this.log.info(`Sqlite connection OK`, { at: filepath }); }, }); /** * Shim `node:sqlite` DatabaseSync to be compatible with the `better-sqlite3` * Drizzle driver. DatabaseSync lacks `stmt.raw()` and `db.transaction()`. */ protected shimDatabaseSync(): void { const db = this.sqlite as any; // Shim transaction() — better-sqlite3 returns a function keyed by behavior if (!db.transaction) { db.transaction = (fn: (...args: any[]) => any) => { const wrapped = (...args: any[]) => { db.exec("BEGIN"); try { const result = fn(...args); db.exec("COMMIT"); return result; } catch (error) { db.exec("ROLLBACK"); throw error; } }; wrapped.deferred = wrapped; wrapped.immediate = wrapped; wrapped.exclusive = wrapped; return wrapped; }; } // Shim prepare() to add stmt.raw() on returned statements. // // node:sqlite returns objects from stmt.all(), but drizzle's better-sqlite3 // driver expects arrays from stmt.raw().all(). We approximate this with // Object.values(row). However, JOIN queries produce duplicate column names // (e.g. "id" from both tables), and JavaScript objects collapse duplicate // keys — losing values and shifting the positional mapping. // // Fix: for SELECT queries containing a JOIN, rewrite the column list with // unique positional aliases (__c0, __c1, ...) so every column gets a // distinct key and Object.values() preserves all values in order. const origPrepare = db.prepare.bind(db); db.prepare = (sql: string) => { const aliased = NodeSqliteProvider.aliasSelectColumns(sql); const stmt = origPrepare(aliased); if (!stmt.raw) { stmt.raw = () => ({ all: (...args: any[]) => stmt.all(...args).map((row: any) => Object.values(row)), get: (...args: any[]) => { const row = stmt.get(...args); return row ? Object.values(row) : undefined; }, }); } return stmt; }; } /** * For SELECT queries with JOINs, add unique positional aliases to each column * so that `Object.values()` preserves all values even when column names collide. * * Only rewrites when the query is a SELECT containing a JOIN keyword and the * column list has duplicate base names. */ protected static aliasSelectColumns(sql: string): string { const trimmed = sql.trimStart(); const lower = trimmed.toLowerCase(); // Only rewrite SELECT queries that contain a JOIN if (!lower.startsWith("select ") || !/ join /i.test(trimmed)) { return sql; } // Find the FROM clause (word boundary, not inside quotes) const fromIdx = trimmed.search(/\bfrom\b/i); if (fromIdx === -1) return sql; const selectPart = trimmed.substring(0, fromIdx); const rest = trimmed.substring(fromIdx); // Extract the SELECT keyword (+ optional DISTINCT) const kw = selectPart.match(/^(\s*select\s+(?:distinct\s+)?)/i); if (!kw) return sql; const prefix = kw[0]; const columnsPart = selectPart.substring(prefix.length).trim(); // Split by top-level commas (not inside parentheses) const columns: string[] = []; let depth = 0; let cur = ""; for (const ch of columnsPart) { if (ch === "(") depth++; else if (ch === ")") depth--; else if (ch === "," && depth === 0) { columns.push(cur.trim()); cur = ""; continue; } cur += ch; } if (cur.trim()) columns.push(cur.trim()); if (columns.length <= 1) return sql; // Extract the trailing column name from each expression to check for duplicates const baseNames = columns.map((col) => { const m = col.match(/"(\w+)"\s*$/); return m ? m[1] : col; }); const seen = new Set<string>(); let hasDuplicates = false; for (const name of baseNames) { if (seen.has(name)) { hasDuplicates = true; break; } seen.add(name); } if (!hasDuplicates) return sql; // Alias every column with a unique positional name const aliased = columns.map((col, i) => `${col} as "__c${i}"`).join(", "); return `${prefix}${aliased} ${rest}`; } /** * Initialize Drizzle using the sync session from `drizzle-orm/better-sqlite3/session` * directly, bypassing `drizzle-orm/better-sqlite3/driver` which has a top-level * `require("better-sqlite3")`. The shimmed `node:sqlite` DatabaseSync is fully * compatible with the sync session — no native `better-sqlite3` package required. */ protected initDrizzle(): void { this.shimDatabaseSync(); const dialect = new SQLiteSyncDialect(); const session = new BetterSQLiteSession(this.sqlite, dialect, undefined, { logger: { logQuery: (query: string, params: unknown[]) => { this.log.trace(query, { params }); }, }, }); this.drizzleDb = new BaseSQLiteDatabase( "sync", dialect, session, undefined, ); this.log.debug("Using node:sqlite with sync driver"); } protected async executeMigrations(migrationsFolder: string): Promise<void> { migrate(this.drizzleDb, { migrationsFolder }); } }