@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.
- package/dist/index.d.ts +17 -2
- package/dist/index.js +8 -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 +46 -0
- package/dist/schema/normalize-table.js +135 -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 +161 -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 +243 -0
- package/dist/schema/schema-run.d.ts +50 -0
- package/dist/schema/schema-run.js +318 -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 +36 -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 +218 -0
- package/src/schema/reclaim-policy.ts +73 -0
- package/src/schema/schema-driver.ts +182 -0
- package/src/schema/schema-ledger.ts +309 -0
- package/src/schema/schema-reconciler.ts +339 -0
- package/src/schema/schema-run.ts +441 -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,441 @@
|
|
|
1
|
+
import { parseDurationMs, type ResourceContext } from "@telorun/sdk";
|
|
2
|
+
import type { DeclaredTable } from "./declared-schema.js";
|
|
3
|
+
import { describeObject } from "./declared-schema.js";
|
|
4
|
+
import { snapshotDeclaration, snapshotDigest, parseObjectKey } from "./declaration-snapshot.js";
|
|
5
|
+
import type { SchemaDriver } from "./schema-driver.js";
|
|
6
|
+
import { ledgerTables, SchemaLedger, type TombstoneRecord } from "./schema-ledger.js";
|
|
7
|
+
import { assessTombstone, type ReclaimPolicy } from "./reclaim-policy.js";
|
|
8
|
+
import {
|
|
9
|
+
migrationStatements,
|
|
10
|
+
orphanedKeys,
|
|
11
|
+
runMigrations,
|
|
12
|
+
type MigrationMap,
|
|
13
|
+
} from "./migration-runner.js";
|
|
14
|
+
import { describeRefusals, planReconciliation } from "./schema-reconciler.js";
|
|
15
|
+
|
|
16
|
+
export interface SchemaRunInput {
|
|
17
|
+
readonly schema: string;
|
|
18
|
+
/**
|
|
19
|
+
* Which ledger this schema keeps its history in — the per-set name, or
|
|
20
|
+
* undefined for the default. Two schema resources over one namespace MUST
|
|
21
|
+
* name different ledgers: the ledger records the declaration, so a shared one
|
|
22
|
+
* would make each read the other's tables as removed.
|
|
23
|
+
*/
|
|
24
|
+
readonly ledger?: string;
|
|
25
|
+
/** The released version this deployment is running. Absent when no `reclaim:`
|
|
26
|
+
* policy is declared — nothing else reads it. */
|
|
27
|
+
readonly version?: string;
|
|
28
|
+
readonly tables: readonly DeclaredTable[];
|
|
29
|
+
readonly beforeMigrations: MigrationMap;
|
|
30
|
+
readonly migrations: MigrationMap;
|
|
31
|
+
readonly reclaim?: ReclaimPolicy;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What the pass did, reported as observed state so there is nothing to invoke to see it. */
|
|
35
|
+
export interface PendingReclamation {
|
|
36
|
+
readonly object: string;
|
|
37
|
+
readonly missingSinceVersion: string;
|
|
38
|
+
/** `null` when no `reclaim:` policy is declared — nothing is ever dropped, so
|
|
39
|
+
* there is no budget to count down, only the fact that the object is held. */
|
|
40
|
+
readonly versionsRemaining: number | null;
|
|
41
|
+
readonly msRemaining: number | null;
|
|
42
|
+
readonly eligible: boolean;
|
|
43
|
+
/** Present when the engine cannot drop an object of this kind at all: the
|
|
44
|
+
* tombstone stands, and this says what has to happen instead. */
|
|
45
|
+
readonly unreclaimable?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SchemaRunStatus {
|
|
49
|
+
/** The released version this deployment is running. Absent when no `reclaim:`
|
|
50
|
+
* policy is declared — nothing else reads it. */
|
|
51
|
+
readonly version?: string;
|
|
52
|
+
readonly digest: string;
|
|
53
|
+
readonly sequence: number;
|
|
54
|
+
readonly migrationsApplied: string[];
|
|
55
|
+
readonly orphanedMigrations: string[];
|
|
56
|
+
readonly tombstoned: string[];
|
|
57
|
+
readonly revived: string[];
|
|
58
|
+
readonly inertRenames: string[];
|
|
59
|
+
readonly reclaimed: string[];
|
|
60
|
+
readonly pendingReclamation: PendingReclamation[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The boot pass, in one defined order: lock, before-migrations, reconcile,
|
|
65
|
+
* migrations, record the version, tombstone, reclaim.
|
|
66
|
+
*
|
|
67
|
+
* The order is the reason schema change is ONE kind. Imperative and declarative
|
|
68
|
+
* schema change need the same lock, the same bookkeeping and a defined order
|
|
69
|
+
* between them; as separate kinds that order would live in the author's
|
|
70
|
+
* `targets:` list, invisible and uncheckable.
|
|
71
|
+
*
|
|
72
|
+
* The version row is written only once reconciliation and both migration phases
|
|
73
|
+
* have succeeded. A pass that fails before that records nothing, and the next
|
|
74
|
+
* boot re-derives everything from live state — which is what keeps the clock
|
|
75
|
+
* that gates an irreversible drop from advancing on a half-applied pass.
|
|
76
|
+
*/
|
|
77
|
+
/**
|
|
78
|
+
* Ledgers already claimed on a connection, so two schema resources sharing one
|
|
79
|
+
* cannot silently share a history.
|
|
80
|
+
*
|
|
81
|
+
* Hung off the CONNECTION INSTANCE rather than held in module scope: a
|
|
82
|
+
* controller bundle inlines its own copy of a shared source file, so a module
|
|
83
|
+
* global is one map per bundle and every lookup a miss (the payload rule,
|
|
84
|
+
* kernel/specs/execution-zones.md §8).
|
|
85
|
+
*
|
|
86
|
+
* This sees only what one process declares. Two APPLICATIONS sharing a namespace
|
|
87
|
+
* with the same ledger name are invisible here — as they are to every tool that
|
|
88
|
+
* separates history by table name — which is why the rule is also documented.
|
|
89
|
+
*/
|
|
90
|
+
const claimedLedgers = new WeakMap<object, Set<string>>();
|
|
91
|
+
|
|
92
|
+
function claimLedger(connection: object, schema: string, versionsTable: string): void {
|
|
93
|
+
const key = `${schema}\u0000${versionsTable}`;
|
|
94
|
+
let claimed = claimedLedgers.get(connection);
|
|
95
|
+
if (!claimed) claimedLedgers.set(connection, (claimed = new Set()));
|
|
96
|
+
if (claimed.has(key)) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Two schema resources share the ledger '${versionsTable}' in namespace '${schema}' on one ` +
|
|
99
|
+
`connection. The ledger records what its schema declares, so a shared one would make ` +
|
|
100
|
+
`each read the other's tables as removed and eventually drop them. Give one of them its ` +
|
|
101
|
+
`own: 'ledger: <name>'.`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
claimed.add(key);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* One physical table has ONE schema resource that manages it.
|
|
109
|
+
*
|
|
110
|
+
* Giving two of them separate ledgers keeps their HISTORIES apart, which is what
|
|
111
|
+
* the ledger name is for — but it says nothing about the tables themselves.
|
|
112
|
+
* Two resources declaring one table is worse than a shared history: remove it
|
|
113
|
+
* from one and that ledger tombstones it and eventually drops it, while the
|
|
114
|
+
* other recreates it empty on its next boot through `CREATE TABLE IF NOT
|
|
115
|
+
* EXISTS`. The data is gone and both manifests still look correct.
|
|
116
|
+
*/
|
|
117
|
+
function claimTable(connection: object, schema: string, table: string): void {
|
|
118
|
+
const key = `${schema}\u0000table:${table}`;
|
|
119
|
+
let claimed = claimedLedgers.get(connection);
|
|
120
|
+
if (!claimed) claimedLedgers.set(connection, (claimed = new Set()));
|
|
121
|
+
if (claimed.has(key)) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`Two schema resources declare the table '${table}' in namespace '${schema}' on one ` +
|
|
124
|
+
`connection. One table has one schema resource that manages it: were it removed from ` +
|
|
125
|
+
`one declaration, that schema would drop it while the other recreated it empty.`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
claimed.add(key);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function runSchemaPass(
|
|
132
|
+
driver: SchemaDriver,
|
|
133
|
+
ctx: ResourceContext,
|
|
134
|
+
input: SchemaRunInput,
|
|
135
|
+
): Promise<SchemaRunStatus> {
|
|
136
|
+
return driver.withLock(input.schema, () => pass(driver, ctx, input));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function pass(
|
|
140
|
+
driver: SchemaDriver,
|
|
141
|
+
ctx: ResourceContext,
|
|
142
|
+
input: SchemaRunInput,
|
|
143
|
+
): Promise<SchemaRunStatus> {
|
|
144
|
+
const now = () => driver.now();
|
|
145
|
+
const tables = ledgerTables(input.ledger);
|
|
146
|
+
claimLedger(driver.connection, input.schema, tables.versions);
|
|
147
|
+
for (const table of input.tables) claimTable(driver.connection, input.schema, table.name);
|
|
148
|
+
const ledger = new SchemaLedger(driver, input.schema, tables);
|
|
149
|
+
await driver.runAtomically(driver.ensureNamespaceStatements(input.schema));
|
|
150
|
+
await ledger.ensureTables();
|
|
151
|
+
|
|
152
|
+
const applied = await ledger.appliedMigrationKeys();
|
|
153
|
+
const history = await ledger.versionHistory();
|
|
154
|
+
const owned = history[history.length - 1]?.declaration ?? {};
|
|
155
|
+
const tombstones = await ledger.tombstones();
|
|
156
|
+
const tombstonedKeys = new Set(tombstones.map((t) => t.objectKey));
|
|
157
|
+
|
|
158
|
+
// The version is the reclamation clock. Declaring a policy without one would
|
|
159
|
+
// make every boot look like the same release, so no tombstone would ever age
|
|
160
|
+
// and the policy would silently never fire. The schema requires the pair; this
|
|
161
|
+
// is the same rule for a caller reaching the library directly, and it also
|
|
162
|
+
// catches an expression that evaluated to nothing.
|
|
163
|
+
// Allowed — the tests need it and an author may genuinely want it — but never
|
|
164
|
+
// silent: the time window is the backstop that exists because several releases
|
|
165
|
+
// can land in an afternoon, and zero switches it off, leaving `afterVersions`
|
|
166
|
+
// alone to gate an irreversible drop. A static diagnostic belongs to the
|
|
167
|
+
// declaration-consistency mechanism (analyzer/nodejs/plans/); until that
|
|
168
|
+
// lands, saying it at boot is better than not saying it.
|
|
169
|
+
if (input.reclaim && parseDurationMs(input.reclaim.afterDuration) === 0) {
|
|
170
|
+
ctx.log.warn("Reclamation has no time backstop", {
|
|
171
|
+
"sql.schema.reclaim.afterVersions": input.reclaim.afterVersions,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const declaredVersion = input.version?.trim() ?? "";
|
|
176
|
+
if (input.reclaim && declaredVersion === "") {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`Schema '${input.schema}': 'reclaim' is declared without a 'version'. The version is the ` +
|
|
179
|
+
`clock reclamation is gated on — without one every boot looks like the same release, so ` +
|
|
180
|
+
`nothing would ever age out. Declare it, conventionally as !cel "module.version".`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Two declarations of one physical table in one schema resource would be
|
|
185
|
+
// reconciled twice against one live table, each pass seeing the other's
|
|
186
|
+
// columns as undeclared.
|
|
187
|
+
const byPhysicalName = new Map<string, number>();
|
|
188
|
+
for (const table of input.tables) {
|
|
189
|
+
byPhysicalName.set(table.name, (byPhysicalName.get(table.name) ?? 0) + 1);
|
|
190
|
+
}
|
|
191
|
+
const duplicated = [...byPhysicalName].filter(([, count]) => count > 1).map(([name]) => name);
|
|
192
|
+
if (duplicated.length > 0) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`Schema '${input.schema}': ${duplicated.map((n) => `'${n}'`).join(", ")} ` +
|
|
195
|
+
`${duplicated.length === 1 ? "is declared" : "are declared"} by more than one table in ` +
|
|
196
|
+
`this schema. One physical table has one declaration; a table in two namespaces means ` +
|
|
197
|
+
`two schema resources.`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// A foreign key can only be created once its target exists, and this pass
|
|
202
|
+
// creates exactly the tables it was given.
|
|
203
|
+
const declaredNames = new Set(input.tables.map((table) => table.name));
|
|
204
|
+
for (const table of input.tables) {
|
|
205
|
+
for (const fk of table.foreignKeys) {
|
|
206
|
+
if (declaredNames.has(fk.references.table)) continue;
|
|
207
|
+
throw new Error(
|
|
208
|
+
`Schema '${input.schema}': foreign key '${table.name}.${fk.name}' references table ` +
|
|
209
|
+
`'${fk.references.table}', which this schema does not declare. Add it to 'tables:', ` +
|
|
210
|
+
`or create the constraint in a 'migrations:' entry if the target is owned elsewhere.`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Phase is not part of identity — the ledger stores the key alone, which is
|
|
216
|
+
// what lets a migration move between the two maps without re-running. The
|
|
217
|
+
// price is that a key in BOTH is meaningless: the merge below would drop one
|
|
218
|
+
// of them and the ledger would skip the other as already applied, so a
|
|
219
|
+
// migration the author wrote would never run and nothing would say so.
|
|
220
|
+
const collisions = Object.keys(input.beforeMigrations).filter(
|
|
221
|
+
(key) => key in input.migrations,
|
|
222
|
+
);
|
|
223
|
+
if (collisions.length > 0) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
`Schema '${input.schema}': ${collisions.map((k) => `'${k}'`).join(", ")} ` +
|
|
226
|
+
`${collisions.length === 1 ? "is declared" : "are declared"} in both ` +
|
|
227
|
+
`'beforeMigrations' and 'migrations'. A migration key is its identity across both ` +
|
|
228
|
+
`phases, so it may appear in only one — move it to the phase it belongs in.`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Both phases are checked for statements up front, so a malformed entry fails
|
|
233
|
+
// before any DDL has run rather than between two that have.
|
|
234
|
+
for (const [key, entry] of Object.entries({ ...input.beforeMigrations, ...input.migrations })) {
|
|
235
|
+
migrationStatements(key, entry);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const beforeApplied = await runMigrations(
|
|
239
|
+
driver,
|
|
240
|
+
ledger,
|
|
241
|
+
input.beforeMigrations,
|
|
242
|
+
applied,
|
|
243
|
+
now,
|
|
244
|
+
);
|
|
245
|
+
for (const key of beforeApplied) applied.add(key);
|
|
246
|
+
|
|
247
|
+
const live = await driver.introspect(
|
|
248
|
+
input.schema,
|
|
249
|
+
input.tables.map((table) => table.name),
|
|
250
|
+
);
|
|
251
|
+
const plan = planReconciliation(
|
|
252
|
+
driver,
|
|
253
|
+
input.schema,
|
|
254
|
+
input.tables,
|
|
255
|
+
live,
|
|
256
|
+
owned,
|
|
257
|
+
tombstonedKeys,
|
|
258
|
+
);
|
|
259
|
+
if (plan.refusals.length > 0) {
|
|
260
|
+
// Never applied, never skipped: the release stops here.
|
|
261
|
+
throw new Error(
|
|
262
|
+
`Schema '${input.schema}': ${plan.refusals.length} declared change(s) cannot be applied ` +
|
|
263
|
+
`safely to the data already present:\n${describeRefusals(plan.refusals)}`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
for (const phase of ["table", "index", "constraint"] as const) {
|
|
267
|
+
const statements = plan.statements.filter((s) => s.phase === phase);
|
|
268
|
+
if (statements.length === 0) continue;
|
|
269
|
+
await driver.runAtomically(statements.map((s) => s.sql));
|
|
270
|
+
for (const statement of statements) {
|
|
271
|
+
ctx.log.info("Schema reconciled", { "sql.schema.object": statement.describes });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const afterApplied = await runMigrations(driver, ledger, input.migrations, applied, now);
|
|
276
|
+
for (const key of afterApplied) applied.add(key);
|
|
277
|
+
|
|
278
|
+
const at = await driver.now();
|
|
279
|
+
const declaration = snapshotDeclaration(input.tables);
|
|
280
|
+
const digest = snapshotDigest(declaration);
|
|
281
|
+
|
|
282
|
+
// One group. The version row records the NEW declaration as owned, and the
|
|
283
|
+
// tombstones record what the old one had that this one does not — so a crash
|
|
284
|
+
// between them loses those objects for ever: the next boot's `owned` no longer
|
|
285
|
+
// mentions them, nothing tombstones them again, and they sit in the database
|
|
286
|
+
// untracked and undroppable. Committing them together is the same rule
|
|
287
|
+
// `runMigrations` follows for a migration and its ledger row.
|
|
288
|
+
const versionWrite = ledger.versionRecordStatements(
|
|
289
|
+
declaredVersion,
|
|
290
|
+
declaration,
|
|
291
|
+
digest,
|
|
292
|
+
at,
|
|
293
|
+
await ledger.versionHistory(),
|
|
294
|
+
);
|
|
295
|
+
const version = versionWrite.record;
|
|
296
|
+
await driver.runAtomically([
|
|
297
|
+
...versionWrite.statements,
|
|
298
|
+
...plan.tombstones.map((entry) =>
|
|
299
|
+
ledger.tombstoneRecordStatement(entry.id, entry.key, entry.definition, version, at),
|
|
300
|
+
),
|
|
301
|
+
]);
|
|
302
|
+
for (const entry of plan.tombstones) {
|
|
303
|
+
ctx.log.info("Schema object tombstoned", {
|
|
304
|
+
"sql.schema.object": describeObject(entry.id),
|
|
305
|
+
"sql.schema.version": version.version,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
// A revival is idempotent on its own — the tombstone is simply gone — so it
|
|
309
|
+
// needs no place in the group above.
|
|
310
|
+
for (const key of plan.revived) await ledger.clearTombstone(key);
|
|
311
|
+
|
|
312
|
+
// Dependents before the thing they hang off. Inherited from `ORDER BY
|
|
313
|
+
// object_key` this happened to be right — `c` < `f` < `i` < `t` — which is a
|
|
314
|
+
// property of the words, not of the design, and nothing said so or tested it.
|
|
315
|
+
const RECLAIM_ORDER: Record<string, number> = { foreignKey: 0, index: 1, column: 2, table: 3 };
|
|
316
|
+
const outstanding = (await ledger.tombstones())
|
|
317
|
+
.filter((t) => !plan.revived.includes(t.objectKey))
|
|
318
|
+
.sort((a, b) => (RECLAIM_ORDER[a.kind] ?? 9) - (RECLAIM_ORDER[b.kind] ?? 9));
|
|
319
|
+
const reclaimed = await reclaim(driver, ledger, ctx, input, outstanding, at);
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
version: version.version,
|
|
323
|
+
digest,
|
|
324
|
+
sequence: version.sequence,
|
|
325
|
+
migrationsApplied: [...beforeApplied, ...afterApplied],
|
|
326
|
+
orphanedMigrations: orphanedKeys(applied, input.beforeMigrations, input.migrations),
|
|
327
|
+
tombstoned: plan.tombstones.map((t) => describeObject(t.id)),
|
|
328
|
+
revived: plan.revived.map((key) => describeObject(parseObjectKey(key))),
|
|
329
|
+
inertRenames: [...plan.inertRenames],
|
|
330
|
+
reclaimed: reclaimed.dropped,
|
|
331
|
+
pendingReclamation: reclaimed.pending,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Reclamation runs automatically, gated by the declared policy. The control is
|
|
337
|
+
* declaring the policy at all: with none, nothing is ever dropped and the
|
|
338
|
+
* ledger still reports what WOULD be eligible, so a schema can run indefinitely
|
|
339
|
+
* with reclamation declared nowhere and still show what it is holding.
|
|
340
|
+
*/
|
|
341
|
+
async function reclaim(
|
|
342
|
+
driver: SchemaDriver,
|
|
343
|
+
ledger: SchemaLedger,
|
|
344
|
+
ctx: ResourceContext,
|
|
345
|
+
input: SchemaRunInput,
|
|
346
|
+
tombstones: readonly TombstoneRecord[],
|
|
347
|
+
at: string,
|
|
348
|
+
): Promise<{ dropped: string[]; pending: PendingReclamation[] }> {
|
|
349
|
+
const history = await ledger.versionHistory();
|
|
350
|
+
const nowMs = Date.parse(at);
|
|
351
|
+
const dropped: string[] = [];
|
|
352
|
+
const pending: PendingReclamation[] = [];
|
|
353
|
+
|
|
354
|
+
for (const tombstone of tombstones) {
|
|
355
|
+
const id = parseObjectKey(tombstone.objectKey);
|
|
356
|
+
const described = describeObject(id);
|
|
357
|
+
if (!input.reclaim) {
|
|
358
|
+
pending.push({
|
|
359
|
+
object: described,
|
|
360
|
+
missingSinceVersion: tombstone.missingSinceVersion,
|
|
361
|
+
versionsRemaining: null,
|
|
362
|
+
msRemaining: null,
|
|
363
|
+
eligible: false,
|
|
364
|
+
});
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
// A tombstone this engine cannot act on is left standing and REPORTED with
|
|
368
|
+
// the reason. Attempting it would fail the boot, and since the tombstone
|
|
369
|
+
// stays eligible it would fail every boot after it too — the application
|
|
370
|
+
// would never start again over a schema object nobody is waiting on.
|
|
371
|
+
const support = driver.canReclaim(id);
|
|
372
|
+
if (!support.safe) {
|
|
373
|
+
pending.push({
|
|
374
|
+
object: described,
|
|
375
|
+
missingSinceVersion: tombstone.missingSinceVersion,
|
|
376
|
+
versionsRemaining: null,
|
|
377
|
+
msRemaining: null,
|
|
378
|
+
eligible: false,
|
|
379
|
+
unreclaimable: support.reason,
|
|
380
|
+
});
|
|
381
|
+
ctx.log.warn("Schema object cannot be reclaimed by this engine", {
|
|
382
|
+
"sql.schema.object": described,
|
|
383
|
+
});
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const verdict = assessTombstone(tombstone, history, input.reclaim, nowMs);
|
|
388
|
+
if (!verdict.eligible) {
|
|
389
|
+
pending.push({
|
|
390
|
+
object: described,
|
|
391
|
+
missingSinceVersion: tombstone.missingSinceVersion,
|
|
392
|
+
versionsRemaining: verdict.versionsRemaining,
|
|
393
|
+
msRemaining: verdict.msRemaining,
|
|
394
|
+
eligible: false,
|
|
395
|
+
});
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
const statements =
|
|
399
|
+
id.kind === "table"
|
|
400
|
+
? driver.dropTable(input.schema, id.table)
|
|
401
|
+
: id.kind === "column"
|
|
402
|
+
? driver.dropColumn(input.schema, id.table, id.name!)
|
|
403
|
+
: id.kind === "index"
|
|
404
|
+
? driver.dropIndex(input.schema, id.table, id.name!)
|
|
405
|
+
: driver.dropForeignKey(input.schema, id.table, id.name!);
|
|
406
|
+
// A drop can still fail for a reason `canReclaim` cannot see — a dependent
|
|
407
|
+
// view, a lock timeout, a constraint discovered at the moment it runs. That
|
|
408
|
+
// must not be why the application stops starting: the tombstone stays
|
|
409
|
+
// eligible, so an unguarded failure here would fail this boot and every boot
|
|
410
|
+
// after it. Reported through the channel that already exists for held
|
|
411
|
+
// objects, and left standing.
|
|
412
|
+
try {
|
|
413
|
+
await driver.runAtomically(statements);
|
|
414
|
+
} catch (error) {
|
|
415
|
+
// The reason travels with the log, not only in observed state: this is on
|
|
416
|
+
// the boot path, and a warning that says an object could not be dropped
|
|
417
|
+
// without saying why sends the reader to a status field they may not be
|
|
418
|
+
// looking at.
|
|
419
|
+
ctx.log.warn("Schema object could not be reclaimed", {
|
|
420
|
+
"sql.schema.object": described,
|
|
421
|
+
"error.message": error instanceof Error ? error.message : String(error),
|
|
422
|
+
});
|
|
423
|
+
pending.push({
|
|
424
|
+
object: described,
|
|
425
|
+
missingSinceVersion: tombstone.missingSinceVersion,
|
|
426
|
+
versionsRemaining: 0,
|
|
427
|
+
msRemaining: 0,
|
|
428
|
+
eligible: true,
|
|
429
|
+
unreclaimable: error instanceof Error ? error.message : String(error),
|
|
430
|
+
});
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
await ledger.clearTombstone(tombstone.objectKey);
|
|
434
|
+
dropped.push(described);
|
|
435
|
+
ctx.log.info("Schema object reclaimed", {
|
|
436
|
+
"sql.schema.object": described,
|
|
437
|
+
"sql.schema.version": tombstone.missingSinceVersion,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
return { dropped, pending };
|
|
441
|
+
}
|
|
@@ -71,6 +71,10 @@ export abstract class SqlConnectionBase implements SqlConnection {
|
|
|
71
71
|
return this.ctx.zonesFor(this, ctx).some((entry) => this.#executors.has(entry));
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
bindsZone(zone: ZoneEntry): boolean {
|
|
75
|
+
return this.#executors.has(zone);
|
|
76
|
+
}
|
|
77
|
+
|
|
74
78
|
/**
|
|
75
79
|
* Every statement this connection runs funnels through here — `executeTemplate`
|
|
76
80
|
* and `executeScript` both delegate — so it is the single instrumentation point.
|
|
@@ -90,11 +94,19 @@ export abstract class SqlConnectionBase implements SqlConnection {
|
|
|
90
94
|
ctx?: InvokeContext,
|
|
91
95
|
): Promise<QueryResult<T>> {
|
|
92
96
|
const executor = this.resolveExecutor(zone, ctx);
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
97
|
+
return this.instrument(sql, () => executor.executeQuery<T>(CompiledQuery.raw(sql, params)));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The single instrumentation point, shared by every path that runs a
|
|
101
|
+
* statement. The disabled branch allocates nothing and takes no clock
|
|
102
|
+
* reading — a query is the hottest thing this module does. */
|
|
103
|
+
private async instrument<T>(
|
|
104
|
+
sql: string,
|
|
105
|
+
run: () => Promise<QueryResult<T>>,
|
|
106
|
+
): Promise<QueryResult<T>> {
|
|
107
|
+
if (!this.ctx.log.enabled(SEVERITY.debug)) return run();
|
|
96
108
|
const startedAt = Date.now();
|
|
97
|
-
const result = await
|
|
109
|
+
const result = await run();
|
|
98
110
|
this.ctx.log.debug("Statement executed", {
|
|
99
111
|
"db.query.text": sql,
|
|
100
112
|
"db.response.returned_rows": result.rows.length,
|
|
@@ -108,6 +120,25 @@ export abstract class SqlConnectionBase implements SqlConnection {
|
|
|
108
120
|
return result;
|
|
109
121
|
}
|
|
110
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Run a statement on the CONNECTION, never on an ambient transaction.
|
|
125
|
+
*
|
|
126
|
+
* The complement of {@link resolveExecutor}, and it exists because "joins
|
|
127
|
+
* whatever transaction is open" is the right default and the wrong one for a
|
|
128
|
+
* particular class of write: a record ABOUT the work rather than part of it.
|
|
129
|
+
* A durable journal settling a run is the case that forced it — a settlement
|
|
130
|
+
* discarded by the caller's rollback leaves a run recorded as still executing
|
|
131
|
+
* while its effects are gone, and a claim that rolls back releases a run
|
|
132
|
+
* another poller may already hold.
|
|
133
|
+
*
|
|
134
|
+
* On the contract rather than left to each caller to reach for `kysely`,
|
|
135
|
+
* because the escape hatch is the same for everyone and a caller that reaches
|
|
136
|
+
* past `execute` also loses its instrumentation — this keeps both.
|
|
137
|
+
*/
|
|
138
|
+
async executeUncommitted<T>(sql: string, params: unknown[] = []): Promise<QueryResult<T>> {
|
|
139
|
+
return this.instrument(sql, () => this.db.executeQuery<T>(CompiledQuery.raw(sql, params)));
|
|
140
|
+
}
|
|
141
|
+
|
|
111
142
|
async executeTemplate<T>(
|
|
112
143
|
fragments: string[],
|
|
113
144
|
values: unknown[],
|
package/src/sql-connection.ts
CHANGED
|
@@ -68,6 +68,15 @@ export interface SqlConnection extends ResourceInstance {
|
|
|
68
68
|
ctx?: InvokeContext,
|
|
69
69
|
): Promise<QueryResult<T>>;
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Run a statement on the CONNECTION, never on an ambient transaction.
|
|
73
|
+
*
|
|
74
|
+
* For a write that is a record ABOUT the work rather than part of it, and so
|
|
75
|
+
* must survive whatever the work was doing — a durable journal settling a run,
|
|
76
|
+
* releasing a claim. Everything else should use {@link execute} and join.
|
|
77
|
+
*/
|
|
78
|
+
executeUncommitted<T>(sql: string, params?: unknown[]): Promise<QueryResult<T>>;
|
|
79
|
+
|
|
71
80
|
/** Run a multi-statement script. */
|
|
72
81
|
executeScript(sql: string): Promise<void>;
|
|
73
82
|
|
|
@@ -83,6 +92,18 @@ export interface SqlConnection extends ResourceInstance {
|
|
|
83
92
|
* open executor here — the flat-nesting check `Sql.Transaction` reuses. */
|
|
84
93
|
hasOpenTransaction(ctx?: InvokeContext): boolean;
|
|
85
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Does THIS connection hold the open executor for THIS zone?
|
|
97
|
+
*
|
|
98
|
+
* The named zone rather than whatever is ambient, which is what an attestation
|
|
99
|
+
* needs: a caller asking whether its own writes land inside a particular
|
|
100
|
+
* region gets a wrong answer from an ambient check the moment a second
|
|
101
|
+
* transaction is open somewhere in the stack. `hasOpenTransaction` answers the
|
|
102
|
+
* dispatch-time question ("is there one to execute on"); this answers the
|
|
103
|
+
* membership question ("is it that one").
|
|
104
|
+
*/
|
|
105
|
+
bindsZone(zone: ZoneEntry): boolean;
|
|
106
|
+
|
|
86
107
|
/** Rows affected by a write, normalized across drivers. */
|
|
87
108
|
toRowCount(result: QueryResult<unknown>): number;
|
|
88
109
|
}
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import type { ResourceInstance } from "@telorun/sdk";
|
|
2
|
-
interface SqlMigrationManifest {
|
|
3
|
-
metadata: {
|
|
4
|
-
name: string;
|
|
5
|
-
module: string;
|
|
6
|
-
};
|
|
7
|
-
sql: string;
|
|
8
|
-
}
|
|
9
|
-
declare class SqlMigrationResource implements ResourceInstance {
|
|
10
|
-
readonly manifest: SqlMigrationManifest;
|
|
11
|
-
constructor(manifest: SqlMigrationManifest);
|
|
12
|
-
snapshot(): Record<string, unknown>;
|
|
13
|
-
}
|
|
14
|
-
export declare function register(): void;
|
|
15
|
-
export declare function create(resource: SqlMigrationManifest): Promise<SqlMigrationResource>;
|
|
16
|
-
export {};
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
class SqlMigrationResource {
|
|
2
|
-
manifest;
|
|
3
|
-
constructor(manifest) {
|
|
4
|
-
this.manifest = manifest;
|
|
5
|
-
}
|
|
6
|
-
snapshot() {
|
|
7
|
-
return {};
|
|
8
|
-
}
|
|
9
|
-
}
|
|
10
|
-
export function register() { }
|
|
11
|
-
export async function create(resource) {
|
|
12
|
-
return new SqlMigrationResource(resource);
|
|
13
|
-
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
2
|
-
import type { SqlConnection } from "./sql-connection.js";
|
|
3
|
-
interface MigrationEntry {
|
|
4
|
-
statement?: string;
|
|
5
|
-
statements?: string[];
|
|
6
|
-
}
|
|
7
|
-
interface SqlMigrationsManifest {
|
|
8
|
-
metadata: {
|
|
9
|
-
name: string;
|
|
10
|
-
module: string;
|
|
11
|
-
};
|
|
12
|
-
connection: SqlConnection;
|
|
13
|
-
migrations?: Record<string, MigrationEntry>;
|
|
14
|
-
}
|
|
15
|
-
declare class SqlMigrationsResource implements ResourceInstance {
|
|
16
|
-
private readonly manifest;
|
|
17
|
-
private readonly ctx;
|
|
18
|
-
constructor(manifest: SqlMigrationsManifest, ctx: ResourceContext);
|
|
19
|
-
run(): Promise<void>;
|
|
20
|
-
}
|
|
21
|
-
export declare function register(): void;
|
|
22
|
-
export declare function create(resource: SqlMigrationsManifest, ctx: ResourceContext): Promise<SqlMigrationsResource>;
|
|
23
|
-
export {};
|