From b10c88ee46631836bbf37137772482b4b2f253d3 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 17:16:40 +0200 Subject: [PATCH 1/8] docs(projects): slice 3 spec, TS DSL cross-space relation table fallback Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- .../slices/ts-dsl-relation-fallback/spec.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 projects/psl-verbatim-table-names/slices/ts-dsl-relation-fallback/spec.md diff --git a/projects/psl-verbatim-table-names/slices/ts-dsl-relation-fallback/spec.md b/projects/psl-verbatim-table-names/slices/ts-dsl-relation-fallback/spec.md new file mode 100644 index 000000000000..6350edfac175 --- /dev/null +++ b/projects/psl-verbatim-table-names/slices/ts-dsl-relation-fallback/spec.md @@ -0,0 +1,35 @@ +# Slice spec — TS DSL cross-space relation table fallback + +**Project:** `projects/psl-verbatim-table-names/` · **Slice 3** · **Branch:** `psl-verbatim-ts-dsl-relation-fallback` (from `main`) + +## At a glance + +In the TypeScript authoring DSL, a `belongsTo` relation whose target model lives in another contract space resolves the target table as `relation.tableName ?? targetModelName.toLowerCase()` in `packages/2-sql/2-authoring/contract-ts/src/contract-lowering.ts` (around line 456). `OrderItem` becomes `orderitem`, which is neither the DSL's identity naming default nor any naming strategy the DSL offers. It is the same class of defect as the PSL default this project removed: an implicit case transform. + +## Chosen design + +Stop guessing. The foreign-key path for the same relation already leaves the target table unset when `relation.tableName` is undefined and defers resolution to the planner, which has the remote contract (ADR 226). The relation node path does the same: for a cross-space relation with no `tableName`, `toTable` and `on.childTable` are left undefined rather than fabricated. The only reader of those fields in `build-contract.ts` already skips cross-space relations, so no consumer changes behaviour. + +If making those two fields optional for the cross-space shape spreads into more than the relation-node type, its constructor, and `build-contract.ts`, fall back to the smaller change: use `targetModelName` unchanged (the identity default) and record in the report that the guess remains but now matches the DSL's default. Either way the `.toLowerCase()` call is deleted. + +## Why `tableName` can be undefined + +`ContractModelBuilder.tableName` is only populated when `.sql()` receives a static object with `table`. A model whose `.sql()` stage is a factory function (`sql(({ cols }) => ({ table: ..., ... }))`) has no statically readable table, so a cross-space handle to it carries no `tableName`. No existing test exercises that shape; every cross-space fixture uses a static `.sql({ table: 'users' })`. + +## Scope + +**In:** the lowering change; the relation-node type change if the primary design is taken; a test in `packages/2-sql/2-authoring/contract-ts/test/cross-space-relation.test.ts` (or `cross-space-fk.test.ts`) with a branded cross-space handle whose `.sql()` is a factory function and whose model is a two-word PascalCase name such as `OrderItem`, asserting the relation node carries no fabricated table (primary) or the identity-cased name (fallback). The test must fail on `main`. + +**Out:** same-space relations; the naming-strategy machinery (`applyNaming`); any change to how the planner resolves remote tables. + +## Done conditions + +- The new test is red on `main` and green on the branch. +- No `.toLowerCase()` on a model name remains in `packages/2-sql/2-authoring/contract-ts/src`. +- `@internal/sql-contract-ts` build, typecheck, test, and lint pass; `pnpm lint:deps` passes; `pnpm test:packages` passes once on the final tree. +- Upgrade coverage: run `pnpm check:upgrade-coverage --mode pr --prev origin/main --head HEAD`; no example or extension should change, so no fragment is expected. If the check demands one, report rather than write a `changes: []` fragment without checking it is truthful. + +## Dispatch plan + +1. Red test only, committed alone. +2. The change, gates, commit. Push with `git push -u bot psl-verbatim-ts-dsl-relation-fallback`; do not open the PR (the orchestrator writes the PR text). From eff5e4339bfb97be84a8e763f8c3d2c3411ab2fa Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 17:19:21 +0200 Subject: [PATCH 2/8] test(contract-ts): cross-space belongsTo with a factory .sql() stage must not fabricate a lowercased target table A cross-space handle whose .sql() stage is a factory function carries no statically readable table name. The relation lowering currently fills the gap with modelName.toLowerCase(), so OrderItem becomes orderitem, a case transform the DSL offers nowhere else. This test is red until the lowering leaves the target table unset. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- .../test/cross-space-relation.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/2-sql/2-authoring/contract-ts/test/cross-space-relation.test.ts b/packages/2-sql/2-authoring/contract-ts/test/cross-space-relation.test.ts index eb8165dfa605..3f2be5bf39e0 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/cross-space-relation.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/cross-space-relation.test.ts @@ -18,6 +18,7 @@ import { describe, expect, it } from 'vitest'; import { createTestSqlNamespace } from '../../../1-core/contract/test/test-support'; import { defineContract, field, model, rel } from '../src/contract-builder'; import { ContractModelBuilder } from '../src/contract-dsl'; +import { buildContractDefinition } from '../src/contract-lowering'; import { modelsOf } from './contract-test-helpers'; import { columnDescriptor } from './helpers/column-descriptor'; @@ -182,6 +183,57 @@ describe('cross-space belongsTo relation lowering', () => { }); }); +/** + * Synthetic supabase OrderItem handle whose `.sql()` stage is a factory + * function, so the handle carries no statically readable table name. + */ +function buildSyntheticSupabaseOrderItem() { + return new ContractModelBuilder( + { + modelName: 'OrderItem' as const, + namespace: 'auth', + fields: { + id: field.column(int4Column).id(), + sku: field.column(textColumn), + }, + relations: {}, + }, + undefined, + undefined, + 'supabase' as const, + ).sql(({ cols }) => ({ table: 'order_items', indexes: [{ columns: [cols.sku] }] })); +} + +describe('cross-space belongsTo relation with no statically readable target table', () => { + it('leaves the target table unset instead of fabricating one from the model name', () => { + const ExtOrderItem = buildSyntheticSupabaseOrderItem(); + + const LineNote = model('LineNote', { + fields: { + id: field.column(int4Column).id(), + orderItemId: field.column(int4Column), + }, + }).relations({ + orderItem: rel.belongsTo(ExtOrderItem, { from: 'orderItemId', to: 'id' }), + }); + + const definition = buildContractDefinition({ + family: bareFamilyPack, + target: postgresTargetPack, + createNamespace: createTestSqlNamespace, + extensions: { supabase: supabasePack }, + models: { LineNote }, + }); + + const relation = definition.models.find((m) => m.modelName === 'LineNote')?.relations?.[0]; + expect(relation).toMatchObject({ toModel: 'OrderItem', spaceId: 'supabase' }); + expect({ toTable: relation?.toTable, childTable: relation?.on.childTable }).toEqual({ + toTable: undefined, + childTable: undefined, + }); + }); +}); + // --------------------------------------------------------------------------- // Cross-space relation — missing-pack fail-fast (AC5 TS half) // --------------------------------------------------------------------------- From 553e225c80c3c8b1b47a2a57663cbd49f9ed807f Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 17:31:06 +0200 Subject: [PATCH 3/8] feat(contract-ts): a cross-space belongsTo with no static target table leaves the table unset instead of lowercasing the model name The TS DSL never transforms identifier case, yet the cross-space relation lowering filled a missing target table with modelName.toLowerCase(), turning OrderItem into orderitem. The relation node now leaves toTable and on.childTable undefined for a cross-space handle whose .sql() stage is a factory function; the planner resolves the table from the remote contract, exactly as it already does for the field-to-column mapping. RelationNode types both fields as string | undefined, and the one reader in build-contract.ts checks the table only when it is known. Local relations are unchanged. The cross-space foreign-key lowering had the same lowercase fallback. Its target table reaches the storage IR, where the table name is required, so it falls back to the model name unchanged, which is the DSL identity default. buildContractDefinition now declares that extensions may be a pack record; it always read them at runtime but its signature said undefined. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- .../contract-ts/src/build-contract.ts | 9 +++++++- .../contract-ts/src/contract-definition.ts | 9 ++++++-- .../contract-ts/src/contract-lowering.ts | 22 +++++++++++-------- .../test/cross-space-relation.test.ts | 2 +- 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts b/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts index 5447eed1255e..8ec2899d8649 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts @@ -1345,7 +1345,14 @@ export function buildSqlContractFromDefinition( relation.toNamespaceId, 'Relation', ); - assertTargetTableMatches(semanticModel.modelName, targetModel, relation.toTable, 'Relation'); + if (relation.toTable !== undefined) { + assertTargetTableMatches( + semanticModel.modelName, + targetModel, + relation.toTable, + 'Relation', + ); + } const targetColumnToField = new Map( targetModel.fields.map((f) => [f.columnName, f.fieldName]), diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts index 3a46c19c8157..983bb29abf6b 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts @@ -144,7 +144,12 @@ export interface ForeignKeyNode { export interface RelationNode { readonly fieldName: string; readonly toModel: string; - readonly toTable: string; + /** + * Physical table of the related model. Undefined only for a cross-space + * relation whose handle carries no static table name; the planner resolves + * it from the remote contract. + */ + readonly toTable: string | undefined; /** * Namespace coordinate of the related model. When omitted the assembler * resolves the coordinate from the referenced model node's own @@ -173,7 +178,7 @@ export interface RelationNode { readonly on: { readonly parentTable: string; readonly parentColumns: readonly string[]; - readonly childTable: string; + readonly childTable: string | undefined; readonly childColumns: readonly string[]; }; readonly through?: { diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-lowering.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-lowering.ts index 3d114887c2da..c8b015f7f4f9 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-lowering.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-lowering.ts @@ -453,7 +453,6 @@ function lowerBelongsToRelation( relation.spaceId, `Relation "${currentSpec.modelName}.${relationName}"`, ); - const targetTable = relation.tableName ?? targetModelName.toLowerCase(); const parentColumns = mapFieldNamesToColumnNames( currentSpec.modelName, fromFields, @@ -461,12 +460,13 @@ function lowerBelongsToRelation( ); // For cross-space relations, the `to` field names map directly to column // names because we have no fieldToColumn map for the remote model. - // (The brand carries the table name; field→column resolution on the remote - // side is deferred to the planner which has access to the remote contract.) + // The brand carries the table name only when the handle's `.sql()` stage + // was a static object; otherwise the table, like field→column resolution, + // is left to the planner, which has the remote contract. return { fieldName: relationName, toModel: targetModelName, - toTable: targetTable, + toTable: relation.tableName, cardinality: 'N:1', nullable: belongsToNullable(relationName, relation.optional, currentSpec, fromFields), spaceId: relation.spaceId, @@ -474,7 +474,7 @@ function lowerBelongsToRelation( on: { parentTable: currentSpec.tableName, parentColumns, - childTable: targetTable, + childTable: relation.tableName, childColumns: toFields, }, }; @@ -716,7 +716,7 @@ function lowerCrossSpaceForeignKeyNode( columns: mapFieldNamesToColumnNames(spec.modelName, foreignKey.fields, spec.fieldToColumn), references: { model: foreignKey.targetModel, - table: foreignKey.targetTableName ?? foreignKey.targetModel.toLowerCase(), + table: foreignKey.targetTableName ?? foreignKey.targetModel, columns: foreignKey.targetFields, ...(foreignKey.targetNamespaceId !== undefined ? { namespaceId: foreignKey.targetNamespaceId } @@ -917,7 +917,11 @@ function resolveModelNode( }; } -function collectRuntimeModelSpecs(definition: ContractInput): RuntimeCollection { +type LoweringInput = Omit & { + readonly extensions?: Record> | undefined; +}; + +function collectRuntimeModelSpecs(definition: LoweringInput): RuntimeCollection { const storageTypes = { ...(definition.types ?? {}) } as Record; const models = { ...(definition.models ?? {}) } as Record; @@ -1033,7 +1037,7 @@ function lowerModels( * No entity kind is named anywhere in this walk. */ function lowerPackEntityHandles( - definition: ContractInput, + definition: LoweringInput, modelSpecs: ReadonlyMap, ): AttachedEntities | undefined { const entities = definition.entities; @@ -1158,7 +1162,7 @@ function lowerPackEntityHandles( return pack; } -export function buildContractDefinition(definition: ContractInput): ContractDefinition { +export function buildContractDefinition(definition: LoweringInput): ContractDefinition { const collection = collectRuntimeModelSpecs(definition); const models = lowerModels(collection, definition.extensions); const attachedEntities = lowerPackEntityHandles(definition, collection.modelSpecs); diff --git a/packages/2-sql/2-authoring/contract-ts/test/cross-space-relation.test.ts b/packages/2-sql/2-authoring/contract-ts/test/cross-space-relation.test.ts index 3f2be5bf39e0..bf5125bdca07 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/cross-space-relation.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/cross-space-relation.test.ts @@ -201,7 +201,7 @@ function buildSyntheticSupabaseOrderItem() { undefined, undefined, 'supabase' as const, - ).sql(({ cols }) => ({ table: 'order_items', indexes: [{ columns: [cols.sku] }] })); + ).sql(() => ({ table: 'order_items' })); } describe('cross-space belongsTo relation with no statically readable target table', () => { From 78119eb4bbc9c7fd903c72c36d8aca8be0a89237 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 17:31:56 +0200 Subject: [PATCH 4/8] docs(projects): slice 3 spec records the foreign-key node builder fallback Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- .../slices/ts-dsl-relation-fallback/spec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/psl-verbatim-table-names/slices/ts-dsl-relation-fallback/spec.md b/projects/psl-verbatim-table-names/slices/ts-dsl-relation-fallback/spec.md index 6350edfac175..8a133a3c6551 100644 --- a/projects/psl-verbatim-table-names/slices/ts-dsl-relation-fallback/spec.md +++ b/projects/psl-verbatim-table-names/slices/ts-dsl-relation-fallback/spec.md @@ -8,7 +8,7 @@ In the TypeScript authoring DSL, a `belongsTo` relation whose target model lives ## Chosen design -Stop guessing. The foreign-key path for the same relation already leaves the target table unset when `relation.tableName` is undefined and defers resolution to the planner, which has the remote contract (ADR 226). The relation node path does the same: for a cross-space relation with no `tableName`, `toTable` and `on.childTable` are left undefined rather than fabricated. The only reader of those fields in `build-contract.ts` already skips cross-space relations, so no consumer changes behaviour. +Stop guessing where the value is unused, and use the identity default where a string is required. For a cross-space relation with no `tableName`, the relation node's `toTable` and `on.childTable` are left undefined rather than fabricated; the only reader of those fields in `build-contract.ts` already skips cross-space relations, so no consumer changes behaviour. The foreign-key node builder for the same relation (`lowerCrossSpaceForeignKeyNode`) had the same lowercase guess, and its target table reaches the storage IR where the name is required and the planner resolves it against the remote contract (ADR 226); there the fallback becomes the model name unchanged, which is the DSL's identity naming default. Amended after implementation: the original text claimed the foreign-key path already left the table unset, which was true one layer up but not in the node builder. If making those two fields optional for the cross-space shape spreads into more than the relation-node type, its constructor, and `build-contract.ts`, fall back to the smaller change: use `targetModelName` unchanged (the identity default) and record in the report that the guess remains but now matches the DSL's default. Either way the `.toLowerCase()` call is deleted. From b8c31864d132e79e78f332fc85de862d932c32e0 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 17:40:19 +0200 Subject: [PATCH 5/8] test(contract-ts): a cross-space FK to a handle with no static table name must fail instead of guessing The cross-space foreign-key target table is carried verbatim into contract.json and the REFERENCES clause; nothing resolves it against the remote contract. When the handle's .sql() stage is a factory function there is no table to read, so any fallback emits a reference to a table that may not exist. This test is red until the lowering throws CONTRACT.FOREIGN_KEY_INVALID. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- .../contract-ts/test/cross-space-fk.test.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/packages/2-sql/2-authoring/contract-ts/test/cross-space-fk.test.ts b/packages/2-sql/2-authoring/contract-ts/test/cross-space-fk.test.ts index f0dbd03d39e0..86bcf4d7e761 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/cross-space-fk.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/cross-space-fk.test.ts @@ -216,6 +216,61 @@ describe('cross-space FK via constraints.foreignKey in sql()', () => { // Missing-pack fail-fast (AC5 TS half) // --------------------------------------------------------------------------- +/** + * Synthetic supabase OrderItem handle whose `.sql()` stage is a factory + * function, so the handle carries no statically readable table name. + */ +function buildSyntheticSupabaseOrderItem() { + return new ContractModelBuilder( + { + modelName: 'OrderItem' as const, + namespace: 'auth', + fields: { + id: field.column(int4Column).id(), + sku: field.column(textColumn), + }, + relations: {}, + }, + undefined, + undefined, + 'supabase' as const, + ).sql(() => ({ table: 'order_items' })); +} + +describe('cross-space FK to a handle with no statically readable table name', () => { + const defineLineNoteContract = () => { + const ExtOrderItem = buildSyntheticSupabaseOrderItem(); + + const LineNote = model('LineNote', { + fields: { + id: field.column(int4Column).id(), + orderItemId: field.column(int4Column), + }, + }).sql(({ cols, constraints }) => ({ + table: 'line_note', + foreignKeys: [constraints.foreignKey(cols.orderItemId, ExtOrderItem.refs.id)], + })); + + return defineContract({ + family: bareFamilyPack, + target: postgresTargetPack, + createNamespace: createTestSqlNamespace, + extensions: { supabase: supabasePack }, + models: { LineNote }, + }); + }; + + it('throws a structured error instead of guessing the target table', () => { + expect(defineLineNoteContract).toThrow( + expect.objectContaining({ + code: 'CONTRACT.FOREIGN_KEY_INVALID', + message: + 'Foreign key on "LineNote" references model "OrderItem" in contract space "supabase" but the target table name is unknown: the handle\'s .sql() stage is a factory function, so its table cannot be read statically. Declare the target model\'s .sql() stage with a static object carrying `table`.', + }), + ); + }); +}); + describe('missing-pack fail-fast diagnostic', () => { it('throws when the referenced spaceId is not in extensions', () => { const ExtUser = buildSyntheticSupabaseAuthUser(); From d3f363c19db1dfe70fac78874a8dd6c16f12a02e Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 17:45:31 +0200 Subject: [PATCH 6/8] feat(contract-ts): a cross-space FK to a handle with no static table name throws instead of guessing The cross-space foreign-key target table is written verbatim into contract.json and the REFERENCES clause, and nothing resolves it against the remote contract. Any fallback for a handle whose .sql() stage is a factory function therefore emits a reference to a table that may not exist. The lowering now throws CONTRACT.FOREIGN_KEY_INVALID naming the source model, the target model, the contract space, and the fix: declare the target model's .sql() stage with a static object carrying table. The local-relation reader in build-contract.ts asserts the table is present instead of silently skipping the mismatch check, and LoweringInput documents why it widens ContractInput. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- .../contract-ts/src/build-contract.ts | 14 ++++++-------- .../contract-ts/src/contract-lowering.ts | 19 ++++++++++++++++++- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts b/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts index 8ec2899d8649..f99bd0effce6 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts @@ -75,6 +75,7 @@ import { computeCheckContentHash, derivedCheckPrefixes, } from '@internal/sql-schema-ir/naming'; +import { invariant } from '@internal/utils/assertions'; import { blindCast } from '@internal/utils/casts'; import { ifDefined } from '@internal/utils/defined'; import { InternalError } from '@internal/utils/internal-error'; @@ -1345,14 +1346,11 @@ export function buildSqlContractFromDefinition( relation.toNamespaceId, 'Relation', ); - if (relation.toTable !== undefined) { - assertTargetTableMatches( - semanticModel.modelName, - targetModel, - relation.toTable, - 'Relation', - ); - } + invariant( + relation.toTable !== undefined, + `Relation "${semanticModel.modelName}.${relation.fieldName}" is local but carries no target table; only cross-space relations may leave it unset.`, + ); + assertTargetTableMatches(semanticModel.modelName, targetModel, relation.toTable, 'Relation'); const targetColumnToField = new Map( targetModel.fields.map((f) => [f.columnName, f.fieldName]), diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-lowering.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-lowering.ts index c8b015f7f4f9..dbc030dc7242 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-lowering.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-lowering.ts @@ -712,11 +712,24 @@ function lowerCrossSpaceForeignKeyNode( readonly index?: boolean | undefined; }, ): ForeignKeyNode { + if (foreignKey.targetTableName === undefined) { + throw contractError( + 'CONTRACT.FOREIGN_KEY_INVALID', + `Foreign key on "${spec.modelName}" references model "${foreignKey.targetModel}" in contract space "${foreignKey.targetSpaceId}" but the target table name is unknown: the handle's .sql() stage is a factory function, so its table cannot be read statically. Declare the target model's .sql() stage with a static object carrying \`table\`.`, + { + meta: { + sourceModel: spec.modelName, + targetModel: foreignKey.targetModel, + spaceId: foreignKey.targetSpaceId, + }, + }, + ); + } return { columns: mapFieldNamesToColumnNames(spec.modelName, foreignKey.fields, spec.fieldToColumn), references: { model: foreignKey.targetModel, - table: foreignKey.targetTableName ?? foreignKey.targetModel, + table: foreignKey.targetTableName, columns: foreignKey.targetFields, ...(foreignKey.targetNamespaceId !== undefined ? { namespaceId: foreignKey.targetNamespaceId } @@ -917,6 +930,10 @@ function resolveModelNode( }; } +/** + * `ContractInput`'s `Extensions` parameter defaults to `undefined`, but lowering + * reads the extension-pack record at runtime, so the input is widened here. + */ type LoweringInput = Omit & { readonly extensions?: Record> | undefined; }; From a67843231a3cda0041ce697bd9441b295ee7e312 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 17:46:31 +0200 Subject: [PATCH 7/8] docs(projects): slice 3 spec records the fail-fast foreign-key rule Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- .../slices/ts-dsl-relation-fallback/spec.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/projects/psl-verbatim-table-names/slices/ts-dsl-relation-fallback/spec.md b/projects/psl-verbatim-table-names/slices/ts-dsl-relation-fallback/spec.md index 8a133a3c6551..68cf4939d370 100644 --- a/projects/psl-verbatim-table-names/slices/ts-dsl-relation-fallback/spec.md +++ b/projects/psl-verbatim-table-names/slices/ts-dsl-relation-fallback/spec.md @@ -8,9 +8,12 @@ In the TypeScript authoring DSL, a `belongsTo` relation whose target model lives ## Chosen design -Stop guessing where the value is unused, and use the identity default where a string is required. For a cross-space relation with no `tableName`, the relation node's `toTable` and `on.childTable` are left undefined rather than fabricated; the only reader of those fields in `build-contract.ts` already skips cross-space relations, so no consumer changes behaviour. The foreign-key node builder for the same relation (`lowerCrossSpaceForeignKeyNode`) had the same lowercase guess, and its target table reaches the storage IR where the name is required and the planner resolves it against the remote contract (ADR 226); there the fallback becomes the model name unchanged, which is the DSL's identity naming default. Amended after implementation: the original text claimed the foreign-key path already left the table unset, which was true one layer up but not in the node builder. +Never guess a table name. Two paths carried the lowercase guess: -If making those two fields optional for the cross-space shape spreads into more than the relation-node type, its constructor, and `build-contract.ts`, fall back to the smaller change: use `targetModelName` unchanged (the identity default) and record in the report that the guess remains but now matches the DSL's default. Either way the `.toLowerCase()` call is deleted. +- The relation node. For a cross-space relation with no `tableName`, `toTable` and `on.childTable` are left undefined rather than fabricated. Their only reader in `build-contract.ts` already skips cross-space relations, so no consumer changes behaviour. +- The foreign-key node (`lowerCrossSpaceForeignKeyNode`). Its target table is written into `contract.json` and into the `REFERENCES` clause of the DDL, and nothing resolves it against the remote contract later. A guessed name there is a foreign key to a table that may not exist. So when a cross-space foreign key targets a handle with no statically readable table, lowering throws `CONTRACT.FOREIGN_KEY_INVALID`, naming the source model, the target model and the space, and telling the author to declare the target model's `.sql()` stage with a static object carrying `table`. That is the only way a cross-space handle can carry its table today; there is no per-relation table option. + +Amended after review: the original text claimed the foreign-key path already left the table unset and the planner resolved it. The reviewer traced the value into the emitted contract and the DDL; neither claim held. ## Why `tableName` can be undefined @@ -18,7 +21,7 @@ If making those two fields optional for the cross-space shape spreads into more ## Scope -**In:** the lowering change; the relation-node type change if the primary design is taken; a test in `packages/2-sql/2-authoring/contract-ts/test/cross-space-relation.test.ts` (or `cross-space-fk.test.ts`) with a branded cross-space handle whose `.sql()` is a factory function and whose model is a two-word PascalCase name such as `OrderItem`, asserting the relation node carries no fabricated table (primary) or the identity-cased name (fallback). The test must fail on `main`. +**In:** the lowering change; the relation-node type change if the primary design is taken; a test in `packages/2-sql/2-authoring/contract-ts/test/cross-space-relation.test.ts` (or `cross-space-fk.test.ts`) with a branded cross-space handle whose `.sql()` is a factory function and whose model is a two-word PascalCase name such as `OrderItem`, asserting the relation node carries no fabricated table; and a test in `cross-space-fk.test.ts` asserting the structured error for a cross-space foreign key to such a handle. Both must fail on `main`. **Out:** same-space relations; the naming-strategy machinery (`applyNaming`); any change to how the planner resolves remote tables. From aba54af5bf93aca810704f65b4ac18dc908224da Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 17:50:54 +0200 Subject: [PATCH 8/8] chore(contract-ts): drop the comments claiming the planner resolves a cross-space relation table Nothing resolves or reads an undefined cross-space toTable; the comments described a step that does not exist. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- .../2-sql/2-authoring/contract-ts/src/contract-definition.ts | 3 +-- .../2-sql/2-authoring/contract-ts/src/contract-lowering.ts | 3 --- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts index 983bb29abf6b..a0a76f10c463 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts @@ -146,8 +146,7 @@ export interface RelationNode { readonly toModel: string; /** * Physical table of the related model. Undefined only for a cross-space - * relation whose handle carries no static table name; the planner resolves - * it from the remote contract. + * relation whose handle carries no static table name. */ readonly toTable: string | undefined; /** diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-lowering.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-lowering.ts index dbc030dc7242..ec7df13e5e33 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-lowering.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-lowering.ts @@ -460,9 +460,6 @@ function lowerBelongsToRelation( ); // For cross-space relations, the `to` field names map directly to column // names because we have no fieldToColumn map for the remote model. - // The brand carries the table name only when the handle's `.sql()` stage - // was a static object; otherwise the table, like field→column resolution, - // is left to the planner, which has the remote contract. return { fieldName: relationName, toModel: targetModelName,