Skip to content

feat(contract-ts): [DRAFT] design new contract.ts#261

Open
jkomyno wants to merge 2 commits intomainfrom
feat/contract-ts-revamp
Open

feat(contract-ts): [DRAFT] design new contract.ts#261
jkomyno wants to merge 2 commits intomainfrom
feat/contract-ts-revamp

Conversation

@jkomyno
Copy link
Copy Markdown
Contributor

@jkomyno jkomyno commented Mar 27, 2026

Summary

  • add the TypeScript contract authoring prototype with model(...).relations(...).attributes(...).sql(...)
  • add typed model tokens for cross-model foreign-key and relation targets, and port the demo contract to the new surface
  • add focused and integration coverage plus redesign specs/docs for the new contract authoring direction

Testing

  • pnpm --filter @prisma-next/sql-contract-ts build
  • pnpm --filter @prisma-next/sql-contract-ts test -- contract-builder.refined-option-a.test.ts
  • pnpm --filter @prisma-next/integration-tests test -- test/contract-builder.test.ts test/contract-builder.types.test-d.ts
  • pnpm --filter prisma-next-demo typecheck
  • pnpm --filter prisma-next-demo emit:ts
  • pnpm --filter prisma-next-demo test

Linear

Summary by CodeRabbit

  • New Features

    • Introduced simplified object-literal syntax for defining contracts using defineContract({ ... }) instead of fluent builder chains.
    • Added new helper functions (field, model, rel) for intuitive field and relation definitions.
  • Documentation

    • Updated README with examples of refined contract authoring patterns, constraint definitions, and migration guidance.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 27, 2026

📝 Walkthrough

Walkthrough

This change introduces a new "refined object-literal" contract authoring style for SQL-based Prisma contracts alongside the existing fluent builder pattern. It adds comprehensive DSL machinery including field, model, and rel helpers with extensive type inference support, updates contract type definitions with new capability flags and metadata, and includes refactored examples and integration tests demonstrating the new authoring surface.

Changes

Cohort / File(s) Summary
Example Contract Migration
examples/prisma-next-demo/prisma/contract.ts
Refactored from fluent builder (defineContract().table(...).model(...).build()) to new object-literal style using defineContract({ target, extensionPacks, capabilities, types, models }) with inline model/type definitions and named foreign-key constraints.
Contract Type Surface Updates
examples/prisma-next-demo/src/prisma/contract.d.ts
Added QueryOperationTypes to TypeMaps, updated storage/profile SHA256 hashes, added postgres defaults.now capability, introduced named foreign-key constraint post_userId_fkey, and extended pgvector metadata with queryOperationTypes import mapping.
Contract Metadata
examples/prisma-next-demo/src/prisma/contract.json
Auto-generated updates: new contract hashes, replaced inline pgvector typeParams with shared typeRef, named foreign-key constraint, postgres capability flag, and extended pgvector extension-pack metadata.
Documentation
packages/2-sql/2-authoring/contract-ts/README.md
Documented new refined object-literal authoring surface ("Option A") alongside legacy fluent builder, added export documentation for field, model, rel helpers, and included example code for constraints, relations, and SQL mapping patterns.
Core Builder Implementation
packages/2-sql/2-authoring/contract-ts/src/contract-builder.ts
Added extensive type-level machinery and runtime builders to support refined contract authoring: derives codec types, storage models, naming rules, constraint wiring, and materializes BuiltRefinedContract; transforms refined definitions into existing SqlContractBuilder IR with error handling for unknown type refs, missing columns, and invalid constraints.
Refined Option A DSL Module
packages/2-sql/2-authoring/contract-ts/src/refined-option-a.ts
New 1000+ line module defining complete refined contract authoring DSL: scalar field builder with nullable/default/generated/id/unique support, relation builders (belongsTo/hasMany/hasOne/manyToMany), constraint specifications, model builder with attributes/SQL staging, and helpers for naming strategy and model-name resolution.
Public API Exports
packages/2-sql/2-authoring/contract-ts/src/exports/contract-builder.ts
Expanded module exports from defineContract + ColumnBuilder to include field, model, rel value exports and new types RefinedContractInput, RefinedModelBuilder, ScalarFieldBuilder.
Test Coverage
packages/2-sql/2-authoring/contract-ts/test/contract-builder.refined-option-a.test.ts, test/integration/test/contract-builder.test.ts, test/integration/test/contract-builder.types.test-d.ts
Added 3 new test files (433+67+87 lines) validating refined contract lowering to SQL tables/constraints, integration with schema/sql builders, type inference preservation, and compile-time field/relation type separation.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • saevarb
  • wmadden

Poem

🐰 Hops through contracts with delight,
From fluent chains to objects bright!
Field, model, rel now take the stage,
A refined DSL turns the page.

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The PR title is vague and non-descriptive, using generic placeholder language ('[DRAFT]') rather than clearly conveying the main change. Replace '[DRAFT]' with a concrete summary of the primary change, such as 'feat(contract-ts): add refined object-literal contract authoring surface' or similar to clarify the new feature being introduced.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/contract-ts-revamp

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 27, 2026

Open in StackBlitz

@prisma-next/runtime-executor

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/runtime-executor@261

@prisma-next/sql-runtime

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-runtime@261

@prisma-next/extension-paradedb

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/extension-paradedb@261

@prisma-next/extension-pgvector

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/extension-pgvector@261

@prisma-next/postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/postgres@261

@prisma-next/sql-orm-client

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-orm-client@261

@prisma-next/contract-authoring

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/contract-authoring@261

@prisma-next/contract-ts

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/contract-ts@261

@prisma-next/ids

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/ids@261

@prisma-next/psl-parser

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/psl-parser@261

@prisma-next/cli

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/cli@261

@prisma-next/emitter

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/emitter@261

@prisma-next/eslint-plugin

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/eslint-plugin@261

@prisma-next/migration-tools

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/migration-tools@261

@prisma-next/vite-plugin-contract-emit

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/vite-plugin-contract-emit@261

@prisma-next/sql-contract

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract@261

@prisma-next/sql-errors

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-errors@261

@prisma-next/sql-operations

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-operations@261

@prisma-next/sql-schema-ir

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-schema-ir@261

@prisma-next/sql-contract-psl

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract-psl@261

@prisma-next/sql-contract-ts

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract-ts@261

@prisma-next/sql-contract-emitter

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract-emitter@261

@prisma-next/family-sql

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/family-sql@261

@prisma-next/sql-kysely-lane

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-kysely-lane@261

@prisma-next/sql-lane-query-builder

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-lane-query-builder@261

@prisma-next/sql-relational-core

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-relational-core@261

@prisma-next/sql-lane-sql-builder-new

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-lane-sql-builder-new@261

@prisma-next/sql-lane

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-lane@261

@prisma-next/target-postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/target-postgres@261

@prisma-next/adapter-postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/adapter-postgres@261

@prisma-next/driver-postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/driver-postgres@261

@prisma-next/core-control-plane

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/core-control-plane@261

@prisma-next/core-execution-plane

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/core-execution-plane@261

@prisma-next/config

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/config@261

@prisma-next/contract

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/contract@261

@prisma-next/operations

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/operations@261

@prisma-next/plan

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/plan@261

@prisma-next/utils

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/utils@261

commit: ec1ef79

@jkomyno jkomyno changed the title TML-2115 feat(contract-ts): prototype refined Option A authoring surface feat(contract-ts): [DRAFT] design new contract.ts Mar 27, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (4)
packages/2-sql/2-authoring/contract-ts/README.md (1)

18-20: Keep the README focused on the supported API, not rollout language.

Calling one surface “legacy” here and the refined DSL the “long-term direction” / “first slice” below makes a durable package README read like migration notes. Please describe the currently supported surfaces directly and move roadmap phrasing out of this file.

As per coding guidelines, packages/**/*.md: Update design docs to reflect the current state only, not the transition history or legacy approaches.

Also applies to: 248-249

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/2-sql/2-authoring/contract-ts/README.md` around lines 18 - 20, The
README currently labels one API as “legacy” and frames the other as a “long-term
direction”; update the document to describe only the currently supported
surfaces (e.g., the two forms of the SQL contract builder: defineContract and
the object-literal DSL using model('User', { fields, relations
}).attributes(...).sql(...)) without rollout/migration language, remove or
relocate any roadmap/transition phrasing (including the text around lines
referencing the same issue such as the content at locations 248-249) to a
separate design/roadmap doc, and ensure the README concisely documents usage and
examples for the supported APIs only.
test/integration/test/contract-builder.types.test-d.ts (1)

106-190: Keep this .test-d.ts case type-only instead of building the contract at runtime.

This block is asserting refined token/ref preservation through defineContract(...), which means the test is going through the builder’s widened runtime return type instead of a stable type-only fixture. That can hide exactly the regression this file is supposed to catch. Please move this scenario to a .test.ts file or rewrite it around a type-only fixture/emitted contract type.

Based on learnings, in Prisma-next, for type-level tests (files ending in .test-d.ts), avoid using runtime builders like defineContract since they can widen types during type-level evaluation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/integration/test/contract-builder.types.test-d.ts` around lines 106 -
190, This test file is type-only (.test-d.ts) but uses runtime builders
(defineContract, validateContract, createTestContext, createStubAdapter, schema,
sql) which widens types and defeats the purpose of the test; replace the runtime
contract construction with a purely type-level fixture (e.g., declare a
type/const type-only shape that mirrors the intended contract and use that type
in ResultType/type assertions) or move the scenario into a runtime test
(.test.ts) if you need actual builder behavior—in short, stop calling
defineContract/validateContract/createTestContext in this .test-d.ts and instead
reference a static emitted contract type (or move the code to a .test.ts) so the
refined token/ref preservation assertions remain type-only.
test/integration/test/contract-builder.test.ts (1)

276-340: Split the refined-surface coverage into its own test file.

test/integration/test/contract-builder.test.ts is already well past the 500-line cap, and this adds another distinct concern to an oversized integration file. Moving this case into something like contract-builder.refined.test.ts would keep failures easier to localize and align with the repo’s test-file limits.

As per coding guidelines, **/*.test.ts: Keep test files under 500 lines to maintain readability and navigability. Split test files when they exceed 500 lines or contain multiple distinct concerns that can be logically separated.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/integration/test/contract-builder.test.ts` around lines 276 - 340,
Extract the "refined object contract works with schema() and sql()" it-block
into a new test file (e.g., contract-builder.refined.test.ts): copy the entire
it(...) including setup of User, Post, contract (defineContract),
adapter/context creation (createStubAdapter, createTestContext), and assertions
using schema and sql; update imports at the top of the new file to include
model, field, rel, int4Column, textColumn, timestamptzColumn, postgresPack,
defineContract, createStubAdapter, createTestContext, schema, sql, SelectAst,
ResultType, and expectTypeOf; remove the copied it-block from
contract-builder.test.ts so the original file stays under 500 lines; run tests
to ensure the new file is detected by the test runner and that no shared test
fixtures are left behind.
packages/2-sql/2-authoring/contract-ts/src/refined-option-a.ts (1)

372-462: Use ifDefined() for these conditional properties.

This helper block repeats the inline ...(cond ? { ... } : {}) pattern for name, using, config, and FK options. The repo already standardizes on ifDefined() for this case, so it would be good to switch these helpers over while the DSL surface is still settling. As per coding guidelines, "Use ifDefined() from @prisma-next/utils/defined for conditional object spreads instead of inline conditional spread patterns."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/2-sql/2-authoring/contract-ts/src/refined-option-a.ts` around lines
372 - 462, Replace the inline conditional spread patterns in the constraint
helper factories with the ifDefined() helper: in id, unique, index and
foreignKey (functions named id, unique, index, foreignKey) remove occurrences of
...(options?.name ? { name: options.name } : {}) and the other inline spreads
for using, config, onDelete, onUpdate, constraint, index and instead use
ifDefined(...) from `@prisma-next/utils/defined` to conditionally add those
properties (e.g., pass the option value and a mapper to produce the property
object); keep normalizeFieldRefInput and normalizeTargetFieldRefInput usage
intact and only change the conditional spreads to ifDefined calls.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/2-sql/2-authoring/contract-ts/src/contract-builder.ts`:
- Around line 1186-1192: The code calls resolveModelIdConstraint(...) then
unconditionally passes the fields to next.primaryKey(...), which can produce
nullable PK columns if any participating model fields are optional; update the
logic in contract-builder.ts to validate the resolved idConstraint.fields
against the model field metadata (using spec.fields or whatever field
descriptors are available on spec) before calling primaryKey: detect any
optional/nullable fields referenced by idConstraint.fields and either (a) throw
or emit a clear error mentioning spec.modelName and the offending field names,
or (b) strip/convert them according to project rules so that primaryKey is only
invoked with non-nullable fields; ensure you reference resolveModelIdConstraint,
mapFieldNamesToColumnNames, primaryKey, spec.modelName and spec.fieldToColumn
when implementing the check so the fix is applied at the correct spot.
- Around line 1122-1139: The code builds tableName via applyNaming and
fieldToColumn for each model but never rejects duplicate storage names; add
checks before modelSpecs.set to validate (1) tableName is unique across
already-processed models in modelSpecs (reject with an Error referencing
modelName and the colliding tableName), and (2) the computed fieldToColumn
values are unique within this model (detect duplicate column names produced by
modelDefinition.stageOne.fields and throw an Error naming the model and
duplicate column). Use the existing symbols (applyNaming, tableName,
fieldToColumn, modelDefinition.stageOne.fields, modelSpecs) to locate the logic
and return/throw on collision so later lowering cannot silently overwrite
storage names.

In `@packages/2-sql/2-authoring/contract-ts/src/refined-option-a.ts`:
- Around line 981-983: The current isRefinedContractInput guard only checks for
a 'target' key; tighten it by verifying that value is an object with a non-null
object "target" and that target has the expected pack-ref shape (e.g.,
properties "kind", "familyId", and "targetId" exist and are strings); update
isRefinedContractInput to return true only when these checks pass so malformed
inputs like { target: 'postgres' } or incomplete contracts are rejected
immediately before dispatching.

In
`@packages/2-sql/2-authoring/contract-ts/test/contract-builder.refined-option-a.test.ts`:
- Around line 379-388: The test currently wraps ts-expect-error checks inside an
if (false) block which triggers lint errors; remove that constant-condition
block and move the failing assertions into a non-executed TypeScript-only
context: either place the three offending calls (rel.belongsTo(User, { from:
'userId', to: 'posts' }); rel.hasMany(Post, { by: 'posts' }); and the two valid
calls for context if needed) inside an uncalled helper function (e.g., function
_tsExpectErrors() { /* keep `@ts-expect-error` lines here */ } ) or move them into
a companion .test-d.ts file so the `@ts-expect-error` comments are checked by TS
without producing runtime/lintrule failures; target the calls to rel.belongsTo
and rel.hasMany and the types User and Post when relocating the lines.

---

Nitpick comments:
In `@packages/2-sql/2-authoring/contract-ts/README.md`:
- Around line 18-20: The README currently labels one API as “legacy” and frames
the other as a “long-term direction”; update the document to describe only the
currently supported surfaces (e.g., the two forms of the SQL contract builder:
defineContract and the object-literal DSL using model('User', { fields,
relations }).attributes(...).sql(...)) without rollout/migration language,
remove or relocate any roadmap/transition phrasing (including the text around
lines referencing the same issue such as the content at locations 248-249) to a
separate design/roadmap doc, and ensure the README concisely documents usage and
examples for the supported APIs only.

In `@packages/2-sql/2-authoring/contract-ts/src/refined-option-a.ts`:
- Around line 372-462: Replace the inline conditional spread patterns in the
constraint helper factories with the ifDefined() helper: in id, unique, index
and foreignKey (functions named id, unique, index, foreignKey) remove
occurrences of ...(options?.name ? { name: options.name } : {}) and the other
inline spreads for using, config, onDelete, onUpdate, constraint, index and
instead use ifDefined(...) from `@prisma-next/utils/defined` to conditionally add
those properties (e.g., pass the option value and a mapper to produce the
property object); keep normalizeFieldRefInput and normalizeTargetFieldRefInput
usage intact and only change the conditional spreads to ifDefined calls.

In `@test/integration/test/contract-builder.test.ts`:
- Around line 276-340: Extract the "refined object contract works with schema()
and sql()" it-block into a new test file (e.g.,
contract-builder.refined.test.ts): copy the entire it(...) including setup of
User, Post, contract (defineContract), adapter/context creation
(createStubAdapter, createTestContext), and assertions using schema and sql;
update imports at the top of the new file to include model, field, rel,
int4Column, textColumn, timestamptzColumn, postgresPack, defineContract,
createStubAdapter, createTestContext, schema, sql, SelectAst, ResultType, and
expectTypeOf; remove the copied it-block from contract-builder.test.ts so the
original file stays under 500 lines; run tests to ensure the new file is
detected by the test runner and that no shared test fixtures are left behind.

In `@test/integration/test/contract-builder.types.test-d.ts`:
- Around line 106-190: This test file is type-only (.test-d.ts) but uses runtime
builders (defineContract, validateContract, createTestContext,
createStubAdapter, schema, sql) which widens types and defeats the purpose of
the test; replace the runtime contract construction with a purely type-level
fixture (e.g., declare a type/const type-only shape that mirrors the intended
contract and use that type in ResultType/type assertions) or move the scenario
into a runtime test (.test.ts) if you need actual builder behavior—in short,
stop calling defineContract/validateContract/createTestContext in this
.test-d.ts and instead reference a static emitted contract type (or move the
code to a .test.ts) so the refined token/ref preservation assertions remain
type-only.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 02933a6b-59b4-4e30-9940-57b835b604e0

📥 Commits

Reviewing files that changed from the base of the PR and between c746f16 and ec1ef79.

⛔ Files ignored due to path filters (4)
  • projects/ts-contract-authoring-redesign/authoring-api-options-recommendation.md is excluded by !projects/**
  • projects/ts-contract-authoring-redesign/plan.md is excluded by !projects/**
  • projects/ts-contract-authoring-redesign/spec.md is excluded by !projects/**
  • projects/ts-contract-authoring-redesign/typed-cross-model-refs-before-and-after.md is excluded by !projects/**
📒 Files selected for processing (10)
  • examples/prisma-next-demo/prisma/contract.ts
  • examples/prisma-next-demo/src/prisma/contract.d.ts
  • examples/prisma-next-demo/src/prisma/contract.json
  • packages/2-sql/2-authoring/contract-ts/README.md
  • packages/2-sql/2-authoring/contract-ts/src/contract-builder.ts
  • packages/2-sql/2-authoring/contract-ts/src/exports/contract-builder.ts
  • packages/2-sql/2-authoring/contract-ts/src/refined-option-a.ts
  • packages/2-sql/2-authoring/contract-ts/test/contract-builder.refined-option-a.test.ts
  • test/integration/test/contract-builder.test.ts
  • test/integration/test/contract-builder.types.test-d.ts

Comment on lines +1122 to +1139
const tableName = sqlSpec?.table ?? applyNaming(modelName, definition.naming?.tables);
const fieldToColumn: Record<string, string> = {};

for (const [fieldName, fieldBuilder] of Object.entries(modelDefinition.stageOne.fields)) {
const fieldState = fieldBuilder.build();
fieldToColumn[fieldName] =
fieldState.columnName ?? applyNaming(fieldName, definition.naming?.columns);
}

modelSpecs.set(modelName, {
modelName,
tableName,
fieldBuilders: modelDefinition.stageOne.fields,
fieldToColumn,
relations: modelDefinition.stageOne.relations,
attributesSpec,
sqlSpec,
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject post-naming storage collisions before lowering.

applyNaming() and explicit table / column overrides can make two models or fields resolve to the same storage name. This loop never checks that invariant, so the later definition silently wins once table() and computeMappings() run.

Diff suggestion
+  const tableOwners = new Map<string, string>();
   for (const [modelName, modelDefinition] of Object.entries(models)) {
     const tokenModelName = modelDefinition.stageOne.modelName;
     if (tokenModelName && tokenModelName !== modelName) {
       throw new Error(
         `Model token "${tokenModelName}" must be assigned to models.${tokenModelName}. Received models.${modelName}.`,
       );
     }

     const attributesSpec = modelDefinition.buildAttributesSpec();
     const sqlSpec = modelDefinition.buildSqlSpec();
     const tableName = sqlSpec?.table ?? applyNaming(modelName, definition.naming?.tables);
+    const existingModel = tableOwners.get(tableName);
+    if (existingModel) {
+      throw new Error(
+        `Models "${existingModel}" and "${modelName}" both map to table "${tableName}".`,
+      );
+    }
+    tableOwners.set(tableName, modelName);
     const fieldToColumn: Record<string, string> = {};
+    const columnOwners = new Map<string, string>();

     for (const [fieldName, fieldBuilder] of Object.entries(modelDefinition.stageOne.fields)) {
       const fieldState = fieldBuilder.build();
-      fieldToColumn[fieldName] =
-        fieldState.columnName ?? applyNaming(fieldName, definition.naming?.columns);
+      const columnName =
+        fieldState.columnName ?? applyNaming(fieldName, definition.naming?.columns);
+      const existingField = columnOwners.get(columnName);
+      if (existingField) {
+        throw new Error(
+          `Model "${modelName}" maps both "${existingField}" and "${fieldName}" to column "${columnName}".`,
+        );
+      }
+      columnOwners.set(columnName, fieldName);
+      fieldToColumn[fieldName] = columnName;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/2-sql/2-authoring/contract-ts/src/contract-builder.ts` around lines
1122 - 1139, The code builds tableName via applyNaming and fieldToColumn for
each model but never rejects duplicate storage names; add checks before
modelSpecs.set to validate (1) tableName is unique across already-processed
models in modelSpecs (reject with an Error referencing modelName and the
colliding tableName), and (2) the computed fieldToColumn values are unique
within this model (detect duplicate column names produced by
modelDefinition.stageOne.fields and throw an Error naming the model and
duplicate column). Use the existing symbols (applyNaming, tableName,
fieldToColumn, modelDefinition.stageOne.fields, modelSpecs) to locate the logic
and return/throw on collision so later lowering cannot silently overwrite
storage names.

Comment on lines +1163 to +1168
if (fieldState.executionDefault) {
next = next.generated(columnName, {
type: descriptor,
generated: fieldState.executionDefault,
});
continue;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't silently drop modifiers on generated fields.

field.generated(...).optional() and field.generated(...).default(...) are both currently expressible, but this branch ignores those flags and emits only the execution default. That leaves the refined builder state out of sync with the contract that gets built.

Diff suggestion
         if (fieldState.executionDefault) {
+          if (fieldState.nullable || fieldState.default) {
+            throw new Error(
+              `Generated field "${spec.modelName}.${fieldName}" cannot also be optional or define a default.`,
+            );
+          }
           next = next.generated(columnName, {
             type: descriptor,
             generated: fieldState.executionDefault,
           });
           continue;
         }

Comment on lines +1186 to +1192
const idConstraint = resolveModelIdConstraint(spec);
if (idConstraint) {
next = next.primaryKey(
mapFieldNamesToColumnNames(spec.modelName, idConstraint.fields, spec.fieldToColumn),
idConstraint.name,
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Block nullable fields from becoming the primary key.

Nothing here re-checks the participating fields before primaryKey(). An author can define .optional().id() or an attribute-level id over optional fields, and the storage IR will describe a nullable primary-key column.

Diff suggestion
       const idConstraint = resolveModelIdConstraint(spec);
       if (idConstraint) {
+        for (const fieldName of idConstraint.fields) {
+          if (spec.fieldBuilders[fieldName]!.build().nullable) {
+            throw new Error(
+              `Model "${spec.modelName}" uses nullable field "${fieldName}" in its identity.`,
+            );
+          }
+        }
         next = next.primaryKey(
           mapFieldNamesToColumnNames(spec.modelName, idConstraint.fields, spec.fieldToColumn),
           idConstraint.name,
         );
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const idConstraint = resolveModelIdConstraint(spec);
if (idConstraint) {
next = next.primaryKey(
mapFieldNamesToColumnNames(spec.modelName, idConstraint.fields, spec.fieldToColumn),
idConstraint.name,
);
}
const idConstraint = resolveModelIdConstraint(spec);
if (idConstraint) {
for (const fieldName of idConstraint.fields) {
if (spec.fieldBuilders[fieldName]!.build().nullable) {
throw new Error(
`Model "${spec.modelName}" uses nullable field "${fieldName}" in its identity.`,
);
}
}
next = next.primaryKey(
mapFieldNamesToColumnNames(spec.modelName, idConstraint.fields, spec.fieldToColumn),
idConstraint.name,
);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/2-sql/2-authoring/contract-ts/src/contract-builder.ts` around lines
1186 - 1192, The code calls resolveModelIdConstraint(...) then unconditionally
passes the fields to next.primaryKey(...), which can produce nullable PK columns
if any participating model fields are optional; update the logic in
contract-builder.ts to validate the resolved idConstraint.fields against the
model field metadata (using spec.fields or whatever field descriptors are
available on spec) before calling primaryKey: detect any optional/nullable
fields referenced by idConstraint.fields and either (a) throw or emit a clear
error mentioning spec.modelName and the offending field names, or (b)
strip/convert them according to project rules so that primaryKey is only invoked
with non-nullable fields; ensure you reference resolveModelIdConstraint,
mapFieldNamesToColumnNames, primaryKey, spec.modelName and spec.fieldToColumn
when implementing the check so the fix is applied at the correct spot.

Comment on lines +981 to +983
export function isRefinedContractInput(value: unknown): value is RefinedContractInput {
return typeof value === 'object' && value !== null && 'target' in value;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Tighten the refined-input guard before dispatching.

Any object with a target key passes this check. In JS or untyped callers, { target: 'postgres' } or even an already-built contract will go down the refined path and only fail later with unrelated errors. Please validate the target pack-ref shape here (kind, familyId, targetId) so bad inputs fail immediately.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/2-sql/2-authoring/contract-ts/src/refined-option-a.ts` around lines
981 - 983, The current isRefinedContractInput guard only checks for a 'target'
key; tighten it by verifying that value is an object with a non-null object
"target" and that target has the expected pack-ref shape (e.g., properties
"kind", "familyId", and "targetId" exist and are strings); update
isRefinedContractInput to return true only when these checks pass so malformed
inputs like { target: 'postgres' } or incomplete contracts are rejected
immediately before dispatching.

Comment on lines +379 to +388
if (false) {
rel.belongsTo(User, { from: 'userId', to: 'id' });
rel.hasMany(Post, { by: 'userId' });

// @ts-expect-error relation targets must expose real scalar fields
rel.belongsTo(User, { from: 'userId', to: 'posts' });

// @ts-expect-error relation targets must expose real scalar fields
rel.hasMany(Post, { by: 'posts' });
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Replace the if (false) compile-only blocks.

Biome already reports both branches as lint/correctness/noConstantCondition, so this new test file won’t pass lint as written. Move these @ts-expect-error checks into an uncalled helper or a .test-d.ts companion instead.

Possible fix
-    if (false) {
-      rel.belongsTo(User, { from: 'userId', to: 'id' });
-      rel.hasMany(Post, { by: 'userId' });
-
-      // `@ts-expect-error` relation targets must expose real scalar fields
-      rel.belongsTo(User, { from: 'userId', to: 'posts' });
-
-      // `@ts-expect-error` relation targets must expose real scalar fields
-      rel.hasMany(Post, { by: 'posts' });
-    }
+    const assertRelationTargets = () => {
+      rel.belongsTo(User, { from: 'userId', to: 'id' });
+      rel.hasMany(Post, { by: 'userId' });
+
+      // `@ts-expect-error` relation targets must expose real scalar fields
+      rel.belongsTo(User, { from: 'userId', to: 'posts' });
+
+      // `@ts-expect-error` relation targets must expose real scalar fields
+      rel.hasMany(Post, { by: 'posts' });
+    };
+    void assertRelationTargets;
...
-    if (false) {
-      // `@ts-expect-error` unnamed models must not expose token-based cross-model refs
-      Anonymous.ref('id');
-
-      // `@ts-expect-error` unnamed models must not expose token-based cross-model refs
-      Anonymous.refs.id;
-
-      // `@ts-expect-error` unnamed models must not compile as relation targets
-      rel.belongsTo(Anonymous, { from: 'id', to: 'id' });
-
-      // `@ts-expect-error` unnamed models must not compile through lazy relation targets
-      rel.hasMany(() => Anonymous, { by: 'id' });
-    }
+    const assertAnonymousModelErrors = () => {
+      // `@ts-expect-error` unnamed models must not expose token-based cross-model refs
+      Anonymous.ref('id');
+
+      // `@ts-expect-error` unnamed models must not expose token-based cross-model refs
+      Anonymous.refs.id;
+
+      // `@ts-expect-error` unnamed models must not compile as relation targets
+      rel.belongsTo(Anonymous, { from: 'id', to: 'id' });
+
+      // `@ts-expect-error` unnamed models must not compile through lazy relation targets
+      rel.hasMany(() => Anonymous, { by: 'id' });
+    };
+    void assertAnonymousModelErrors;

Also applies to: 400-412

🧰 Tools
🪛 Biome (2.4.9)

[error] 379-379: Unexpected constant condition.

(lint/correctness/noConstantCondition)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/2-sql/2-authoring/contract-ts/test/contract-builder.refined-option-a.test.ts`
around lines 379 - 388, The test currently wraps ts-expect-error checks inside
an if (false) block which triggers lint errors; remove that constant-condition
block and move the failing assertions into a non-executed TypeScript-only
context: either place the three offending calls (rel.belongsTo(User, { from:
'userId', to: 'posts' }); rel.hasMany(Post, { by: 'posts' }); and the two valid
calls for context if needed) inside an uncalled helper function (e.g., function
_tsExpectErrors() { /* keep `@ts-expect-error` lines here */ } ) or move them into
a companion .test-d.ts file so the `@ts-expect-error` comments are checked by TS
without producing runtime/lintrule failures; target the calls to rel.belongsTo
and rel.hasMany and the types User and Post when relocating the lines.

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.

1 participant