@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,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
|
-
|
|
72
|
-
|
|
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
|
|
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++) {
|
package/dist/sql-connection.d.ts
CHANGED
|
@@ -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.
|
|
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
|
-
"
|
|
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
|
+
}
|