@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.
- package/LICENSE +17 -0
- package/README.md +94 -0
- package/dist/connection-controller.d.ts +22 -0
- package/dist/connection-controller.js +67 -0
- package/dist/schema/schema-controller.d.ts +37 -0
- package/dist/schema/schema-controller.js +49 -0
- package/dist/schema/sqlite-schema-driver.d.ts +70 -0
- package/dist/schema/sqlite-schema-driver.js +330 -0
- package/dist/schema/table-controller.d.ts +19 -0
- package/dist/schema/table-controller.js +26 -0
- package/dist/sqlite-driver-bun.d.ts +2 -0
- package/dist/sqlite-driver-bun.js +38 -0
- package/dist/sqlite-driver-interface.d.ts +14 -0
- package/dist/sqlite-driver-interface.js +1 -0
- package/dist/sqlite-driver-node.d.ts +2 -0
- package/dist/sqlite-driver-node.js +31 -0
- package/package.json +62 -0
- package/src/connection-controller.ts +89 -0
- package/src/schema/schema-controller.ts +80 -0
- package/src/schema/sqlite-schema-driver.ts +392 -0
- package/src/schema/table-controller.ts +36 -0
- package/src/sqlite-driver-bun.ts +41 -0
- package/src/sqlite-driver-interface.ts +15 -0
- package/src/sqlite-driver-node.ts +35 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
2
|
+
import {
|
|
3
|
+
resolveSqlConnection,
|
|
4
|
+
runSchemaPass,
|
|
5
|
+
type MigrationMap,
|
|
6
|
+
type ReclaimPolicy,
|
|
7
|
+
type SqlConnection,
|
|
8
|
+
} from "@telorun/sql";
|
|
9
|
+
import { SqliteSchemaDriver } from "./sqlite-schema-driver.js";
|
|
10
|
+
import type { SqliteTableResource } from "./table-controller.js";
|
|
11
|
+
|
|
12
|
+
interface SqliteSchemaManifest {
|
|
13
|
+
metadata: { name: string; module: string };
|
|
14
|
+
connection: SqlConnection;
|
|
15
|
+
version?: string;
|
|
16
|
+
ledger?: string;
|
|
17
|
+
tables?: SqliteTableResource[];
|
|
18
|
+
beforeMigrations?: MigrationMap;
|
|
19
|
+
migrations?: MigrationMap;
|
|
20
|
+
reclaim?: ReclaimPolicy;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `SQLite.Schema` — the single schema-change kind: declared tables and
|
|
25
|
+
* imperative migrations reconciled in one boot pass, under one clock.
|
|
26
|
+
*
|
|
27
|
+
* SQLite has exactly one namespace, so unlike the PostgreSQL kind there is no
|
|
28
|
+
* `schema:` field to name and nothing to create.
|
|
29
|
+
*/
|
|
30
|
+
class SqliteSchemaResource implements ResourceInstance {
|
|
31
|
+
constructor(
|
|
32
|
+
private readonly manifest: SqliteSchemaManifest,
|
|
33
|
+
private readonly ctx: ResourceContext,
|
|
34
|
+
) {}
|
|
35
|
+
|
|
36
|
+
/** Configured state is pulled, observed state is pushed — everything this
|
|
37
|
+
* resource knows is learned while running, so the snapshot is empty and the
|
|
38
|
+
* whole report arrives through `setStatus`. It still has to exist: a
|
|
39
|
+
* resource that publishes nothing is absent from the `resources` scope. */
|
|
40
|
+
snapshot(): Record<string, unknown> {
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async run(): Promise<void> {
|
|
45
|
+
const connection = resolveSqlConnection(
|
|
46
|
+
this.manifest.connection,
|
|
47
|
+
this.ctx,
|
|
48
|
+
() => `SQLite.Schema "${this.manifest.metadata.name}": 'connection'`,
|
|
49
|
+
);
|
|
50
|
+
if (!connection) {
|
|
51
|
+
throw new Error(`SQLite.Schema "${this.manifest.metadata.name}": missing connection`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const status = await runSchemaPass(new SqliteSchemaDriver(connection), this.ctx, {
|
|
55
|
+
schema: "main",
|
|
56
|
+
ledger: this.manifest.ledger,
|
|
57
|
+
version: this.manifest.version,
|
|
58
|
+
tables: (this.manifest.tables ?? []).map((table) => table.declaration),
|
|
59
|
+
beforeMigrations: this.manifest.beforeMigrations ?? {},
|
|
60
|
+
migrations: this.manifest.migrations ?? {},
|
|
61
|
+
reclaim: this.manifest.reclaim,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
for (const key of status.orphanedMigrations) {
|
|
65
|
+
// Deleting decade-old migrations from a manifest is normal, so an applied
|
|
66
|
+
// key with no declaration is reported and never an error.
|
|
67
|
+
this.ctx.log.info("Applied migration has no declaration", { "sql.migration.name": key });
|
|
68
|
+
}
|
|
69
|
+
this.ctx.setStatus({ ...status });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function register(): void {}
|
|
74
|
+
|
|
75
|
+
export async function create(
|
|
76
|
+
resource: SqliteSchemaManifest,
|
|
77
|
+
ctx: ResourceContext,
|
|
78
|
+
): Promise<SqliteSchemaResource> {
|
|
79
|
+
return new SqliteSchemaResource(resource, ctx);
|
|
80
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import {
|
|
2
|
+
quoteAnsiIdentifier,
|
|
3
|
+
type ChangeSafety,
|
|
4
|
+
type DeclaredColumn,
|
|
5
|
+
type DeclaredForeignKey,
|
|
6
|
+
type DeclaredIndex,
|
|
7
|
+
type DeclaredTable,
|
|
8
|
+
type LiveColumn,
|
|
9
|
+
type SchemaObjectId,
|
|
10
|
+
type LiveTable,
|
|
11
|
+
type LedgerTables,
|
|
12
|
+
type LiveForeignKey,
|
|
13
|
+
type LiveIndex,
|
|
14
|
+
type SchemaDriver,
|
|
15
|
+
type SqlConnection,
|
|
16
|
+
} from "@telorun/sql";
|
|
17
|
+
import { CompiledQuery, type Kysely } from "kysely";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* SQLite's half of declarative schema.
|
|
21
|
+
*
|
|
22
|
+
* The vocabulary is honestly smaller than PostgreSQL's because the engine is:
|
|
23
|
+
* five storage classes, no namespaces, no `ALTER COLUMN`, and foreign keys that
|
|
24
|
+
* exist only as part of the table they were created with. Nothing here pretends
|
|
25
|
+
* otherwise — a change SQLite cannot make in place is refused with the reason,
|
|
26
|
+
* which is what sends the author to a `migrations:` entry that rebuilds the
|
|
27
|
+
* table rather than leaving them with a silently unapplied declaration.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** SQLite storage classes. There is no date, boolean or UUID type — those are
|
|
31
|
+
* conventions over these five, and inventing names for them here would be the
|
|
32
|
+
* lowest-common-denominator type vocabulary this design rejects. */
|
|
33
|
+
export const SQLITE_TYPES = ["integer", "real", "text", "blob", "numeric"] as const;
|
|
34
|
+
export type SqliteType = (typeof SQLITE_TYPES)[number];
|
|
35
|
+
|
|
36
|
+
function literal(value: unknown): string {
|
|
37
|
+
if (value === null) return "NULL";
|
|
38
|
+
if (typeof value === "number" || typeof value === "bigint") return String(value);
|
|
39
|
+
if (typeof value === "boolean") return value ? "1" : "0";
|
|
40
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function columnDefault(column: DeclaredColumn): string {
|
|
44
|
+
if (column.defaultExpression !== undefined) return ` DEFAULT (${column.defaultExpression})`;
|
|
45
|
+
if (column.default !== undefined) return ` DEFAULT ${literal(column.default)}`;
|
|
46
|
+
return "";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class SqliteSchemaDriver implements SchemaDriver {
|
|
50
|
+
constructor(readonly connection: SqlConnection) {}
|
|
51
|
+
|
|
52
|
+
get #db(): Kysely<any> {
|
|
53
|
+
const db = this.connection.kysely;
|
|
54
|
+
if (!db) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
"SQLite.Schema: the referenced connection is not built on kysely, which the schema " +
|
|
57
|
+
"runner requires.",
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return db;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
quote(name: string): string {
|
|
64
|
+
return quoteAnsiIdentifier(name);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** SQLite has exactly one namespace, so a table is named on its own. */
|
|
68
|
+
qualify(_schema: string, table: string): string {
|
|
69
|
+
return this.quote(table);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* SQLite has no advisory lock, so the contract is met by a weaker mechanism
|
|
74
|
+
* and this says which.
|
|
75
|
+
*
|
|
76
|
+
* The engine serializes WRITERS, so no two passes interleave a write; each
|
|
77
|
+
* group `runAtomically` submits is a transaction. What is NOT excluded is two
|
|
78
|
+
* passes running concurrently against one database file and interleaving
|
|
79
|
+
* between groups. That is survivable rather than merely unlikely: every step
|
|
80
|
+
* is derived from live state and re-derivable, the DDL is `IF NOT EXISTS`, and
|
|
81
|
+
* the ledger writes are last in their groups — so two racing passes converge
|
|
82
|
+
* on the same schema instead of diverging.
|
|
83
|
+
*
|
|
84
|
+
* What it does not buy is exclusion for the destructive phase: two passes
|
|
85
|
+
* could both find a tombstone eligible, and the second's `DROP … IF EXISTS`
|
|
86
|
+
* is then a no-op. Acceptable because the outcome is identical; a genuine
|
|
87
|
+
* lock would need `BEGIN IMMEDIATE` held across the whole pass, which cannot
|
|
88
|
+
* nest with the per-group transactions.
|
|
89
|
+
*/
|
|
90
|
+
async withLock<T>(_schema: string, body: () => Promise<T>): Promise<T> {
|
|
91
|
+
return body();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
ensureNamespaceStatements(): string[] {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
ledgerStatements(_schema: string, tables: LedgerTables): string[] {
|
|
99
|
+
return [
|
|
100
|
+
`CREATE TABLE IF NOT EXISTS ${this.quote(tables.migrations)} (` +
|
|
101
|
+
`key TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`,
|
|
102
|
+
`CREATE TABLE IF NOT EXISTS ${this.quote(tables.versions)} (` +
|
|
103
|
+
`sequence INTEGER PRIMARY KEY, version TEXT NOT NULL, digest TEXT NOT NULL, ` +
|
|
104
|
+
`first_seen_at TEXT NOT NULL, declaration TEXT NOT NULL)`,
|
|
105
|
+
`CREATE TABLE IF NOT EXISTS ${this.quote(tables.tombstones)} (` +
|
|
106
|
+
`object_key TEXT PRIMARY KEY, kind TEXT NOT NULL, table_name TEXT NOT NULL, ` +
|
|
107
|
+
`name TEXT, definition TEXT NOT NULL, missing_since_version TEXT NOT NULL, ` +
|
|
108
|
+
`missing_since_sequence INTEGER NOT NULL, missing_since_at TEXT NOT NULL)`,
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async now(): Promise<string> {
|
|
113
|
+
const result = await this.connection.execute<{ now: string }>(
|
|
114
|
+
`SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now') AS now`,
|
|
115
|
+
);
|
|
116
|
+
return String(result.rows[0]?.now);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async runAtomically(statements: readonly string[]): Promise<void> {
|
|
120
|
+
if (statements.length === 0) return;
|
|
121
|
+
await this.#db.transaction().execute(async (trx) => {
|
|
122
|
+
for (const statement of statements) {
|
|
123
|
+
await trx.executeQuery(CompiledQuery.raw(statement));
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async introspect(_schema: string, tables: readonly string[]): Promise<LiveTable[]> {
|
|
129
|
+
const live: LiveTable[] = [];
|
|
130
|
+
for (const table of tables) {
|
|
131
|
+
const info = await this.connection.execute<Record<string, unknown>>(
|
|
132
|
+
`PRAGMA table_info(${this.quote(table)})`,
|
|
133
|
+
);
|
|
134
|
+
if (info.rows.length === 0) continue;
|
|
135
|
+
|
|
136
|
+
// `index_list` reports every index, including the ones SQLite creates for
|
|
137
|
+
// UNIQUE and (for a non-rowid table) the primary key. Those are how a
|
|
138
|
+
// single-column uniqueness constraint is visible at all, so they are read
|
|
139
|
+
// for the column flags and then left out of the diff: they were never
|
|
140
|
+
// declared, so nothing owns them.
|
|
141
|
+
const indexList = await this.connection.execute<Record<string, unknown>>(
|
|
142
|
+
`PRAGMA index_list(${this.quote(table)})`,
|
|
143
|
+
);
|
|
144
|
+
const uniqueColumns = new Set<string>();
|
|
145
|
+
const indexes: LiveIndex[] = [];
|
|
146
|
+
for (const row of indexList.rows) {
|
|
147
|
+
const name = String(row.name);
|
|
148
|
+
const unique = Number(row.unique ?? 0) === 1;
|
|
149
|
+
const columnsResult = await this.connection.execute<Record<string, unknown>>(
|
|
150
|
+
`PRAGMA index_info(${this.quote(name)})`,
|
|
151
|
+
);
|
|
152
|
+
const columns = columnsResult.rows.map((entry) => String(entry.name));
|
|
153
|
+
if (unique && columns.length === 1) uniqueColumns.add(columns[0]!);
|
|
154
|
+
// `origin` is `c` for an index the author created, `u`/`pk` for one
|
|
155
|
+
// SQLite made to back a constraint.
|
|
156
|
+
if (String(row.origin ?? "c") === "c") indexes.push({ name, columns, unique });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const columns: LiveColumn[] = info.rows.map((row) => {
|
|
160
|
+
const name = String(row.name);
|
|
161
|
+
return {
|
|
162
|
+
name,
|
|
163
|
+
typeSignature: String(row.type ?? "").toLowerCase(),
|
|
164
|
+
nullable: Number(row.notnull ?? 0) === 0,
|
|
165
|
+
hasDefault: row.dflt_value != null,
|
|
166
|
+
primaryKey: Number(row.pk ?? 0) > 0,
|
|
167
|
+
unique: uniqueColumns.has(name),
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const fkList = await this.connection.execute<Record<string, unknown>>(
|
|
172
|
+
`PRAGMA foreign_key_list(${this.quote(table)})`,
|
|
173
|
+
);
|
|
174
|
+
// SQLite does not name a foreign key, so one cannot be matched to a
|
|
175
|
+
// declaration by name — which is also why it has no ADD/DROP CONSTRAINT.
|
|
176
|
+
// Reported as unnamed rather than invented, so the diff sees no match and
|
|
177
|
+
// `addForeignKey` refuses with the reason.
|
|
178
|
+
const foreignKeys: LiveForeignKey[] = [];
|
|
179
|
+
const byId = new Map<number, Record<string, unknown>[]>();
|
|
180
|
+
for (const row of fkList.rows) {
|
|
181
|
+
const id = Number(row.id ?? 0);
|
|
182
|
+
byId.set(id, [...(byId.get(id) ?? []), row]);
|
|
183
|
+
}
|
|
184
|
+
for (const [, rows] of byId) {
|
|
185
|
+
const first = rows[0]!;
|
|
186
|
+
foreignKeys.push({
|
|
187
|
+
name: `sqlite_fk_${Number(first.id ?? 0)}`,
|
|
188
|
+
columns: rows.map((row) => String(row.from)),
|
|
189
|
+
references: {
|
|
190
|
+
table: String(first.table),
|
|
191
|
+
columns: rows.map((row) => String(row.to)),
|
|
192
|
+
},
|
|
193
|
+
onDelete: first.on_delete == null ? undefined : String(first.on_delete),
|
|
194
|
+
onUpdate: first.on_update == null ? undefined : String(first.on_update),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
live.push({ name: table, columns, indexes, foreignKeys });
|
|
199
|
+
}
|
|
200
|
+
return live;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
typeSignature(column: DeclaredColumn): string {
|
|
204
|
+
return column.type.toLowerCase();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** An index is dropped and recreated, which SQLite does support. */
|
|
208
|
+
classifyIndexChange(): ChangeSafety {
|
|
209
|
+
return { safe: true };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** A foreign key exists only as part of the table it was created with, so
|
|
213
|
+
* changing one means rebuilding the table. */
|
|
214
|
+
classifyForeignKeyChange(live: LiveForeignKey): ChangeSafety {
|
|
215
|
+
return {
|
|
216
|
+
safe: false,
|
|
217
|
+
reason:
|
|
218
|
+
`the foreign key on (${live.columns.join(", ")}) differs from its declaration, and ` +
|
|
219
|
+
`SQLite has no ALTER for a constraint — a foreign key exists only as part of the table ` +
|
|
220
|
+
`it was created with. Rebuild the table in a 'migrations:' entry.`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
classifyAlter(live: LiveColumn, declared: DeclaredColumn): ChangeSafety {
|
|
225
|
+
if (live.typeSignature !== this.typeSignature(declared)) {
|
|
226
|
+
return {
|
|
227
|
+
safe: false,
|
|
228
|
+
reason:
|
|
229
|
+
`SQLite cannot change a column's type in place (${live.typeSignature} → ` +
|
|
230
|
+
`${this.typeSignature(declared)}). Rebuild the table in a 'migrations:' entry.`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
if (live.nullable !== declared.nullable) {
|
|
234
|
+
return {
|
|
235
|
+
safe: false,
|
|
236
|
+
reason:
|
|
237
|
+
"SQLite cannot add or drop NOT NULL in place. Rebuild the table in a " +
|
|
238
|
+
"'migrations:' entry.",
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
if (live.primaryKey !== declared.primaryKey || live.unique !== declared.unique) {
|
|
242
|
+
return {
|
|
243
|
+
safe: false,
|
|
244
|
+
reason:
|
|
245
|
+
`SQLite cannot add or drop a column constraint in place (primaryKey ` +
|
|
246
|
+
`${live.primaryKey} → ${declared.primaryKey}, unique ${live.unique} → ` +
|
|
247
|
+
`${declared.unique}). Rebuild the table in a 'migrations:' entry, or declare a named ` +
|
|
248
|
+
`unique index instead of a column flag.`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
safe: false,
|
|
253
|
+
reason:
|
|
254
|
+
"SQLite cannot change a column default in place. Rebuild the table in a " +
|
|
255
|
+
"'migrations:' entry.",
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
classifyCopy(live: LiveColumn, target: DeclaredColumn): ChangeSafety {
|
|
260
|
+
if (live.typeSignature === this.typeSignature(target)) return { safe: true };
|
|
261
|
+
// SQLite would accept this and store the source's representation as it is —
|
|
262
|
+
// a column declared `integer` holding text. Nothing later would report it.
|
|
263
|
+
return {
|
|
264
|
+
safe: false,
|
|
265
|
+
reason:
|
|
266
|
+
`copying ${live.typeSignature} values into a ${this.typeSignature(target)} column ` +
|
|
267
|
+
`would store them unconverted, because SQLite applies affinity rather than rejecting ` +
|
|
268
|
+
`them. Convert the data in a 'migrations:' entry, or declare the same type.`,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
#columnDefinition(column: DeclaredColumn): string {
|
|
273
|
+
if (column.array) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
`SQLite.Table: column '${column.name}' declares 'array', which SQLite has no type for.`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
// AUTOINCREMENT is only legal on INTEGER PRIMARY KEY — SQLite rejects it
|
|
279
|
+
// anywhere else, and the complaint names a statement the author never wrote.
|
|
280
|
+
if (column.identity && !(column.primaryKey && column.type === "integer")) {
|
|
281
|
+
throw new Error(
|
|
282
|
+
`SQLite.Table: column '${column.name}' declares identity, which SQLite allows only on ` +
|
|
283
|
+
`an integer primary key. Declare 'type: integer' and 'primaryKey: true', or drop it.`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
const parts = [this.quote(column.name), column.type.toUpperCase()];
|
|
287
|
+
if (column.primaryKey) parts.push("PRIMARY KEY");
|
|
288
|
+
if (column.identity) parts.push("AUTOINCREMENT");
|
|
289
|
+
if (!column.nullable) parts.push("NOT NULL");
|
|
290
|
+
if (column.unique) parts.push("UNIQUE");
|
|
291
|
+
const def = columnDefault(column);
|
|
292
|
+
return parts.join(" ") + def;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
createTable(schema: string, table: DeclaredTable): string[] {
|
|
296
|
+
const parts = table.columns.map((column) => this.#columnDefinition(column));
|
|
297
|
+
// Foreign keys are part of the table in SQLite — there is no ADD CONSTRAINT
|
|
298
|
+
// — so they are emitted here and nowhere else.
|
|
299
|
+
for (const fk of table.foreignKeys) {
|
|
300
|
+
parts.push(this.#foreignKeyClause(fk));
|
|
301
|
+
}
|
|
302
|
+
return [
|
|
303
|
+
`CREATE TABLE IF NOT EXISTS ${this.qualify(schema, table.name)} (\n ${parts.join(",\n ")}\n)`,
|
|
304
|
+
];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
#foreignKeyClause(fk: DeclaredForeignKey): string {
|
|
308
|
+
const cols = fk.columns.map((c) => this.quote(c)).join(", ");
|
|
309
|
+
const refCols = fk.references.columns.map((c) => this.quote(c)).join(", ");
|
|
310
|
+
let clause = `FOREIGN KEY (${cols}) REFERENCES ${this.quote(fk.references.table)} (${refCols})`;
|
|
311
|
+
if (fk.onDelete) clause += ` ON DELETE ${fk.onDelete.toUpperCase()}`;
|
|
312
|
+
if (fk.onUpdate) clause += ` ON UPDATE ${fk.onUpdate.toUpperCase()}`;
|
|
313
|
+
return clause;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
addColumn(schema: string, table: string, column: DeclaredColumn): string[] {
|
|
317
|
+
if (!column.nullable && column.default === undefined && column.defaultExpression === undefined) {
|
|
318
|
+
throw new Error(
|
|
319
|
+
`SQLite.Table: column '${table}.${column.name}' is NOT NULL with no default, which ` +
|
|
320
|
+
`cannot be added to a table that already has rows. Give it a default, or add it ` +
|
|
321
|
+
`nullable and backfill in a 'migrations:' entry.`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
return [
|
|
325
|
+
`ALTER TABLE ${this.qualify(schema, table)} ADD COLUMN ${this.#columnDefinition(column)}`,
|
|
326
|
+
];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Unreachable: `classifyAlter` refuses every in-place column change SQLite
|
|
330
|
+
* cannot make, which is all of them. */
|
|
331
|
+
alterColumn(_schema: string, table: string, _live: LiveColumn, column: DeclaredColumn): string[] {
|
|
332
|
+
throw new Error(`SQLite.Table: column '${table}.${column.name}' cannot be altered in place.`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
copyColumn(schema: string, table: string, from: string, to: string): string[] {
|
|
336
|
+
return [
|
|
337
|
+
`UPDATE ${this.qualify(schema, table)} SET ${this.quote(to)} = ${this.quote(from)} ` +
|
|
338
|
+
`WHERE ${this.quote(to)} IS NULL`,
|
|
339
|
+
];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
createIndex(schema: string, table: string, index: DeclaredIndex): string[] {
|
|
343
|
+
const unique = index.unique ? "UNIQUE " : "";
|
|
344
|
+
const columns = index.columns.map((c) => this.quote(c)).join(", ");
|
|
345
|
+
const where = typeof index.options.where === "string" ? ` WHERE ${index.options.where}` : "";
|
|
346
|
+
return [
|
|
347
|
+
`CREATE ${unique}INDEX IF NOT EXISTS ${this.quote(index.name)} ` +
|
|
348
|
+
`ON ${this.qualify(schema, table)} (${columns})${where}`,
|
|
349
|
+
];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
dropIndex(_schema: string, _table: string, index: string): string[] {
|
|
353
|
+
return [`DROP INDEX IF EXISTS ${this.quote(index)}`];
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
addForeignKey(_schema: string, table: string, fk: DeclaredForeignKey): string[] {
|
|
357
|
+
throw new Error(
|
|
358
|
+
`SQLite.Table: foreign key '${fk.name}' cannot be added to the existing table '${table}' — ` +
|
|
359
|
+
`SQLite has no ADD CONSTRAINT and a foreign key exists only as part of the table it was ` +
|
|
360
|
+
`created with. Rebuild the table in a 'migrations:' entry.`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Unreachable: `canReclaim` refuses a foreign key before the drop is planned. */
|
|
365
|
+
dropForeignKey(_schema: string, table: string, name: string): string[] {
|
|
366
|
+
throw new Error(
|
|
367
|
+
`SQLite.Table: foreign key '${name}' cannot be dropped from '${table}' — SQLite has no ` +
|
|
368
|
+
`DROP CONSTRAINT. Rebuild the table in a 'migrations:' entry.`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
canReclaim(id: SchemaObjectId): ChangeSafety {
|
|
373
|
+
if (id.kind === "foreignKey") {
|
|
374
|
+
return {
|
|
375
|
+
safe: false,
|
|
376
|
+
reason:
|
|
377
|
+
"SQLite has no DROP CONSTRAINT, so this foreign key cannot be dropped in place. " +
|
|
378
|
+
"Rebuild the table in a 'migrations:' entry; the tombstone is cleared when the " +
|
|
379
|
+
"constraint is gone.",
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
return { safe: true };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
dropColumn(schema: string, table: string, column: string): string[] {
|
|
386
|
+
return [`ALTER TABLE ${this.qualify(schema, table)} DROP COLUMN ${this.quote(column)}`];
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
dropTable(schema: string, table: string): string[] {
|
|
390
|
+
return [`DROP TABLE IF EXISTS ${this.qualify(schema, table)}`];
|
|
391
|
+
}
|
|
392
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
2
|
+
import { normalizeTable, type DeclaredTable, type RawTable } from "@telorun/sql";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `SQLite.Table` — one physical table, declared rather than migrated to.
|
|
6
|
+
*
|
|
7
|
+
* The resource holds a declaration and performs no I/O; the `Schema` resource
|
|
8
|
+
* that lists it reconciles it. Nothing is dispatched to a table, which is why
|
|
9
|
+
* a schema's `tables:` slot declares `use: dependency`.
|
|
10
|
+
*/
|
|
11
|
+
export class SqliteTableResource implements ResourceInstance {
|
|
12
|
+
readonly declaration: DeclaredTable;
|
|
13
|
+
|
|
14
|
+
constructor(raw: RawTable) {
|
|
15
|
+
this.declaration = normalizeTable(raw);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The physical table name, read by consumers that build statements against
|
|
19
|
+
* it (`self.table.table` in a repository's template). */
|
|
20
|
+
get table(): string {
|
|
21
|
+
return this.declaration.name;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
snapshot(): Record<string, unknown> {
|
|
25
|
+
return { table: this.declaration.name };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function register(): void {}
|
|
30
|
+
|
|
31
|
+
export async function create(
|
|
32
|
+
resource: RawTable,
|
|
33
|
+
_ctx: ResourceContext,
|
|
34
|
+
): Promise<SqliteTableResource> {
|
|
35
|
+
return new SqliteTableResource(resource);
|
|
36
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import type { SqliteDb } from "./sqlite-driver-interface.js";
|
|
3
|
+
|
|
4
|
+
export function openDatabase(file: string): SqliteDb {
|
|
5
|
+
const db = new Database(file);
|
|
6
|
+
|
|
7
|
+
return {
|
|
8
|
+
prepare(sql: string) {
|
|
9
|
+
const stmt = db.prepare(sql);
|
|
10
|
+
return {
|
|
11
|
+
// A statement is a reader iff it yields a result set. bun:sqlite has no
|
|
12
|
+
// `reader` flag (better-sqlite3 does), so derive it from the output
|
|
13
|
+
// columns: SELECT and `... RETURNING` expose column names, plain
|
|
14
|
+
// INSERT/UPDATE/DELETE expose none. Kysely routes readers through
|
|
15
|
+
// `all()` and everything else through `run()` — getting this wrong sent
|
|
16
|
+
// every mutation down the `all()` path, so `numAffectedRows` was never
|
|
17
|
+
// reported (rowCount always 0).
|
|
18
|
+
reader: stmt.columnNames.length > 0,
|
|
19
|
+
all(params: ReadonlyArray<unknown>) {
|
|
20
|
+
return stmt.all(...(params as any[]));
|
|
21
|
+
},
|
|
22
|
+
run(params: ReadonlyArray<unknown>) {
|
|
23
|
+
const result = stmt.run(...(params as any[]));
|
|
24
|
+
return {
|
|
25
|
+
changes: result.changes,
|
|
26
|
+
lastInsertRowid: result.lastInsertRowid,
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
iterate(params: ReadonlyArray<unknown>) {
|
|
30
|
+
return stmt.iterate(...(params as any[])) as IterableIterator<unknown>;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
exec(sql: string) {
|
|
35
|
+
db.exec(sql);
|
|
36
|
+
},
|
|
37
|
+
close() {
|
|
38
|
+
db.close();
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
|
|
11
|
+
export interface SqliteDb {
|
|
12
|
+
prepare(sql: string): SqliteStatement;
|
|
13
|
+
exec(sql: string): void;
|
|
14
|
+
close(): void;
|
|
15
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import type { SqliteDb } from "./sqlite-driver-interface.js";
|
|
3
|
+
|
|
4
|
+
export function openDatabase(file: string): SqliteDb {
|
|
5
|
+
const db = new Database(file);
|
|
6
|
+
|
|
7
|
+
return {
|
|
8
|
+
prepare(sql: string) {
|
|
9
|
+
const stmt = db.prepare(sql);
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
reader: stmt.reader,
|
|
13
|
+
all(params: ReadonlyArray<unknown>) {
|
|
14
|
+
return stmt.all(...(params as unknown[]));
|
|
15
|
+
},
|
|
16
|
+
run(params: ReadonlyArray<unknown>) {
|
|
17
|
+
const result = stmt.run(...(params as unknown[]));
|
|
18
|
+
return {
|
|
19
|
+
changes: result.changes,
|
|
20
|
+
lastInsertRowid: result.lastInsertRowid,
|
|
21
|
+
};
|
|
22
|
+
},
|
|
23
|
+
iterate(params: ReadonlyArray<unknown>) {
|
|
24
|
+
return stmt.iterate(...(params as unknown[])) as IterableIterator<unknown>;
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
exec(sql: string) {
|
|
29
|
+
db.exec(sql);
|
|
30
|
+
},
|
|
31
|
+
close() {
|
|
32
|
+
db.close();
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|