@telorun/sql 0.21.3 → 0.22.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.
Files changed (42) hide show
  1. package/dist/index.d.ts +17 -2
  2. package/dist/index.js +8 -2
  3. package/dist/schema/declaration-snapshot.d.ts +21 -0
  4. package/dist/schema/declaration-snapshot.js +46 -0
  5. package/dist/schema/declared-schema.d.ts +64 -0
  6. package/dist/schema/declared-schema.js +16 -0
  7. package/dist/schema/migration-runner.d.ts +30 -0
  8. package/dist/schema/migration-runner.js +38 -0
  9. package/dist/schema/normalize-table.d.ts +46 -0
  10. package/dist/schema/normalize-table.js +135 -0
  11. package/dist/schema/reclaim-policy.d.ts +34 -0
  12. package/dist/schema/reclaim-policy.js +40 -0
  13. package/dist/schema/schema-driver.d.ts +161 -0
  14. package/dist/schema/schema-driver.js +1 -0
  15. package/dist/schema/schema-ledger.d.ts +119 -0
  16. package/dist/schema/schema-ledger.js +231 -0
  17. package/dist/schema/schema-reconciler.d.ts +45 -0
  18. package/dist/schema/schema-reconciler.js +243 -0
  19. package/dist/schema/schema-run.d.ts +50 -0
  20. package/dist/schema/schema-run.js +318 -0
  21. package/dist/sql-connection-base.d.ts +21 -0
  22. package/dist/sql-connection-base.js +30 -4
  23. package/dist/sql-connection.d.ts +19 -0
  24. package/package.json +5 -3
  25. package/src/index.ts +36 -2
  26. package/src/schema/declaration-snapshot.ts +71 -0
  27. package/src/schema/declared-schema.ts +73 -0
  28. package/src/schema/migration-runner.ts +69 -0
  29. package/src/schema/normalize-table.ts +218 -0
  30. package/src/schema/reclaim-policy.ts +73 -0
  31. package/src/schema/schema-driver.ts +182 -0
  32. package/src/schema/schema-ledger.ts +309 -0
  33. package/src/schema/schema-reconciler.ts +339 -0
  34. package/src/schema/schema-run.ts +441 -0
  35. package/src/sql-connection-base.ts +35 -4
  36. package/src/sql-connection.ts +21 -0
  37. package/dist/sql-migration-controller.d.ts +0 -16
  38. package/dist/sql-migration-controller.js +0 -13
  39. package/dist/sql-migrations-controller.d.ts +0 -23
  40. package/dist/sql-migrations-controller.js +0 -98
  41. package/src/sql-migration-controller.ts +0 -20
  42. package/src/sql-migrations-controller.ts +0 -143
@@ -0,0 +1,182 @@
1
+ import type { SqlConnection } from "../sql-connection.js";
2
+ import type { LedgerTables } from "./schema-ledger.js";
3
+ import type {
4
+ SchemaObjectId,
5
+ DeclaredColumn,
6
+ DeclaredForeignKey,
7
+ DeclaredIndex,
8
+ DeclaredTable,
9
+ } from "./declared-schema.js";
10
+
11
+ /** The live shape of one table, as the driver reads it back from the database. */
12
+ export interface LiveColumn {
13
+ readonly name: string;
14
+ /**
15
+ * The driver's canonical rendering of the column's type, compared verbatim
16
+ * against what {@link SchemaDriver.typeSignature} produces for a declaration.
17
+ * Comparing signatures rather than parsed parts is what keeps every type rule
18
+ * inside the driver.
19
+ */
20
+ readonly typeSignature: string;
21
+ readonly nullable: boolean;
22
+ readonly hasDefault: boolean;
23
+ /** Whether the column is part of the table's primary key. */
24
+ readonly primaryKey: boolean;
25
+ /** Whether a single-column uniqueness constraint covers it. */
26
+ readonly unique: boolean;
27
+ }
28
+
29
+ /**
30
+ * An index as the database has it, not merely its name.
31
+ *
32
+ * Compared by name alone, changing which columns an index covers — or making it
33
+ * unique — emitted nothing and reported nothing, while the ledger recorded the
34
+ * new definition as owned. The declaration then asserted an index the database
35
+ * was not providing.
36
+ */
37
+ export interface LiveIndex {
38
+ readonly name: string;
39
+ readonly columns: readonly string[];
40
+ readonly unique: boolean;
41
+ }
42
+
43
+ /** A foreign key as the database has it. Same reasoning as {@link LiveIndex}:
44
+ * changing `onDelete` is a change to what the constraint DOES. */
45
+ export interface LiveForeignKey {
46
+ readonly name: string;
47
+ readonly columns: readonly string[];
48
+ readonly references: { readonly table: string; readonly columns: readonly string[] };
49
+ readonly onDelete?: string;
50
+ readonly onUpdate?: string;
51
+ }
52
+
53
+ export interface LiveTable {
54
+ readonly name: string;
55
+ readonly columns: readonly LiveColumn[];
56
+ readonly indexes: readonly LiveIndex[];
57
+ readonly foreignKeys: readonly LiveForeignKey[];
58
+ }
59
+
60
+ /** How a declared change relates to the data already in the column. */
61
+ export type ChangeSafety =
62
+ | { readonly safe: true }
63
+ | { readonly safe: false; readonly reason: string };
64
+
65
+ /**
66
+ * Everything reconciliation needs that only the engine knows: how a type is
67
+ * spelled, how it is read back, what a lock is, and which alterations the
68
+ * engine can perform at all. The diff, the ledger, the tombstones and the
69
+ * ordering live in the shared library and are identical for every backend.
70
+ *
71
+ * Statement renderers return SQL text; the runner executes and logs it. They
72
+ * never touch the connection themselves, so a plan can be built, reported and
73
+ * refused without anything having run.
74
+ */
75
+ export interface SchemaDriver {
76
+ readonly connection: SqlConnection;
77
+
78
+ /** Quote an identifier. */
79
+ quote(name: string): string;
80
+ /** Qualify a table with the namespace this schema resource owns. */
81
+ qualify(schema: string, table: string): string;
82
+
83
+ /**
84
+ * Hold the engine's cross-process schema lock for the duration of `body`.
85
+ * Every replica of an app boots the same pass, so this is what makes
86
+ * "reconcile once" true rather than hoped for.
87
+ */
88
+ withLock<T>(schema: string, body: () => Promise<T>): Promise<T>;
89
+
90
+ /** Create the namespace when absent. Purely additive. */
91
+ ensureNamespaceStatements(schema: string): string[];
92
+
93
+ /** The three ledger tables, `CREATE TABLE IF NOT EXISTS`. Named by the caller:
94
+ * which ledger a schema resource writes to is its own declaration. */
95
+ ledgerStatements(schema: string, tables: LedgerTables): string[];
96
+
97
+ /**
98
+ * Run `statements` atomically where the engine allows DDL inside a
99
+ * transaction, and sequentially where it does not. Which of the two an engine
100
+ * offers is the driver's to know; the runner only needs the strongest
101
+ * grouping available, and relies on the pass being idempotent for the rest.
102
+ *
103
+ * A migration's ledger row is the LAST statement of its group, so on an engine
104
+ * with transactional DDL "applied" and "recorded as applied" are one commit.
105
+ * An implementation whose engine cannot do that MUST say so here, because the
106
+ * consequence is specific: a crash mid-group can re-run a migration that is
107
+ * not idempotent.
108
+ */
109
+ runAtomically(statements: readonly string[]): Promise<void>;
110
+
111
+ /**
112
+ * The current time, as ISO-8601 UTC text, **from the database**.
113
+ *
114
+ * Not the application's clock: the grace window gates an irreversible drop,
115
+ * and one replica with a skewed clock would satisfy `afterDuration`
116
+ * instantly. The ledger's history already treats the database as the
117
+ * authority; the clock measured against it has to come from there too, so
118
+ * every replica measures the same elapsed time.
119
+ */
120
+ now(): Promise<string>;
121
+
122
+ /** Read back the live shape of the named tables. Absent tables are omitted. */
123
+ introspect(schema: string, tables: readonly string[]): Promise<LiveTable[]>;
124
+
125
+ /** The canonical signature of a declared column's type, for comparison with {@link LiveColumn.typeSignature}. */
126
+ typeSignature(column: DeclaredColumn): string;
127
+
128
+ /**
129
+ * Whether altering a live column to the declared one can be applied without
130
+ * risking the data already in it. Type widening is safe, narrowing is not,
131
+ * and only the engine knows which is which.
132
+ */
133
+ classifyAlter(live: LiveColumn, declared: DeclaredColumn): ChangeSafety;
134
+
135
+ /**
136
+ * Whether the values in a live column can be COPIED into a newly added one —
137
+ * the expand-contract half of a rename.
138
+ *
139
+ * A different question from {@link classifyAlter}, which asks whether a column
140
+ * can be changed in place. Here the target is brand new, so its nullability
141
+ * and default are already what the declaration says and only the VALUES have
142
+ * to survive the move. An engine that can alter nothing in place can still
143
+ * copy freely between compatible types, and one that copies between anything
144
+ * may still lose data doing it.
145
+ */
146
+ classifyCopy(live: LiveColumn, target: DeclaredColumn): ChangeSafety;
147
+
148
+ /**
149
+ * Whether an index can be brought to its declaration in place. Most engines
150
+ * cannot alter one, so the honest answer is usually to drop and recreate —
151
+ * which this returns as statements, or refuses when the index is not this
152
+ * schema's to rebuild.
153
+ */
154
+ classifyIndexChange(live: LiveIndex, declared: DeclaredIndex): ChangeSafety;
155
+
156
+ /** Whether a foreign key can be brought to its declaration in place. */
157
+ classifyForeignKeyChange(live: LiveForeignKey, declared: DeclaredForeignKey): ChangeSafety;
158
+
159
+ createTable(schema: string, table: DeclaredTable): string[];
160
+ addColumn(schema: string, table: string, column: DeclaredColumn): string[];
161
+ alterColumn(schema: string, table: string, live: LiveColumn, column: DeclaredColumn): string[];
162
+ copyColumn(schema: string, table: string, from: string, to: string): string[];
163
+ createIndex(schema: string, table: string, index: DeclaredIndex): string[];
164
+ dropIndex(schema: string, table: string, index: string): string[];
165
+ addForeignKey(schema: string, table: string, fk: DeclaredForeignKey): string[];
166
+ dropForeignKey(schema: string, table: string, name: string): string[];
167
+
168
+ /**
169
+ * Whether this engine can reclaim an object of this kind at all.
170
+ *
171
+ * Asked BEFORE the drop is attempted, because a tombstone the engine cannot
172
+ * act on is otherwise permanent breakage rather than a one-off failure: it
173
+ * stays eligible, so every boot from then on fails in the same place, and the
174
+ * application never starts again. Reported instead, with the reason, and the
175
+ * tombstone is left standing.
176
+ */
177
+ canReclaim(id: SchemaObjectId): ChangeSafety;
178
+
179
+ /** Reclamation. Separated from the rest because these are the only destructive statements. */
180
+ dropColumn(schema: string, table: string, column: string): string[];
181
+ dropTable(schema: string, table: string): string[];
182
+ }
@@ -0,0 +1,309 @@
1
+ import type { SqlConnection } from "../sql-connection.js";
2
+ import type { SchemaDriver } from "./schema-driver.js";
3
+ import type { SchemaObjectId } from "./declared-schema.js";
4
+ import type { DeclarationSnapshot } from "./declaration-snapshot.js";
5
+
6
+ /**
7
+ * The ledger lives in the target schema, not in the repository: one manifest
8
+ * deploys to many databases and each has its own history, so the database is
9
+ * the authority. Three tables — what has been applied, what versions have been
10
+ * observed, and what is tombstoned awaiting reclamation.
11
+ *
12
+ * Which ledger a schema resource writes to is chosen by NAME (see
13
+ * {@link ledgerTables}), not recorded in a column, so two resources over one
14
+ * namespace keep separate histories by keeping separate tables.
15
+ *
16
+ * Timestamps are ISO-8601 UTC text so ordering is lexicographic on every
17
+ * engine and no driver has to agree about a timestamp type.
18
+ */
19
+
20
+ /**
21
+ * The reserved root for everything Telo keeps in someone else's database.
22
+ *
23
+ * `telo_` reserves the root and `schema` names the domain, so a later subsystem
24
+ * that needs SQL bookkeeping of its own (a durable journal, a lease table) sits
25
+ * beside this one under one convention rather than inventing a second. Fixed:
26
+ * moving it would mean a rename on every deployed database, which is why the
27
+ * per-set name is a SUFFIX the author chooses and this part is not.
28
+ */
29
+ const LEDGER_ROOT = "telo_schema";
30
+
31
+ /** The three tables one ledger is kept in. */
32
+ export interface LedgerTables {
33
+ readonly migrations: string;
34
+ readonly versions: string;
35
+ readonly tombstones: string;
36
+ }
37
+
38
+ /**
39
+ * Where a schema resource keeps its history.
40
+ *
41
+ * A namespace can be reached by more than one schema resource — two libraries in
42
+ * one application, or two applications over one database — and each needs its
43
+ * own history, because the ledger records the DECLARATION and a shared one would
44
+ * make each read the other's tables as removed. Separating them by TABLE NAME is
45
+ * how every tool in this space does it (`flyway.table`, `databaseChangeLogTableName`,
46
+ * Alembic's `version_table`, kysely's `migrationTableName`), and it has the
47
+ * property an owner column lacks: the identity is written down, so renaming the
48
+ * resource that declares it changes nothing.
49
+ */
50
+ export function ledgerTables(ledger?: string): LedgerTables {
51
+ const stem = ledger ? `${LEDGER_ROOT}_${ledger}` : LEDGER_ROOT;
52
+ return {
53
+ migrations: `${stem}_migrations`,
54
+ versions: `${stem}_versions`,
55
+ tombstones: `${stem}_tombstones`,
56
+ };
57
+ }
58
+
59
+ /** One observation of a deployed `(version, digest)` pair, with the declaration
60
+ * that produced it — the record of which objects this schema resource owns. */
61
+ export interface VersionRecord {
62
+ readonly sequence: number;
63
+ readonly version: string;
64
+ readonly digest: string;
65
+ readonly firstSeenAt: string;
66
+ readonly declaration: DeclarationSnapshot;
67
+ }
68
+
69
+ export interface TombstoneRecord {
70
+ readonly objectKey: string;
71
+ readonly kind: string;
72
+ readonly tableName: string;
73
+ readonly name: string | null;
74
+ /** The object's last-known definition, kept so reclamation can report what it is dropping. */
75
+ readonly definition: string;
76
+ readonly missingSinceVersion: string;
77
+ readonly missingSinceSequence: number;
78
+ readonly missingSinceAt: string;
79
+ }
80
+
81
+ function num(value: unknown): number {
82
+ return typeof value === "bigint" ? Number(value) : Number(value ?? 0);
83
+ }
84
+
85
+ function text(value: unknown): string {
86
+ return value == null ? "" : String(value);
87
+ }
88
+
89
+ /** A SQL string literal. Standard doubling of the quote, which every engine here reads. */
90
+ function sqlText(value: string): string {
91
+ return `'${value.replace(/'/g, "''")}'`;
92
+ }
93
+
94
+ function parseDeclaration(value: unknown): DeclarationSnapshot {
95
+ if (value == null || value === "") return {};
96
+ if (typeof value === "object") return value as DeclarationSnapshot;
97
+ return JSON.parse(String(value)) as DeclarationSnapshot;
98
+ }
99
+
100
+ export class SchemaLedger {
101
+ readonly #conn: SqlConnection;
102
+ readonly #driver: SchemaDriver;
103
+ readonly #schema: string;
104
+ readonly #tables: LedgerTables;
105
+
106
+ constructor(driver: SchemaDriver, schema: string, tables: LedgerTables) {
107
+ this.#driver = driver;
108
+ this.#conn = driver.connection;
109
+ this.#schema = schema;
110
+ this.#tables = tables;
111
+ }
112
+
113
+ #table(name: string): string {
114
+ return this.#driver.qualify(this.#schema, name);
115
+ }
116
+
117
+ async ensureTables(): Promise<void> {
118
+ for (const statement of this.#driver.ledgerStatements(this.#schema, this.#tables)) {
119
+ await this.#conn.execute(statement);
120
+ }
121
+ }
122
+
123
+ // --- applied migrations -------------------------------------------------
124
+
125
+ async appliedMigrationKeys(): Promise<Set<string>> {
126
+ const result = await this.#conn.execute<{ key: string }>(
127
+ `SELECT key FROM ${this.#table(this.#tables.migrations)}`,
128
+ );
129
+ return new Set(result.rows.map((row) => text(row.key)));
130
+ }
131
+
132
+ /**
133
+ * The ledger row for an applied migration, as a STATEMENT rather than an
134
+ * executed write — so it can join the migration's own atomic group.
135
+ *
136
+ * Recording it separately would leave a window in which the migration has run
137
+ * and the ledger does not know: a crash there re-runs a migration that may not
138
+ * be idempotent. Literals rather than bound parameters because this statement
139
+ * is grouped with DDL the driver executes as one unit; both values are
140
+ * escaped, and the key is already constrained to `[A-Za-z0-9_.-]`.
141
+ */
142
+ migrationRecordStatement(key: string, at: string): string {
143
+ return (
144
+ `INSERT INTO ${this.#table(this.#tables.migrations)} (key, applied_at) ` +
145
+ `VALUES (${sqlText(key)}, ${sqlText(at)})`
146
+ );
147
+ }
148
+
149
+ // --- observed version sequence -----------------------------------------
150
+
151
+ async versionHistory(): Promise<VersionRecord[]> {
152
+ const result = await this.#conn.execute<Record<string, unknown>>(
153
+ `SELECT sequence, version, digest, first_seen_at, declaration FROM ` +
154
+ `${this.#table(this.#tables.versions)} ORDER BY sequence ASC`,
155
+ );
156
+ return result.rows.map((row) => ({
157
+ sequence: num(row.sequence),
158
+ version: text(row.version),
159
+ digest: text(row.digest),
160
+ firstSeenAt: text(row.first_seen_at),
161
+ declaration: parseDeclaration(row.declaration),
162
+ }));
163
+ }
164
+
165
+ /**
166
+ * Record this boot's `(version, digest)`.
167
+ *
168
+ * The *sequence* is recorded rather than a counter, so going backwards is
169
+ * visible: an older version booting proves older code is live, which is
170
+ * exactly the condition a grace window exists to survive. A boot at the
171
+ * version already at the head updates its digest in place and advances
172
+ * nothing — so forgetting to bump costs grace progress rather than causing
173
+ * harm, and local iteration accrues no budget.
174
+ */
175
+ /**
176
+ * A schema with no `reclaim:` policy declares no version, so its rows carry an
177
+ * empty label: the head is then always "the same version", updated in place,
178
+ * and the sequence never advances. That is the honest degenerate case — there
179
+ * is no clock because nothing is counting — and the declaration and digest,
180
+ * which are what ownership rests on, are recorded either way.
181
+ */
182
+ /**
183
+ * The version row as a STATEMENT, for the group that also carries its
184
+ * tombstones, plus the row it will write so the caller can stamp with it.
185
+ *
186
+ * The *sequence* is recorded rather than a counter, so going backwards is
187
+ * visible: an older version booting proves older code is live, which is
188
+ * exactly the condition a grace window exists to survive. A boot at the
189
+ * version already at the head updates its digest in place and advances
190
+ * nothing — so forgetting to bump costs grace progress rather than causing
191
+ * harm, and local iteration accrues no budget.
192
+ *
193
+ * A schema with no `reclaim:` policy declares no version, so its rows carry an
194
+ * empty label: the head is then always "the same version", updated in place,
195
+ * and the sequence never advances. That is the honest degenerate case — there
196
+ * is no clock because nothing is counting — and the declaration and digest,
197
+ * which are what ownership rests on, are recorded either way.
198
+ */
199
+ versionRecordStatements(
200
+ version: string,
201
+ declaration: DeclarationSnapshot,
202
+ digest: string,
203
+ at: string,
204
+ history: readonly VersionRecord[],
205
+ ): { record: VersionRecord; statements: string[] } {
206
+ const head = history[history.length - 1];
207
+ const encoded = JSON.stringify(declaration);
208
+ if (head && head.version === version) {
209
+ if (head.digest === digest) return { record: head, statements: [] };
210
+ return {
211
+ record: { ...head, digest, declaration },
212
+ statements: [
213
+ `UPDATE ${this.#table(this.#tables.versions)} SET digest = ${sqlText(digest)}, ` +
214
+ `declaration = ${sqlText(encoded)} WHERE sequence = ${head.sequence}`,
215
+ ],
216
+ };
217
+ }
218
+ const record: VersionRecord = {
219
+ sequence: (head?.sequence ?? 0) + 1,
220
+ version,
221
+ digest,
222
+ firstSeenAt: at,
223
+ declaration,
224
+ };
225
+ return {
226
+ record,
227
+ statements: [
228
+ `INSERT INTO ${this.#table(this.#tables.versions)} ` +
229
+ `(sequence, version, digest, first_seen_at, declaration) VALUES (` +
230
+ `${record.sequence}, ${sqlText(version)}, ${sqlText(digest)}, ` +
231
+ `${sqlText(at)}, ${sqlText(encoded)})`,
232
+ ],
233
+ };
234
+ }
235
+
236
+ // --- tombstones ---------------------------------------------------------
237
+
238
+ async tombstones(): Promise<TombstoneRecord[]> {
239
+ const result = await this.#conn.execute<Record<string, unknown>>(
240
+ `SELECT object_key, kind, table_name, name, definition, missing_since_version, ` +
241
+ `missing_since_sequence, missing_since_at FROM ${this.#table(this.#tables.tombstones)} ` +
242
+ `ORDER BY object_key ASC`,
243
+ );
244
+ return result.rows.map((row) => ({
245
+ objectKey: text(row.object_key),
246
+ kind: text(row.kind),
247
+ tableName: text(row.table_name),
248
+ name: row.name == null ? null : text(row.name),
249
+ definition: text(row.definition),
250
+ missingSinceVersion: text(row.missing_since_version),
251
+ missingSinceSequence: num(row.missing_since_sequence),
252
+ missingSinceAt: text(row.missing_since_at),
253
+ }));
254
+ }
255
+
256
+ /**
257
+ * The row for a tombstone, as a STATEMENT — so it can be committed with the
258
+ * version row that stamps it.
259
+ *
260
+ * Recording them separately leaves a window in which the new declaration is
261
+ * `owned` and the removals are not yet tombstoned. A crash there loses them
262
+ * for good: the next boot's `owned` no longer mentions them, so nothing is
263
+ * ever tombstoned for those objects again and they sit in the database
264
+ * untracked, invisible to `pendingReclamation` and undroppable without
265
+ * hand-written SQL. The whole ownership model rests on that snapshot being the
266
+ * PREVIOUS declaration.
267
+ *
268
+ * `ON CONFLICT DO NOTHING` because two passes can race where the engine has no
269
+ * cross-process lock (SQLite): recording the same tombstone twice is a
270
+ * convergent no-op, and a primary-key error there would be a failure over
271
+ * agreement.
272
+ */
273
+ tombstoneRecordStatement(
274
+ id: SchemaObjectId,
275
+ objectKey: string,
276
+ definition: string,
277
+ version: VersionRecord,
278
+ at: string,
279
+ ): string {
280
+ const values = [
281
+ objectKey,
282
+ id.kind,
283
+ id.table,
284
+ id.name,
285
+ definition,
286
+ version.version,
287
+ String(version.sequence),
288
+ at,
289
+ ];
290
+ const encoded = values
291
+ .map((value, index) =>
292
+ value == null ? "NULL" : index === 6 ? String(value) : sqlText(String(value)),
293
+ )
294
+ .join(", ");
295
+ return (
296
+ `INSERT INTO ${this.#table(this.#tables.tombstones)} (object_key, kind, table_name, ` +
297
+ `name, definition, missing_since_version, missing_since_sequence, missing_since_at) ` +
298
+ `VALUES (${encoded}) ON CONFLICT DO NOTHING`
299
+ );
300
+ }
301
+
302
+ /** A tombstoned object that came back — the declaration is authoritative again. */
303
+ async clearTombstone(objectKey: string): Promise<void> {
304
+ await this.#conn.executeTemplate(
305
+ [`DELETE FROM ${this.#table(this.#tables.tombstones)} WHERE object_key = `, ``],
306
+ [objectKey],
307
+ );
308
+ }
309
+ }