@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,184 @@
|
|
|
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
|
+
/**
|
|
141
|
+
* Whether `createTable` already carries the table's foreign keys, so the
|
|
142
|
+
* reconciler must not also plan an `addForeignKey` for a table it just made.
|
|
143
|
+
*
|
|
144
|
+
* REQUIRED, like every other member here, because the wrong answer is silent:
|
|
145
|
+
* a driver that omitted it would get name matching by default, and if its
|
|
146
|
+
* engine also emits keys inside `CREATE TABLE` it would get exactly the
|
|
147
|
+
* unrestartable application this member exists to prevent — with no compile
|
|
148
|
+
* error and no failing test. Stating an answer is the point.
|
|
149
|
+
*/
|
|
150
|
+
readonly foreignKeysInCreateTable: boolean;
|
|
151
|
+
/**
|
|
152
|
+
* Whether the engine reports a foreign key back under the name the
|
|
153
|
+
* declaration gave it.
|
|
154
|
+
*
|
|
155
|
+
* Separate from `foreignKeysInCreateTable` because they are separate facts and
|
|
156
|
+
* an engine can hold one without the other: MySQL emits keys inside `CREATE
|
|
157
|
+
* TABLE` and names them. Where this is false a declaration is matched to a
|
|
158
|
+
* live key by its columns, target and referential actions, since there is no
|
|
159
|
+
* name to match on and matching by one reads a table's own key as missing on
|
|
160
|
+
* every boot after the one that created it.
|
|
161
|
+
*/
|
|
162
|
+
readonly namesForeignKeys: boolean;
|
|
163
|
+
createTable(schema: string, table: DeclaredTable): string[];
|
|
164
|
+
addColumn(schema: string, table: string, column: DeclaredColumn): string[];
|
|
165
|
+
alterColumn(schema: string, table: string, live: LiveColumn, column: DeclaredColumn): string[];
|
|
166
|
+
copyColumn(schema: string, table: string, from: string, to: string): string[];
|
|
167
|
+
createIndex(schema: string, table: string, index: DeclaredIndex): string[];
|
|
168
|
+
dropIndex(schema: string, table: string, index: string): string[];
|
|
169
|
+
addForeignKey(schema: string, table: string, fk: DeclaredForeignKey): string[];
|
|
170
|
+
dropForeignKey(schema: string, table: string, name: string): string[];
|
|
171
|
+
/**
|
|
172
|
+
* Whether this engine can reclaim an object of this kind at all.
|
|
173
|
+
*
|
|
174
|
+
* Asked BEFORE the drop is attempted, because a tombstone the engine cannot
|
|
175
|
+
* act on is otherwise permanent breakage rather than a one-off failure: it
|
|
176
|
+
* stays eligible, so every boot from then on fails in the same place, and the
|
|
177
|
+
* application never starts again. Reported instead, with the reason, and the
|
|
178
|
+
* tombstone is left standing.
|
|
179
|
+
*/
|
|
180
|
+
canReclaim(id: SchemaObjectId): ChangeSafety;
|
|
181
|
+
/** Reclamation. Separated from the rest because these are the only destructive statements. */
|
|
182
|
+
dropColumn(schema: string, table: string, column: string): string[];
|
|
183
|
+
dropTable(schema: string, table: string): string[];
|
|
184
|
+
}
|
|
@@ -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;
|