@better-forge/db 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BetterStatusPage contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,33 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # @better-forge/db
2
+
3
+ SQLite (`node:sqlite`) and Drizzle bootstrap, transactions, and the registries that let each module own its tables and
4
+ migrations. No state of its own: the application opens the connection and passes it in.
5
+
6
+ ```ts
7
+ import { composeModules, openDatabase, runMigrations, type DbModule } from '@better-forge/db'
8
+
9
+ const notes: DbModule = {
10
+ id: 'notes',
11
+ tables: { notesTable }, // Drizzle tables the module owns
12
+ migrations: [
13
+ { kind: 'exec', sql: 'CREATE TABLE IF NOT EXISTS notes (...)' }, // idempotent DDL
14
+ { kind: 'column', sql: 'ALTER TABLE notes ADD COLUMN pinned INTEGER', desc: 'notes.pinned' }, // tolerates "already applied" only
15
+ { kind: 'data', name: 'seed-notes-v1', run: (sqlite) => { /* once, in a transaction */ } },
16
+ ],
17
+ }
18
+
19
+ const { schema } = composeModules([auth, audit, notes]) // throws on a repeated module, table export or data name
20
+ const { sqlite, db, close } = openDatabase({ path, schema })
21
+ runMigrations(sqlite, [auth, audit, notes]) // modules run in the order given
22
+ ```
23
+
24
+ Rules for a module's migrations:
25
+
26
+ - Steps run in the listed order on every start, so each must be idempotent.
27
+ - A `data` step's name is stored in `schema_migrations`. Never rename or drop one: it would run again on existing data.
28
+ - Order matters where SQLite stores the statement text: columns added with `ALTER` keep their relative order per table.
29
+ - A module may rely on the tables of the modules listed before it.
30
+
31
+ `drizzle-orm` is a peer dependency, so the application and every module share one copy. Requires Node 22.5 or newer.
32
+
33
+ Status: pre-1.0.
@@ -0,0 +1,23 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import type { SqliteRemoteDatabase } from 'drizzle-orm/sqlite-proxy';
3
+ /**
4
+ * The Drizzle handle as a package sees it. Which tables it knows is the application's business, so the schema is left
5
+ * open; each package names the tables it uses in its queries.
6
+ */
7
+ export type SqliteDatabase = SqliteRemoteDatabase<any>;
8
+ export interface Database<Schema extends Record<string, unknown>> {
9
+ /** Raw node:sqlite handle. */
10
+ sqlite: DatabaseSync;
11
+ /** Drizzle handle over the same connection. */
12
+ db: SqliteRemoteDatabase<Schema>;
13
+ close(): void;
14
+ }
15
+ /**
16
+ * Opens (creating its directory when needed) the SQLite file at `path` in WAL mode with foreign keys on, and wires
17
+ * Drizzle to the same connection. A process uses one connection; the caller decides where that instance lives.
18
+ */
19
+ export declare function openDatabase<Schema extends Record<string, unknown>>(options: {
20
+ path: string;
21
+ schema: Schema;
22
+ }): Database<Schema>;
23
+ //# sourceMappingURL=database.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"database.d.ts","sourceRoot":"","sources":["../src/database.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAE1C,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAA;AAEpE;;;GAGG;AAEH,MAAM,MAAM,cAAc,GAAG,oBAAoB,CAAC,GAAG,CAAC,CAAA;AAItD,MAAM,WAAW,QAAQ,CAAC,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAC9D,8BAA8B;IAC9B,MAAM,EAAE,YAAY,CAAA;IACpB,+CAA+C;IAC/C,EAAE,EAAE,oBAAoB,CAAC,MAAM,CAAC,CAAA;IAChC,KAAK,IAAI,IAAI,CAAA;CACd;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,QAAQ,CAAC,MAAM,CAAC,CAyBhI"}
@@ -0,0 +1,35 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { drizzle } from 'drizzle-orm/sqlite-proxy';
5
+ /**
6
+ * Opens (creating its directory when needed) the SQLite file at `path` in WAL mode with foreign keys on, and wires
7
+ * Drizzle to the same connection. A process uses one connection; the caller decides where that instance lives.
8
+ */
9
+ export function openDatabase(options) {
10
+ const dir = path.dirname(options.path);
11
+ if (!fs.existsSync(dir))
12
+ fs.mkdirSync(dir, { recursive: true });
13
+ const sqlite = new DatabaseSync(options.path);
14
+ sqlite.exec('PRAGMA journal_mode = WAL');
15
+ sqlite.exec('PRAGMA foreign_keys = ON');
16
+ // sqlite-proxy maps result rows by position, so rows are handed over as value arrays.
17
+ const db = drizzle(async (sql, params, method) => {
18
+ const p = params;
19
+ const stmt = sqlite.prepare(sql);
20
+ if (method === 'run') {
21
+ stmt.run(...p);
22
+ return { rows: [] };
23
+ }
24
+ else if (method === 'get') {
25
+ const row = stmt.get(...p);
26
+ return { rows: row ? [Object.values(row)] : [] };
27
+ }
28
+ else {
29
+ const rows = stmt.all(...p);
30
+ return { rows: rows.map((r) => Object.values(r)) };
31
+ }
32
+ }, { schema: options.schema });
33
+ return { sqlite, db, close: () => sqlite.close() };
34
+ }
35
+ //# sourceMappingURL=database.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"database.js","sourceRoot":"","sources":["../src/database.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAA;AACxB,OAAO,IAAI,MAAM,WAAW,CAAA;AAC5B,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAC1C,OAAO,EAAE,OAAO,EAAE,MAAM,0BAA0B,CAAA;AAoBlD;;;GAGG;AACH,MAAM,UAAU,YAAY,CAAyC,OAAyC;IAC5G,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,CAAA;IACtC,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,EAAE,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;IAC/D,MAAM,MAAM,GAAG,IAAI,YAAY,CAAC,OAAO,CAAC,IAAI,CAAC,CAAA;IAC7C,MAAM,CAAC,IAAI,CAAC,2BAA2B,CAAC,CAAA;IACxC,MAAM,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAA;IACvC,sFAAsF;IACtF,MAAM,EAAE,GAAG,OAAO,CAChB,KAAK,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE;QAC5B,MAAM,CAAC,GAAG,MAAiB,CAAA;QAC3B,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;QAChC,IAAI,MAAM,KAAK,KAAK,EAAE,CAAC;YACrB,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAA;YACd,OAAO,EAAE,IAAI,EAAE,EAAE,EAAE,CAAA;QACrB,CAAC;aAAM,IAAI,MAAM,KAAK,KAAK,EAAE,CAAC;YAC5B,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAwC,CAAA;YACjE,OAAO,EAAE,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAA;QAClD,CAAC;aAAM,CAAC;YACN,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAA8B,CAAA;YACxD,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,EAAE,CAAA;QACpD,CAAC;IACH,CAAC,EACD,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,CAC3B,CAAA;IACD,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,KAAK,EAAE,EAAE,CAAA;AACpD,CAAC"}
@@ -0,0 +1,4 @@
1
+ export { openDatabase, type Database, type SqliteDatabase } from './database.js';
2
+ export { applyColumnMigration, composeModules, runMigrations, type DbModule, type MigrationStep } from './migrations.js';
3
+ export { withImmediateTransaction } from './transaction.js';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,KAAK,QAAQ,EAAE,KAAK,cAAc,EAAE,MAAM,eAAe,CAAA;AAChF,OAAO,EAAE,oBAAoB,EAAE,cAAc,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,iBAAiB,CAAA;AACxH,OAAO,EAAE,wBAAwB,EAAE,MAAM,kBAAkB,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { openDatabase } from './database.js';
2
+ export { applyColumnMigration, composeModules, runMigrations } from './migrations.js';
3
+ export { withImmediateTransaction } from './transaction.js';
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAsC,MAAM,eAAe,CAAA;AAChF,OAAO,EAAE,oBAAoB,EAAE,cAAc,EAAE,aAAa,EAAqC,MAAM,iBAAiB,CAAA;AACxH,OAAO,EAAE,wBAAwB,EAAE,MAAM,kBAAkB,CAAA"}
@@ -0,0 +1,54 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ /**
3
+ * One step of a module's migrations. Steps run in the order they are listed, on every start, so each must be
4
+ * idempotent; a `data` step is made so by being recorded in `schema_migrations` under its name.
5
+ */
6
+ export type MigrationStep =
7
+ /** Idempotent DDL, such as `CREATE TABLE IF NOT EXISTS`. */
8
+ {
9
+ kind: 'exec';
10
+ sql: string;
11
+ }
12
+ /**
13
+ * A change that fails when it was already applied (`ADD COLUMN`, `DROP COLUMN`). Only that failure is expected;
14
+ * anything else (typo, locked database, disk full) surfaces.
15
+ */
16
+ | {
17
+ kind: 'column';
18
+ sql: string;
19
+ desc: string;
20
+ }
21
+ /** Runs once in an immediate transaction. The name is stored: renaming it would run the step again on existing data. */
22
+ | {
23
+ kind: 'data';
24
+ name: string;
25
+ run(sqlite: DatabaseSync): void;
26
+ };
27
+ /** A part of the schema with the migrations that create and evolve it. */
28
+ export interface DbModule<Tables extends Record<string, unknown> = Record<string, unknown>> {
29
+ id: string;
30
+ /** Drizzle table definitions the module owns. */
31
+ tables: Tables;
32
+ migrations: readonly MigrationStep[];
33
+ }
34
+ /**
35
+ * Merges the tables of several modules into one Drizzle schema. A repeated module id, table export or data
36
+ * migration name is a mistake that would silently shadow or double-run something, so it throws.
37
+ */
38
+ export declare function composeModules<const Modules extends readonly DbModule[]>(modules: Modules): {
39
+ schema: Record<string, unknown>;
40
+ modules: Modules;
41
+ };
42
+ /**
43
+ * Applies the `column` change in `sql`. Returns true when it changed the schema and false when it had already been
44
+ * applied.
45
+ */
46
+ export declare function applyColumnMigration(sqlite: DatabaseSync, sql: string): boolean;
47
+ /**
48
+ * Brings the database to the schema the modules describe: first the migration ledger, then each module's steps in
49
+ * the order the modules are given. Safe to run on every start.
50
+ */
51
+ export declare function runMigrations(sqlite: DatabaseSync, modules: readonly DbModule[], options?: {
52
+ log?: (message: string) => void;
53
+ }): void;
54
+ //# sourceMappingURL=migrations.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"migrations.d.ts","sourceRoot":"","sources":["../src/migrations.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAE/C;;;GAGG;AACH,MAAM,MAAM,aAAa;AACvB,4DAA4D;AAC1D;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE;AAC/B;;;GAGG;GACD;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE;AAC/C,wHAAwH;GACtH;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,MAAM,EAAE,YAAY,GAAG,IAAI,CAAA;CAAE,CAAA;AAEnE,0EAA0E;AAC1E,MAAM,WAAW,QAAQ,CAAC,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IACxF,EAAE,EAAE,MAAM,CAAA;IACV,iDAAiD;IACjD,MAAM,EAAE,MAAM,CAAA;IACd,UAAU,EAAE,SAAS,aAAa,EAAE,CAAA;CACrC;AASD;;;GAGG;AACH,wBAAgB,cAAc,CAAC,KAAK,CAAC,OAAO,SAAS,SAAS,QAAQ,EAAE,EAAE,OAAO,EAAE,OAAO,GAAG;IAC3F,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC/B,OAAO,EAAE,OAAO,CAAA;CACjB,CAkBA;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,YAAY,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAW/E;AAiBD;;;GAGG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,YAAY,EAAE,OAAO,EAAE,SAAS,QAAQ,EAAE,EAAE,OAAO,GAAE;IAAE,GAAG,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;CAAO,GAAG,IAAI,CAYzI"}
@@ -0,0 +1,89 @@
1
+ const SCHEMA_MIGRATIONS = `
2
+ CREATE TABLE IF NOT EXISTS schema_migrations (
3
+ name TEXT PRIMARY KEY,
4
+ applied_at INTEGER NOT NULL
5
+ );
6
+ `;
7
+ /**
8
+ * Merges the tables of several modules into one Drizzle schema. A repeated module id, table export or data
9
+ * migration name is a mistake that would silently shadow or double-run something, so it throws.
10
+ */
11
+ export function composeModules(modules) {
12
+ const ids = new Set();
13
+ const dataNames = new Set();
14
+ const schema = {};
15
+ for (const module of modules) {
16
+ if (ids.has(module.id))
17
+ throw new Error(`Duplicate database module "${module.id}"`);
18
+ ids.add(module.id);
19
+ for (const [exportName, table] of Object.entries(module.tables)) {
20
+ if (Object.hasOwn(schema, exportName))
21
+ throw new Error(`Duplicate table export "${exportName}" in module "${module.id}"`);
22
+ schema[exportName] = table;
23
+ }
24
+ for (const step of module.migrations) {
25
+ if (step.kind !== 'data')
26
+ continue;
27
+ if (dataNames.has(step.name))
28
+ throw new Error(`Duplicate data migration "${step.name}" in module "${module.id}"`);
29
+ dataNames.add(step.name);
30
+ }
31
+ }
32
+ return { schema, modules };
33
+ }
34
+ /**
35
+ * Applies the `column` change in `sql`. Returns true when it changed the schema and false when it had already been
36
+ * applied.
37
+ */
38
+ export function applyColumnMigration(sqlite, sql) {
39
+ const alreadyApplied = /\bADD\s+COLUMN\b/i.test(sql) ? /duplicate column name/i
40
+ : /\bDROP\s+COLUMN\b/i.test(sql) ? /no such column/i
41
+ : null;
42
+ try {
43
+ sqlite.exec(sql);
44
+ return true;
45
+ }
46
+ catch (error) {
47
+ if (alreadyApplied && error instanceof Error && alreadyApplied.test(error.message))
48
+ return false;
49
+ throw error;
50
+ }
51
+ }
52
+ function runDataMigration(sqlite, name, migrate) {
53
+ sqlite.exec('BEGIN IMMEDIATE');
54
+ try {
55
+ const applied = sqlite.prepare('SELECT 1 FROM schema_migrations WHERE name = ?').get(name);
56
+ if (!applied) {
57
+ migrate();
58
+ sqlite.prepare('INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)').run(name, Date.now());
59
+ }
60
+ sqlite.exec('COMMIT');
61
+ }
62
+ catch (error) {
63
+ sqlite.exec('ROLLBACK');
64
+ throw error;
65
+ }
66
+ }
67
+ /**
68
+ * Brings the database to the schema the modules describe: first the migration ledger, then each module's steps in
69
+ * the order the modules are given. Safe to run on every start.
70
+ */
71
+ export function runMigrations(sqlite, modules, options = {}) {
72
+ const log = options.log ?? console.log;
73
+ composeModules(modules);
74
+ sqlite.exec(SCHEMA_MIGRATIONS);
75
+ for (const module of modules) {
76
+ for (const step of module.migrations) {
77
+ if (step.kind === 'exec')
78
+ sqlite.exec(step.sql);
79
+ else if (step.kind === 'column') {
80
+ if (applyColumnMigration(sqlite, step.sql))
81
+ log(`✓ Column migration: ${step.desc}`);
82
+ }
83
+ else
84
+ runDataMigration(sqlite, step.name, () => step.run(sqlite));
85
+ }
86
+ }
87
+ log('✓ Migrations applied');
88
+ }
89
+ //# sourceMappingURL=migrations.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"migrations.js","sourceRoot":"","sources":["../src/migrations.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,14 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ /**
3
+ * Runs `work` between BEGIN IMMEDIATE and COMMIT (or ROLLBACK if it throws).
4
+ *
5
+ * There is one SQLite connection, so a second transaction cannot begin while another is open. Callers that arrive
6
+ * meanwhile wait their turn instead of failing. A transaction started from inside `work` can never get its turn, so
7
+ * it throws.
8
+ *
9
+ * The `await`s inside `work` only yield to microtasks as long as every database call is synchronous underneath, which
10
+ * holds for the node:sqlite driver: no other request can run its statements inside this transaction. Do not await real
11
+ * I/O here (a network call, a timer): that lets another request's writes join the transaction and be rolled back with it.
12
+ */
13
+ export declare function withImmediateTransaction<T>(sqlite: DatabaseSync, work: () => Promise<T>): Promise<T>;
14
+ //# sourceMappingURL=transaction.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transaction.d.ts","sourceRoot":"","sources":["../src/transaction.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAO/C;;;;;;;;;;GAUG;AACH,wBAAgB,wBAAwB,CAAC,CAAC,EAAE,MAAM,EAAE,YAAY,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAKpG"}
@@ -0,0 +1,39 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ // Transactions of one connection take turns: the next one begins when the previous one has committed or rolled back.
3
+ const queues = new WeakMap();
4
+ // The connection whose transaction the current async chain is inside, to refuse a nested one.
5
+ const inside = new AsyncLocalStorage();
6
+ /**
7
+ * Runs `work` between BEGIN IMMEDIATE and COMMIT (or ROLLBACK if it throws).
8
+ *
9
+ * There is one SQLite connection, so a second transaction cannot begin while another is open. Callers that arrive
10
+ * meanwhile wait their turn instead of failing. A transaction started from inside `work` can never get its turn, so
11
+ * it throws.
12
+ *
13
+ * The `await`s inside `work` only yield to microtasks as long as every database call is synchronous underneath, which
14
+ * holds for the node:sqlite driver: no other request can run its statements inside this transaction. Do not await real
15
+ * I/O here (a network call, a timer): that lets another request's writes join the transaction and be rolled back with it.
16
+ */
17
+ export function withImmediateTransaction(sqlite, work) {
18
+ if (inside.getStore() === sqlite)
19
+ return Promise.reject(new Error('Nested transaction: it would wait for the one it is part of'));
20
+ const run = (queues.get(sqlite) ?? Promise.resolve()).then(() => runTransaction(sqlite, work));
21
+ queues.set(sqlite, run.catch(() => undefined));
22
+ return run;
23
+ }
24
+ async function runTransaction(sqlite, work) {
25
+ sqlite.exec('BEGIN IMMEDIATE');
26
+ try {
27
+ const result = await inside.run(sqlite, work);
28
+ sqlite.exec('COMMIT');
29
+ return result;
30
+ }
31
+ catch (error) {
32
+ try {
33
+ sqlite.exec('ROLLBACK');
34
+ }
35
+ catch { /* preserve the original error */ }
36
+ throw error;
37
+ }
38
+ }
39
+ //# sourceMappingURL=transaction.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transaction.js","sourceRoot":"","sources":["../src/transaction.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AAGpD,qHAAqH;AACrH,MAAM,MAAM,GAAG,IAAI,OAAO,EAAkC,CAAA;AAC5D,8FAA8F;AAC9F,MAAM,MAAM,GAAG,IAAI,iBAAiB,EAAgB,CAAA;AAEpD;;;;;;;;;;GAUG;AACH,MAAM,UAAU,wBAAwB,CAAI,MAAoB,EAAE,IAAsB;IACtF,IAAI,MAAM,CAAC,QAAQ,EAAE,KAAK,MAAM;QAAE,OAAO,OAAO,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,6DAA6D,CAAC,CAAC,CAAA;IACjI,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,cAAc,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAA;IAC9F,MAAM,CAAC,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC,CAAA;IAC9C,OAAO,GAAG,CAAA;AACZ,CAAC;AAED,KAAK,UAAU,cAAc,CAAI,MAAoB,EAAE,IAAsB;IAC3E,MAAM,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAA;IAC9B,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,CAAA;QAC7C,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;QACrB,OAAO,MAAM,CAAA;IACf,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,CAAC;YAAC,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC,CAAA;QAAC,CAAC;QAAC,MAAM,CAAC,CAAC,iCAAiC,CAAC,CAAC;QAC3E,MAAM,KAAK,CAAA;IACb,CAAC;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
- {
2
- "name": "@better-forge/db",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "@better-forge/db",
3
+ "version": "0.1.0",
4
+ "description": "SQLite (node:sqlite) and Drizzle bootstrap, transactions, schema and migration registries.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/BElluu/Better-Forge.git",
9
+ "directory": "packages/db"
10
+ },
11
+ "homepage": "https://github.com/BElluu/Better-Forge/tree/main/packages/db#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/BElluu/Better-Forge/issues"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "type": "module",
19
+ "main": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "import": "./dist/index.js"
25
+ },
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "src",
31
+ "!src/**/*.test.*"
32
+ ],
33
+ "scripts": {
34
+ "build": "tsc -p tsconfig.json",
35
+ "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput"
36
+ },
37
+ "peerDependencies": {
38
+ "drizzle-orm": "^0.45.2"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "*",
42
+ "drizzle-orm": "^0.45.2",
43
+ "typescript": "^5.7.3"
44
+ },
45
+ "engines": {
46
+ "node": ">=26.10.0"
47
+ }
48
+ }
@@ -0,0 +1,53 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { DatabaseSync } from 'node:sqlite'
4
+ import { drizzle } from 'drizzle-orm/sqlite-proxy'
5
+ import type { SqliteRemoteDatabase } from 'drizzle-orm/sqlite-proxy'
6
+
7
+ /**
8
+ * The Drizzle handle as a package sees it. Which tables it knows is the application's business, so the schema is left
9
+ * open; each package names the tables it uses in its queries.
10
+ */
11
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
12
+ export type SqliteDatabase = SqliteRemoteDatabase<any>
13
+
14
+ type Param = string | number | bigint | null | Uint8Array
15
+
16
+ export interface Database<Schema extends Record<string, unknown>> {
17
+ /** Raw node:sqlite handle. */
18
+ sqlite: DatabaseSync
19
+ /** Drizzle handle over the same connection. */
20
+ db: SqliteRemoteDatabase<Schema>
21
+ close(): void
22
+ }
23
+
24
+ /**
25
+ * Opens (creating its directory when needed) the SQLite file at `path` in WAL mode with foreign keys on, and wires
26
+ * Drizzle to the same connection. A process uses one connection; the caller decides where that instance lives.
27
+ */
28
+ export function openDatabase<Schema extends Record<string, unknown>>(options: { path: string; schema: Schema }): Database<Schema> {
29
+ const dir = path.dirname(options.path)
30
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
31
+ const sqlite = new DatabaseSync(options.path)
32
+ sqlite.exec('PRAGMA journal_mode = WAL')
33
+ sqlite.exec('PRAGMA foreign_keys = ON')
34
+ // sqlite-proxy maps result rows by position, so rows are handed over as value arrays.
35
+ const db = drizzle(
36
+ async (sql, params, method) => {
37
+ const p = params as Param[]
38
+ const stmt = sqlite.prepare(sql)
39
+ if (method === 'run') {
40
+ stmt.run(...p)
41
+ return { rows: [] }
42
+ } else if (method === 'get') {
43
+ const row = stmt.get(...p) as Record<string, unknown> | undefined
44
+ return { rows: row ? [Object.values(row)] : [] }
45
+ } else {
46
+ const rows = stmt.all(...p) as Record<string, unknown>[]
47
+ return { rows: rows.map((r) => Object.values(r)) }
48
+ }
49
+ },
50
+ { schema: options.schema },
51
+ )
52
+ return { sqlite, db, close: () => sqlite.close() }
53
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { openDatabase, type Database, type SqliteDatabase } from './database.js'
2
+ export { applyColumnMigration, composeModules, runMigrations, type DbModule, type MigrationStep } from './migrations.js'
3
+ export { withImmediateTransaction } from './transaction.js'
@@ -0,0 +1,108 @@
1
+ import type { DatabaseSync } from 'node:sqlite'
2
+
3
+ /**
4
+ * One step of a module's migrations. Steps run in the order they are listed, on every start, so each must be
5
+ * idempotent; a `data` step is made so by being recorded in `schema_migrations` under its name.
6
+ */
7
+ export type MigrationStep =
8
+ /** Idempotent DDL, such as `CREATE TABLE IF NOT EXISTS`. */
9
+ | { kind: 'exec'; sql: string }
10
+ /**
11
+ * A change that fails when it was already applied (`ADD COLUMN`, `DROP COLUMN`). Only that failure is expected;
12
+ * anything else (typo, locked database, disk full) surfaces.
13
+ */
14
+ | { kind: 'column'; sql: string; desc: string }
15
+ /** Runs once in an immediate transaction. The name is stored: renaming it would run the step again on existing data. */
16
+ | { kind: 'data'; name: string; run(sqlite: DatabaseSync): void }
17
+
18
+ /** A part of the schema with the migrations that create and evolve it. */
19
+ export interface DbModule<Tables extends Record<string, unknown> = Record<string, unknown>> {
20
+ id: string
21
+ /** Drizzle table definitions the module owns. */
22
+ tables: Tables
23
+ migrations: readonly MigrationStep[]
24
+ }
25
+
26
+ const SCHEMA_MIGRATIONS = `
27
+ CREATE TABLE IF NOT EXISTS schema_migrations (
28
+ name TEXT PRIMARY KEY,
29
+ applied_at INTEGER NOT NULL
30
+ );
31
+ `
32
+
33
+ /**
34
+ * Merges the tables of several modules into one Drizzle schema. A repeated module id, table export or data
35
+ * migration name is a mistake that would silently shadow or double-run something, so it throws.
36
+ */
37
+ export function composeModules<const Modules extends readonly DbModule[]>(modules: Modules): {
38
+ schema: Record<string, unknown>
39
+ modules: Modules
40
+ } {
41
+ const ids = new Set<string>()
42
+ const dataNames = new Set<string>()
43
+ const schema: Record<string, unknown> = {}
44
+ for (const module of modules) {
45
+ if (ids.has(module.id)) throw new Error(`Duplicate database module "${module.id}"`)
46
+ ids.add(module.id)
47
+ for (const [exportName, table] of Object.entries(module.tables)) {
48
+ if (Object.hasOwn(schema, exportName)) throw new Error(`Duplicate table export "${exportName}" in module "${module.id}"`)
49
+ schema[exportName] = table
50
+ }
51
+ for (const step of module.migrations) {
52
+ if (step.kind !== 'data') continue
53
+ if (dataNames.has(step.name)) throw new Error(`Duplicate data migration "${step.name}" in module "${module.id}"`)
54
+ dataNames.add(step.name)
55
+ }
56
+ }
57
+ return { schema, modules }
58
+ }
59
+
60
+ /**
61
+ * Applies the `column` change in `sql`. Returns true when it changed the schema and false when it had already been
62
+ * applied.
63
+ */
64
+ export function applyColumnMigration(sqlite: DatabaseSync, sql: string): boolean {
65
+ const alreadyApplied = /\bADD\s+COLUMN\b/i.test(sql) ? /duplicate column name/i
66
+ : /\bDROP\s+COLUMN\b/i.test(sql) ? /no such column/i
67
+ : null
68
+ try {
69
+ sqlite.exec(sql)
70
+ return true
71
+ } catch (error) {
72
+ if (alreadyApplied && error instanceof Error && alreadyApplied.test(error.message)) return false
73
+ throw error
74
+ }
75
+ }
76
+
77
+ function runDataMigration(sqlite: DatabaseSync, name: string, migrate: () => void): void {
78
+ sqlite.exec('BEGIN IMMEDIATE')
79
+ try {
80
+ const applied = sqlite.prepare('SELECT 1 FROM schema_migrations WHERE name = ?').get(name)
81
+ if (!applied) {
82
+ migrate()
83
+ sqlite.prepare('INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)').run(name, Date.now())
84
+ }
85
+ sqlite.exec('COMMIT')
86
+ } catch (error) {
87
+ sqlite.exec('ROLLBACK')
88
+ throw error
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Brings the database to the schema the modules describe: first the migration ledger, then each module's steps in
94
+ * the order the modules are given. Safe to run on every start.
95
+ */
96
+ export function runMigrations(sqlite: DatabaseSync, modules: readonly DbModule[], options: { log?: (message: string) => void } = {}): void {
97
+ const log = options.log ?? console.log
98
+ composeModules(modules)
99
+ sqlite.exec(SCHEMA_MIGRATIONS)
100
+ for (const module of modules) {
101
+ for (const step of module.migrations) {
102
+ if (step.kind === 'exec') sqlite.exec(step.sql)
103
+ else if (step.kind === 'column') { if (applyColumnMigration(sqlite, step.sql)) log(`✓ Column migration: ${step.desc}`) }
104
+ else runDataMigration(sqlite, step.name, () => step.run(sqlite))
105
+ }
106
+ }
107
+ log('✓ Migrations applied')
108
+ }
@@ -0,0 +1,37 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import type { DatabaseSync } from 'node:sqlite'
3
+
4
+ // Transactions of one connection take turns: the next one begins when the previous one has committed or rolled back.
5
+ const queues = new WeakMap<DatabaseSync, Promise<unknown>>()
6
+ // The connection whose transaction the current async chain is inside, to refuse a nested one.
7
+ const inside = new AsyncLocalStorage<DatabaseSync>()
8
+
9
+ /**
10
+ * Runs `work` between BEGIN IMMEDIATE and COMMIT (or ROLLBACK if it throws).
11
+ *
12
+ * There is one SQLite connection, so a second transaction cannot begin while another is open. Callers that arrive
13
+ * meanwhile wait their turn instead of failing. A transaction started from inside `work` can never get its turn, so
14
+ * it throws.
15
+ *
16
+ * The `await`s inside `work` only yield to microtasks as long as every database call is synchronous underneath, which
17
+ * holds for the node:sqlite driver: no other request can run its statements inside this transaction. Do not await real
18
+ * I/O here (a network call, a timer): that lets another request's writes join the transaction and be rolled back with it.
19
+ */
20
+ export function withImmediateTransaction<T>(sqlite: DatabaseSync, work: () => Promise<T>): Promise<T> {
21
+ if (inside.getStore() === sqlite) return Promise.reject(new Error('Nested transaction: it would wait for the one it is part of'))
22
+ const run = (queues.get(sqlite) ?? Promise.resolve()).then(() => runTransaction(sqlite, work))
23
+ queues.set(sqlite, run.catch(() => undefined))
24
+ return run
25
+ }
26
+
27
+ async function runTransaction<T>(sqlite: DatabaseSync, work: () => Promise<T>): Promise<T> {
28
+ sqlite.exec('BEGIN IMMEDIATE')
29
+ try {
30
+ const result = await inside.run(sqlite, work)
31
+ sqlite.exec('COMMIT')
32
+ return result
33
+ } catch (error) {
34
+ try { sqlite.exec('ROLLBACK') } catch { /* preserve the original error */ }
35
+ throw error
36
+ }
37
+ }