@telorun/sqlite 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.
@@ -0,0 +1,330 @@
1
+ import { quoteAnsiIdentifier, } from "@telorun/sql";
2
+ import { CompiledQuery } from "kysely";
3
+ /**
4
+ * SQLite's half of declarative schema.
5
+ *
6
+ * The vocabulary is honestly smaller than PostgreSQL's because the engine is:
7
+ * five storage classes, no namespaces, no `ALTER COLUMN`, and foreign keys that
8
+ * exist only as part of the table they were created with. Nothing here pretends
9
+ * otherwise — a change SQLite cannot make in place is refused with the reason,
10
+ * which is what sends the author to a `migrations:` entry that rebuilds the
11
+ * table rather than leaving them with a silently unapplied declaration.
12
+ */
13
+ /** SQLite storage classes. There is no date, boolean or UUID type — those are
14
+ * conventions over these five, and inventing names for them here would be the
15
+ * lowest-common-denominator type vocabulary this design rejects. */
16
+ export const SQLITE_TYPES = ["integer", "real", "text", "blob", "numeric"];
17
+ function literal(value) {
18
+ if (value === null)
19
+ return "NULL";
20
+ if (typeof value === "number" || typeof value === "bigint")
21
+ return String(value);
22
+ if (typeof value === "boolean")
23
+ return value ? "1" : "0";
24
+ return `'${String(value).replace(/'/g, "''")}'`;
25
+ }
26
+ function columnDefault(column) {
27
+ if (column.defaultExpression !== undefined)
28
+ return ` DEFAULT (${column.defaultExpression})`;
29
+ if (column.default !== undefined)
30
+ return ` DEFAULT ${literal(column.default)}`;
31
+ return "";
32
+ }
33
+ export class SqliteSchemaDriver {
34
+ connection;
35
+ constructor(connection) {
36
+ this.connection = connection;
37
+ }
38
+ get #db() {
39
+ const db = this.connection.kysely;
40
+ if (!db) {
41
+ throw new Error("SQLite.Schema: the referenced connection is not built on kysely, which the schema " +
42
+ "runner requires.");
43
+ }
44
+ return db;
45
+ }
46
+ quote(name) {
47
+ return quoteAnsiIdentifier(name);
48
+ }
49
+ /** SQLite has exactly one namespace, so a table is named on its own. */
50
+ qualify(_schema, table) {
51
+ return this.quote(table);
52
+ }
53
+ /**
54
+ * SQLite has no advisory lock, so the contract is met by a weaker mechanism
55
+ * and this says which.
56
+ *
57
+ * The engine serializes WRITERS, so no two passes interleave a write; each
58
+ * group `runAtomically` submits is a transaction. What is NOT excluded is two
59
+ * passes running concurrently against one database file and interleaving
60
+ * between groups. That is survivable rather than merely unlikely: every step
61
+ * is derived from live state and re-derivable, the DDL is `IF NOT EXISTS`, and
62
+ * the ledger writes are last in their groups — so two racing passes converge
63
+ * on the same schema instead of diverging.
64
+ *
65
+ * What it does not buy is exclusion for the destructive phase: two passes
66
+ * could both find a tombstone eligible, and the second's `DROP … IF EXISTS`
67
+ * is then a no-op. Acceptable because the outcome is identical; a genuine
68
+ * lock would need `BEGIN IMMEDIATE` held across the whole pass, which cannot
69
+ * nest with the per-group transactions.
70
+ */
71
+ async withLock(_schema, body) {
72
+ return body();
73
+ }
74
+ ensureNamespaceStatements() {
75
+ return [];
76
+ }
77
+ ledgerStatements(_schema, tables) {
78
+ return [
79
+ `CREATE TABLE IF NOT EXISTS ${this.quote(tables.migrations)} (` +
80
+ `key TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`,
81
+ `CREATE TABLE IF NOT EXISTS ${this.quote(tables.versions)} (` +
82
+ `sequence INTEGER PRIMARY KEY, version TEXT NOT NULL, digest TEXT NOT NULL, ` +
83
+ `first_seen_at TEXT NOT NULL, declaration TEXT NOT NULL)`,
84
+ `CREATE TABLE IF NOT EXISTS ${this.quote(tables.tombstones)} (` +
85
+ `object_key TEXT PRIMARY KEY, kind TEXT NOT NULL, table_name TEXT NOT NULL, ` +
86
+ `name TEXT, definition TEXT NOT NULL, missing_since_version TEXT NOT NULL, ` +
87
+ `missing_since_sequence INTEGER NOT NULL, missing_since_at TEXT NOT NULL)`,
88
+ ];
89
+ }
90
+ async now() {
91
+ const result = await this.connection.execute(`SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now') AS now`);
92
+ return String(result.rows[0]?.now);
93
+ }
94
+ async runAtomically(statements) {
95
+ if (statements.length === 0)
96
+ return;
97
+ await this.#db.transaction().execute(async (trx) => {
98
+ for (const statement of statements) {
99
+ await trx.executeQuery(CompiledQuery.raw(statement));
100
+ }
101
+ });
102
+ }
103
+ async introspect(_schema, tables) {
104
+ const live = [];
105
+ for (const table of tables) {
106
+ const info = await this.connection.execute(`PRAGMA table_info(${this.quote(table)})`);
107
+ if (info.rows.length === 0)
108
+ continue;
109
+ // `index_list` reports every index, including the ones SQLite creates for
110
+ // UNIQUE and (for a non-rowid table) the primary key. Those are how a
111
+ // single-column uniqueness constraint is visible at all, so they are read
112
+ // for the column flags and then left out of the diff: they were never
113
+ // declared, so nothing owns them.
114
+ const indexList = await this.connection.execute(`PRAGMA index_list(${this.quote(table)})`);
115
+ const uniqueColumns = new Set();
116
+ const indexes = [];
117
+ for (const row of indexList.rows) {
118
+ const name = String(row.name);
119
+ const unique = Number(row.unique ?? 0) === 1;
120
+ const columnsResult = await this.connection.execute(`PRAGMA index_info(${this.quote(name)})`);
121
+ const columns = columnsResult.rows.map((entry) => String(entry.name));
122
+ if (unique && columns.length === 1)
123
+ uniqueColumns.add(columns[0]);
124
+ // `origin` is `c` for an index the author created, `u`/`pk` for one
125
+ // SQLite made to back a constraint.
126
+ if (String(row.origin ?? "c") === "c")
127
+ indexes.push({ name, columns, unique });
128
+ }
129
+ const columns = info.rows.map((row) => {
130
+ const name = String(row.name);
131
+ return {
132
+ name,
133
+ typeSignature: String(row.type ?? "").toLowerCase(),
134
+ nullable: Number(row.notnull ?? 0) === 0,
135
+ hasDefault: row.dflt_value != null,
136
+ primaryKey: Number(row.pk ?? 0) > 0,
137
+ unique: uniqueColumns.has(name),
138
+ };
139
+ });
140
+ const fkList = await this.connection.execute(`PRAGMA foreign_key_list(${this.quote(table)})`);
141
+ // SQLite does not name a foreign key, so one cannot be matched to a
142
+ // declaration by name — which is also why it has no ADD/DROP CONSTRAINT.
143
+ // Reported as unnamed rather than invented, so the diff sees no match and
144
+ // `addForeignKey` refuses with the reason.
145
+ const foreignKeys = [];
146
+ const byId = new Map();
147
+ for (const row of fkList.rows) {
148
+ const id = Number(row.id ?? 0);
149
+ byId.set(id, [...(byId.get(id) ?? []), row]);
150
+ }
151
+ for (const [, rows] of byId) {
152
+ const first = rows[0];
153
+ foreignKeys.push({
154
+ name: `sqlite_fk_${Number(first.id ?? 0)}`,
155
+ columns: rows.map((row) => String(row.from)),
156
+ references: {
157
+ table: String(first.table),
158
+ columns: rows.map((row) => String(row.to)),
159
+ },
160
+ onDelete: first.on_delete == null ? undefined : String(first.on_delete),
161
+ onUpdate: first.on_update == null ? undefined : String(first.on_update),
162
+ });
163
+ }
164
+ live.push({ name: table, columns, indexes, foreignKeys });
165
+ }
166
+ return live;
167
+ }
168
+ typeSignature(column) {
169
+ return column.type.toLowerCase();
170
+ }
171
+ /** An index is dropped and recreated, which SQLite does support. */
172
+ classifyIndexChange() {
173
+ return { safe: true };
174
+ }
175
+ /** A foreign key exists only as part of the table it was created with, so
176
+ * changing one means rebuilding the table. */
177
+ classifyForeignKeyChange(live) {
178
+ return {
179
+ safe: false,
180
+ reason: `the foreign key on (${live.columns.join(", ")}) differs from its declaration, and ` +
181
+ `SQLite has no ALTER for a constraint — a foreign key exists only as part of the table ` +
182
+ `it was created with. Rebuild the table in a 'migrations:' entry.`,
183
+ };
184
+ }
185
+ classifyAlter(live, declared) {
186
+ if (live.typeSignature !== this.typeSignature(declared)) {
187
+ return {
188
+ safe: false,
189
+ reason: `SQLite cannot change a column's type in place (${live.typeSignature} → ` +
190
+ `${this.typeSignature(declared)}). Rebuild the table in a 'migrations:' entry.`,
191
+ };
192
+ }
193
+ if (live.nullable !== declared.nullable) {
194
+ return {
195
+ safe: false,
196
+ reason: "SQLite cannot add or drop NOT NULL in place. Rebuild the table in a " +
197
+ "'migrations:' entry.",
198
+ };
199
+ }
200
+ if (live.primaryKey !== declared.primaryKey || live.unique !== declared.unique) {
201
+ return {
202
+ safe: false,
203
+ reason: `SQLite cannot add or drop a column constraint in place (primaryKey ` +
204
+ `${live.primaryKey} → ${declared.primaryKey}, unique ${live.unique} → ` +
205
+ `${declared.unique}). Rebuild the table in a 'migrations:' entry, or declare a named ` +
206
+ `unique index instead of a column flag.`,
207
+ };
208
+ }
209
+ return {
210
+ safe: false,
211
+ reason: "SQLite cannot change a column default in place. Rebuild the table in a " +
212
+ "'migrations:' entry.",
213
+ };
214
+ }
215
+ classifyCopy(live, target) {
216
+ if (live.typeSignature === this.typeSignature(target))
217
+ return { safe: true };
218
+ // SQLite would accept this and store the source's representation as it is —
219
+ // a column declared `integer` holding text. Nothing later would report it.
220
+ return {
221
+ safe: false,
222
+ reason: `copying ${live.typeSignature} values into a ${this.typeSignature(target)} column ` +
223
+ `would store them unconverted, because SQLite applies affinity rather than rejecting ` +
224
+ `them. Convert the data in a 'migrations:' entry, or declare the same type.`,
225
+ };
226
+ }
227
+ #columnDefinition(column) {
228
+ if (column.array) {
229
+ throw new Error(`SQLite.Table: column '${column.name}' declares 'array', which SQLite has no type for.`);
230
+ }
231
+ // AUTOINCREMENT is only legal on INTEGER PRIMARY KEY — SQLite rejects it
232
+ // anywhere else, and the complaint names a statement the author never wrote.
233
+ if (column.identity && !(column.primaryKey && column.type === "integer")) {
234
+ throw new Error(`SQLite.Table: column '${column.name}' declares identity, which SQLite allows only on ` +
235
+ `an integer primary key. Declare 'type: integer' and 'primaryKey: true', or drop it.`);
236
+ }
237
+ const parts = [this.quote(column.name), column.type.toUpperCase()];
238
+ if (column.primaryKey)
239
+ parts.push("PRIMARY KEY");
240
+ if (column.identity)
241
+ parts.push("AUTOINCREMENT");
242
+ if (!column.nullable)
243
+ parts.push("NOT NULL");
244
+ if (column.unique)
245
+ parts.push("UNIQUE");
246
+ const def = columnDefault(column);
247
+ return parts.join(" ") + def;
248
+ }
249
+ createTable(schema, table) {
250
+ const parts = table.columns.map((column) => this.#columnDefinition(column));
251
+ // Foreign keys are part of the table in SQLite — there is no ADD CONSTRAINT
252
+ // — so they are emitted here and nowhere else.
253
+ for (const fk of table.foreignKeys) {
254
+ parts.push(this.#foreignKeyClause(fk));
255
+ }
256
+ return [
257
+ `CREATE TABLE IF NOT EXISTS ${this.qualify(schema, table.name)} (\n ${parts.join(",\n ")}\n)`,
258
+ ];
259
+ }
260
+ #foreignKeyClause(fk) {
261
+ const cols = fk.columns.map((c) => this.quote(c)).join(", ");
262
+ const refCols = fk.references.columns.map((c) => this.quote(c)).join(", ");
263
+ let clause = `FOREIGN KEY (${cols}) REFERENCES ${this.quote(fk.references.table)} (${refCols})`;
264
+ if (fk.onDelete)
265
+ clause += ` ON DELETE ${fk.onDelete.toUpperCase()}`;
266
+ if (fk.onUpdate)
267
+ clause += ` ON UPDATE ${fk.onUpdate.toUpperCase()}`;
268
+ return clause;
269
+ }
270
+ addColumn(schema, table, column) {
271
+ if (!column.nullable && column.default === undefined && column.defaultExpression === undefined) {
272
+ throw new Error(`SQLite.Table: column '${table}.${column.name}' is NOT NULL with no default, which ` +
273
+ `cannot be added to a table that already has rows. Give it a default, or add it ` +
274
+ `nullable and backfill in a 'migrations:' entry.`);
275
+ }
276
+ return [
277
+ `ALTER TABLE ${this.qualify(schema, table)} ADD COLUMN ${this.#columnDefinition(column)}`,
278
+ ];
279
+ }
280
+ /** Unreachable: `classifyAlter` refuses every in-place column change SQLite
281
+ * cannot make, which is all of them. */
282
+ alterColumn(_schema, table, _live, column) {
283
+ throw new Error(`SQLite.Table: column '${table}.${column.name}' cannot be altered in place.`);
284
+ }
285
+ copyColumn(schema, table, from, to) {
286
+ return [
287
+ `UPDATE ${this.qualify(schema, table)} SET ${this.quote(to)} = ${this.quote(from)} ` +
288
+ `WHERE ${this.quote(to)} IS NULL`,
289
+ ];
290
+ }
291
+ createIndex(schema, table, index) {
292
+ const unique = index.unique ? "UNIQUE " : "";
293
+ const columns = index.columns.map((c) => this.quote(c)).join(", ");
294
+ const where = typeof index.options.where === "string" ? ` WHERE ${index.options.where}` : "";
295
+ return [
296
+ `CREATE ${unique}INDEX IF NOT EXISTS ${this.quote(index.name)} ` +
297
+ `ON ${this.qualify(schema, table)} (${columns})${where}`,
298
+ ];
299
+ }
300
+ dropIndex(_schema, _table, index) {
301
+ return [`DROP INDEX IF EXISTS ${this.quote(index)}`];
302
+ }
303
+ addForeignKey(_schema, table, fk) {
304
+ throw new Error(`SQLite.Table: foreign key '${fk.name}' cannot be added to the existing table '${table}' — ` +
305
+ `SQLite has no ADD CONSTRAINT and a foreign key exists only as part of the table it was ` +
306
+ `created with. Rebuild the table in a 'migrations:' entry.`);
307
+ }
308
+ /** Unreachable: `canReclaim` refuses a foreign key before the drop is planned. */
309
+ dropForeignKey(_schema, table, name) {
310
+ throw new Error(`SQLite.Table: foreign key '${name}' cannot be dropped from '${table}' — SQLite has no ` +
311
+ `DROP CONSTRAINT. Rebuild the table in a 'migrations:' entry.`);
312
+ }
313
+ canReclaim(id) {
314
+ if (id.kind === "foreignKey") {
315
+ return {
316
+ safe: false,
317
+ reason: "SQLite has no DROP CONSTRAINT, so this foreign key cannot be dropped in place. " +
318
+ "Rebuild the table in a 'migrations:' entry; the tombstone is cleared when the " +
319
+ "constraint is gone.",
320
+ };
321
+ }
322
+ return { safe: true };
323
+ }
324
+ dropColumn(schema, table, column) {
325
+ return [`ALTER TABLE ${this.qualify(schema, table)} DROP COLUMN ${this.quote(column)}`];
326
+ }
327
+ dropTable(schema, table) {
328
+ return [`DROP TABLE IF EXISTS ${this.qualify(schema, table)}`];
329
+ }
330
+ }
@@ -0,0 +1,19 @@
1
+ import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
2
+ import { type DeclaredTable, type RawTable } from "@telorun/sql";
3
+ /**
4
+ * `SQLite.Table` — one physical table, declared rather than migrated to.
5
+ *
6
+ * The resource holds a declaration and performs no I/O; the `Schema` resource
7
+ * that lists it reconciles it. Nothing is dispatched to a table, which is why
8
+ * a schema's `tables:` slot declares `use: dependency`.
9
+ */
10
+ export declare class SqliteTableResource implements ResourceInstance {
11
+ readonly declaration: DeclaredTable;
12
+ constructor(raw: RawTable);
13
+ /** The physical table name, read by consumers that build statements against
14
+ * it (`self.table.table` in a repository's template). */
15
+ get table(): string;
16
+ snapshot(): Record<string, unknown>;
17
+ }
18
+ export declare function register(): void;
19
+ export declare function create(resource: RawTable, _ctx: ResourceContext): Promise<SqliteTableResource>;
@@ -0,0 +1,26 @@
1
+ import { normalizeTable } from "@telorun/sql";
2
+ /**
3
+ * `SQLite.Table` — one physical table, declared rather than migrated to.
4
+ *
5
+ * The resource holds a declaration and performs no I/O; the `Schema` resource
6
+ * that lists it reconciles it. Nothing is dispatched to a table, which is why
7
+ * a schema's `tables:` slot declares `use: dependency`.
8
+ */
9
+ export class SqliteTableResource {
10
+ declaration;
11
+ constructor(raw) {
12
+ this.declaration = normalizeTable(raw);
13
+ }
14
+ /** The physical table name, read by consumers that build statements against
15
+ * it (`self.table.table` in a repository's template). */
16
+ get table() {
17
+ return this.declaration.name;
18
+ }
19
+ snapshot() {
20
+ return { table: this.declaration.name };
21
+ }
22
+ }
23
+ export function register() { }
24
+ export async function create(resource, _ctx) {
25
+ return new SqliteTableResource(resource);
26
+ }
@@ -0,0 +1,2 @@
1
+ import type { SqliteDb } from "./sqlite-driver-interface.js";
2
+ export declare function openDatabase(file: string): SqliteDb;
@@ -0,0 +1,38 @@
1
+ import { Database } from "bun:sqlite";
2
+ export function openDatabase(file) {
3
+ const db = new Database(file);
4
+ return {
5
+ prepare(sql) {
6
+ const stmt = db.prepare(sql);
7
+ return {
8
+ // A statement is a reader iff it yields a result set. bun:sqlite has no
9
+ // `reader` flag (better-sqlite3 does), so derive it from the output
10
+ // columns: SELECT and `... RETURNING` expose column names, plain
11
+ // INSERT/UPDATE/DELETE expose none. Kysely routes readers through
12
+ // `all()` and everything else through `run()` — getting this wrong sent
13
+ // every mutation down the `all()` path, so `numAffectedRows` was never
14
+ // reported (rowCount always 0).
15
+ reader: stmt.columnNames.length > 0,
16
+ all(params) {
17
+ return stmt.all(...params);
18
+ },
19
+ run(params) {
20
+ const result = stmt.run(...params);
21
+ return {
22
+ changes: result.changes,
23
+ lastInsertRowid: result.lastInsertRowid,
24
+ };
25
+ },
26
+ iterate(params) {
27
+ return stmt.iterate(...params);
28
+ },
29
+ };
30
+ },
31
+ exec(sql) {
32
+ db.exec(sql);
33
+ },
34
+ close() {
35
+ db.close();
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,14 @@
1
+ export interface SqliteStatement {
2
+ readonly reader: boolean;
3
+ all(params: ReadonlyArray<unknown>): unknown[];
4
+ run(params: ReadonlyArray<unknown>): {
5
+ changes: number | bigint;
6
+ lastInsertRowid: number | bigint;
7
+ };
8
+ iterate(params: ReadonlyArray<unknown>): IterableIterator<unknown>;
9
+ }
10
+ export interface SqliteDb {
11
+ prepare(sql: string): SqliteStatement;
12
+ exec(sql: string): void;
13
+ close(): void;
14
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { SqliteDb } from "./sqlite-driver-interface.js";
2
+ export declare function openDatabase(file: string): SqliteDb;
@@ -0,0 +1,31 @@
1
+ import Database from "better-sqlite3";
2
+ export function openDatabase(file) {
3
+ const db = new Database(file);
4
+ return {
5
+ prepare(sql) {
6
+ const stmt = db.prepare(sql);
7
+ return {
8
+ reader: stmt.reader,
9
+ all(params) {
10
+ return stmt.all(...params);
11
+ },
12
+ run(params) {
13
+ const result = stmt.run(...params);
14
+ return {
15
+ changes: result.changes,
16
+ lastInsertRowid: result.lastInsertRowid,
17
+ };
18
+ },
19
+ iterate(params) {
20
+ return stmt.iterate(...params);
21
+ },
22
+ };
23
+ },
24
+ exec(sql) {
25
+ db.exec(sql);
26
+ },
27
+ close() {
28
+ db.close();
29
+ },
30
+ };
31
+ }
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@telorun/sqlite",
3
+ "version": "0.1.0",
4
+ "description": "Telo SQLite.Connection — SQLite backend for the Sql.Connection abstract (better-sqlite3 / bun:sqlite, transactional DDL).",
5
+ "keywords": [
6
+ "telo",
7
+ "sql",
8
+ "sqlite"
9
+ ],
10
+ "author": "Bartosz Pasiński <bartosz.pasinski@codenet.pl>",
11
+ "license": "SEE LICENSE IN LICENSE",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/telorun/telo.git",
15
+ "directory": "modules/sqlite/nodejs"
16
+ },
17
+ "homepage": "https://github.com/telorun/telo#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/telorun/telo/issues"
20
+ },
21
+ "type": "module",
22
+ "exports": {
23
+ "./connection": {
24
+ "bun": "./src/connection-controller.ts",
25
+ "import": "./dist/connection-controller.js"
26
+ },
27
+ "./sqlite-driver": {
28
+ "bun": "./src/sqlite-driver-bun.ts",
29
+ "import": "./dist/sqlite-driver-node.js"
30
+ },
31
+ "./schema": {
32
+ "bun": "./src/schema/schema-controller.ts",
33
+ "import": "./dist/schema/schema-controller.js"
34
+ },
35
+ "./table": {
36
+ "bun": "./src/schema/table-controller.ts",
37
+ "import": "./dist/schema/table-controller.js"
38
+ }
39
+ },
40
+ "files": [
41
+ "dist",
42
+ "src/**"
43
+ ],
44
+ "dependencies": {
45
+ "better-sqlite3": "^12.8.0",
46
+ "kysely": "^0.28.15",
47
+ "@telorun/sql": "0.21.3"
48
+ },
49
+ "devDependencies": {
50
+ "@types/better-sqlite3": "^7.0.0",
51
+ "@types/bun": "^1.3.10",
52
+ "@types/node": "^20.0.0",
53
+ "typescript": "^5.0.0",
54
+ "@telorun/sdk": "0.79.0"
55
+ },
56
+ "peerDependencies": {
57
+ "@telorun/sdk": "*"
58
+ },
59
+ "scripts": {
60
+ "build": "tsc -p tsconfig.lib.json"
61
+ }
62
+ }
@@ -0,0 +1,89 @@
1
+ import type { ResourceContext } from "@telorun/sdk";
2
+ import { quoteAnsiIdentifier, SqlConnectionBase, type SqlDialect } from "@telorun/sql";
3
+ import { Kysely, SqliteAdapter, SqliteDialect } from "kysely";
4
+ import type { SqliteDb } from "./sqlite-driver-interface.js";
5
+
6
+ interface SqliteConnectionManifest {
7
+ metadata: { name: string; module: string };
8
+ /** File path, or omitted / `:memory:` for an in-memory database. */
9
+ file?: string;
10
+ }
11
+
12
+ const sqliteDialect: SqlDialect = {
13
+ placeholderStyle: "qmark",
14
+ quoteIdentifier: quoteAnsiIdentifier,
15
+ // SQLite has no array type, so set membership expands to one placeholder per
16
+ // element.
17
+ renderIn(column, values, addParam) {
18
+ return `${column} IN (${values.map((value) => addParam(value)).join(", ")})`;
19
+ },
20
+ };
21
+
22
+ class SqliteConnection extends SqlConnectionBase {
23
+ constructor(
24
+ db: Kysely<any>,
25
+ private readonly sqlite: SqliteDb,
26
+ ctx: ResourceContext,
27
+ ) {
28
+ super(db, sqliteDialect, ctx);
29
+ }
30
+
31
+ /** The driver's native multi-statement entry point — kysely binds one
32
+ * statement per call. */
33
+ override async executeScript(sql: string): Promise<void> {
34
+ this.sqlite.exec(sql);
35
+ }
36
+ }
37
+
38
+ // Kysely's stock SQLite adapter reports `supportsTransactionalDdl = false`, so
39
+ // its Migrator runs migrations without a transaction. SQLite does support
40
+ // transactional DDL, so we flip the flag — letting the Migrator wrap the whole
41
+ // migration batch in a single transaction, matching PostgreSQL.
42
+ class TransactionalSqliteAdapter extends SqliteAdapter {
43
+ override get supportsTransactionalDdl(): boolean {
44
+ return true;
45
+ }
46
+ }
47
+
48
+ class TransactionalSqliteDialect extends SqliteDialect {
49
+ override createAdapter(): SqliteAdapter {
50
+ return new TransactionalSqliteAdapter();
51
+ }
52
+ }
53
+
54
+ async function openSqliteDatabase(file = ":memory:"): Promise<SqliteDb> {
55
+ // Auto-create the parent directory for file-backed databases. SQLite
56
+ // drivers fail-fast when the directory doesn't exist; mirroring `mkdir
57
+ // -p` here lets manifests use paths like `./tmp/foo.sqlite` without a
58
+ // separate filesystem-prep step. `:memory:` skips filesystem entirely.
59
+ if (file !== ":memory:") {
60
+ const { mkdir } = await import("node:fs/promises");
61
+ const { dirname } = await import("node:path");
62
+ const dir = dirname(file);
63
+ if (dir && dir !== "." && dir !== "/") {
64
+ await mkdir(dir, { recursive: true });
65
+ }
66
+ }
67
+
68
+ // Route through this package's own `./sqlite-driver` subpath export so the
69
+ // resolver selects the driver per runtime (Bun → bun:sqlite, Node →
70
+ // better-sqlite3). A manual `typeof Bun` check with relative imports gets
71
+ // flattened by the controller bundler into an unconditional top-level
72
+ // `import "bun:sqlite"`, which Node's ESM loader rejects before the guard
73
+ // runs; an external `@telorun/*` specifier stays a deferred dynamic import.
74
+ const { openDatabase } = await import("@telorun/sqlite/sqlite-driver");
75
+ return openDatabase(file);
76
+ }
77
+
78
+ export function register(): void {}
79
+
80
+ export async function create(
81
+ resource: SqliteConnectionManifest,
82
+ ctx: ResourceContext,
83
+ ): Promise<SqliteConnection> {
84
+ const sqlite = await openSqliteDatabase(resource.file ?? ":memory:");
85
+ const db = new Kysely<any>({
86
+ dialect: new TransactionalSqliteDialect({ database: sqlite }),
87
+ });
88
+ return new SqliteConnection(db, sqlite, ctx);
89
+ }