@telorun/sql 0.22.0 → 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 +2 -1
- package/dist/index.js +1 -0
- package/dist/schema/normalize-table.d.ts +12 -1
- package/dist/schema/normalize-table.js +2 -12
- package/dist/schema/schema-driver.d.ts +23 -0
- package/dist/schema/schema-reconciler.js +35 -2
- package/dist/schema/table-reference.d.ts +25 -0
- package/dist/schema/table-reference.js +61 -0
- package/package.json +2 -2
- package/src/index.ts +8 -1
- package/src/schema/normalize-table.ts +16 -10
- package/src/schema/schema-driver.ts +25 -0
- package/src/schema/schema-reconciler.ts +37 -4
- package/src/schema/table-reference.ts +78 -0
package/dist/index.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export type { PlannedStatement, PlannedTombstone, SchemaPlan } from "./schema/sc
|
|
|
19
19
|
export { migrationStatements, pendingKeys } from "./schema/migration-runner.js";
|
|
20
20
|
export type { MigrationEntry, MigrationMap } from "./schema/migration-runner.js";
|
|
21
21
|
export { normalizeTable } from "./schema/normalize-table.js";
|
|
22
|
-
export
|
|
22
|
+
export { tableReferenceResolver } from "./schema/table-reference.js";
|
|
23
|
+
export type { RawColumn, RawForeignKey, RawIndex, RawTable, TableReferenceResolver, } from "./schema/normalize-table.js";
|
|
23
24
|
export { runSchemaPass } from "./schema/schema-run.js";
|
|
24
25
|
export type { PendingReclamation, SchemaRunInput, SchemaRunStatus } from "./schema/schema-run.js";
|
package/dist/index.js
CHANGED
|
@@ -15,4 +15,5 @@ export { snapshotDeclaration, snapshotDigest } from "./schema/declaration-snapsh
|
|
|
15
15
|
export { planReconciliation } from "./schema/schema-reconciler.js";
|
|
16
16
|
export { migrationStatements, pendingKeys } from "./schema/migration-runner.js";
|
|
17
17
|
export { normalizeTable } from "./schema/normalize-table.js";
|
|
18
|
+
export { tableReferenceResolver } from "./schema/table-reference.js";
|
|
18
19
|
export { runSchemaPass } from "./schema/schema-run.js";
|
|
@@ -43,4 +43,15 @@ export interface RawTable {
|
|
|
43
43
|
readonly indexes?: Record<string, RawIndex>;
|
|
44
44
|
readonly foreignKeys?: Record<string, RawForeignKey>;
|
|
45
45
|
}
|
|
46
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Turns whatever sits at a `references.table` slot into the referenced table's
|
|
48
|
+
* physical name.
|
|
49
|
+
*
|
|
50
|
+
* REQUIRED rather than optional: a `!ref` is not resolved when a controller is
|
|
51
|
+
* constructed — Phase-5 injection runs after `create()` returns — so a caller
|
|
52
|
+
* that omitted one would read the sentinel and reproduce, silently, the exact
|
|
53
|
+
* defect this parameter exists to fix. `tableReferenceResolver` is the one every
|
|
54
|
+
* backend uses.
|
|
55
|
+
*/
|
|
56
|
+
export type TableReferenceResolver = (value: unknown, fk: string) => string;
|
|
57
|
+
export declare function normalizeTable(raw: RawTable, resolveReference: TableReferenceResolver): DeclaredTable;
|
|
@@ -45,16 +45,6 @@ function normalizeColumn(name, raw) {
|
|
|
45
45
|
renamedFrom: raw.renamedFrom,
|
|
46
46
|
};
|
|
47
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
48
|
/**
|
|
59
49
|
* Structural checks over one declaration, at resource creation — before any
|
|
60
50
|
* connection is opened, let alone any DDL planned.
|
|
@@ -111,7 +101,7 @@ function validateTable(table) {
|
|
|
111
101
|
}
|
|
112
102
|
}
|
|
113
103
|
}
|
|
114
|
-
export function normalizeTable(raw) {
|
|
104
|
+
export function normalizeTable(raw, resolveReference) {
|
|
115
105
|
const columns = Object.entries(raw.columns ?? {}).map(([name, column]) => normalizeColumn(name, column));
|
|
116
106
|
const indexes = Object.entries(raw.indexes ?? {}).map(([name, index]) => ({
|
|
117
107
|
name,
|
|
@@ -123,7 +113,7 @@ export function normalizeTable(raw) {
|
|
|
123
113
|
name,
|
|
124
114
|
columns: [...fk.columns],
|
|
125
115
|
references: {
|
|
126
|
-
table:
|
|
116
|
+
table: resolveReference(fk.references.table, name),
|
|
127
117
|
columns: [...fk.references.columns],
|
|
128
118
|
},
|
|
129
119
|
onDelete: fk.onDelete,
|
|
@@ -137,6 +137,29 @@ export interface SchemaDriver {
|
|
|
137
137
|
classifyIndexChange(live: LiveIndex, declared: DeclaredIndex): ChangeSafety;
|
|
138
138
|
/** Whether a foreign key can be brought to its declaration in place. */
|
|
139
139
|
classifyForeignKeyChange(live: LiveForeignKey, declared: DeclaredForeignKey): ChangeSafety;
|
|
140
|
+
/**
|
|
141
|
+
* Whether `createTable` already carries the table's foreign keys, so the
|
|
142
|
+
* reconciler must not also plan an `addForeignKey` for a table it just made.
|
|
143
|
+
*
|
|
144
|
+
* REQUIRED, like every other member here, because the wrong answer is silent:
|
|
145
|
+
* a driver that omitted it would get name matching by default, and if its
|
|
146
|
+
* engine also emits keys inside `CREATE TABLE` it would get exactly the
|
|
147
|
+
* unrestartable application this member exists to prevent — with no compile
|
|
148
|
+
* error and no failing test. Stating an answer is the point.
|
|
149
|
+
*/
|
|
150
|
+
readonly foreignKeysInCreateTable: boolean;
|
|
151
|
+
/**
|
|
152
|
+
* Whether the engine reports a foreign key back under the name the
|
|
153
|
+
* declaration gave it.
|
|
154
|
+
*
|
|
155
|
+
* Separate from `foreignKeysInCreateTable` because they are separate facts and
|
|
156
|
+
* an engine can hold one without the other: MySQL emits keys inside `CREATE
|
|
157
|
+
* TABLE` and names them. Where this is false a declaration is matched to a
|
|
158
|
+
* live key by its columns, target and referential actions, since there is no
|
|
159
|
+
* name to match on and matching by one reads a table's own key as missing on
|
|
160
|
+
* every boot after the one that created it.
|
|
161
|
+
*/
|
|
162
|
+
readonly namesForeignKeys: boolean;
|
|
140
163
|
createTable(schema: string, table: DeclaredTable): string[];
|
|
141
164
|
addColumn(schema: string, table: string, column: DeclaredColumn): string[];
|
|
142
165
|
alterColumn(schema: string, table: string, live: LiveColumn, column: DeclaredColumn): string[];
|
|
@@ -27,6 +27,20 @@ function indexDiffers(live, declared) {
|
|
|
27
27
|
/** A referential action is what the constraint DOES, so a change to it is a
|
|
28
28
|
* change to the constraint. An action the engine did not report is not compared
|
|
29
29
|
* — an absent reading is not evidence of a difference. */
|
|
30
|
+
/**
|
|
31
|
+
* The columns a key maps to what, which is what makes it THAT key rather than
|
|
32
|
+
* another. Its referential actions are settable properties of it, deliberately
|
|
33
|
+
* excluded: an engine that keeps no name matches on this, and folding the
|
|
34
|
+
* actions in would make a changed delete rule read as a brand new key — an ADD
|
|
35
|
+
* where the author should have been told the rule cannot be changed in place.
|
|
36
|
+
*/
|
|
37
|
+
function sameForeignKeyIdentity(live, declared) {
|
|
38
|
+
return (live.references.table === declared.references.table &&
|
|
39
|
+
live.columns.length === declared.columns.length &&
|
|
40
|
+
live.columns.every((column, i) => column === declared.columns[i]) &&
|
|
41
|
+
live.references.columns.length === declared.references.columns.length &&
|
|
42
|
+
live.references.columns.every((column, i) => column === declared.references.columns[i]));
|
|
43
|
+
}
|
|
30
44
|
function foreignKeyDiffers(live, declared) {
|
|
31
45
|
const action = (value) => value?.toUpperCase();
|
|
32
46
|
return (live.references.table !== declared.references.table ||
|
|
@@ -166,10 +180,29 @@ export function planReconciliation(driver, schema, declared, live, owned, tombst
|
|
|
166
180
|
...driver.createIndex(schema, table.name, index),
|
|
167
181
|
]);
|
|
168
182
|
}
|
|
169
|
-
|
|
183
|
+
// A table this pass just created already carries its keys where the engine
|
|
184
|
+
// can only emit them there. They are still MARKED declared, or the next boot
|
|
185
|
+
// would read every one of them as removed and tombstone it.
|
|
186
|
+
const carriedByCreate = !liveTable && driver.foreignKeysInCreateTable;
|
|
187
|
+
// Where the engine keeps no name, a declaration is matched to a live key by
|
|
188
|
+
// its structure. Matching by name regardless is what made such a table
|
|
189
|
+
// unrestartable: every later boot read its own key as missing and refused to
|
|
190
|
+
// add what the engine cannot add. Matches are CONSUMED, so two keys that are
|
|
191
|
+
// structurally identical pair up one for one instead of both claiming the
|
|
192
|
+
// first.
|
|
193
|
+
const unmatched = [...(liveTable?.foreignKeys ?? [])];
|
|
194
|
+
const liveForeignKeys = new Map(unmatched.map((fk) => [fk.name, fk]));
|
|
195
|
+
const takeStructural = (fk) => {
|
|
196
|
+
const at = unmatched.findIndex((live) => sameForeignKeyIdentity(live, fk));
|
|
197
|
+
return at < 0 ? undefined : unmatched.splice(at, 1)[0];
|
|
198
|
+
};
|
|
170
199
|
for (const fk of table.foreignKeys) {
|
|
171
200
|
markDeclared({ kind: "foreignKey", table: table.name, name: fk.name });
|
|
172
|
-
|
|
201
|
+
if (carriedByCreate)
|
|
202
|
+
continue;
|
|
203
|
+
const existing = driver.namesForeignKeys
|
|
204
|
+
? liveForeignKeys.get(fk.name)
|
|
205
|
+
: takeStructural(fk);
|
|
173
206
|
if (!existing) {
|
|
174
207
|
emit("constraint", `foreign key ${fk.name}`, driver.addForeignKey(schema, table.name, fk));
|
|
175
208
|
continue;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ResourceContext } from "@telorun/sdk";
|
|
2
|
+
import type { TableReferenceResolver } from "./normalize-table.js";
|
|
3
|
+
/**
|
|
4
|
+
* Resolves a `references.table` slot to the referenced table's physical name.
|
|
5
|
+
*
|
|
6
|
+
* **BOTH shapes arrive, and which one is a race.** A table reads this slot while
|
|
7
|
+
* it is being CREATED, and Phase-5 injection replaces a reference only when the
|
|
8
|
+
* target is already registered — a local ref naming nothing pending is left
|
|
9
|
+
* exactly as written. So the same manifest hands over a live instance on one
|
|
10
|
+
* pass of the init loop and the raw `{ kind, name }` on another, and reading
|
|
11
|
+
* only the instance is what made every cross-table foreign key fail outright.
|
|
12
|
+
*
|
|
13
|
+
* The reference is resolved to the target's DECLARATION, which carries the one
|
|
14
|
+
* thing a foreign key needs from it — the physical name — and carries it whether
|
|
15
|
+
* or not the target has been constructed. That is also why the slot stays
|
|
16
|
+
* `use: schema` and registers no ordering edge: nothing here requires the
|
|
17
|
+
* referenced table to exist first, and an edge would make a tree table (which
|
|
18
|
+
* references ITSELF) and a mutual pair into init cycles, though both are
|
|
19
|
+
* perfectly creatable on an engine that emits keys after every table.
|
|
20
|
+
*
|
|
21
|
+
* A plain string is accepted for an internal caller that already holds a name;
|
|
22
|
+
* an author cannot write one, since a ref slot rejects a bare string
|
|
23
|
+
* (`INVALID_REFERENCE_FORM`).
|
|
24
|
+
*/
|
|
25
|
+
export declare function tableReferenceResolver(ctx: ResourceContext, kind: string, table: string): TableReferenceResolver;
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/sql",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.1",
|
|
4
4
|
"description": "Telo SQL module - SQL database resource kinds for Telo manifests.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"telo",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"esbuild": "^0.25.12",
|
|
45
45
|
"typescript": "^5.0.0",
|
|
46
46
|
"vitest": "^2.1.8",
|
|
47
|
-
"@telorun/sdk": "0.
|
|
47
|
+
"@telorun/sdk": "0.80.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"@telorun/sdk": "*"
|
package/src/index.ts
CHANGED
|
@@ -47,6 +47,13 @@ export type { PlannedStatement, PlannedTombstone, SchemaPlan } from "./schema/sc
|
|
|
47
47
|
export { migrationStatements, pendingKeys } from "./schema/migration-runner.js";
|
|
48
48
|
export type { MigrationEntry, MigrationMap } from "./schema/migration-runner.js";
|
|
49
49
|
export { normalizeTable } from "./schema/normalize-table.js";
|
|
50
|
-
export
|
|
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";
|
|
51
58
|
export { runSchemaPass } from "./schema/schema-run.js";
|
|
52
59
|
export type { PendingReclamation, SchemaRunInput, SchemaRunStatus } from "./schema/schema-run.js";
|
|
@@ -109,14 +109,17 @@ function normalizeColumn(name: string, raw: RawColumn): DeclaredColumn {
|
|
|
109
109
|
};
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
/**
|
|
113
|
-
*
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
112
|
+
/**
|
|
113
|
+
* Turns whatever sits at a `references.table` slot into the referenced table's
|
|
114
|
+
* physical name.
|
|
115
|
+
*
|
|
116
|
+
* REQUIRED rather than optional: a `!ref` is not resolved when a controller is
|
|
117
|
+
* constructed — Phase-5 injection runs after `create()` returns — so a caller
|
|
118
|
+
* that omitted one would read the sentinel and reproduce, silently, the exact
|
|
119
|
+
* defect this parameter exists to fix. `tableReferenceResolver` is the one every
|
|
120
|
+
* backend uses.
|
|
121
|
+
*/
|
|
122
|
+
export type TableReferenceResolver = (value: unknown, fk: string) => string;
|
|
120
123
|
|
|
121
124
|
/**
|
|
122
125
|
* Structural checks over one declaration, at resource creation — before any
|
|
@@ -190,7 +193,10 @@ function validateTable(table: DeclaredTable): void {
|
|
|
190
193
|
}
|
|
191
194
|
}
|
|
192
195
|
|
|
193
|
-
export function normalizeTable(
|
|
196
|
+
export function normalizeTable(
|
|
197
|
+
raw: RawTable,
|
|
198
|
+
resolveReference: TableReferenceResolver,
|
|
199
|
+
): DeclaredTable {
|
|
194
200
|
const columns = Object.entries(raw.columns ?? {}).map(([name, column]) =>
|
|
195
201
|
normalizeColumn(name, column),
|
|
196
202
|
);
|
|
@@ -205,7 +211,7 @@ export function normalizeTable(raw: RawTable): DeclaredTable {
|
|
|
205
211
|
name,
|
|
206
212
|
columns: [...fk.columns],
|
|
207
213
|
references: {
|
|
208
|
-
table:
|
|
214
|
+
table: resolveReference(fk.references.table, name),
|
|
209
215
|
columns: [...fk.references.columns],
|
|
210
216
|
},
|
|
211
217
|
onDelete: fk.onDelete,
|
|
@@ -156,6 +156,31 @@ export interface SchemaDriver {
|
|
|
156
156
|
/** Whether a foreign key can be brought to its declaration in place. */
|
|
157
157
|
classifyForeignKeyChange(live: LiveForeignKey, declared: DeclaredForeignKey): ChangeSafety;
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Whether `createTable` already carries the table's foreign keys, so the
|
|
161
|
+
* reconciler must not also plan an `addForeignKey` for a table it just made.
|
|
162
|
+
*
|
|
163
|
+
* REQUIRED, like every other member here, because the wrong answer is silent:
|
|
164
|
+
* a driver that omitted it would get name matching by default, and if its
|
|
165
|
+
* engine also emits keys inside `CREATE TABLE` it would get exactly the
|
|
166
|
+
* unrestartable application this member exists to prevent — with no compile
|
|
167
|
+
* error and no failing test. Stating an answer is the point.
|
|
168
|
+
*/
|
|
169
|
+
readonly foreignKeysInCreateTable: boolean;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Whether the engine reports a foreign key back under the name the
|
|
173
|
+
* declaration gave it.
|
|
174
|
+
*
|
|
175
|
+
* Separate from `foreignKeysInCreateTable` because they are separate facts and
|
|
176
|
+
* an engine can hold one without the other: MySQL emits keys inside `CREATE
|
|
177
|
+
* TABLE` and names them. Where this is false a declaration is matched to a
|
|
178
|
+
* live key by its columns, target and referential actions, since there is no
|
|
179
|
+
* name to match on and matching by one reads a table's own key as missing on
|
|
180
|
+
* every boot after the one that created it.
|
|
181
|
+
*/
|
|
182
|
+
readonly namesForeignKeys: boolean;
|
|
183
|
+
|
|
159
184
|
createTable(schema: string, table: DeclaredTable): string[];
|
|
160
185
|
addColumn(schema: string, table: string, column: DeclaredColumn): string[];
|
|
161
186
|
alterColumn(schema: string, table: string, live: LiveColumn, column: DeclaredColumn): string[];
|
|
@@ -96,6 +96,23 @@ function indexDiffers(live: LiveIndex, declared: DeclaredIndex): boolean {
|
|
|
96
96
|
/** A referential action is what the constraint DOES, so a change to it is a
|
|
97
97
|
* change to the constraint. An action the engine did not report is not compared
|
|
98
98
|
* — an absent reading is not evidence of a difference. */
|
|
99
|
+
/**
|
|
100
|
+
* The columns a key maps to what, which is what makes it THAT key rather than
|
|
101
|
+
* another. Its referential actions are settable properties of it, deliberately
|
|
102
|
+
* excluded: an engine that keeps no name matches on this, and folding the
|
|
103
|
+
* actions in would make a changed delete rule read as a brand new key — an ADD
|
|
104
|
+
* where the author should have been told the rule cannot be changed in place.
|
|
105
|
+
*/
|
|
106
|
+
function sameForeignKeyIdentity(live: LiveForeignKey, declared: DeclaredForeignKey): boolean {
|
|
107
|
+
return (
|
|
108
|
+
live.references.table === declared.references.table &&
|
|
109
|
+
live.columns.length === declared.columns.length &&
|
|
110
|
+
live.columns.every((column, i) => column === declared.columns[i]) &&
|
|
111
|
+
live.references.columns.length === declared.references.columns.length &&
|
|
112
|
+
live.references.columns.every((column, i) => column === declared.references.columns[i])
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
99
116
|
function foreignKeyDiffers(live: LiveForeignKey, declared: DeclaredForeignKey): boolean {
|
|
100
117
|
const action = (value: string | undefined): string | undefined => value?.toUpperCase();
|
|
101
118
|
return (
|
|
@@ -259,12 +276,28 @@ export function planReconciliation(
|
|
|
259
276
|
]);
|
|
260
277
|
}
|
|
261
278
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
279
|
+
// A table this pass just created already carries its keys where the engine
|
|
280
|
+
// can only emit them there. They are still MARKED declared, or the next boot
|
|
281
|
+
// would read every one of them as removed and tombstone it.
|
|
282
|
+
const carriedByCreate = !liveTable && driver.foreignKeysInCreateTable;
|
|
283
|
+
// Where the engine keeps no name, a declaration is matched to a live key by
|
|
284
|
+
// its structure. Matching by name regardless is what made such a table
|
|
285
|
+
// unrestartable: every later boot read its own key as missing and refused to
|
|
286
|
+
// add what the engine cannot add. Matches are CONSUMED, so two keys that are
|
|
287
|
+
// structurally identical pair up one for one instead of both claiming the
|
|
288
|
+
// first.
|
|
289
|
+
const unmatched = [...(liveTable?.foreignKeys ?? [])];
|
|
290
|
+
const liveForeignKeys = new Map(unmatched.map((fk) => [fk.name, fk]));
|
|
291
|
+
const takeStructural = (fk: DeclaredForeignKey): LiveForeignKey | undefined => {
|
|
292
|
+
const at = unmatched.findIndex((live) => sameForeignKeyIdentity(live, fk));
|
|
293
|
+
return at < 0 ? undefined : unmatched.splice(at, 1)[0];
|
|
294
|
+
};
|
|
265
295
|
for (const fk of table.foreignKeys) {
|
|
266
296
|
markDeclared({ kind: "foreignKey", table: table.name, name: fk.name });
|
|
267
|
-
|
|
297
|
+
if (carriedByCreate) continue;
|
|
298
|
+
const existing = driver.namesForeignKeys
|
|
299
|
+
? liveForeignKeys.get(fk.name)
|
|
300
|
+
: takeStructural(fk);
|
|
268
301
|
if (!existing) {
|
|
269
302
|
emit("constraint", `foreign key ${fk.name}`, driver.addForeignKey(schema, table.name, fk));
|
|
270
303
|
continue;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { ResourceContext } from "@telorun/sdk";
|
|
2
|
+
import type { TableReferenceResolver } from "./normalize-table.js";
|
|
3
|
+
|
|
4
|
+
/** A `!ref` as it reaches a controller when Phase-5 injection has not replaced
|
|
5
|
+
* it: rewritten from the YAML tag to `{ kind, name, alias? }` at load. */
|
|
6
|
+
interface TableRef {
|
|
7
|
+
readonly name?: unknown;
|
|
8
|
+
readonly alias?: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function asRef(value: unknown): { name: string; alias?: string } | undefined {
|
|
12
|
+
const ref = value as TableRef | null;
|
|
13
|
+
if (!ref || typeof ref !== "object" || typeof ref.name !== "string") return undefined;
|
|
14
|
+
return { name: ref.name, alias: typeof ref.alias === "string" ? ref.alias : undefined };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolves a `references.table` slot to the referenced table's physical name.
|
|
19
|
+
*
|
|
20
|
+
* **BOTH shapes arrive, and which one is a race.** A table reads this slot while
|
|
21
|
+
* it is being CREATED, and Phase-5 injection replaces a reference only when the
|
|
22
|
+
* target is already registered — a local ref naming nothing pending is left
|
|
23
|
+
* exactly as written. So the same manifest hands over a live instance on one
|
|
24
|
+
* pass of the init loop and the raw `{ kind, name }` on another, and reading
|
|
25
|
+
* only the instance is what made every cross-table foreign key fail outright.
|
|
26
|
+
*
|
|
27
|
+
* The reference is resolved to the target's DECLARATION, which carries the one
|
|
28
|
+
* thing a foreign key needs from it — the physical name — and carries it whether
|
|
29
|
+
* or not the target has been constructed. That is also why the slot stays
|
|
30
|
+
* `use: schema` and registers no ordering edge: nothing here requires the
|
|
31
|
+
* referenced table to exist first, and an edge would make a tree table (which
|
|
32
|
+
* references ITSELF) and a mutual pair into init cycles, though both are
|
|
33
|
+
* perfectly creatable on an engine that emits keys after every table.
|
|
34
|
+
*
|
|
35
|
+
* A plain string is accepted for an internal caller that already holds a name;
|
|
36
|
+
* an author cannot write one, since a ref slot rejects a bare string
|
|
37
|
+
* (`INVALID_REFERENCE_FORM`).
|
|
38
|
+
*/
|
|
39
|
+
export function tableReferenceResolver(
|
|
40
|
+
ctx: ResourceContext,
|
|
41
|
+
kind: string,
|
|
42
|
+
table: string,
|
|
43
|
+
): TableReferenceResolver {
|
|
44
|
+
return (value, fk) => {
|
|
45
|
+
if (typeof value === "string") return value;
|
|
46
|
+
const where = `${kind} '${table}': foreign key '${fk}': 'references.table'`;
|
|
47
|
+
|
|
48
|
+
const ref = asRef(value);
|
|
49
|
+
if (ref) {
|
|
50
|
+
const declared = ctx.resolveDeclaredManifest?.(ref.name, ref.alias);
|
|
51
|
+
if (!declared) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`${where} names '${ref.name}', which resolves to no declared resource. A foreign key ` +
|
|
54
|
+
`reads its target from that resource's DECLARATION, so the table it names has to be ` +
|
|
55
|
+
`declared in a scope this one can see.`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
// The kind is constrained statically by `x-telo-ref`, so this is a
|
|
59
|
+
// backstop — but one that must not accept the wrong kind, since any
|
|
60
|
+
// resource carrying a `table` field would otherwise put a wrong
|
|
61
|
+
// identifier into DDL.
|
|
62
|
+
if (declared.kind !== kind) {
|
|
63
|
+
throw new Error(`${where} names '${ref.name}', which is a ${declared.kind}, not a ${kind}.`);
|
|
64
|
+
}
|
|
65
|
+
if (typeof declared.table !== "string") {
|
|
66
|
+
throw new Error(`${where} names '${ref.name}', which declares no 'table'.`);
|
|
67
|
+
}
|
|
68
|
+
return declared.table;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Injection won the race: the slot holds the live table resource, which
|
|
72
|
+
// reports the same name its declaration carries.
|
|
73
|
+
const injected = (value as { table?: unknown } | null)?.table;
|
|
74
|
+
if (typeof injected === "string") return injected;
|
|
75
|
+
|
|
76
|
+
throw new Error(`${where} is not a reference to a ${kind}.`);
|
|
77
|
+
};
|
|
78
|
+
}
|