@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.
Files changed (45) hide show
  1. package/dist/index.d.ts +18 -2
  2. package/dist/index.js +9 -2
  3. package/dist/schema/declaration-snapshot.d.ts +21 -0
  4. package/dist/schema/declaration-snapshot.js +46 -0
  5. package/dist/schema/declared-schema.d.ts +64 -0
  6. package/dist/schema/declared-schema.js +16 -0
  7. package/dist/schema/migration-runner.d.ts +30 -0
  8. package/dist/schema/migration-runner.js +38 -0
  9. package/dist/schema/normalize-table.d.ts +57 -0
  10. package/dist/schema/normalize-table.js +125 -0
  11. package/dist/schema/reclaim-policy.d.ts +34 -0
  12. package/dist/schema/reclaim-policy.js +40 -0
  13. package/dist/schema/schema-driver.d.ts +184 -0
  14. package/dist/schema/schema-driver.js +1 -0
  15. package/dist/schema/schema-ledger.d.ts +119 -0
  16. package/dist/schema/schema-ledger.js +231 -0
  17. package/dist/schema/schema-reconciler.d.ts +45 -0
  18. package/dist/schema/schema-reconciler.js +276 -0
  19. package/dist/schema/schema-run.d.ts +50 -0
  20. package/dist/schema/schema-run.js +318 -0
  21. package/dist/schema/table-reference.d.ts +25 -0
  22. package/dist/schema/table-reference.js +61 -0
  23. package/dist/sql-connection-base.d.ts +21 -0
  24. package/dist/sql-connection-base.js +30 -4
  25. package/dist/sql-connection.d.ts +19 -0
  26. package/package.json +5 -3
  27. package/src/index.ts +43 -2
  28. package/src/schema/declaration-snapshot.ts +71 -0
  29. package/src/schema/declared-schema.ts +73 -0
  30. package/src/schema/migration-runner.ts +69 -0
  31. package/src/schema/normalize-table.ts +224 -0
  32. package/src/schema/reclaim-policy.ts +73 -0
  33. package/src/schema/schema-driver.ts +207 -0
  34. package/src/schema/schema-ledger.ts +309 -0
  35. package/src/schema/schema-reconciler.ts +372 -0
  36. package/src/schema/schema-run.ts +441 -0
  37. package/src/schema/table-reference.ts +78 -0
  38. package/src/sql-connection-base.ts +35 -4
  39. package/src/sql-connection.ts +21 -0
  40. package/dist/sql-migration-controller.d.ts +0 -16
  41. package/dist/sql-migration-controller.js +0 -13
  42. package/dist/sql-migrations-controller.d.ts +0 -23
  43. package/dist/sql-migrations-controller.js +0 -98
  44. package/src/sql-migration-controller.ts +0 -20
  45. package/src/sql-migrations-controller.ts +0 -143
@@ -0,0 +1,61 @@
1
+ function asRef(value) {
2
+ const ref = value;
3
+ if (!ref || typeof ref !== "object" || typeof ref.name !== "string")
4
+ return undefined;
5
+ return { name: ref.name, alias: typeof ref.alias === "string" ? ref.alias : undefined };
6
+ }
7
+ /**
8
+ * Resolves a `references.table` slot to the referenced table's physical name.
9
+ *
10
+ * **BOTH shapes arrive, and which one is a race.** A table reads this slot while
11
+ * it is being CREATED, and Phase-5 injection replaces a reference only when the
12
+ * target is already registered — a local ref naming nothing pending is left
13
+ * exactly as written. So the same manifest hands over a live instance on one
14
+ * pass of the init loop and the raw `{ kind, name }` on another, and reading
15
+ * only the instance is what made every cross-table foreign key fail outright.
16
+ *
17
+ * The reference is resolved to the target's DECLARATION, which carries the one
18
+ * thing a foreign key needs from it — the physical name — and carries it whether
19
+ * or not the target has been constructed. That is also why the slot stays
20
+ * `use: schema` and registers no ordering edge: nothing here requires the
21
+ * referenced table to exist first, and an edge would make a tree table (which
22
+ * references ITSELF) and a mutual pair into init cycles, though both are
23
+ * perfectly creatable on an engine that emits keys after every table.
24
+ *
25
+ * A plain string is accepted for an internal caller that already holds a name;
26
+ * an author cannot write one, since a ref slot rejects a bare string
27
+ * (`INVALID_REFERENCE_FORM`).
28
+ */
29
+ export function tableReferenceResolver(ctx, kind, table) {
30
+ return (value, fk) => {
31
+ if (typeof value === "string")
32
+ return value;
33
+ const where = `${kind} '${table}': foreign key '${fk}': 'references.table'`;
34
+ const ref = asRef(value);
35
+ if (ref) {
36
+ const declared = ctx.resolveDeclaredManifest?.(ref.name, ref.alias);
37
+ if (!declared) {
38
+ throw new Error(`${where} names '${ref.name}', which resolves to no declared resource. A foreign key ` +
39
+ `reads its target from that resource's DECLARATION, so the table it names has to be ` +
40
+ `declared in a scope this one can see.`);
41
+ }
42
+ // The kind is constrained statically by `x-telo-ref`, so this is a
43
+ // backstop — but one that must not accept the wrong kind, since any
44
+ // resource carrying a `table` field would otherwise put a wrong
45
+ // identifier into DDL.
46
+ if (declared.kind !== kind) {
47
+ throw new Error(`${where} names '${ref.name}', which is a ${declared.kind}, not a ${kind}.`);
48
+ }
49
+ if (typeof declared.table !== "string") {
50
+ throw new Error(`${where} names '${ref.name}', which declares no 'table'.`);
51
+ }
52
+ return declared.table;
53
+ }
54
+ // Injection won the race: the slot holds the live table resource, which
55
+ // reports the same name its declaration carries.
56
+ const injected = value?.table;
57
+ if (typeof injected === "string")
58
+ return injected;
59
+ throw new Error(`${where} is not a reference to a ${kind}.`);
60
+ };
61
+ }
@@ -20,6 +20,7 @@ export declare abstract class SqlConnectionBase implements SqlConnection {
20
20
  teardown(): Promise<void>;
21
21
  runInTransaction<T>(body: (bind: (entry: ZoneEntry) => void) => Promise<T>): Promise<T>;
22
22
  hasOpenTransaction(ctx?: InvokeContext): boolean;
23
+ bindsZone(zone: ZoneEntry): boolean;
23
24
  /**
24
25
  * Every statement this connection runs funnels through here — `executeTemplate`
25
26
  * and `executeScript` both delegate — so it is the single instrumentation point.
@@ -33,6 +34,26 @@ export declare abstract class SqlConnectionBase implements SqlConnection {
33
34
  * the hottest thing this module does.
34
35
  */
35
36
  execute<T>(sql: string, params?: unknown[], zone?: ZoneEntry, ctx?: InvokeContext): Promise<QueryResult<T>>;
37
+ /** The single instrumentation point, shared by every path that runs a
38
+ * statement. The disabled branch allocates nothing and takes no clock
39
+ * reading — a query is the hottest thing this module does. */
40
+ private instrument;
41
+ /**
42
+ * Run a statement on the CONNECTION, never on an ambient transaction.
43
+ *
44
+ * The complement of {@link resolveExecutor}, and it exists because "joins
45
+ * whatever transaction is open" is the right default and the wrong one for a
46
+ * particular class of write: a record ABOUT the work rather than part of it.
47
+ * A durable journal settling a run is the case that forced it — a settlement
48
+ * discarded by the caller's rollback leaves a run recorded as still executing
49
+ * while its effects are gone, and a claim that rolls back releases a run
50
+ * another poller may already hold.
51
+ *
52
+ * On the contract rather than left to each caller to reach for `kysely`,
53
+ * because the escape hatch is the same for everyone and a caller that reaches
54
+ * past `execute` also loses its instrumentation — this keeps both.
55
+ */
56
+ executeUncommitted<T>(sql: string, params?: unknown[]): Promise<QueryResult<T>>;
36
57
  executeTemplate<T>(fragments: string[], values: unknown[], zone?: ZoneEntry, ctx?: InvokeContext): Promise<QueryResult<T>>;
37
58
  /** Hand the whole script to the driver as one statement. Backends whose driver
38
59
  * needs a dedicated multi-statement entry point override this. */
@@ -54,6 +54,9 @@ export class SqlConnectionBase {
54
54
  hasOpenTransaction(ctx) {
55
55
  return this.ctx.zonesFor(this, ctx).some((entry) => this.#executors.has(entry));
56
56
  }
57
+ bindsZone(zone) {
58
+ return this.#executors.has(zone);
59
+ }
57
60
  /**
58
61
  * Every statement this connection runs funnels through here — `executeTemplate`
59
62
  * and `executeScript` both delegate — so it is the single instrumentation point.
@@ -68,11 +71,16 @@ export class SqlConnectionBase {
68
71
  */
69
72
  async execute(sql, params = [], zone, ctx) {
70
73
  const executor = this.resolveExecutor(zone, ctx);
71
- if (!this.ctx.log.enabled(SEVERITY.debug)) {
72
- return executor.executeQuery(CompiledQuery.raw(sql, params));
73
- }
74
+ return this.instrument(sql, () => executor.executeQuery(CompiledQuery.raw(sql, params)));
75
+ }
76
+ /** The single instrumentation point, shared by every path that runs a
77
+ * statement. The disabled branch allocates nothing and takes no clock
78
+ * reading — a query is the hottest thing this module does. */
79
+ async instrument(sql, run) {
80
+ if (!this.ctx.log.enabled(SEVERITY.debug))
81
+ return run();
74
82
  const startedAt = Date.now();
75
- const result = await executor.executeQuery(CompiledQuery.raw(sql, params));
83
+ const result = await run();
76
84
  this.ctx.log.debug("Statement executed", {
77
85
  "db.query.text": sql,
78
86
  "db.response.returned_rows": result.rows.length,
@@ -85,6 +93,24 @@ export class SqlConnectionBase {
85
93
  });
86
94
  return result;
87
95
  }
96
+ /**
97
+ * Run a statement on the CONNECTION, never on an ambient transaction.
98
+ *
99
+ * The complement of {@link resolveExecutor}, and it exists because "joins
100
+ * whatever transaction is open" is the right default and the wrong one for a
101
+ * particular class of write: a record ABOUT the work rather than part of it.
102
+ * A durable journal settling a run is the case that forced it — a settlement
103
+ * discarded by the caller's rollback leaves a run recorded as still executing
104
+ * while its effects are gone, and a claim that rolls back releases a run
105
+ * another poller may already hold.
106
+ *
107
+ * On the contract rather than left to each caller to reach for `kysely`,
108
+ * because the escape hatch is the same for everyone and a caller that reaches
109
+ * past `execute` also loses its instrumentation — this keeps both.
110
+ */
111
+ async executeUncommitted(sql, params = []) {
112
+ return this.instrument(sql, () => this.db.executeQuery(CompiledQuery.raw(sql, params)));
113
+ }
88
114
  async executeTemplate(fragments, values, zone, ctx) {
89
115
  let sql = fragments[0] ?? "";
90
116
  for (let i = 1; i < fragments.length; i++) {
@@ -42,6 +42,14 @@ export interface SqlConnection extends ResourceInstance {
42
42
  /** Assemble SQL from literal fragments by interleaving dialect-native
43
43
  * placeholders, then bind `values` positionally. */
44
44
  executeTemplate<T>(fragments: string[], values: unknown[], zone?: ZoneEntry, ctx?: InvokeContext): Promise<QueryResult<T>>;
45
+ /**
46
+ * Run a statement on the CONNECTION, never on an ambient transaction.
47
+ *
48
+ * For a write that is a record ABOUT the work rather than part of it, and so
49
+ * must survive whatever the work was doing — a durable journal settling a run,
50
+ * releasing a claim. Everything else should use {@link execute} and join.
51
+ */
52
+ executeUncommitted<T>(sql: string, params?: unknown[]): Promise<QueryResult<T>>;
45
53
  /** Run a multi-statement script. */
46
54
  executeScript(sql: string): Promise<void>;
47
55
  /** Open a database transaction and hand the caller a `bind` that keys the
@@ -51,6 +59,17 @@ export interface SqlConnection extends ResourceInstance {
51
59
  /** True when an ambient transaction zone correlated on this connection has an
52
60
  * open executor here — the flat-nesting check `Sql.Transaction` reuses. */
53
61
  hasOpenTransaction(ctx?: InvokeContext): boolean;
62
+ /**
63
+ * Does THIS connection hold the open executor for THIS zone?
64
+ *
65
+ * The named zone rather than whatever is ambient, which is what an attestation
66
+ * needs: a caller asking whether its own writes land inside a particular
67
+ * region gets a wrong answer from an ambient check the moment a second
68
+ * transaction is open somewhere in the stack. `hasOpenTransaction` answers the
69
+ * dispatch-time question ("is there one to execute on"); this answers the
70
+ * membership question ("is it that one").
71
+ */
72
+ bindsZone(zone: ZoneEntry): boolean;
54
73
  /** Rows affected by a write, normalized across drivers. */
55
74
  toRowCount(result: QueryResult<unknown>): number;
56
75
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/sql",
3
- "version": "0.21.3",
3
+ "version": "0.22.1",
4
4
  "description": "Telo SQL module - SQL database resource kinds for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -43,12 +43,14 @@
43
43
  "@types/node": "^20.0.0",
44
44
  "esbuild": "^0.25.12",
45
45
  "typescript": "^5.0.0",
46
- "@telorun/sdk": "0.77.0"
46
+ "vitest": "^2.1.8",
47
+ "@telorun/sdk": "0.80.0"
47
48
  },
48
49
  "peerDependencies": {
49
50
  "@telorun/sdk": "*"
50
51
  },
51
52
  "scripts": {
52
- "build": "tsc -p tsconfig.lib.json"
53
+ "build": "tsc -p tsconfig.lib.json",
54
+ "test": "vitest run"
53
55
  }
54
56
  }
package/src/index.ts CHANGED
@@ -11,8 +11,49 @@ export { isSqlConnection, resolveSqlConnection } from "./sql-connection-ref.js";
11
11
  // these by PURL fragment, so the whole module is one bundle and its shared
12
12
  // state is one module scope.
13
13
  export * as SqlCommandController from "./sql-command-controller.js";
14
- export * as SqlMigrationController from "./sql-migration-controller.js";
15
- export * as SqlMigrationsController from "./sql-migrations-controller.js";
16
14
  export * as SqlQueryController from "./sql-query-controller.js";
17
15
  export * as SqlSelectionController from "./sql-selection-controller.js";
18
16
  export * as SqlTransactionController from "./sql-transaction-controller.js";
17
+
18
+ // Declarative schema. The shared half only — the diff, the ledger, the
19
+ // tombstones and the ordering. Every backend implements `SchemaDriver` and owns
20
+ // its own type vocabulary, DDL rendering, introspection and locking, so nothing
21
+ // here is a lowest-common-denominator type layer.
22
+ export type {
23
+ DeclaredColumn,
24
+ DeclaredForeignKey,
25
+ DeclaredIndex,
26
+ DeclaredTable,
27
+ SchemaObjectId,
28
+ SchemaObjectKind,
29
+ } from "./schema/declared-schema.js";
30
+ export { describeObject, objectKey } from "./schema/declared-schema.js";
31
+ export type {
32
+ ChangeSafety,
33
+ LiveColumn,
34
+ LiveForeignKey,
35
+ LiveIndex,
36
+ LiveTable,
37
+ SchemaDriver,
38
+ } from "./schema/schema-driver.js";
39
+ export { ledgerTables, SchemaLedger } from "./schema/schema-ledger.js";
40
+ export type { LedgerTables, TombstoneRecord, VersionRecord } from "./schema/schema-ledger.js";
41
+ export { assessTombstone } from "./schema/reclaim-policy.js";
42
+ export type { Eligibility, ReclaimPolicy } from "./schema/reclaim-policy.js";
43
+ export { snapshotDeclaration, snapshotDigest } from "./schema/declaration-snapshot.js";
44
+ export type { DeclarationSnapshot } from "./schema/declaration-snapshot.js";
45
+ export { planReconciliation } from "./schema/schema-reconciler.js";
46
+ export type { PlannedStatement, PlannedTombstone, SchemaPlan } from "./schema/schema-reconciler.js";
47
+ export { migrationStatements, pendingKeys } from "./schema/migration-runner.js";
48
+ export type { MigrationEntry, MigrationMap } from "./schema/migration-runner.js";
49
+ export { normalizeTable } from "./schema/normalize-table.js";
50
+ export { tableReferenceResolver } from "./schema/table-reference.js";
51
+ export type {
52
+ RawColumn,
53
+ RawForeignKey,
54
+ RawIndex,
55
+ RawTable,
56
+ TableReferenceResolver,
57
+ } from "./schema/normalize-table.js";
58
+ export { runSchemaPass } from "./schema/schema-run.js";
59
+ export type { PendingReclamation, SchemaRunInput, SchemaRunStatus } from "./schema/schema-run.js";
@@ -0,0 +1,71 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { DeclaredTable, SchemaObjectId } from "./declared-schema.js";
3
+ import { objectKey } from "./declared-schema.js";
4
+
5
+ /**
6
+ * The declaration, flattened to one entry per schema object.
7
+ *
8
+ * Recording the snapshot rather than only its digest is what answers the
9
+ * question reconciliation cannot answer from live state alone: which objects
10
+ * THIS schema resource owns. An object in the namespace that has never appeared
11
+ * in a snapshot was never declared here — a legacy table, another application's,
12
+ * one predating adoption — and is invisible to both the diff and reclamation.
13
+ * Inferring ownership from presence would make adopting an existing database a
14
+ * data-loss event.
15
+ *
16
+ * It also supplies a tombstone's last-known definition for free, so nothing has
17
+ * to reconstruct what a dropped object was after the declaration stopped
18
+ * describing it.
19
+ */
20
+ export type DeclarationSnapshot = Record<string, string>;
21
+
22
+ function stable(value: unknown): unknown {
23
+ if (Array.isArray(value)) return value.map(stable);
24
+ if (value && typeof value === "object") {
25
+ return Object.fromEntries(
26
+ Object.entries(value as Record<string, unknown>)
27
+ .filter(([, v]) => v !== undefined)
28
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
29
+ .map(([k, v]) => [k, stable(v)]),
30
+ );
31
+ }
32
+ return value;
33
+ }
34
+
35
+ function entry(snapshot: DeclarationSnapshot, id: SchemaObjectId, definition: unknown): void {
36
+ snapshot[objectKey(id)] = JSON.stringify(stable(definition));
37
+ }
38
+
39
+ export function snapshotDeclaration(tables: readonly DeclaredTable[]): DeclarationSnapshot {
40
+ const snapshot: DeclarationSnapshot = {};
41
+ for (const table of tables) {
42
+ entry(snapshot, { kind: "table", table: table.name }, { name: table.name });
43
+ for (const column of table.columns) {
44
+ entry(snapshot, { kind: "column", table: table.name, name: column.name }, column);
45
+ }
46
+ for (const index of table.indexes) {
47
+ entry(snapshot, { kind: "index", table: table.name, name: index.name }, index);
48
+ }
49
+ for (const fk of table.foreignKeys) {
50
+ entry(snapshot, { kind: "foreignKey", table: table.name, name: fk.name }, fk);
51
+ }
52
+ }
53
+ return snapshot;
54
+ }
55
+
56
+ /** Stable digest of a snapshot — what the ledger compares boots against. */
57
+ export function snapshotDigest(snapshot: DeclarationSnapshot): string {
58
+ const canonical = JSON.stringify(
59
+ Object.keys(snapshot)
60
+ .sort()
61
+ .map((key) => [key, snapshot[key]]),
62
+ );
63
+ return createHash("sha256").update(canonical).digest("hex");
64
+ }
65
+
66
+ export function parseObjectKey(key: string): SchemaObjectId {
67
+ const [kind, rest] = key.split(":", 2) as [SchemaObjectId["kind"], string];
68
+ const dot = rest.indexOf(".");
69
+ if (kind === "table" || dot < 0) return { kind, table: rest };
70
+ return { kind, table: rest.slice(0, dot), name: rest.slice(dot + 1) };
71
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The normalized declaration a backend's `Table` kind reduces to.
3
+ *
4
+ * Backends declare columns in their own vocabulary — `citext`, `jsonb`, SQLite
5
+ * storage classes — and this model carries the type through as an opaque
6
+ * `type` plus its structured `params`. Nothing here parses a type; only the
7
+ * driver understands one. What IS shared is the shape of a table: named
8
+ * columns, named indexes, named foreign keys, each with the durable identity
9
+ * reconciliation diffs and tombstones key on.
10
+ */
11
+
12
+ export interface DeclaredColumn {
13
+ /** Durable identity. Tombstones key on it; renaming is expand-contract. */
14
+ readonly name: string;
15
+ /** Backend-native type name, never parsed here. */
16
+ readonly type: string;
17
+ /** Structured type parameters (`length`, `precision`, …), never spelled into `type`. */
18
+ readonly params: Readonly<Record<string, unknown>>;
19
+ readonly nullable: boolean;
20
+ readonly array: boolean;
21
+ readonly primaryKey: boolean;
22
+ readonly unique: boolean;
23
+ /** A typed literal. Mutually exclusive with {@link defaultExpression}. */
24
+ readonly default?: unknown;
25
+ /** Raw backend SQL evaluated by the database. */
26
+ readonly defaultExpression?: string;
27
+ /** Backend-specific identity/auto-increment mode, passed through to the driver. */
28
+ readonly identity?: string;
29
+ /** The column this one supersedes. The pass adds, copies, then tombstones the source. */
30
+ readonly renamedFrom?: string;
31
+ }
32
+
33
+ export interface DeclaredIndex {
34
+ readonly name: string;
35
+ readonly columns: readonly string[];
36
+ readonly unique: boolean;
37
+ /** Backend-specific extras (partial predicate, method), passed through. */
38
+ readonly options: Readonly<Record<string, unknown>>;
39
+ }
40
+
41
+ export interface DeclaredForeignKey {
42
+ readonly name: string;
43
+ readonly columns: readonly string[];
44
+ readonly references: { readonly table: string; readonly columns: readonly string[] };
45
+ readonly onDelete?: string;
46
+ readonly onUpdate?: string;
47
+ }
48
+
49
+ export interface DeclaredTable {
50
+ /** Physical table name. */
51
+ readonly name: string;
52
+ readonly columns: readonly DeclaredColumn[];
53
+ readonly indexes: readonly DeclaredIndex[];
54
+ readonly foreignKeys: readonly DeclaredForeignKey[];
55
+ }
56
+
57
+ /** Every schema object reconciliation tracks, by the identity a tombstone keys on. */
58
+ export type SchemaObjectKind = "table" | "column" | "index" | "foreignKey";
59
+
60
+ export interface SchemaObjectId {
61
+ readonly kind: SchemaObjectKind;
62
+ readonly table: string;
63
+ /** The column / index / constraint name; absent for a table. */
64
+ readonly name?: string;
65
+ }
66
+
67
+ export function objectKey(id: SchemaObjectId): string {
68
+ return id.name == null ? `${id.kind}:${id.table}` : `${id.kind}:${id.table}.${id.name}`;
69
+ }
70
+
71
+ export function describeObject(id: SchemaObjectId): string {
72
+ return id.name == null ? `table ${id.table}` : `${id.kind} ${id.table}.${id.name}`;
73
+ }
@@ -0,0 +1,69 @@
1
+ import type { SchemaLedger } from "./schema-ledger.js";
2
+ import type { SchemaDriver } from "./schema-driver.js";
3
+
4
+ /**
5
+ * A migration is one `statement` or an ordered list of `statements`, keyed by a
6
+ * durable id. Phase is NOT part of identity: keys are unique across both maps
7
+ * and the ledger stores the key alone, so moving a migration between
8
+ * `beforeMigrations:` and `migrations:` keeps its identity and does not re-run
9
+ * it.
10
+ */
11
+ export interface MigrationEntry {
12
+ readonly statement?: string;
13
+ readonly statements?: readonly string[];
14
+ }
15
+
16
+ export type MigrationMap = Record<string, MigrationEntry>;
17
+
18
+ export function migrationStatements(key: string, entry: MigrationEntry): string[] {
19
+ const statements = entry.statements ?? (entry.statement != null ? [entry.statement] : []);
20
+ if (statements.length === 0) {
21
+ throw new Error(
22
+ `migration '${key}' has no statement(s) — set 'statement' or a non-empty 'statements'`,
23
+ );
24
+ }
25
+ return [...statements];
26
+ }
27
+
28
+ /** Keys not yet in the ledger, in key order — the order they are applied in. */
29
+ export function pendingKeys(migrations: MigrationMap, applied: ReadonlySet<string>): string[] {
30
+ return Object.keys(migrations)
31
+ .filter((key) => !applied.has(key))
32
+ .sort();
33
+ }
34
+
35
+ /**
36
+ * Apply the pending migrations in key order, each with its ledger row in the
37
+ * SAME atomic group.
38
+ *
39
+ * Applying and recording are one operation, not two: a crash between them would
40
+ * re-run a migration that may not be idempotent. Whether the group is genuinely
41
+ * atomic is the engine's to say — {@link SchemaDriver.runAtomically} groups it
42
+ * where DDL is transactional and runs it sequentially where it is not — but the
43
+ * ledger write is never the thing left outstanding, because it is last in the
44
+ * group.
45
+ */
46
+ export async function runMigrations(
47
+ driver: SchemaDriver,
48
+ ledger: SchemaLedger,
49
+ migrations: MigrationMap,
50
+ applied: ReadonlySet<string>,
51
+ now: () => Promise<string>,
52
+ ): Promise<string[]> {
53
+ const pending = pendingKeys(migrations, applied);
54
+ for (const key of pending) {
55
+ await driver.runAtomically([
56
+ ...migrationStatements(key, migrations[key]!),
57
+ ledger.migrationRecordStatement(key, await now()),
58
+ ]);
59
+ }
60
+ return pending;
61
+ }
62
+
63
+ export function orphanedKeys(
64
+ applied: ReadonlySet<string>,
65
+ ...declared: readonly MigrationMap[]
66
+ ): string[] {
67
+ const known = new Set(declared.flatMap((map) => Object.keys(map)));
68
+ return [...applied].filter((key) => !known.has(key)).sort();
69
+ }