@telorun/sqlite 0.3.0 → 0.4.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,18 @@
1
+ import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
2
+ import { type DeclaredEnum, type RawEnum } from "@telorun/sql";
3
+ /**
4
+ * `SQLite.Enum` — one declared set of permitted values.
5
+ *
6
+ * SQLite has no named types, so nothing in the database corresponds to this
7
+ * resource: the values are rendered as a `CHECK` on every column that references
8
+ * it. The DECLARATION is still a ledger object like any other, which is what
9
+ * lets a change to it be detected at all.
10
+ */
11
+ export declare class SqliteEnumResource implements ResourceInstance {
12
+ readonly declaration: DeclaredEnum;
13
+ constructor(raw: RawEnum);
14
+ get typeName(): string;
15
+ snapshot(): Record<string, unknown>;
16
+ }
17
+ export declare function register(): void;
18
+ export declare function create(resource: RawEnum, _ctx: ResourceContext): Promise<SqliteEnumResource>;
@@ -0,0 +1,25 @@
1
+ import { normalizeEnum } from "@telorun/sql";
2
+ /**
3
+ * `SQLite.Enum` — one declared set of permitted values.
4
+ *
5
+ * SQLite has no named types, so nothing in the database corresponds to this
6
+ * resource: the values are rendered as a `CHECK` on every column that references
7
+ * it. The DECLARATION is still a ledger object like any other, which is what
8
+ * lets a change to it be detected at all.
9
+ */
10
+ export class SqliteEnumResource {
11
+ declaration;
12
+ constructor(raw) {
13
+ this.declaration = normalizeEnum(raw);
14
+ }
15
+ get typeName() {
16
+ return this.declaration.typeName;
17
+ }
18
+ snapshot() {
19
+ return { typeName: this.declaration.typeName, values: [...this.declaration.values] };
20
+ }
21
+ }
22
+ export function register() { }
23
+ export async function create(resource, _ctx) {
24
+ return new SqliteEnumResource(resource);
25
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
2
2
  import { type MigrationMap, type ReclaimPolicy, type SqlConnection } from "@telorun/sql";
3
+ import type { SqliteEnumResource } from "./enum-controller.js";
3
4
  import type { SqliteTableResource } from "./table-controller.js";
4
5
  interface SqliteSchemaManifest {
5
6
  metadata: {
@@ -10,7 +11,8 @@ interface SqliteSchemaManifest {
10
11
  version?: string;
11
12
  ledger?: string;
12
13
  tables?: SqliteTableResource[];
13
- beforeMigrations?: MigrationMap;
14
+ enums?: SqliteEnumResource[];
15
+ prepare?: MigrationMap;
14
16
  migrations?: MigrationMap;
15
17
  reclaim?: ReclaimPolicy;
16
18
  }
@@ -31,7 +31,8 @@ class SqliteSchemaResource {
31
31
  ledger: this.manifest.ledger,
32
32
  version: this.manifest.version,
33
33
  tables: (this.manifest.tables ?? []).map((table) => table.declaration),
34
- beforeMigrations: this.manifest.beforeMigrations ?? {},
34
+ enums: (this.manifest.enums ?? []).map((declared) => declared.declaration),
35
+ prepare: this.manifest.prepare ?? {},
35
36
  migrations: this.manifest.migrations ?? {},
36
37
  reclaim: this.manifest.reclaim,
37
38
  });
@@ -1,4 +1,4 @@
1
- import { type ChangeSafety, type DeclaredColumn, type DeclaredForeignKey, type DeclaredIndex, type DeclaredTable, type LiveColumn, type SchemaObjectId, type LiveTable, type LedgerTables, type LiveForeignKey, type SchemaDriver, type SqlConnection } from "@telorun/sql";
1
+ import { type ChangeSafety, type DeclaredCheck, type DeclaredColumn, type DeclaredEnum, type DeclaredForeignKey, type DeclaredIndex, type DeclaredTable, type LiveCheck, type LiveColumn, type LiveEnum, type SchemaObjectId, type LiveTable, type LedgerTables, type LiveForeignKey, type SchemaDriver, type SqlConnection } from "@telorun/sql";
2
2
  /**
3
3
  * SQLite's half of declarative schema.
4
4
  *
@@ -44,6 +44,11 @@ export declare class SqliteSchemaDriver implements SchemaDriver {
44
44
  ledgerStatements(_schema: string, tables: LedgerTables): string[];
45
45
  now(): Promise<string>;
46
46
  runAtomically(statements: readonly string[]): Promise<void>;
47
+ /** SQLite has no DDL this design must keep out of a transaction, so this is
48
+ * `runAtomically` under the name the contract asks for. */
49
+ runSequentially(statements: readonly string[]): Promise<void>;
50
+ /** All of them: SQLite has no DDL this design must keep out of a transaction. */
51
+ transactionalPhase(): boolean;
47
52
  introspect(_schema: string, tables: readonly string[]): Promise<LiveTable[]>;
48
53
  typeSignature(column: DeclaredColumn): string;
49
54
  /** An index is dropped and recreated, which SQLite does support. */
@@ -70,7 +75,59 @@ export declare class SqliteSchemaDriver implements SchemaDriver {
70
75
  addForeignKey(_schema: string, table: string, fk: DeclaredForeignKey): string[];
71
76
  /** Unreachable: `canReclaim` refuses a foreign key before the drop is planned. */
72
77
  dropForeignKey(_schema: string, table: string, name: string): string[];
78
+ /** SQLite has no `ADD CONSTRAINT`, so a check exists only as part of the table
79
+ * it was created with — the same place its foreign keys are. */
80
+ readonly checksInCreateTable = true;
81
+ /** Compared against the RECORDED declaration rather than live state, so this
82
+ * is declaration against declaration and the text is the whole comparison. */
83
+ checkDiffers(live: LiveCheck, declared: DeclaredCheck): boolean;
84
+ classifyCheckChange(_live: LiveCheck, declared: DeclaredCheck): ChangeSafety;
85
+ addCheck(_schema: string, table: string, check: DeclaredCheck): string[];
86
+ dropCheck(_schema: string, table: string, name: string): string[];
87
+ /** Unreachable: nothing on SQLite is ever added `NOT VALID`, so nothing is
88
+ * waiting to be proven. */
89
+ validateCheck(_schema: string, table: string, name: string): string[];
90
+ /**
91
+ * SQLite has no named types, so a domain has no database object behind it: the
92
+ * values are rendered as a `CHECK` on every column that references the enum.
93
+ *
94
+ * The DECLARATION is still a ledger object like any other, which is what lets a
95
+ * change to it be detected at all — see `classifyEnumChange`.
96
+ */
97
+ readonly namedEnumTypes = false;
98
+ /** Nothing to read back: there is no type to introspect. */
99
+ introspectEnums(): Promise<LiveEnum[]>;
100
+ /** Unreachable: the shared half asks only when `namedEnumTypes` holds. */
101
+ createEnum(_schema: string, declared: DeclaredEnum): string[];
102
+ addEnumValues(_schema: string, declared: DeclaredEnum): string[];
103
+ /** No statement at all — the `CHECK`s on referencing tables never named the
104
+ * type, so the ledger key rewrite IS the rename. */
105
+ renameEnum(): string[];
106
+ /** The one part of renaming where the two engines need no separate story:
107
+ * SQLite renames a table with the same statement PostgreSQL does. */
108
+ renameTable(schema: string, from: string, to: string): string[];
109
+ /**
110
+ * A domain reaches the database inside the tables that use it, so changing one
111
+ * would mean rebuilding every referencing table — which is where SQLite's
112
+ * foreign keys and column alterations already are.
113
+ *
114
+ * The comparison is against the RECORDED declaration rather than live state,
115
+ * because there is no live state: nothing in the database corresponds to the
116
+ * type. That is exactly what makes snapshotting the whole enum declaration
117
+ * load-bearing rather than decorative.
118
+ */
119
+ classifyEnumChange(_live: LiveEnum | undefined, declared: DeclaredEnum, owned: DeclaredEnum | undefined): ChangeSafety;
120
+ dropEnum(): string[];
121
+ /** SQLite has no installable extensions in the sense this declares — a
122
+ * loadable extension is a build-time or connection-time concern, not a schema
123
+ * object a pass can create. */
124
+ readonly namedExtensions = false;
125
+ introspectExtensions(): Promise<string[]>;
126
+ createExtension(_schema: string, name: string): string[];
127
+ dropExtension(_schema: string, name: string): string[];
73
128
  canReclaim(id: SchemaObjectId): ChangeSafety;
74
129
  dropColumn(schema: string, table: string, column: string): string[];
75
130
  dropTable(schema: string, table: string): string[];
131
+ upsertRow(schema: string, table: string, key: readonly string[], row: Record<string, unknown>): string[];
132
+ deleteRow(schema: string, table: string, key: readonly string[], row: Record<string, unknown>): string[];
76
133
  }
@@ -1,4 +1,4 @@
1
- import { quoteAnsiIdentifier, } from "@telorun/sql";
1
+ import { quoteAnsiIdentifier, deleteRowStatements, upsertRowStatements, } from "@telorun/sql";
2
2
  import { CompiledQuery } from "kysely";
3
3
  /**
4
4
  * SQLite's half of declarative schema.
@@ -14,14 +14,36 @@ import { CompiledQuery } from "kysely";
14
14
  * conventions over these five, and inventing names for them here would be the
15
15
  * lowest-common-denominator type vocabulary this design rejects. */
16
16
  export const SQLITE_TYPES = ["integer", "real", "text", "blob", "numeric"];
17
+ /**
18
+ * A declared value as SQLite literal text.
19
+ *
20
+ * Structured values are SERIALIZED, never stringified. `String({})` is
21
+ * `[object Object]` and `String([1,2])` is `1,2`, so a structured seed value —
22
+ * which the row projection admits wherever a column's mapped node is open —
23
+ * reached the database as that text. SQLite has no JSON type, only the
24
+ * convention of JSON in a `text` column, and the serialized form is exactly what
25
+ * that convention (and `json_extract`) reads.
26
+ *
27
+ * Bytes get SQLite's own blob literal: `!include-bytes` resolves to a
28
+ * `Uint8Array` before a controller sees it, and `String(...)` on one yields its
29
+ * elements comma-joined.
30
+ */
17
31
  function literal(value) {
18
- if (value === null)
32
+ if (value === null || value === undefined)
19
33
  return "NULL";
20
34
  if (typeof value === "number" || typeof value === "bigint")
21
35
  return String(value);
22
36
  if (typeof value === "boolean")
23
37
  return value ? "1" : "0";
24
- return `'${String(value).replace(/'/g, "''")}'`;
38
+ if (value instanceof Uint8Array)
39
+ return `X'${Buffer.from(value).toString("hex")}'`;
40
+ if (typeof value === "object")
41
+ return quoted(JSON.stringify(value));
42
+ return quoted(String(value));
43
+ }
44
+ /** Single quotes doubled — SQLite's only string escape. */
45
+ function quoted(text) {
46
+ return `'${text.replace(/'/g, "''")}'`;
25
47
  }
26
48
  function columnDefault(column) {
27
49
  if (column.defaultExpression !== undefined)
@@ -100,6 +122,15 @@ export class SqliteSchemaDriver {
100
122
  }
101
123
  });
102
124
  }
125
+ /** SQLite has no DDL this design must keep out of a transaction, so this is
126
+ * `runAtomically` under the name the contract asks for. */
127
+ async runSequentially(statements) {
128
+ await this.runAtomically(statements);
129
+ }
130
+ /** All of them: SQLite has no DDL this design must keep out of a transaction. */
131
+ transactionalPhase() {
132
+ return true;
133
+ }
103
134
  async introspect(_schema, tables) {
104
135
  const live = [];
105
136
  for (const table of tables) {
@@ -161,7 +192,11 @@ export class SqliteSchemaDriver {
161
192
  onUpdate: first.on_update == null ? undefined : String(first.on_update),
162
193
  });
163
194
  }
164
- live.push({ name: table, columns, indexes, foreignKeys });
195
+ // Checks are deliberately NOT read back: SQLite keeps them only inside the
196
+ // table's stored DDL text, and parsing that would be a SQL parser in a
197
+ // schema driver. The shared half compares this engine's checks against the
198
+ // RECORDED declaration instead — see `checksInCreateTable`.
199
+ live.push({ name: table, columns, indexes, foreignKeys, checks: [] });
165
200
  }
166
201
  return live;
167
202
  }
@@ -243,6 +278,14 @@ export class SqliteSchemaDriver {
243
278
  parts.push("NOT NULL");
244
279
  if (column.unique)
245
280
  parts.push("UNIQUE");
281
+ // SQLite has no named types, so a domain reaches the database only as a
282
+ // constraint on the column that uses it. The values sort as text rather than
283
+ // in declaration order — the one thing the rendering cannot reproduce, and
284
+ // why the enum's `values:` order is documented as PostgreSQL's alone.
285
+ if (column.enum) {
286
+ const values = column.enum.values.map((value) => literal(value)).join(", ");
287
+ parts.push(`CHECK (${this.quote(column.name)} IN (${values}))`);
288
+ }
246
289
  const def = columnDefault(column);
247
290
  return parts.join(" ") + def;
248
291
  }
@@ -255,10 +298,14 @@ export class SqliteSchemaDriver {
255
298
  createTable(schema, table) {
256
299
  const parts = table.columns.map((column) => this.#columnDefinition(column));
257
300
  // Foreign keys are part of the table in SQLite — there is no ADD CONSTRAINT
258
- // — so they are emitted here and nowhere else.
301
+ // — so they are emitted here and nowhere else. Named checks land in exactly
302
+ // the same place, for exactly the same reason.
259
303
  for (const fk of table.foreignKeys) {
260
304
  parts.push(this.#foreignKeyClause(fk));
261
305
  }
306
+ for (const check of table.checks) {
307
+ parts.push(`CONSTRAINT ${this.quote(check.name)} CHECK (${check.expression})`);
308
+ }
262
309
  return [
263
310
  `CREATE TABLE IF NOT EXISTS ${this.qualify(schema, table.name)} (\n ${parts.join(",\n ")}\n)`,
264
311
  ];
@@ -316,13 +363,116 @@ export class SqliteSchemaDriver {
316
363
  throw new Error(`SQLite.Table: foreign key '${name}' cannot be dropped from '${table}' — SQLite has no ` +
317
364
  `DROP CONSTRAINT. Rebuild the table in a 'migrations:' entry.`);
318
365
  }
366
+ /** SQLite has no `ADD CONSTRAINT`, so a check exists only as part of the table
367
+ * it was created with — the same place its foreign keys are. */
368
+ checksInCreateTable = true;
369
+ /** Compared against the RECORDED declaration rather than live state, so this
370
+ * is declaration against declaration and the text is the whole comparison. */
371
+ checkDiffers(live, declared) {
372
+ return live.expression !== declared.expression;
373
+ }
374
+ classifyCheckChange(_live, declared) {
375
+ return {
376
+ safe: false,
377
+ reason: `SQLite has no ALTER for a constraint — a check exists only as part of the table it was ` +
378
+ `created with — so '${declared.name}' cannot be changed in place. Rebuild the table in a ` +
379
+ `'migrations:' entry.`,
380
+ };
381
+ }
382
+ addCheck(_schema, table, check) {
383
+ throw new Error(`SQLite.Table: check '${check.name}' cannot be added to the existing table '${table}' — ` +
384
+ `SQLite has no ADD CONSTRAINT and a check exists only as part of the table it was ` +
385
+ `created with. Rebuild the table in a 'migrations:' entry.`);
386
+ }
387
+ dropCheck(_schema, table, name) {
388
+ throw new Error(`SQLite.Table: check '${name}' cannot be dropped from '${table}' — SQLite has no DROP ` +
389
+ `CONSTRAINT. Rebuild the table in a 'migrations:' entry.`);
390
+ }
391
+ /** Unreachable: nothing on SQLite is ever added `NOT VALID`, so nothing is
392
+ * waiting to be proven. */
393
+ validateCheck(_schema, table, name) {
394
+ throw new Error(`SQLite.Table: check '${table}.${name}' has no deferred validation to run.`);
395
+ }
396
+ /**
397
+ * SQLite has no named types, so a domain has no database object behind it: the
398
+ * values are rendered as a `CHECK` on every column that references the enum.
399
+ *
400
+ * The DECLARATION is still a ledger object like any other, which is what lets a
401
+ * change to it be detected at all — see `classifyEnumChange`.
402
+ */
403
+ namedEnumTypes = false;
404
+ /** Nothing to read back: there is no type to introspect. */
405
+ async introspectEnums() {
406
+ return [];
407
+ }
408
+ /** Unreachable: the shared half asks only when `namedEnumTypes` holds. */
409
+ createEnum(_schema, declared) {
410
+ throw new Error(`SQLite.Enum '${declared.typeName}': SQLite has no named types, so nothing creates one.`);
411
+ }
412
+ addEnumValues(_schema, declared) {
413
+ throw new Error(`SQLite.Enum '${declared.typeName}': SQLite has no named types, so nothing alters one.`);
414
+ }
415
+ /** No statement at all — the `CHECK`s on referencing tables never named the
416
+ * type, so the ledger key rewrite IS the rename. */
417
+ renameEnum() {
418
+ return [];
419
+ }
420
+ /** The one part of renaming where the two engines need no separate story:
421
+ * SQLite renames a table with the same statement PostgreSQL does. */
422
+ renameTable(schema, from, to) {
423
+ return [`ALTER TABLE ${this.qualify(schema, from)} RENAME TO ${this.quote(to)}`];
424
+ }
425
+ /**
426
+ * A domain reaches the database inside the tables that use it, so changing one
427
+ * would mean rebuilding every referencing table — which is where SQLite's
428
+ * foreign keys and column alterations already are.
429
+ *
430
+ * The comparison is against the RECORDED declaration rather than live state,
431
+ * because there is no live state: nothing in the database corresponds to the
432
+ * type. That is exactly what makes snapshotting the whole enum declaration
433
+ * load-bearing rather than decorative.
434
+ */
435
+ classifyEnumChange(_live, declared, owned) {
436
+ if (!owned)
437
+ return { safe: true };
438
+ const same = owned.baseType === declared.baseType &&
439
+ owned.values.length === declared.values.length &&
440
+ owned.values.every((value, i) => value === declared.values[i]);
441
+ if (same)
442
+ return { safe: true };
443
+ return {
444
+ safe: false,
445
+ reason: `SQLite has no named types, so '${declared.typeName}' is rendered as a CHECK on every ` +
446
+ `column that references it — and SQLite has no ALTER for a constraint. Changing the ` +
447
+ `declaration would leave every existing table enforcing the old values. Rebuild the ` +
448
+ `referencing tables in a 'migrations:' entry, which is where a changed constraint on ` +
449
+ `this engine belongs.`,
450
+ };
451
+ }
452
+ dropEnum() {
453
+ return [];
454
+ }
455
+ /** SQLite has no installable extensions in the sense this declares — a
456
+ * loadable extension is a build-time or connection-time concern, not a schema
457
+ * object a pass can create. */
458
+ namedExtensions = false;
459
+ async introspectExtensions() {
460
+ return [];
461
+ }
462
+ createExtension(_schema, name) {
463
+ throw new Error(`SQLite.Schema: extension '${name}' cannot be created — SQLite has no installable ` +
464
+ `extensions a schema pass can provision.`);
465
+ }
466
+ dropExtension(_schema, name) {
467
+ throw new Error(`SQLite.Schema: extension '${name}' cannot be dropped.`);
468
+ }
319
469
  canReclaim(id) {
320
- if (id.kind === "foreignKey") {
470
+ if (id.kind === "foreignKey" || id.kind === "check") {
321
471
  return {
322
472
  safe: false,
323
- reason: "SQLite has no DROP CONSTRAINT, so this foreign key cannot be dropped in place. " +
324
- "Rebuild the table in a 'migrations:' entry; the tombstone is cleared when the " +
325
- "constraint is gone.",
473
+ reason: `SQLite has no DROP CONSTRAINT, so this ${id.kind === "check" ? "check" : "foreign key"} ` +
474
+ "cannot be dropped in place. Rebuild the table in a 'migrations:' entry; the tombstone " +
475
+ "is cleared when the constraint is gone.",
326
476
  };
327
477
  }
328
478
  return { safe: true };
@@ -333,4 +483,21 @@ export class SqliteSchemaDriver {
333
483
  dropTable(schema, table) {
334
484
  return [`DROP TABLE IF EXISTS ${this.qualify(schema, table)}`];
335
485
  }
486
+ /** SQLite has had upsert since 3.24, so both engines render the same statement
487
+ * SHAPE — which is why it lives in `row-statements.ts` and only the dialect
488
+ * is answered here. */
489
+ get #rowDialect() {
490
+ return {
491
+ quote: (name) => this.quote(name),
492
+ literal,
493
+ qualify: (schema, table) => this.qualify(schema, table),
494
+ excludedAlias: "excluded",
495
+ };
496
+ }
497
+ upsertRow(schema, table, key, row) {
498
+ return upsertRowStatements(this.#rowDialect, schema, table, key, row);
499
+ }
500
+ deleteRow(schema, table, key, row) {
501
+ return deleteRowStatements(this.#rowDialect, schema, table, key, row);
502
+ }
336
503
  }
@@ -1,4 +1,4 @@
1
- import { normalizeTable, tableReferenceResolver, } from "@telorun/sql";
1
+ import { enumReferenceResolver, normalizeTable, tableReferenceResolver, } from "@telorun/sql";
2
2
  /**
3
3
  * `SQLite.Table` — one physical table, declared rather than migrated to.
4
4
  *
@@ -9,7 +9,7 @@ import { normalizeTable, tableReferenceResolver, } from "@telorun/sql";
9
9
  export class SqliteTableResource {
10
10
  declaration;
11
11
  constructor(raw, ctx) {
12
- this.declaration = normalizeTable(raw, tableReferenceResolver(ctx, "SQLite.Table", raw.table));
12
+ this.declaration = normalizeTable(raw, tableReferenceResolver(ctx, "SQLite.Table", raw.table), sqliteColumnType(ctx, raw.table));
13
13
  }
14
14
  /** The physical table name, read by consumers that build statements against
15
15
  * it (`self.table.table` in a repository's template). */
@@ -20,6 +20,25 @@ export class SqliteTableResource {
20
20
  return { table: this.declaration.name };
21
21
  }
22
22
  }
23
+ /** SQLite has no named types, so an enum column's engine-native type is the
24
+ * enum's declared base storage class; the values ride along as a CHECK. */
25
+ function sqliteColumnType(ctx, table) {
26
+ const resolveEnum = enumReferenceResolver(ctx, "SQLite.Enum", table);
27
+ return (value, column) => {
28
+ const declared = resolveEnum(value, column);
29
+ if (!declared)
30
+ return { type: String(value) };
31
+ if (!declared.baseType) {
32
+ throw new Error(`SQLite.Table '${table}': column '${column}' references enum '${declared.typeName}', ` +
33
+ `which declares no 'baseType'. SQLite has no named types, so something has to say ` +
34
+ `which storage class the values sit in.`);
35
+ }
36
+ return {
37
+ type: declared.baseType,
38
+ enum: { typeName: declared.typeName, values: declared.values },
39
+ };
40
+ };
41
+ }
23
42
  export function register() { }
24
43
  export async function create(resource, ctx) {
25
44
  return new SqliteTableResource(resource, ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/sqlite",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Telo SQLite.Connection — SQLite backend for the Sql.Connection abstract (better-sqlite3 / bun:sqlite, transactional DDL).",
5
5
  "keywords": [
6
6
  "telo",
@@ -35,6 +35,10 @@
35
35
  "./table": {
36
36
  "bun": "./src/schema/table-controller.ts",
37
37
  "import": "./dist/schema/table-controller.js"
38
+ },
39
+ "./enum": {
40
+ "bun": "./src/schema/enum-controller.ts",
41
+ "import": "./dist/schema/enum-controller.js"
38
42
  }
39
43
  },
40
44
  "files": [
@@ -44,14 +48,14 @@
44
48
  "dependencies": {
45
49
  "better-sqlite3": "^12.8.0",
46
50
  "kysely": "^0.28.15",
47
- "@telorun/sql": "0.23.0"
51
+ "@telorun/sql": "0.24.0"
48
52
  },
49
53
  "devDependencies": {
50
54
  "@types/better-sqlite3": "^7.0.0",
51
55
  "@types/bun": "^1.3.10",
52
56
  "@types/node": "^20.0.0",
53
57
  "typescript": "^5.0.0",
54
- "@telorun/sdk": "0.82.0"
58
+ "@telorun/sdk": "0.82.1"
55
59
  },
56
60
  "peerDependencies": {
57
61
  "@telorun/sdk": "*"
@@ -0,0 +1,35 @@
1
+ import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
2
+ import { normalizeEnum, type DeclaredEnum, type RawEnum } from "@telorun/sql";
3
+
4
+ /**
5
+ * `SQLite.Enum` — one declared set of permitted values.
6
+ *
7
+ * SQLite has no named types, so nothing in the database corresponds to this
8
+ * resource: the values are rendered as a `CHECK` on every column that references
9
+ * it. The DECLARATION is still a ledger object like any other, which is what
10
+ * lets a change to it be detected at all.
11
+ */
12
+ export class SqliteEnumResource implements ResourceInstance {
13
+ readonly declaration: DeclaredEnum;
14
+
15
+ constructor(raw: RawEnum) {
16
+ this.declaration = normalizeEnum(raw);
17
+ }
18
+
19
+ get typeName(): string {
20
+ return this.declaration.typeName;
21
+ }
22
+
23
+ snapshot(): Record<string, unknown> {
24
+ return { typeName: this.declaration.typeName, values: [...this.declaration.values] };
25
+ }
26
+ }
27
+
28
+ export function register(): void {}
29
+
30
+ export async function create(
31
+ resource: RawEnum,
32
+ _ctx: ResourceContext,
33
+ ): Promise<SqliteEnumResource> {
34
+ return new SqliteEnumResource(resource);
35
+ }
@@ -7,6 +7,7 @@ import {
7
7
  type SqlConnection,
8
8
  } from "@telorun/sql";
9
9
  import { SqliteSchemaDriver } from "./sqlite-schema-driver.js";
10
+ import type { SqliteEnumResource } from "./enum-controller.js";
10
11
  import type { SqliteTableResource } from "./table-controller.js";
11
12
 
12
13
  interface SqliteSchemaManifest {
@@ -15,7 +16,8 @@ interface SqliteSchemaManifest {
15
16
  version?: string;
16
17
  ledger?: string;
17
18
  tables?: SqliteTableResource[];
18
- beforeMigrations?: MigrationMap;
19
+ enums?: SqliteEnumResource[];
20
+ prepare?: MigrationMap;
19
21
  migrations?: MigrationMap;
20
22
  reclaim?: ReclaimPolicy;
21
23
  }
@@ -56,7 +58,8 @@ class SqliteSchemaResource implements ResourceInstance {
56
58
  ledger: this.manifest.ledger,
57
59
  version: this.manifest.version,
58
60
  tables: (this.manifest.tables ?? []).map((table) => table.declaration),
59
- beforeMigrations: this.manifest.beforeMigrations ?? {},
61
+ enums: (this.manifest.enums ?? []).map((declared) => declared.declaration),
62
+ prepare: this.manifest.prepare ?? {},
60
63
  migrations: this.manifest.migrations ?? {},
61
64
  reclaim: this.manifest.reclaim,
62
65
  });
@@ -1,11 +1,15 @@
1
1
  import {
2
2
  quoteAnsiIdentifier,
3
3
  type ChangeSafety,
4
+ type DeclaredCheck,
4
5
  type DeclaredColumn,
6
+ type DeclaredEnum,
5
7
  type DeclaredForeignKey,
6
8
  type DeclaredIndex,
7
9
  type DeclaredTable,
10
+ type LiveCheck,
8
11
  type LiveColumn,
12
+ type LiveEnum,
9
13
  type SchemaObjectId,
10
14
  type LiveTable,
11
15
  type LedgerTables,
@@ -13,6 +17,9 @@ import {
13
17
  type LiveIndex,
14
18
  type SchemaDriver,
15
19
  type SqlConnection,
20
+ deleteRowStatements,
21
+ upsertRowStatements,
22
+ type RowDialect,
16
23
  } from "@telorun/sql";
17
24
  import { CompiledQuery, type Kysely } from "kysely";
18
25
 
@@ -33,11 +40,32 @@ import { CompiledQuery, type Kysely } from "kysely";
33
40
  export const SQLITE_TYPES = ["integer", "real", "text", "blob", "numeric"] as const;
34
41
  export type SqliteType = (typeof SQLITE_TYPES)[number];
35
42
 
43
+ /**
44
+ * A declared value as SQLite literal text.
45
+ *
46
+ * Structured values are SERIALIZED, never stringified. `String({})` is
47
+ * `[object Object]` and `String([1,2])` is `1,2`, so a structured seed value —
48
+ * which the row projection admits wherever a column's mapped node is open —
49
+ * reached the database as that text. SQLite has no JSON type, only the
50
+ * convention of JSON in a `text` column, and the serialized form is exactly what
51
+ * that convention (and `json_extract`) reads.
52
+ *
53
+ * Bytes get SQLite's own blob literal: `!include-bytes` resolves to a
54
+ * `Uint8Array` before a controller sees it, and `String(...)` on one yields its
55
+ * elements comma-joined.
56
+ */
36
57
  function literal(value: unknown): string {
37
- if (value === null) return "NULL";
58
+ if (value === null || value === undefined) return "NULL";
38
59
  if (typeof value === "number" || typeof value === "bigint") return String(value);
39
60
  if (typeof value === "boolean") return value ? "1" : "0";
40
- return `'${String(value).replace(/'/g, "''")}'`;
61
+ if (value instanceof Uint8Array) return `X'${Buffer.from(value).toString("hex")}'`;
62
+ if (typeof value === "object") return quoted(JSON.stringify(value));
63
+ return quoted(String(value));
64
+ }
65
+
66
+ /** Single quotes doubled — SQLite's only string escape. */
67
+ function quoted(text: string): string {
68
+ return `'${text.replace(/'/g, "''")}'`;
41
69
  }
42
70
 
43
71
  function columnDefault(column: DeclaredColumn): string {
@@ -125,6 +153,17 @@ export class SqliteSchemaDriver implements SchemaDriver {
125
153
  });
126
154
  }
127
155
 
156
+ /** SQLite has no DDL this design must keep out of a transaction, so this is
157
+ * `runAtomically` under the name the contract asks for. */
158
+ async runSequentially(statements: readonly string[]): Promise<void> {
159
+ await this.runAtomically(statements);
160
+ }
161
+
162
+ /** All of them: SQLite has no DDL this design must keep out of a transaction. */
163
+ transactionalPhase(): boolean {
164
+ return true;
165
+ }
166
+
128
167
  async introspect(_schema: string, tables: readonly string[]): Promise<LiveTable[]> {
129
168
  const live: LiveTable[] = [];
130
169
  for (const table of tables) {
@@ -195,7 +234,11 @@ export class SqliteSchemaDriver implements SchemaDriver {
195
234
  });
196
235
  }
197
236
 
198
- live.push({ name: table, columns, indexes, foreignKeys });
237
+ // Checks are deliberately NOT read back: SQLite keeps them only inside the
238
+ // table's stored DDL text, and parsing that would be a SQL parser in a
239
+ // schema driver. The shared half compares this engine's checks against the
240
+ // RECORDED declaration instead — see `checksInCreateTable`.
241
+ live.push({ name: table, columns, indexes, foreignKeys, checks: [] });
199
242
  }
200
243
  return live;
201
244
  }
@@ -288,6 +331,14 @@ export class SqliteSchemaDriver implements SchemaDriver {
288
331
  if (column.identity) parts.push("AUTOINCREMENT");
289
332
  if (!column.nullable) parts.push("NOT NULL");
290
333
  if (column.unique) parts.push("UNIQUE");
334
+ // SQLite has no named types, so a domain reaches the database only as a
335
+ // constraint on the column that uses it. The values sort as text rather than
336
+ // in declaration order — the one thing the rendering cannot reproduce, and
337
+ // why the enum's `values:` order is documented as PostgreSQL's alone.
338
+ if (column.enum) {
339
+ const values = column.enum.values.map((value) => literal(value)).join(", ");
340
+ parts.push(`CHECK (${this.quote(column.name)} IN (${values}))`);
341
+ }
291
342
  const def = columnDefault(column);
292
343
  return parts.join(" ") + def;
293
344
  }
@@ -303,10 +354,14 @@ export class SqliteSchemaDriver implements SchemaDriver {
303
354
  createTable(schema: string, table: DeclaredTable): string[] {
304
355
  const parts = table.columns.map((column) => this.#columnDefinition(column));
305
356
  // Foreign keys are part of the table in SQLite — there is no ADD CONSTRAINT
306
- // — so they are emitted here and nowhere else.
357
+ // — so they are emitted here and nowhere else. Named checks land in exactly
358
+ // the same place, for exactly the same reason.
307
359
  for (const fk of table.foreignKeys) {
308
360
  parts.push(this.#foreignKeyClause(fk));
309
361
  }
362
+ for (const check of table.checks) {
363
+ parts.push(`CONSTRAINT ${this.quote(check.name)} CHECK (${check.expression})`);
364
+ }
310
365
  return [
311
366
  `CREATE TABLE IF NOT EXISTS ${this.qualify(schema, table.name)} (\n ${parts.join(",\n ")}\n)`,
312
367
  ];
@@ -377,14 +432,150 @@ export class SqliteSchemaDriver implements SchemaDriver {
377
432
  );
378
433
  }
379
434
 
435
+ /** SQLite has no `ADD CONSTRAINT`, so a check exists only as part of the table
436
+ * it was created with — the same place its foreign keys are. */
437
+ readonly checksInCreateTable = true;
438
+
439
+ /** Compared against the RECORDED declaration rather than live state, so this
440
+ * is declaration against declaration and the text is the whole comparison. */
441
+ checkDiffers(live: LiveCheck, declared: DeclaredCheck): boolean {
442
+ return live.expression !== declared.expression;
443
+ }
444
+
445
+ classifyCheckChange(_live: LiveCheck, declared: DeclaredCheck): ChangeSafety {
446
+ return {
447
+ safe: false,
448
+ reason:
449
+ `SQLite has no ALTER for a constraint — a check exists only as part of the table it was ` +
450
+ `created with — so '${declared.name}' cannot be changed in place. Rebuild the table in a ` +
451
+ `'migrations:' entry.`,
452
+ };
453
+ }
454
+
455
+ addCheck(_schema: string, table: string, check: DeclaredCheck): string[] {
456
+ throw new Error(
457
+ `SQLite.Table: check '${check.name}' cannot be added to the existing table '${table}' — ` +
458
+ `SQLite has no ADD CONSTRAINT and a check exists only as part of the table it was ` +
459
+ `created with. Rebuild the table in a 'migrations:' entry.`,
460
+ );
461
+ }
462
+
463
+ dropCheck(_schema: string, table: string, name: string): string[] {
464
+ throw new Error(
465
+ `SQLite.Table: check '${name}' cannot be dropped from '${table}' — SQLite has no DROP ` +
466
+ `CONSTRAINT. Rebuild the table in a 'migrations:' entry.`,
467
+ );
468
+ }
469
+
470
+ /** Unreachable: nothing on SQLite is ever added `NOT VALID`, so nothing is
471
+ * waiting to be proven. */
472
+ validateCheck(_schema: string, table: string, name: string): string[] {
473
+ throw new Error(`SQLite.Table: check '${table}.${name}' has no deferred validation to run.`);
474
+ }
475
+
476
+ /**
477
+ * SQLite has no named types, so a domain has no database object behind it: the
478
+ * values are rendered as a `CHECK` on every column that references the enum.
479
+ *
480
+ * The DECLARATION is still a ledger object like any other, which is what lets a
481
+ * change to it be detected at all — see `classifyEnumChange`.
482
+ */
483
+ readonly namedEnumTypes = false;
484
+
485
+ /** Nothing to read back: there is no type to introspect. */
486
+ async introspectEnums(): Promise<LiveEnum[]> {
487
+ return [];
488
+ }
489
+
490
+ /** Unreachable: the shared half asks only when `namedEnumTypes` holds. */
491
+ createEnum(_schema: string, declared: DeclaredEnum): string[] {
492
+ throw new Error(
493
+ `SQLite.Enum '${declared.typeName}': SQLite has no named types, so nothing creates one.`,
494
+ );
495
+ }
496
+
497
+ addEnumValues(_schema: string, declared: DeclaredEnum): string[] {
498
+ throw new Error(
499
+ `SQLite.Enum '${declared.typeName}': SQLite has no named types, so nothing alters one.`,
500
+ );
501
+ }
502
+
503
+ /** No statement at all — the `CHECK`s on referencing tables never named the
504
+ * type, so the ledger key rewrite IS the rename. */
505
+ renameEnum(): string[] {
506
+ return [];
507
+ }
508
+
509
+ /** The one part of renaming where the two engines need no separate story:
510
+ * SQLite renames a table with the same statement PostgreSQL does. */
511
+ renameTable(schema: string, from: string, to: string): string[] {
512
+ return [`ALTER TABLE ${this.qualify(schema, from)} RENAME TO ${this.quote(to)}`];
513
+ }
514
+
515
+ /**
516
+ * A domain reaches the database inside the tables that use it, so changing one
517
+ * would mean rebuilding every referencing table — which is where SQLite's
518
+ * foreign keys and column alterations already are.
519
+ *
520
+ * The comparison is against the RECORDED declaration rather than live state,
521
+ * because there is no live state: nothing in the database corresponds to the
522
+ * type. That is exactly what makes snapshotting the whole enum declaration
523
+ * load-bearing rather than decorative.
524
+ */
525
+ classifyEnumChange(
526
+ _live: LiveEnum | undefined,
527
+ declared: DeclaredEnum,
528
+ owned: DeclaredEnum | undefined,
529
+ ): ChangeSafety {
530
+ if (!owned) return { safe: true };
531
+ const same =
532
+ owned.baseType === declared.baseType &&
533
+ owned.values.length === declared.values.length &&
534
+ owned.values.every((value, i) => value === declared.values[i]);
535
+ if (same) return { safe: true };
536
+ return {
537
+ safe: false,
538
+ reason:
539
+ `SQLite has no named types, so '${declared.typeName}' is rendered as a CHECK on every ` +
540
+ `column that references it — and SQLite has no ALTER for a constraint. Changing the ` +
541
+ `declaration would leave every existing table enforcing the old values. Rebuild the ` +
542
+ `referencing tables in a 'migrations:' entry, which is where a changed constraint on ` +
543
+ `this engine belongs.`,
544
+ };
545
+ }
546
+
547
+ dropEnum(): string[] {
548
+ return [];
549
+ }
550
+
551
+ /** SQLite has no installable extensions in the sense this declares — a
552
+ * loadable extension is a build-time or connection-time concern, not a schema
553
+ * object a pass can create. */
554
+ readonly namedExtensions = false;
555
+
556
+ async introspectExtensions(): Promise<string[]> {
557
+ return [];
558
+ }
559
+
560
+ createExtension(_schema: string, name: string): string[] {
561
+ throw new Error(
562
+ `SQLite.Schema: extension '${name}' cannot be created — SQLite has no installable ` +
563
+ `extensions a schema pass can provision.`,
564
+ );
565
+ }
566
+
567
+ dropExtension(_schema: string, name: string): string[] {
568
+ throw new Error(`SQLite.Schema: extension '${name}' cannot be dropped.`);
569
+ }
570
+
380
571
  canReclaim(id: SchemaObjectId): ChangeSafety {
381
- if (id.kind === "foreignKey") {
572
+ if (id.kind === "foreignKey" || id.kind === "check") {
382
573
  return {
383
574
  safe: false,
384
575
  reason:
385
- "SQLite has no DROP CONSTRAINT, so this foreign key cannot be dropped in place. " +
386
- "Rebuild the table in a 'migrations:' entry; the tombstone is cleared when the " +
387
- "constraint is gone.",
576
+ `SQLite has no DROP CONSTRAINT, so this ${id.kind === "check" ? "check" : "foreign key"} ` +
577
+ "cannot be dropped in place. Rebuild the table in a 'migrations:' entry; the tombstone " +
578
+ "is cleared when the constraint is gone.",
388
579
  };
389
580
  }
390
581
  return { safe: true };
@@ -397,4 +588,34 @@ export class SqliteSchemaDriver implements SchemaDriver {
397
588
  dropTable(schema: string, table: string): string[] {
398
589
  return [`DROP TABLE IF EXISTS ${this.qualify(schema, table)}`];
399
590
  }
591
+
592
+ /** SQLite has had upsert since 3.24, so both engines render the same statement
593
+ * SHAPE — which is why it lives in `row-statements.ts` and only the dialect
594
+ * is answered here. */
595
+ get #rowDialect(): RowDialect {
596
+ return {
597
+ quote: (name) => this.quote(name),
598
+ literal,
599
+ qualify: (schema, table) => this.qualify(schema, table),
600
+ excludedAlias: "excluded",
601
+ };
602
+ }
603
+
604
+ upsertRow(
605
+ schema: string,
606
+ table: string,
607
+ key: readonly string[],
608
+ row: Record<string, unknown>,
609
+ ): string[] {
610
+ return upsertRowStatements(this.#rowDialect, schema, table, key, row);
611
+ }
612
+
613
+ deleteRow(
614
+ schema: string,
615
+ table: string,
616
+ key: readonly string[],
617
+ row: Record<string, unknown>,
618
+ ): string[] {
619
+ return deleteRowStatements(this.#rowDialect, schema, table, key, row);
620
+ }
400
621
  }
@@ -1,7 +1,9 @@
1
1
  import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
2
2
  import {
3
+ enumReferenceResolver,
3
4
  normalizeTable,
4
5
  tableReferenceResolver,
6
+ type ColumnTypeResolver,
5
7
  type DeclaredTable,
6
8
  type RawTable,
7
9
  } from "@telorun/sql";
@@ -17,7 +19,11 @@ export class SqliteTableResource implements ResourceInstance {
17
19
  readonly declaration: DeclaredTable;
18
20
 
19
21
  constructor(raw: RawTable, ctx: ResourceContext) {
20
- this.declaration = normalizeTable(raw, tableReferenceResolver(ctx, "SQLite.Table", raw.table));
22
+ this.declaration = normalizeTable(
23
+ raw,
24
+ tableReferenceResolver(ctx, "SQLite.Table", raw.table),
25
+ sqliteColumnType(ctx, raw.table),
26
+ );
21
27
  }
22
28
 
23
29
  /** The physical table name, read by consumers that build statements against
@@ -31,6 +37,27 @@ export class SqliteTableResource implements ResourceInstance {
31
37
  }
32
38
  }
33
39
 
40
+ /** SQLite has no named types, so an enum column's engine-native type is the
41
+ * enum's declared base storage class; the values ride along as a CHECK. */
42
+ function sqliteColumnType(ctx: ResourceContext, table: string): ColumnTypeResolver {
43
+ const resolveEnum = enumReferenceResolver(ctx, "SQLite.Enum", table);
44
+ return (value, column) => {
45
+ const declared = resolveEnum(value, column);
46
+ if (!declared) return { type: String(value) };
47
+ if (!declared.baseType) {
48
+ throw new Error(
49
+ `SQLite.Table '${table}': column '${column}' references enum '${declared.typeName}', ` +
50
+ `which declares no 'baseType'. SQLite has no named types, so something has to say ` +
51
+ `which storage class the values sit in.`,
52
+ );
53
+ }
54
+ return {
55
+ type: declared.baseType,
56
+ enum: { typeName: declared.typeName, values: declared.values },
57
+ };
58
+ };
59
+ }
60
+
34
61
  export function register(): void {}
35
62
 
36
63
  export async function create(