@telorun/sql 0.21.2 → 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.
Files changed (42) hide show
  1. package/dist/index.d.ts +17 -2
  2. package/dist/index.js +8 -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 +46 -0
  10. package/dist/schema/normalize-table.js +135 -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 +161 -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 +243 -0
  19. package/dist/schema/schema-run.d.ts +50 -0
  20. package/dist/schema/schema-run.js +318 -0
  21. package/dist/sql-connection-base.d.ts +21 -0
  22. package/dist/sql-connection-base.js +30 -4
  23. package/dist/sql-connection.d.ts +19 -0
  24. package/package.json +5 -3
  25. package/src/index.ts +36 -2
  26. package/src/schema/declaration-snapshot.ts +71 -0
  27. package/src/schema/declared-schema.ts +73 -0
  28. package/src/schema/migration-runner.ts +69 -0
  29. package/src/schema/normalize-table.ts +218 -0
  30. package/src/schema/reclaim-policy.ts +73 -0
  31. package/src/schema/schema-driver.ts +182 -0
  32. package/src/schema/schema-ledger.ts +309 -0
  33. package/src/schema/schema-reconciler.ts +339 -0
  34. package/src/schema/schema-run.ts +441 -0
  35. package/src/sql-connection-base.ts +35 -4
  36. package/src/sql-connection.ts +21 -0
  37. package/dist/sql-migration-controller.d.ts +0 -16
  38. package/dist/sql-migration-controller.js +0 -13
  39. package/dist/sql-migrations-controller.d.ts +0 -23
  40. package/dist/sql-migrations-controller.js +0 -98
  41. package/src/sql-migration-controller.ts +0 -20
  42. package/src/sql-migrations-controller.ts +0 -143
package/dist/index.d.ts CHANGED
@@ -2,8 +2,23 @@ export { SqlConnectionBase } from "./sql-connection-base.js";
2
2
  export { quoteAnsiIdentifier, type PlaceholderStyle, type SqlConnection, type SqlDialect, } from "./sql-connection.js";
3
3
  export { isSqlConnection, resolveSqlConnection } from "./sql-connection-ref.js";
4
4
  export * as SqlCommandController from "./sql-command-controller.js";
5
- export * as SqlMigrationController from "./sql-migration-controller.js";
6
- export * as SqlMigrationsController from "./sql-migrations-controller.js";
7
5
  export * as SqlQueryController from "./sql-query-controller.js";
8
6
  export * as SqlSelectionController from "./sql-selection-controller.js";
9
7
  export * as SqlTransactionController from "./sql-transaction-controller.js";
8
+ export type { DeclaredColumn, DeclaredForeignKey, DeclaredIndex, DeclaredTable, SchemaObjectId, SchemaObjectKind, } from "./schema/declared-schema.js";
9
+ export { describeObject, objectKey } from "./schema/declared-schema.js";
10
+ export type { ChangeSafety, LiveColumn, LiveForeignKey, LiveIndex, LiveTable, SchemaDriver, } from "./schema/schema-driver.js";
11
+ export { ledgerTables, SchemaLedger } from "./schema/schema-ledger.js";
12
+ export type { LedgerTables, TombstoneRecord, VersionRecord } from "./schema/schema-ledger.js";
13
+ export { assessTombstone } from "./schema/reclaim-policy.js";
14
+ export type { Eligibility, ReclaimPolicy } from "./schema/reclaim-policy.js";
15
+ export { snapshotDeclaration, snapshotDigest } from "./schema/declaration-snapshot.js";
16
+ export type { DeclarationSnapshot } from "./schema/declaration-snapshot.js";
17
+ export { planReconciliation } from "./schema/schema-reconciler.js";
18
+ export type { PlannedStatement, PlannedTombstone, SchemaPlan } from "./schema/schema-reconciler.js";
19
+ export { migrationStatements, pendingKeys } from "./schema/migration-runner.js";
20
+ export type { MigrationEntry, MigrationMap } from "./schema/migration-runner.js";
21
+ export { normalizeTable } from "./schema/normalize-table.js";
22
+ export type { RawColumn, RawForeignKey, RawIndex, RawTable } from "./schema/normalize-table.js";
23
+ export { runSchemaPass } from "./schema/schema-run.js";
24
+ export type { PendingReclamation, SchemaRunInput, SchemaRunStatus } from "./schema/schema-run.js";
package/dist/index.js CHANGED
@@ -5,8 +5,14 @@ export { isSqlConnection, resolveSqlConnection } from "./sql-connection-ref.js";
5
5
  // these by PURL fragment, so the whole module is one bundle and its shared
6
6
  // state is one module scope.
7
7
  export * as SqlCommandController from "./sql-command-controller.js";
8
- export * as SqlMigrationController from "./sql-migration-controller.js";
9
- export * as SqlMigrationsController from "./sql-migrations-controller.js";
10
8
  export * as SqlQueryController from "./sql-query-controller.js";
11
9
  export * as SqlSelectionController from "./sql-selection-controller.js";
12
10
  export * as SqlTransactionController from "./sql-transaction-controller.js";
11
+ export { describeObject, objectKey } from "./schema/declared-schema.js";
12
+ export { ledgerTables, SchemaLedger } from "./schema/schema-ledger.js";
13
+ export { assessTombstone } from "./schema/reclaim-policy.js";
14
+ export { snapshotDeclaration, snapshotDigest } from "./schema/declaration-snapshot.js";
15
+ export { planReconciliation } from "./schema/schema-reconciler.js";
16
+ export { migrationStatements, pendingKeys } from "./schema/migration-runner.js";
17
+ export { normalizeTable } from "./schema/normalize-table.js";
18
+ export { runSchemaPass } from "./schema/schema-run.js";
@@ -0,0 +1,21 @@
1
+ import type { DeclaredTable, SchemaObjectId } from "./declared-schema.js";
2
+ /**
3
+ * The declaration, flattened to one entry per schema object.
4
+ *
5
+ * Recording the snapshot rather than only its digest is what answers the
6
+ * question reconciliation cannot answer from live state alone: which objects
7
+ * THIS schema resource owns. An object in the namespace that has never appeared
8
+ * in a snapshot was never declared here — a legacy table, another application's,
9
+ * one predating adoption — and is invisible to both the diff and reclamation.
10
+ * Inferring ownership from presence would make adopting an existing database a
11
+ * data-loss event.
12
+ *
13
+ * It also supplies a tombstone's last-known definition for free, so nothing has
14
+ * to reconstruct what a dropped object was after the declaration stopped
15
+ * describing it.
16
+ */
17
+ export type DeclarationSnapshot = Record<string, string>;
18
+ export declare function snapshotDeclaration(tables: readonly DeclaredTable[]): DeclarationSnapshot;
19
+ /** Stable digest of a snapshot — what the ledger compares boots against. */
20
+ export declare function snapshotDigest(snapshot: DeclarationSnapshot): string;
21
+ export declare function parseObjectKey(key: string): SchemaObjectId;
@@ -0,0 +1,46 @@
1
+ import { createHash } from "node:crypto";
2
+ import { objectKey } from "./declared-schema.js";
3
+ function stable(value) {
4
+ if (Array.isArray(value))
5
+ return value.map(stable);
6
+ if (value && typeof value === "object") {
7
+ return Object.fromEntries(Object.entries(value)
8
+ .filter(([, v]) => v !== undefined)
9
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
10
+ .map(([k, v]) => [k, stable(v)]));
11
+ }
12
+ return value;
13
+ }
14
+ function entry(snapshot, id, definition) {
15
+ snapshot[objectKey(id)] = JSON.stringify(stable(definition));
16
+ }
17
+ export function snapshotDeclaration(tables) {
18
+ const snapshot = {};
19
+ for (const table of tables) {
20
+ entry(snapshot, { kind: "table", table: table.name }, { name: table.name });
21
+ for (const column of table.columns) {
22
+ entry(snapshot, { kind: "column", table: table.name, name: column.name }, column);
23
+ }
24
+ for (const index of table.indexes) {
25
+ entry(snapshot, { kind: "index", table: table.name, name: index.name }, index);
26
+ }
27
+ for (const fk of table.foreignKeys) {
28
+ entry(snapshot, { kind: "foreignKey", table: table.name, name: fk.name }, fk);
29
+ }
30
+ }
31
+ return snapshot;
32
+ }
33
+ /** Stable digest of a snapshot — what the ledger compares boots against. */
34
+ export function snapshotDigest(snapshot) {
35
+ const canonical = JSON.stringify(Object.keys(snapshot)
36
+ .sort()
37
+ .map((key) => [key, snapshot[key]]));
38
+ return createHash("sha256").update(canonical).digest("hex");
39
+ }
40
+ export function parseObjectKey(key) {
41
+ const [kind, rest] = key.split(":", 2);
42
+ const dot = rest.indexOf(".");
43
+ if (kind === "table" || dot < 0)
44
+ return { kind, table: rest };
45
+ return { kind, table: rest.slice(0, dot), name: rest.slice(dot + 1) };
46
+ }
@@ -0,0 +1,64 @@
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
+ export interface DeclaredColumn {
12
+ /** Durable identity. Tombstones key on it; renaming is expand-contract. */
13
+ readonly name: string;
14
+ /** Backend-native type name, never parsed here. */
15
+ readonly type: string;
16
+ /** Structured type parameters (`length`, `precision`, …), never spelled into `type`. */
17
+ readonly params: Readonly<Record<string, unknown>>;
18
+ readonly nullable: boolean;
19
+ readonly array: boolean;
20
+ readonly primaryKey: boolean;
21
+ readonly unique: boolean;
22
+ /** A typed literal. Mutually exclusive with {@link defaultExpression}. */
23
+ readonly default?: unknown;
24
+ /** Raw backend SQL evaluated by the database. */
25
+ readonly defaultExpression?: string;
26
+ /** Backend-specific identity/auto-increment mode, passed through to the driver. */
27
+ readonly identity?: string;
28
+ /** The column this one supersedes. The pass adds, copies, then tombstones the source. */
29
+ readonly renamedFrom?: string;
30
+ }
31
+ export interface DeclaredIndex {
32
+ readonly name: string;
33
+ readonly columns: readonly string[];
34
+ readonly unique: boolean;
35
+ /** Backend-specific extras (partial predicate, method), passed through. */
36
+ readonly options: Readonly<Record<string, unknown>>;
37
+ }
38
+ export interface DeclaredForeignKey {
39
+ readonly name: string;
40
+ readonly columns: readonly string[];
41
+ readonly references: {
42
+ readonly table: string;
43
+ readonly columns: readonly string[];
44
+ };
45
+ readonly onDelete?: string;
46
+ readonly onUpdate?: string;
47
+ }
48
+ export interface DeclaredTable {
49
+ /** Physical table name. */
50
+ readonly name: string;
51
+ readonly columns: readonly DeclaredColumn[];
52
+ readonly indexes: readonly DeclaredIndex[];
53
+ readonly foreignKeys: readonly DeclaredForeignKey[];
54
+ }
55
+ /** Every schema object reconciliation tracks, by the identity a tombstone keys on. */
56
+ export type SchemaObjectKind = "table" | "column" | "index" | "foreignKey";
57
+ export interface SchemaObjectId {
58
+ readonly kind: SchemaObjectKind;
59
+ readonly table: string;
60
+ /** The column / index / constraint name; absent for a table. */
61
+ readonly name?: string;
62
+ }
63
+ export declare function objectKey(id: SchemaObjectId): string;
64
+ export declare function describeObject(id: SchemaObjectId): string;
@@ -0,0 +1,16 @@
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
+ export function objectKey(id) {
12
+ return id.name == null ? `${id.kind}:${id.table}` : `${id.kind}:${id.table}.${id.name}`;
13
+ }
14
+ export function describeObject(id) {
15
+ return id.name == null ? `table ${id.table}` : `${id.kind} ${id.table}.${id.name}`;
16
+ }
@@ -0,0 +1,30 @@
1
+ import type { SchemaLedger } from "./schema-ledger.js";
2
+ import type { SchemaDriver } from "./schema-driver.js";
3
+ /**
4
+ * A migration is one `statement` or an ordered list of `statements`, keyed by a
5
+ * durable id. Phase is NOT part of identity: keys are unique across both maps
6
+ * and the ledger stores the key alone, so moving a migration between
7
+ * `beforeMigrations:` and `migrations:` keeps its identity and does not re-run
8
+ * it.
9
+ */
10
+ export interface MigrationEntry {
11
+ readonly statement?: string;
12
+ readonly statements?: readonly string[];
13
+ }
14
+ export type MigrationMap = Record<string, MigrationEntry>;
15
+ export declare function migrationStatements(key: string, entry: MigrationEntry): string[];
16
+ /** Keys not yet in the ledger, in key order — the order they are applied in. */
17
+ export declare function pendingKeys(migrations: MigrationMap, applied: ReadonlySet<string>): string[];
18
+ /**
19
+ * Apply the pending migrations in key order, each with its ledger row in the
20
+ * SAME atomic group.
21
+ *
22
+ * Applying and recording are one operation, not two: a crash between them would
23
+ * re-run a migration that may not be idempotent. Whether the group is genuinely
24
+ * atomic is the engine's to say — {@link SchemaDriver.runAtomically} groups it
25
+ * where DDL is transactional and runs it sequentially where it is not — but the
26
+ * ledger write is never the thing left outstanding, because it is last in the
27
+ * group.
28
+ */
29
+ export declare function runMigrations(driver: SchemaDriver, ledger: SchemaLedger, migrations: MigrationMap, applied: ReadonlySet<string>, now: () => Promise<string>): Promise<string[]>;
30
+ export declare function orphanedKeys(applied: ReadonlySet<string>, ...declared: readonly MigrationMap[]): string[];
@@ -0,0 +1,38 @@
1
+ export function migrationStatements(key, entry) {
2
+ const statements = entry.statements ?? (entry.statement != null ? [entry.statement] : []);
3
+ if (statements.length === 0) {
4
+ throw new Error(`migration '${key}' has no statement(s) — set 'statement' or a non-empty 'statements'`);
5
+ }
6
+ return [...statements];
7
+ }
8
+ /** Keys not yet in the ledger, in key order — the order they are applied in. */
9
+ export function pendingKeys(migrations, applied) {
10
+ return Object.keys(migrations)
11
+ .filter((key) => !applied.has(key))
12
+ .sort();
13
+ }
14
+ /**
15
+ * Apply the pending migrations in key order, each with its ledger row in the
16
+ * SAME atomic group.
17
+ *
18
+ * Applying and recording are one operation, not two: a crash between them would
19
+ * re-run a migration that may not be idempotent. Whether the group is genuinely
20
+ * atomic is the engine's to say — {@link SchemaDriver.runAtomically} groups it
21
+ * where DDL is transactional and runs it sequentially where it is not — but the
22
+ * ledger write is never the thing left outstanding, because it is last in the
23
+ * group.
24
+ */
25
+ export async function runMigrations(driver, ledger, migrations, applied, now) {
26
+ const pending = pendingKeys(migrations, applied);
27
+ for (const key of pending) {
28
+ await driver.runAtomically([
29
+ ...migrationStatements(key, migrations[key]),
30
+ ledger.migrationRecordStatement(key, await now()),
31
+ ]);
32
+ }
33
+ return pending;
34
+ }
35
+ export function orphanedKeys(applied, ...declared) {
36
+ const known = new Set(declared.flatMap((map) => Object.keys(map)));
37
+ return [...applied].filter((key) => !known.has(key)).sort();
38
+ }
@@ -0,0 +1,46 @@
1
+ import type { DeclaredTable } from "./declared-schema.js";
2
+ /**
3
+ * The manifest shape a backend's `Table` kind declares, reduced to the
4
+ * normalized model.
5
+ *
6
+ * The STRUCTURE is shared — named columns, named indexes, named foreign keys —
7
+ * while the type vocabulary is not: `type` is carried through opaquely and
8
+ * every field the shared model does not name becomes a type parameter, so a
9
+ * backend adds `length`, `precision` or `collation` to its schema and nothing
10
+ * here changes. Types are structured rather than spelled into a scalar
11
+ * (`type: varchar` with `length: 64`, never `varchar(64)`), so nothing has to
12
+ * parse a type back apart.
13
+ */
14
+ export interface RawColumn {
15
+ readonly type: string;
16
+ readonly nullable?: boolean;
17
+ readonly array?: boolean;
18
+ readonly primaryKey?: boolean;
19
+ readonly unique?: boolean;
20
+ readonly default?: unknown;
21
+ readonly defaultExpression?: string;
22
+ readonly identity?: string;
23
+ readonly renamedFrom?: string;
24
+ readonly [param: string]: unknown;
25
+ }
26
+ export interface RawIndex {
27
+ readonly columns: readonly string[];
28
+ readonly unique?: boolean;
29
+ readonly [option: string]: unknown;
30
+ }
31
+ export interface RawForeignKey {
32
+ readonly columns: readonly string[];
33
+ readonly references: {
34
+ readonly table: unknown;
35
+ readonly columns: readonly string[];
36
+ };
37
+ readonly onDelete?: string;
38
+ readonly onUpdate?: string;
39
+ }
40
+ export interface RawTable {
41
+ readonly table: string;
42
+ readonly columns?: Record<string, RawColumn>;
43
+ readonly indexes?: Record<string, RawIndex>;
44
+ readonly foreignKeys?: Record<string, RawForeignKey>;
45
+ }
46
+ export declare function normalizeTable(raw: RawTable): DeclaredTable;
@@ -0,0 +1,135 @@
1
+ const COLUMN_KEYS = new Set([
2
+ "type",
3
+ "nullable",
4
+ "array",
5
+ "primaryKey",
6
+ "unique",
7
+ "default",
8
+ "defaultExpression",
9
+ "identity",
10
+ "renamedFrom",
11
+ ]);
12
+ const INDEX_KEYS = new Set(["columns", "unique"]);
13
+ function params(raw, known) {
14
+ return Object.fromEntries(Object.entries(raw).filter(([key, value]) => !known.has(key) && value !== undefined));
15
+ }
16
+ function normalizeColumn(name, raw) {
17
+ if (raw.default !== undefined && raw.defaultExpression !== undefined) {
18
+ throw new Error(`column '${name}' declares both 'default' and 'defaultExpression' — a typed literal and ` +
19
+ `a backend SQL expression are separate fields and exactly one may be set`);
20
+ }
21
+ // A primary key and an identity column cannot hold NULL, and the engine
22
+ // enforces that whether or not the declaration says so. Left at the `nullable`
23
+ // default of true, the column would read back NOT NULL on the next boot, the
24
+ // pass would see a difference it could "fix", and every boot from then on
25
+ // would try to DROP NOT NULL on a primary key and fail. So the implication is
26
+ // applied here, once, where both the DDL and the comparison read it — and a
27
+ // declaration that states the opposite is refused rather than quietly
28
+ // overruled.
29
+ const impliesNotNull = raw.primaryKey === true || raw.identity !== undefined;
30
+ if (impliesNotNull && raw.nullable === true) {
31
+ throw new Error(`column '${name}' is declared nullable and ${raw.primaryKey ? "a primary key" : "an identity column"}, ` +
32
+ `which cannot hold NULL. Remove 'nullable: true'.`);
33
+ }
34
+ return {
35
+ name,
36
+ type: raw.type,
37
+ params: params(raw, COLUMN_KEYS),
38
+ nullable: impliesNotNull ? false : (raw.nullable ?? true),
39
+ array: raw.array ?? false,
40
+ primaryKey: raw.primaryKey ?? false,
41
+ unique: raw.unique ?? false,
42
+ default: raw.default,
43
+ defaultExpression: raw.defaultExpression,
44
+ identity: raw.identity,
45
+ renamedFrom: raw.renamedFrom,
46
+ };
47
+ }
48
+ /** A `references.table` is a `!ref` to another table resource, injected as the
49
+ * live instance by the time a controller reads it. */
50
+ function referencedTableName(value, fk) {
51
+ if (typeof value === "string")
52
+ return value;
53
+ const table = value?.table;
54
+ if (typeof table === "string")
55
+ return table;
56
+ throw new Error(`foreign key '${fk}': 'references.table' does not name a table`);
57
+ }
58
+ /**
59
+ * Structural checks over one declaration, at resource creation — before any
60
+ * connection is opened, let alone any DDL planned.
61
+ *
62
+ * Each of these would otherwise reach the engine as raw SQL and come back as a
63
+ * driver error naming a statement the author never wrote. They are decidable
64
+ * from the declaration alone, so they are decided here and reported against the
65
+ * field that is wrong.
66
+ */
67
+ function validateTable(table) {
68
+ const where = `${table.name}`;
69
+ if (table.columns.length === 0) {
70
+ throw new Error(`table '${where}' declares no columns — a table needs at least one.`);
71
+ }
72
+ const names = new Set(table.columns.map((c) => c.name));
73
+ const primaryKeys = table.columns.filter((c) => c.primaryKey).map((c) => c.name);
74
+ if (primaryKeys.length > 1) {
75
+ throw new Error(`table '${where}' marks ${primaryKeys.map((n) => `'${n}'`).join(" and ")} as primaryKey. ` +
76
+ `A composite primary key is not expressible as a per-column flag — declare one column ` +
77
+ `as the key, or create the constraint in a 'migrations:' entry.`);
78
+ }
79
+ for (const column of table.columns) {
80
+ if (!column.renamedFrom)
81
+ continue;
82
+ if (column.renamedFrom === column.name) {
83
+ throw new Error(`column '${where}.${column.name}' declares renamedFrom itself, which describes no rename.`);
84
+ }
85
+ if (names.has(column.renamedFrom)) {
86
+ throw new Error(`column '${where}.${column.name}' renames from '${column.renamedFrom}', which this table ` +
87
+ `also declares. A rename's source is the column being retired, so declaring both would ` +
88
+ `copy one live column into another and retire neither.`);
89
+ }
90
+ }
91
+ // An index or foreign key over a column the table does not declare cannot be
92
+ // created, and the engine's complaint would name a generated statement.
93
+ for (const index of table.indexes) {
94
+ for (const column of index.columns) {
95
+ if (!names.has(column)) {
96
+ throw new Error(`index '${where}.${index.name}' names column '${column}', which this table does not ` +
97
+ `declare.`);
98
+ }
99
+ }
100
+ }
101
+ for (const fk of table.foreignKeys) {
102
+ for (const column of fk.columns) {
103
+ if (!names.has(column)) {
104
+ throw new Error(`foreign key '${where}.${fk.name}' names column '${column}', which this table does not ` +
105
+ `declare.`);
106
+ }
107
+ }
108
+ if (fk.references.columns.length !== fk.columns.length) {
109
+ throw new Error(`foreign key '${where}.${fk.name}' has ${fk.columns.length} column(s) but references ` +
110
+ `${fk.references.columns.length} — a foreign key maps its columns one for one.`);
111
+ }
112
+ }
113
+ }
114
+ export function normalizeTable(raw) {
115
+ const columns = Object.entries(raw.columns ?? {}).map(([name, column]) => normalizeColumn(name, column));
116
+ const indexes = Object.entries(raw.indexes ?? {}).map(([name, index]) => ({
117
+ name,
118
+ columns: [...index.columns],
119
+ unique: index.unique ?? false,
120
+ options: params(index, INDEX_KEYS),
121
+ }));
122
+ const foreignKeys = Object.entries(raw.foreignKeys ?? {}).map(([name, fk]) => ({
123
+ name,
124
+ columns: [...fk.columns],
125
+ references: {
126
+ table: referencedTableName(fk.references.table, name),
127
+ columns: [...fk.references.columns],
128
+ },
129
+ onDelete: fk.onDelete,
130
+ onUpdate: fk.onUpdate,
131
+ }));
132
+ const table = { name: raw.table, columns, indexes, foreignKeys };
133
+ validateTable(table);
134
+ return table;
135
+ }
@@ -0,0 +1,34 @@
1
+ import type { TombstoneRecord, VersionRecord } from "./schema-ledger.js";
2
+ /**
3
+ * When a tombstone may be reclaimed. Eligibility is a CONJUNCTION: N released
4
+ * versions must have been observed since the object went missing, AND T must
5
+ * have elapsed. Version is the primary signal — it is what proves older code is
6
+ * no longer live — and time is the backstop, because N versions can land in an
7
+ * afternoon.
8
+ *
9
+ * Declaring no policy means nothing is ever dropped, so reclamation is opt-in
10
+ * by declaration rather than by invocation.
11
+ */
12
+ export interface ReclaimPolicy {
13
+ readonly afterVersions: number;
14
+ readonly afterDuration: string;
15
+ }
16
+ export interface Eligibility {
17
+ readonly eligible: boolean;
18
+ /** Versions observed since the tombstone, and how many are still needed. */
19
+ readonly versionsObserved: number;
20
+ readonly versionsRemaining: number;
21
+ readonly msElapsed: number;
22
+ readonly msRemaining: number;
23
+ }
24
+ /**
25
+ * A rollback resets progress rather than merely pausing it.
26
+ *
27
+ * Going backwards proves older code is live, so a boot at a version that was
28
+ * already observed at or before the tombstone is not one more release past the
29
+ * removal — it is evidence the removal is not yet safe. Both counters restart
30
+ * from that observation: the version count, and the elapsed-time baseline. This
31
+ * is answerable only because the ledger records the observed *sequence* rather
32
+ * than a counter.
33
+ */
34
+ export declare function assessTombstone(tombstone: TombstoneRecord, history: readonly VersionRecord[], policy: ReclaimPolicy, nowMs: number): Eligibility;
@@ -0,0 +1,40 @@
1
+ import { parseDurationMs } from "@telorun/sdk";
2
+ /**
3
+ * A rollback resets progress rather than merely pausing it.
4
+ *
5
+ * Going backwards proves older code is live, so a boot at a version that was
6
+ * already observed at or before the tombstone is not one more release past the
7
+ * removal — it is evidence the removal is not yet safe. Both counters restart
8
+ * from that observation: the version count, and the elapsed-time baseline. This
9
+ * is answerable only because the ledger records the observed *sequence* rather
10
+ * than a counter.
11
+ */
12
+ export function assessTombstone(tombstone, history, policy, nowMs) {
13
+ const priorVersions = new Set(history
14
+ .filter((entry) => entry.sequence <= tombstone.missingSinceSequence)
15
+ .map((entry) => entry.version));
16
+ let counted = new Set();
17
+ let baselineAt = tombstone.missingSinceAt;
18
+ for (const entry of history) {
19
+ if (entry.sequence <= tombstone.missingSinceSequence)
20
+ continue;
21
+ if (priorVersions.has(entry.version)) {
22
+ counted = new Set();
23
+ baselineAt = entry.firstSeenAt;
24
+ continue;
25
+ }
26
+ counted.add(entry.version);
27
+ }
28
+ const versionsObserved = counted.size;
29
+ const msElapsed = Math.max(0, nowMs - Date.parse(baselineAt));
30
+ const requiredMs = parseDurationMs(policy.afterDuration);
31
+ const versionsRemaining = Math.max(0, policy.afterVersions - versionsObserved);
32
+ const msRemaining = Math.max(0, requiredMs - msElapsed);
33
+ return {
34
+ eligible: versionsRemaining === 0 && msRemaining === 0,
35
+ versionsObserved,
36
+ versionsRemaining,
37
+ msElapsed,
38
+ msRemaining,
39
+ };
40
+ }