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