alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
351 lines (303 loc) • 11.1 kB
text/typescript
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 });
}
}