@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 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 type { RawColumn, RawForeignKey, RawIndex, RawTable } from "./schema/normalize-table.js";
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
- export declare function normalizeTable(raw: RawTable): DeclaredTable;
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: referencedTableName(fk.references.table, name),
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
- const liveForeignKeys = new Map((liveTable?.foreignKeys ?? []).map((fk) => [fk.name, fk]));
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
- const existing = liveForeignKeys.get(fk.name);
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.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",
@@ -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.79.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 type { RawColumn, RawForeignKey, RawIndex, RawTable } 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";
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
- /** 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
- }
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(raw: RawTable): DeclaredTable {
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: referencedTableName(fk.references.table, name),
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
- const liveForeignKeys = new Map(
263
- (liveTable?.foreignKeys ?? []).map((fk) => [fk.name, fk]),
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
- const existing = liveForeignKeys.get(fk.name);
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
+ }