@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
|
@@ -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.0",
|
|
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.79.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,42 @@ 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 type { RawColumn, RawForeignKey, RawIndex, RawTable } from "./schema/normalize-table.js";
|
|
51
|
+
export { runSchemaPass } from "./schema/schema-run.js";
|
|
52
|
+
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
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DeclaredColumn,
|
|
3
|
+
DeclaredForeignKey,
|
|
4
|
+
DeclaredIndex,
|
|
5
|
+
DeclaredTable,
|
|
6
|
+
} from "./declared-schema.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The manifest shape a backend's `Table` kind declares, reduced to the
|
|
10
|
+
* normalized model.
|
|
11
|
+
*
|
|
12
|
+
* The STRUCTURE is shared — named columns, named indexes, named foreign keys —
|
|
13
|
+
* while the type vocabulary is not: `type` is carried through opaquely and
|
|
14
|
+
* every field the shared model does not name becomes a type parameter, so a
|
|
15
|
+
* backend adds `length`, `precision` or `collation` to its schema and nothing
|
|
16
|
+
* here changes. Types are structured rather than spelled into a scalar
|
|
17
|
+
* (`type: varchar` with `length: 64`, never `varchar(64)`), so nothing has to
|
|
18
|
+
* parse a type back apart.
|
|
19
|
+
*/
|
|
20
|
+
export interface RawColumn {
|
|
21
|
+
readonly type: string;
|
|
22
|
+
readonly nullable?: boolean;
|
|
23
|
+
readonly array?: boolean;
|
|
24
|
+
readonly primaryKey?: boolean;
|
|
25
|
+
readonly unique?: boolean;
|
|
26
|
+
readonly default?: unknown;
|
|
27
|
+
readonly defaultExpression?: string;
|
|
28
|
+
readonly identity?: string;
|
|
29
|
+
readonly renamedFrom?: string;
|
|
30
|
+
readonly [param: string]: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RawIndex {
|
|
34
|
+
readonly columns: readonly string[];
|
|
35
|
+
readonly unique?: boolean;
|
|
36
|
+
readonly [option: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RawForeignKey {
|
|
40
|
+
readonly columns: readonly string[];
|
|
41
|
+
readonly references: { readonly table: unknown; readonly columns: readonly string[] };
|
|
42
|
+
readonly onDelete?: string;
|
|
43
|
+
readonly onUpdate?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RawTable {
|
|
47
|
+
readonly table: string;
|
|
48
|
+
readonly columns?: Record<string, RawColumn>;
|
|
49
|
+
readonly indexes?: Record<string, RawIndex>;
|
|
50
|
+
readonly foreignKeys?: Record<string, RawForeignKey>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const COLUMN_KEYS = new Set([
|
|
54
|
+
"type",
|
|
55
|
+
"nullable",
|
|
56
|
+
"array",
|
|
57
|
+
"primaryKey",
|
|
58
|
+
"unique",
|
|
59
|
+
"default",
|
|
60
|
+
"defaultExpression",
|
|
61
|
+
"identity",
|
|
62
|
+
"renamedFrom",
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
const INDEX_KEYS = new Set(["columns", "unique"]);
|
|
66
|
+
|
|
67
|
+
function params(raw: Record<string, unknown>, known: ReadonlySet<string>): Record<string, unknown> {
|
|
68
|
+
return Object.fromEntries(
|
|
69
|
+
Object.entries(raw).filter(([key, value]) => !known.has(key) && value !== undefined),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeColumn(name: string, raw: RawColumn): DeclaredColumn {
|
|
74
|
+
if (raw.default !== undefined && raw.defaultExpression !== undefined) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`column '${name}' declares both 'default' and 'defaultExpression' — a typed literal and ` +
|
|
77
|
+
`a backend SQL expression are separate fields and exactly one may be set`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A primary key and an identity column cannot hold NULL, and the engine
|
|
82
|
+
// enforces that whether or not the declaration says so. Left at the `nullable`
|
|
83
|
+
// default of true, the column would read back NOT NULL on the next boot, the
|
|
84
|
+
// pass would see a difference it could "fix", and every boot from then on
|
|
85
|
+
// would try to DROP NOT NULL on a primary key and fail. So the implication is
|
|
86
|
+
// applied here, once, where both the DDL and the comparison read it — and a
|
|
87
|
+
// declaration that states the opposite is refused rather than quietly
|
|
88
|
+
// overruled.
|
|
89
|
+
const impliesNotNull = raw.primaryKey === true || raw.identity !== undefined;
|
|
90
|
+
if (impliesNotNull && raw.nullable === true) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`column '${name}' is declared nullable and ${raw.primaryKey ? "a primary key" : "an identity column"}, ` +
|
|
93
|
+
`which cannot hold NULL. Remove 'nullable: true'.`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
name,
|
|
99
|
+
type: raw.type,
|
|
100
|
+
params: params(raw as Record<string, unknown>, COLUMN_KEYS),
|
|
101
|
+
nullable: impliesNotNull ? false : (raw.nullable ?? true),
|
|
102
|
+
array: raw.array ?? false,
|
|
103
|
+
primaryKey: raw.primaryKey ?? false,
|
|
104
|
+
unique: raw.unique ?? false,
|
|
105
|
+
default: raw.default,
|
|
106
|
+
defaultExpression: raw.defaultExpression,
|
|
107
|
+
identity: raw.identity,
|
|
108
|
+
renamedFrom: raw.renamedFrom,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A `references.table` is a `!ref` to another table resource, injected as the
|
|
113
|
+
* live instance by the time a controller reads it. */
|
|
114
|
+
function referencedTableName(value: unknown, fk: string): string {
|
|
115
|
+
if (typeof value === "string") return value;
|
|
116
|
+
const table = (value as { table?: unknown } | null)?.table;
|
|
117
|
+
if (typeof table === "string") return table;
|
|
118
|
+
throw new Error(`foreign key '${fk}': 'references.table' does not name a table`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Structural checks over one declaration, at resource creation — before any
|
|
123
|
+
* connection is opened, let alone any DDL planned.
|
|
124
|
+
*
|
|
125
|
+
* Each of these would otherwise reach the engine as raw SQL and come back as a
|
|
126
|
+
* driver error naming a statement the author never wrote. They are decidable
|
|
127
|
+
* from the declaration alone, so they are decided here and reported against the
|
|
128
|
+
* field that is wrong.
|
|
129
|
+
*/
|
|
130
|
+
function validateTable(table: DeclaredTable): void {
|
|
131
|
+
const where = `${table.name}`;
|
|
132
|
+
if (table.columns.length === 0) {
|
|
133
|
+
throw new Error(`table '${where}' declares no columns — a table needs at least one.`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const names = new Set(table.columns.map((c) => c.name));
|
|
137
|
+
|
|
138
|
+
const primaryKeys = table.columns.filter((c) => c.primaryKey).map((c) => c.name);
|
|
139
|
+
if (primaryKeys.length > 1) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`table '${where}' marks ${primaryKeys.map((n) => `'${n}'`).join(" and ")} as primaryKey. ` +
|
|
142
|
+
`A composite primary key is not expressible as a per-column flag — declare one column ` +
|
|
143
|
+
`as the key, or create the constraint in a 'migrations:' entry.`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (const column of table.columns) {
|
|
148
|
+
if (!column.renamedFrom) continue;
|
|
149
|
+
if (column.renamedFrom === column.name) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`column '${where}.${column.name}' declares renamedFrom itself, which describes no rename.`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (names.has(column.renamedFrom)) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`column '${where}.${column.name}' renames from '${column.renamedFrom}', which this table ` +
|
|
157
|
+
`also declares. A rename's source is the column being retired, so declaring both would ` +
|
|
158
|
+
`copy one live column into another and retire neither.`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// An index or foreign key over a column the table does not declare cannot be
|
|
164
|
+
// created, and the engine's complaint would name a generated statement.
|
|
165
|
+
for (const index of table.indexes) {
|
|
166
|
+
for (const column of index.columns) {
|
|
167
|
+
if (!names.has(column)) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`index '${where}.${index.name}' names column '${column}', which this table does not ` +
|
|
170
|
+
`declare.`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
for (const fk of table.foreignKeys) {
|
|
176
|
+
for (const column of fk.columns) {
|
|
177
|
+
if (!names.has(column)) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`foreign key '${where}.${fk.name}' names column '${column}', which this table does not ` +
|
|
180
|
+
`declare.`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (fk.references.columns.length !== fk.columns.length) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`foreign key '${where}.${fk.name}' has ${fk.columns.length} column(s) but references ` +
|
|
187
|
+
`${fk.references.columns.length} — a foreign key maps its columns one for one.`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function normalizeTable(raw: RawTable): DeclaredTable {
|
|
194
|
+
const columns = Object.entries(raw.columns ?? {}).map(([name, column]) =>
|
|
195
|
+
normalizeColumn(name, column),
|
|
196
|
+
);
|
|
197
|
+
const indexes: DeclaredIndex[] = Object.entries(raw.indexes ?? {}).map(([name, index]) => ({
|
|
198
|
+
name,
|
|
199
|
+
columns: [...index.columns],
|
|
200
|
+
unique: index.unique ?? false,
|
|
201
|
+
options: params(index as Record<string, unknown>, INDEX_KEYS),
|
|
202
|
+
}));
|
|
203
|
+
const foreignKeys: DeclaredForeignKey[] = Object.entries(raw.foreignKeys ?? {}).map(
|
|
204
|
+
([name, fk]) => ({
|
|
205
|
+
name,
|
|
206
|
+
columns: [...fk.columns],
|
|
207
|
+
references: {
|
|
208
|
+
table: referencedTableName(fk.references.table, name),
|
|
209
|
+
columns: [...fk.references.columns],
|
|
210
|
+
},
|
|
211
|
+
onDelete: fk.onDelete,
|
|
212
|
+
onUpdate: fk.onUpdate,
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
const table: DeclaredTable = { name: raw.table, columns, indexes, foreignKeys };
|
|
216
|
+
validateTable(table);
|
|
217
|
+
return table;
|
|
218
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { parseDurationMs } from "@telorun/sdk";
|
|
2
|
+
import type { TombstoneRecord, VersionRecord } from "./schema-ledger.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* When a tombstone may be reclaimed. Eligibility is a CONJUNCTION: N released
|
|
6
|
+
* versions must have been observed since the object went missing, AND T must
|
|
7
|
+
* have elapsed. Version is the primary signal — it is what proves older code is
|
|
8
|
+
* no longer live — and time is the backstop, because N versions can land in an
|
|
9
|
+
* afternoon.
|
|
10
|
+
*
|
|
11
|
+
* Declaring no policy means nothing is ever dropped, so reclamation is opt-in
|
|
12
|
+
* by declaration rather than by invocation.
|
|
13
|
+
*/
|
|
14
|
+
export interface ReclaimPolicy {
|
|
15
|
+
readonly afterVersions: number;
|
|
16
|
+
readonly afterDuration: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Eligibility {
|
|
20
|
+
readonly eligible: boolean;
|
|
21
|
+
/** Versions observed since the tombstone, and how many are still needed. */
|
|
22
|
+
readonly versionsObserved: number;
|
|
23
|
+
readonly versionsRemaining: number;
|
|
24
|
+
readonly msElapsed: number;
|
|
25
|
+
readonly msRemaining: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A rollback resets progress rather than merely pausing it.
|
|
30
|
+
*
|
|
31
|
+
* Going backwards proves older code is live, so a boot at a version that was
|
|
32
|
+
* already observed at or before the tombstone is not one more release past the
|
|
33
|
+
* removal — it is evidence the removal is not yet safe. Both counters restart
|
|
34
|
+
* from that observation: the version count, and the elapsed-time baseline. This
|
|
35
|
+
* is answerable only because the ledger records the observed *sequence* rather
|
|
36
|
+
* than a counter.
|
|
37
|
+
*/
|
|
38
|
+
export function assessTombstone(
|
|
39
|
+
tombstone: TombstoneRecord,
|
|
40
|
+
history: readonly VersionRecord[],
|
|
41
|
+
policy: ReclaimPolicy,
|
|
42
|
+
nowMs: number,
|
|
43
|
+
): Eligibility {
|
|
44
|
+
const priorVersions = new Set(
|
|
45
|
+
history
|
|
46
|
+
.filter((entry) => entry.sequence <= tombstone.missingSinceSequence)
|
|
47
|
+
.map((entry) => entry.version),
|
|
48
|
+
);
|
|
49
|
+
let counted = new Set<string>();
|
|
50
|
+
let baselineAt = tombstone.missingSinceAt;
|
|
51
|
+
for (const entry of history) {
|
|
52
|
+
if (entry.sequence <= tombstone.missingSinceSequence) continue;
|
|
53
|
+
if (priorVersions.has(entry.version)) {
|
|
54
|
+
counted = new Set();
|
|
55
|
+
baselineAt = entry.firstSeenAt;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
counted.add(entry.version);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const versionsObserved = counted.size;
|
|
62
|
+
const msElapsed = Math.max(0, nowMs - Date.parse(baselineAt));
|
|
63
|
+
const requiredMs = parseDurationMs(policy.afterDuration);
|
|
64
|
+
const versionsRemaining = Math.max(0, policy.afterVersions - versionsObserved);
|
|
65
|
+
const msRemaining = Math.max(0, requiredMs - msElapsed);
|
|
66
|
+
return {
|
|
67
|
+
eligible: versionsRemaining === 0 && msRemaining === 0,
|
|
68
|
+
versionsObserved,
|
|
69
|
+
versionsRemaining,
|
|
70
|
+
msElapsed,
|
|
71
|
+
msRemaining,
|
|
72
|
+
};
|
|
73
|
+
}
|