@telorun/sql 0.21.3 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +18 -2
- package/dist/index.js +9 -2
- package/dist/schema/declaration-snapshot.d.ts +21 -0
- package/dist/schema/declaration-snapshot.js +46 -0
- package/dist/schema/declared-schema.d.ts +64 -0
- package/dist/schema/declared-schema.js +16 -0
- package/dist/schema/migration-runner.d.ts +30 -0
- package/dist/schema/migration-runner.js +38 -0
- package/dist/schema/normalize-table.d.ts +57 -0
- package/dist/schema/normalize-table.js +125 -0
- package/dist/schema/reclaim-policy.d.ts +34 -0
- package/dist/schema/reclaim-policy.js +40 -0
- package/dist/schema/schema-driver.d.ts +184 -0
- package/dist/schema/schema-driver.js +1 -0
- package/dist/schema/schema-ledger.d.ts +119 -0
- package/dist/schema/schema-ledger.js +231 -0
- package/dist/schema/schema-reconciler.d.ts +45 -0
- package/dist/schema/schema-reconciler.js +276 -0
- package/dist/schema/schema-run.d.ts +50 -0
- package/dist/schema/schema-run.js +318 -0
- package/dist/schema/table-reference.d.ts +25 -0
- package/dist/schema/table-reference.js +61 -0
- package/dist/sql-connection-base.d.ts +21 -0
- package/dist/sql-connection-base.js +30 -4
- package/dist/sql-connection.d.ts +19 -0
- package/package.json +5 -3
- package/src/index.ts +43 -2
- package/src/schema/declaration-snapshot.ts +71 -0
- package/src/schema/declared-schema.ts +73 -0
- package/src/schema/migration-runner.ts +69 -0
- package/src/schema/normalize-table.ts +224 -0
- package/src/schema/reclaim-policy.ts +73 -0
- package/src/schema/schema-driver.ts +207 -0
- package/src/schema/schema-ledger.ts +309 -0
- package/src/schema/schema-reconciler.ts +372 -0
- package/src/schema/schema-run.ts +441 -0
- package/src/schema/table-reference.ts +78 -0
- package/src/sql-connection-base.ts +35 -4
- package/src/sql-connection.ts +21 -0
- package/dist/sql-migration-controller.d.ts +0 -16
- package/dist/sql-migration-controller.js +0 -13
- package/dist/sql-migrations-controller.d.ts +0 -23
- package/dist/sql-migrations-controller.js +0 -98
- package/src/sql-migration-controller.ts +0 -20
- package/src/sql-migrations-controller.ts +0 -143
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DeclaredColumn,
|
|
3
|
+
DeclaredForeignKey,
|
|
4
|
+
DeclaredIndex,
|
|
5
|
+
DeclaredTable,
|
|
6
|
+
SchemaObjectId,
|
|
7
|
+
} from "./declared-schema.js";
|
|
8
|
+
import { objectKey } from "./declared-schema.js";
|
|
9
|
+
import type { DeclarationSnapshot } from "./declaration-snapshot.js";
|
|
10
|
+
import { parseObjectKey } from "./declaration-snapshot.js";
|
|
11
|
+
import type {
|
|
12
|
+
LiveColumn,
|
|
13
|
+
LiveForeignKey,
|
|
14
|
+
LiveIndex,
|
|
15
|
+
LiveTable,
|
|
16
|
+
SchemaDriver,
|
|
17
|
+
} from "./schema-driver.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The diff. One global pass over the declaration: the manifest holds only
|
|
21
|
+
* current declared state, so there is exactly one reconciliation target and no
|
|
22
|
+
* historical state to replay towards.
|
|
23
|
+
*
|
|
24
|
+
* Nothing here executes. A plan is built, refusals are collected, and the
|
|
25
|
+
* runner decides — which is what lets an unsafe change stop the release before
|
|
26
|
+
* any DDL has run rather than half way through it.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** DDL ordering. Cross-table constraints come last so declaration order is
|
|
30
|
+
* never load-bearing and foreign-key ordering is never the author's problem. */
|
|
31
|
+
export type PlanPhase = "table" | "index" | "constraint";
|
|
32
|
+
|
|
33
|
+
export interface PlannedStatement {
|
|
34
|
+
readonly phase: PlanPhase;
|
|
35
|
+
readonly sql: string;
|
|
36
|
+
readonly describes: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PlannedTombstone {
|
|
40
|
+
readonly id: SchemaObjectId;
|
|
41
|
+
readonly key: string;
|
|
42
|
+
readonly definition: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Refusal {
|
|
46
|
+
readonly object: string;
|
|
47
|
+
readonly reason: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SchemaPlan {
|
|
51
|
+
readonly statements: readonly PlannedStatement[];
|
|
52
|
+
readonly tombstones: readonly PlannedTombstone[];
|
|
53
|
+
/** Tombstoned objects the declaration has brought back. */
|
|
54
|
+
readonly revived: readonly string[];
|
|
55
|
+
/**
|
|
56
|
+
* `renamedFrom:` mentions that can no longer do anything — the source column
|
|
57
|
+
* is neither present nor tombstoned, so there is nothing left to copy and
|
|
58
|
+
* nothing left to hold. The rename is finished; the mention is now dead
|
|
59
|
+
* manifest text, and saying so is the only way the author learns it can go.
|
|
60
|
+
*/
|
|
61
|
+
readonly inertRenames: readonly string[];
|
|
62
|
+
readonly refusals: readonly Refusal[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether a live column still matches its declaration. Comparison is over the
|
|
67
|
+
* driver's own canonical type signature, nullability and the presence of a
|
|
68
|
+
* default — every type rule stays inside the driver, and nothing here parses a
|
|
69
|
+
* type.
|
|
70
|
+
*/
|
|
71
|
+
function columnDiffers(driver: SchemaDriver, live: LiveColumn, declared: DeclaredColumn): boolean {
|
|
72
|
+
const declaresDefault =
|
|
73
|
+
declared.default !== undefined || declared.defaultExpression !== undefined;
|
|
74
|
+
return (
|
|
75
|
+
live.typeSignature !== driver.typeSignature(declared) ||
|
|
76
|
+
live.nullable !== declared.nullable ||
|
|
77
|
+
live.hasDefault !== declaresDefault ||
|
|
78
|
+
// Uniqueness and key membership are what the declaration PROMISES about the
|
|
79
|
+
// data. Compared by nothing at all, adding `unique: true` to a live column
|
|
80
|
+
// emitted no DDL and no report while the ledger recorded it as owned — so
|
|
81
|
+
// the manifest asserted a constraint the database was not enforcing.
|
|
82
|
+
live.primaryKey !== declared.primaryKey ||
|
|
83
|
+
live.unique !== declared.unique
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Column order is part of an index: `(a, b)` and `(b, a)` are different indexes. */
|
|
88
|
+
function indexDiffers(live: LiveIndex, declared: DeclaredIndex): boolean {
|
|
89
|
+
return (
|
|
90
|
+
live.unique !== declared.unique ||
|
|
91
|
+
live.columns.length !== declared.columns.length ||
|
|
92
|
+
live.columns.some((column, i) => column !== declared.columns[i])
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A referential action is what the constraint DOES, so a change to it is a
|
|
97
|
+
* change to the constraint. An action the engine did not report is not compared
|
|
98
|
+
* — an absent reading is not evidence of a difference. */
|
|
99
|
+
/**
|
|
100
|
+
* The columns a key maps to what, which is what makes it THAT key rather than
|
|
101
|
+
* another. Its referential actions are settable properties of it, deliberately
|
|
102
|
+
* excluded: an engine that keeps no name matches on this, and folding the
|
|
103
|
+
* actions in would make a changed delete rule read as a brand new key — an ADD
|
|
104
|
+
* where the author should have been told the rule cannot be changed in place.
|
|
105
|
+
*/
|
|
106
|
+
function sameForeignKeyIdentity(live: LiveForeignKey, declared: DeclaredForeignKey): boolean {
|
|
107
|
+
return (
|
|
108
|
+
live.references.table === declared.references.table &&
|
|
109
|
+
live.columns.length === declared.columns.length &&
|
|
110
|
+
live.columns.every((column, i) => column === declared.columns[i]) &&
|
|
111
|
+
live.references.columns.length === declared.references.columns.length &&
|
|
112
|
+
live.references.columns.every((column, i) => column === declared.references.columns[i])
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function foreignKeyDiffers(live: LiveForeignKey, declared: DeclaredForeignKey): boolean {
|
|
117
|
+
const action = (value: string | undefined): string | undefined => value?.toUpperCase();
|
|
118
|
+
return (
|
|
119
|
+
live.references.table !== declared.references.table ||
|
|
120
|
+
live.columns.length !== declared.columns.length ||
|
|
121
|
+
live.columns.some((column, i) => column !== declared.columns[i]) ||
|
|
122
|
+
live.references.columns.length !== declared.references.columns.length ||
|
|
123
|
+
live.references.columns.some((column, i) => column !== declared.references.columns[i]) ||
|
|
124
|
+
(live.onDelete !== undefined && action(live.onDelete) !== (action(declared.onDelete) ?? "NO ACTION")) ||
|
|
125
|
+
(live.onUpdate !== undefined && action(live.onUpdate) !== (action(declared.onUpdate) ?? "NO ACTION"))
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function liveByName(live: readonly LiveTable[]): Map<string, LiveTable> {
|
|
130
|
+
return new Map(live.map((table) => [table.name, table]));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function planReconciliation(
|
|
134
|
+
driver: SchemaDriver,
|
|
135
|
+
schema: string,
|
|
136
|
+
declared: readonly DeclaredTable[],
|
|
137
|
+
live: readonly LiveTable[],
|
|
138
|
+
owned: DeclarationSnapshot,
|
|
139
|
+
tombstoned: ReadonlySet<string>,
|
|
140
|
+
): SchemaPlan {
|
|
141
|
+
const statements: PlannedStatement[] = [];
|
|
142
|
+
const tombstones: PlannedTombstone[] = [];
|
|
143
|
+
const revived: string[] = [];
|
|
144
|
+
const inertRenames: string[] = [];
|
|
145
|
+
const refusals: Refusal[] = [];
|
|
146
|
+
const liveTables = liveByName(live);
|
|
147
|
+
const declaredKeys = new Set<string>();
|
|
148
|
+
|
|
149
|
+
const emit = (phase: PlanPhase, describes: string, sql: readonly string[]): void => {
|
|
150
|
+
for (const one of sql) statements.push({ phase, sql: one, describes });
|
|
151
|
+
};
|
|
152
|
+
// NOT named `declare`: `declare` is a TypeScript modifier keyword, and a
|
|
153
|
+
// statement that begins with it is parsed as an ambient declaration and
|
|
154
|
+
// STRIPPED by a type-stripping transpiler — so `declare({ … });` at statement
|
|
155
|
+
// position vanished while `const k = declare(…)` survived, and the pass
|
|
156
|
+
// tombstoned every object it had just declared. Silent under Node, silent at
|
|
157
|
+
// `tsc`, and destructive only on the runtime that strips types.
|
|
158
|
+
const markDeclared = (id: SchemaObjectId): string => {
|
|
159
|
+
const key = objectKey(id);
|
|
160
|
+
declaredKeys.add(key);
|
|
161
|
+
if (tombstoned.has(key)) revived.push(key);
|
|
162
|
+
return key;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
for (const table of declared) {
|
|
166
|
+
markDeclared({ kind: "table", table: table.name });
|
|
167
|
+
for (const column of table.columns) {
|
|
168
|
+
markDeclared({ kind: "column", table: table.name, name: column.name });
|
|
169
|
+
}
|
|
170
|
+
const liveTable = liveTables.get(table.name);
|
|
171
|
+
|
|
172
|
+
if (!liveTable) {
|
|
173
|
+
emit("table", `table ${table.name}`, driver.createTable(schema, table));
|
|
174
|
+
} else {
|
|
175
|
+
const liveColumns = new Map(liveTable.columns.map((c) => [c.name, c]));
|
|
176
|
+
for (const column of table.columns) {
|
|
177
|
+
const existing = liveColumns.get(column.name);
|
|
178
|
+
if (!existing) {
|
|
179
|
+
const renamedFrom = column.renamedFrom;
|
|
180
|
+
const source = renamedFrom ? liveColumns.get(renamedFrom) : undefined;
|
|
181
|
+
// Classified BEFORE anything is emitted, so a refused rename
|
|
182
|
+
// contributes no statements at all. The runner refuses to execute a
|
|
183
|
+
// plan carrying refusals, but a plan that is half a rename is still
|
|
184
|
+
// the wrong thing to hand anyone.
|
|
185
|
+
if (source && renamedFrom) {
|
|
186
|
+
// A rename that changes the type is two changes wearing one name.
|
|
187
|
+
// Unchecked, the copy is a raw driver error on an engine that
|
|
188
|
+
// refuses the assignment, and silently stores the old
|
|
189
|
+
// representation on one that does not.
|
|
190
|
+
const safety = driver.classifyCopy(source, column);
|
|
191
|
+
if (!safety.safe) {
|
|
192
|
+
refusals.push({
|
|
193
|
+
object: `${table.name}.${column.name}`,
|
|
194
|
+
reason: `renamedFrom '${renamedFrom}': ${safety.reason}`,
|
|
195
|
+
});
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
emit(
|
|
200
|
+
"table",
|
|
201
|
+
`column ${table.name}.${column.name}`,
|
|
202
|
+
driver.addColumn(schema, table.name, column),
|
|
203
|
+
);
|
|
204
|
+
// Expand-contract: the source column is copied, then tombstoned. A
|
|
205
|
+
// native RENAME would take effect immediately and break the older
|
|
206
|
+
// version still running — the one operation that would be exempt from
|
|
207
|
+
// the deferral this design exists for.
|
|
208
|
+
if (source && renamedFrom) {
|
|
209
|
+
emit(
|
|
210
|
+
"table",
|
|
211
|
+
`copy ${table.name}.${renamedFrom} → ${column.name}`,
|
|
212
|
+
driver.copyColumn(schema, table.name, renamedFrom, column.name),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (!columnDiffers(driver, existing, column)) continue;
|
|
218
|
+
// Classification happens here, against live state, because the
|
|
219
|
+
// declaration is the only artifact: there is no historical declared
|
|
220
|
+
// state to diff against, so whether a change is safe depends on what is
|
|
221
|
+
// in the column right now.
|
|
222
|
+
const safety = driver.classifyAlter(existing, column);
|
|
223
|
+
if (!safety.safe) {
|
|
224
|
+
refusals.push({ object: `${table.name}.${column.name}`, reason: safety.reason });
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
emit(
|
|
228
|
+
"table",
|
|
229
|
+
`column ${table.name}.${column.name}`,
|
|
230
|
+
driver.alterColumn(schema, table.name, existing, column),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// A rename is inert when its source is gone for good: not present, and not
|
|
236
|
+
// held by a tombstone. Only asked of a table that ALREADY existed — on one
|
|
237
|
+
// this pass creates there was never anything to copy, so every rename would
|
|
238
|
+
// look finished when in fact it has not run anywhere yet, and the same
|
|
239
|
+
// manifest still deploys to databases that do need it.
|
|
240
|
+
if (liveTable) {
|
|
241
|
+
const liveColumnNames = new Set(liveTable.columns.map((c) => c.name));
|
|
242
|
+
for (const column of table.columns) {
|
|
243
|
+
if (!column.renamedFrom) continue;
|
|
244
|
+
const sourceKey = objectKey({
|
|
245
|
+
kind: "column",
|
|
246
|
+
table: table.name,
|
|
247
|
+
name: column.renamedFrom,
|
|
248
|
+
});
|
|
249
|
+
if (liveColumnNames.has(column.renamedFrom) || tombstoned.has(sourceKey)) continue;
|
|
250
|
+
inertRenames.push(
|
|
251
|
+
`column ${table.name}.${column.name} (renamedFrom ${column.renamedFrom})`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const liveIndexes = new Map((liveTable?.indexes ?? []).map((index) => [index.name, index]));
|
|
257
|
+
for (const index of table.indexes) {
|
|
258
|
+
markDeclared({ kind: "index", table: table.name, name: index.name });
|
|
259
|
+
const existing = liveIndexes.get(index.name);
|
|
260
|
+
if (!existing) {
|
|
261
|
+
emit("index", `index ${index.name}`, driver.createIndex(schema, table.name, index));
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
// An index that exists under the right name may still cover the wrong
|
|
265
|
+
// columns, or have stopped being unique. Silence there is the declaration
|
|
266
|
+
// asserting something the database is not doing.
|
|
267
|
+
if (!indexDiffers(existing, index)) continue;
|
|
268
|
+
const safety = driver.classifyIndexChange(existing, index);
|
|
269
|
+
if (!safety.safe) {
|
|
270
|
+
refusals.push({ object: `${table.name}.${index.name}`, reason: safety.reason });
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
emit("index", `index ${index.name}`, [
|
|
274
|
+
...driver.dropIndex(schema, table.name, index.name),
|
|
275
|
+
...driver.createIndex(schema, table.name, index),
|
|
276
|
+
]);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// A table this pass just created already carries its keys where the engine
|
|
280
|
+
// can only emit them there. They are still MARKED declared, or the next boot
|
|
281
|
+
// would read every one of them as removed and tombstone it.
|
|
282
|
+
const carriedByCreate = !liveTable && driver.foreignKeysInCreateTable;
|
|
283
|
+
// Where the engine keeps no name, a declaration is matched to a live key by
|
|
284
|
+
// its structure. Matching by name regardless is what made such a table
|
|
285
|
+
// unrestartable: every later boot read its own key as missing and refused to
|
|
286
|
+
// add what the engine cannot add. Matches are CONSUMED, so two keys that are
|
|
287
|
+
// structurally identical pair up one for one instead of both claiming the
|
|
288
|
+
// first.
|
|
289
|
+
const unmatched = [...(liveTable?.foreignKeys ?? [])];
|
|
290
|
+
const liveForeignKeys = new Map(unmatched.map((fk) => [fk.name, fk]));
|
|
291
|
+
const takeStructural = (fk: DeclaredForeignKey): LiveForeignKey | undefined => {
|
|
292
|
+
const at = unmatched.findIndex((live) => sameForeignKeyIdentity(live, fk));
|
|
293
|
+
return at < 0 ? undefined : unmatched.splice(at, 1)[0];
|
|
294
|
+
};
|
|
295
|
+
for (const fk of table.foreignKeys) {
|
|
296
|
+
markDeclared({ kind: "foreignKey", table: table.name, name: fk.name });
|
|
297
|
+
if (carriedByCreate) continue;
|
|
298
|
+
const existing = driver.namesForeignKeys
|
|
299
|
+
? liveForeignKeys.get(fk.name)
|
|
300
|
+
: takeStructural(fk);
|
|
301
|
+
if (!existing) {
|
|
302
|
+
emit("constraint", `foreign key ${fk.name}`, driver.addForeignKey(schema, table.name, fk));
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (!foreignKeyDiffers(existing, fk)) continue;
|
|
306
|
+
const safety = driver.classifyForeignKeyChange(existing, fk);
|
|
307
|
+
if (!safety.safe) {
|
|
308
|
+
refusals.push({ object: `${table.name}.${fk.name}`, reason: safety.reason });
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
emit("constraint", `foreign key ${fk.name}`, [
|
|
312
|
+
...driver.dropForeignKey(schema, table.name, fk.name),
|
|
313
|
+
...driver.addForeignKey(schema, table.name, fk),
|
|
314
|
+
]);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Removal never emits DDL. An object this resource once declared and no longer
|
|
319
|
+
// does is tombstoned; the drop is deferred to reclamation, which is the whole
|
|
320
|
+
// point. An object it has NEVER declared is not ours and is not considered.
|
|
321
|
+
const tombstoneKeys = new Set<string>();
|
|
322
|
+
const tombstone = (id: SchemaObjectId, key: string, definition: string): void => {
|
|
323
|
+
if (tombstoneKeys.has(key)) return;
|
|
324
|
+
tombstoneKeys.add(key);
|
|
325
|
+
tombstones.push({ id, key, definition });
|
|
326
|
+
};
|
|
327
|
+
// A table that is going away takes its columns, indexes and constraints with
|
|
328
|
+
// it, so only the TABLE is tombstoned. Recording the children too would plan a
|
|
329
|
+
// drop for each — and they are dropped first, since reclamation walks
|
|
330
|
+
// dependents before their table — so an engine that refuses to drop a primary
|
|
331
|
+
// key or an indexed column (SQLite refuses both) would fail the pass, and go
|
|
332
|
+
// on failing it, over objects the DROP TABLE was about to remove anyway.
|
|
333
|
+
const retiredTables = new Set(
|
|
334
|
+
Object.keys(owned)
|
|
335
|
+
.filter((key) => key.startsWith("table:"))
|
|
336
|
+
.map((key) => parseObjectKey(key).table)
|
|
337
|
+
.filter((table) => !declaredKeys.has(objectKey({ kind: "table", table }))),
|
|
338
|
+
);
|
|
339
|
+
for (const [key, definition] of Object.entries(owned)) {
|
|
340
|
+
if (declaredKeys.has(key) || tombstoned.has(key)) continue;
|
|
341
|
+
const id = parseObjectKey(key);
|
|
342
|
+
if (id.kind !== "table" && retiredTables.has(id.table)) continue;
|
|
343
|
+
tombstone(id, key, definition);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// A renamed-away source column is tombstoned even while the declaration still
|
|
347
|
+
// names it through `renamedFrom`, so its budget starts at the rename rather
|
|
348
|
+
// than at whichever later release deletes the mention.
|
|
349
|
+
//
|
|
350
|
+
// Only a source that is actually THERE. Once a rename's source has been
|
|
351
|
+
// reclaimed the mention is inert, and tombstoning it again would put a column
|
|
352
|
+
// that no longer exists back on the books and eventually emit a DROP for it.
|
|
353
|
+
for (const table of declared) {
|
|
354
|
+
const liveColumnNames = new Set(
|
|
355
|
+
(liveTables.get(table.name)?.columns ?? []).map((c) => c.name),
|
|
356
|
+
);
|
|
357
|
+
for (const column of table.columns) {
|
|
358
|
+
if (!column.renamedFrom) continue;
|
|
359
|
+
if (!liveColumnNames.has(column.renamedFrom)) continue;
|
|
360
|
+
const id: SchemaObjectId = { kind: "column", table: table.name, name: column.renamedFrom };
|
|
361
|
+
const key = objectKey(id);
|
|
362
|
+
if (tombstoned.has(key) || declaredKeys.has(key)) continue;
|
|
363
|
+
tombstone(id, key, owned[key] ?? JSON.stringify({ name: column.renamedFrom }));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return { statements, tombstones, revived, inertRenames, refusals };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function describeRefusals(refusals: readonly Refusal[]): string {
|
|
371
|
+
return refusals.map((r) => ` ${r.object}: ${r.reason}`).join("\n");
|
|
372
|
+
}
|