Skip to content

feat(contract-ts): a cross-space relation never guesses its target table; a foreign key to an unreadable one is an authoring error - #30323

Merged
wmadden-electric merged 8 commits into
mainfrom
psl-verbatim-ts-dsl-relation-fallback
Sep 21, 2026
Merged

wmadden-electric merged 8 commits into
mainfrom
psl-verbatim-ts-dsl-relation-fallback

Conversation

@wmadden-electric

@wmadden-electric wmadden-electric commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

At a glance

In the TypeScript authoring DSL, this relation to a model in another contract space:

const LineNote = model('LineNote')
  .fields((f) => ({ id: f.int(), orderItemId: f.int() }))
  .belongsTo(ExtOrderItem, { fields: ['orderItemId'] })
  .sql(({ cols, constraints }) => ({ table: 'line_notes', ... }));

used to resolve the target table as OrderItem lowercased to orderitem whenever the target model's own .sql() stage was a factory function, which hides its table from static reading. Now the relation node carries no table for that case, and a foreign key to such a target is an authoring error instead of a REFERENCES "orderitem" clause to a table that does not exist.

The decision

The DSL never guesses a table name. This is slice 3 of the project that removed the PSL interpreter's lowered-first-letter default (#30317): the same class of defect, an implicit case transform, in a different surface. Two code paths carried it.

  • The relation node. For a cross-space belongsTo with no statically readable target table, toTable and on.childTable are now undefined. Their only reader in build-contract.ts already skipped cross-space relations, and the emitted cross-space relation is deliberately non-navigable, so nothing downstream changes. The local-relation path now asserts the table is present instead of skipping its check when it is not.
  • The foreign-key node. Its target table is written into contract.json and into the DDL's REFERENCES clause, and nothing resolves it against the remote contract afterwards. Any guess there is a broken constraint. So when a cross-space foreign key targets a handle whose table cannot be read statically, 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; there is no per-relation table option.

What changes for authors

A cross-space foreign key to a factory-form handle no longer compiles a contract. Code that worked only because the remote table happened to equal the lowercased model name now fails at authoring time with the error above. Nothing in this repository relied on it, and the upgrade coverage check demands no fragment, but this is a behaviour change worth a release-note line. The fix on the author's side is one edit: give the target model a static .sql({ table: '...' }).

RelationNode.toTable and on.childTable, which extension packages re-export, are now typed string | undefined. Every producer in the repo still sets them for local relations; only cross-space consumers, of which there are none, would see the widening.

Tests

Both new tests were red on main for the stated reason before the change: the relation-node test saw orderitem where it expected undefined, and the foreign-key test saw no error where it expected one. test:packages, lint:deps, and the cast ratchet pass.

Alternatives considered

  • Fall back to the model name unchanged, the DSL's identity naming default. Rejected for the foreign key: it is still a guess, and it lands in the DDL. Kept out of the relation node too, since the value there has no reader.
  • Resolve the remote table at lowering time. Would be correct, but lowering only receives pack metadata for other spaces, not their model bodies (ADR 226 resolves space, model and column, not the table string). Out of scope for this project.
  • Add a per-relation table option so authors can name the remote table on the relation. A new DSL surface; noted as a possible follow-up, not needed for correctness.

projects/psl-verbatim-table-names/slices/ts-dsl-relation-fallback/spec.md records the slice, including the retraction of an earlier claim that the planner resolved the foreign-key table.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved handling of cross-space relationships when the target table cannot be determined statically.
    • Prevented the system from incorrectly inferring a target table from the model name.
    • Added clearer validation errors for invalid foreign-key and relationship definitions.
    • Local relationships without a target table are now rejected during contract building.
  • Tests

    • Added coverage for relationships and foreign keys targeting dynamically defined tables.

wmadden-electric and others added 8 commits September 16, 2026 17:46
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…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 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…e 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 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…lback

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…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 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…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 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
… 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 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@wmadden-electric
wmadden-electric requested a review from a team as a code owner September 16, 2026 15:51
@coderabbitai

coderabbitai Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Advanced

Run ID: 831601ec-a1da-4381-8fa7-dd3987705ca4

📥 Commits

Reviewing files that changed from the base of the PR and between eb6b3d3 and aba54af.

⛔ Files ignored due to path filters (1)
  • projects/psl-verbatim-table-names/slices/ts-dsl-relation-fallback/spec.md is excluded by !projects/**
📒 Files selected for processing (5)
  • packages/2-sql/2-authoring/contract-ts/src/build-contract.ts
  • packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts
  • packages/2-sql/2-authoring/contract-ts/src/contract-lowering.ts
  • packages/2-sql/2-authoring/contract-ts/test/cross-space-fk.test.ts
  • packages/2-sql/2-authoring/contract-ts/test/cross-space-relation.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 6 remain after this review.


📝 Walkthrough

Walkthrough

Cross-space relation lowering no longer derives table names from model names. Missing static tables remain unset for relations and cause foreign-key validation errors. Local relations now require a target table before validation. Input types accept SQL extension-pack records.

Changes

Cross-space table metadata

Layer / File(s) Summary
Cross-space lowering contracts
packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts, packages/2-sql/2-authoring/contract-ts/src/contract-lowering.ts, packages/2-sql/2-authoring/contract-ts/test/cross-space-relation.test.ts, packages/2-sql/2-authoring/contract-ts/test/cross-space-fk.test.ts
Relation table fields are optional for cross-space targets without static table names. Relation lowering preserves missing values, while foreign-key lowering reports CONTRACT.FOREIGN_KEY_INVALID. The tests cover both cases.
Local relation target validation
packages/2-sql/2-authoring/contract-ts/src/build-contract.ts
Local relations now fail with an invariant when relation.toTable is unset before target-table matching.

Priority: ⬇️ Low

Estimated code review effort: 2 (Simple) | ~15 minutes

Change: Bug fix

Suggested reviewers: sevinf

Merge Risk: ⚪ Minimal · up to aba54

The changed cross-space relation behavior preserves unavailable static table metadata without affecting local validation, and foreign keys reject unsupported declarations. No actionable merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 9 functions across 5 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: cross-space relations no longer guess target tables, and foreign keys to unreadable target tables now fail during authoring.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch psl-verbatim-ts-dsl-relation-fallback

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Sep 16, 2026

Copy link
Copy Markdown

Open in StackBlitz

@prisma/orm-extension-arktype-json

npm i https://pkg.pr.new/@prisma/orm-extension-arktype-json@30323

@prisma/orm-extension-middleware-cache

npm i https://pkg.pr.new/@prisma/orm-extension-middleware-cache@30323

@prisma/orm-extension-paradedb

npm i https://pkg.pr.new/@prisma/orm-extension-paradedb@30323

@prisma/orm-extension-pgvector

npm i https://pkg.pr.new/@prisma/orm-extension-pgvector@30323

@prisma/orm-extension-postgis

npm i https://pkg.pr.new/@prisma/orm-extension-postgis@30323

@prisma/orm-extension-supabase

npm i https://pkg.pr.new/@prisma/orm-extension-supabase@30323

@prisma/orm-family-mongo

npm i https://pkg.pr.new/@prisma/orm-family-mongo@30323

@prisma/orm-family-sql

npm i https://pkg.pr.new/@prisma/orm-family-sql@30323

@prisma/orm-framework

npm i https://pkg.pr.new/@prisma/orm-framework@30323

@prisma/orm-mongo

npm i https://pkg.pr.new/@prisma/orm-mongo@30323

@prisma/orm-postgres

npm i https://pkg.pr.new/@prisma/orm-postgres@30323

@prisma/orm-sqlite

npm i https://pkg.pr.new/@prisma/orm-sqlite@30323

@prisma/orm-target-mongo

npm i https://pkg.pr.new/@prisma/orm-target-mongo@30323

@prisma/orm-target-postgres

npm i https://pkg.pr.new/@prisma/orm-target-postgres@30323

@prisma/orm-target-sqlite

npm i https://pkg.pr.new/@prisma/orm-target-sqlite@30323

@prisma/orm-toolchain

npm i https://pkg.pr.new/@prisma/orm-toolchain@30323

commit: aba54af

@github-actions

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
postgres / no-emit 188.13 KB (+0.09% 🔺)
postgres / emit 159.8 KB (0%)
mongo / no-emit 108.55 KB (0%)
mongo / emit 91.8 KB (0%)
cf-worker / no-emit 210.94 KB (+0.08% 🔺)
cf-worker / emit 179.5 KB (0%)

@wmadden-electric
wmadden-electric added this pull request to the merge queue Sep 21, 2026
Merged via the queue into main with commit 3e52086 Sep 21, 2026
26 checks passed
@wmadden-electric
wmadden-electric deleted the psl-verbatim-ts-dsl-relation-fallback branch September 21, 2026 12:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants