Skip to content

TML-2787: M:N slice 3 — nested writes through the junction#683

Open
tensordreams wants to merge 11 commits into
tml-2786-slice-2-filterfrom
tml-2787-slice-3-write
Open

TML-2787: M:N slice 3 — nested writes through the junction#683
tensordreams wants to merge 11 commits into
tml-2786-slice-2-filterfrom
tml-2787-slice-3-write

Conversation

@tensordreams

@tensordreams tensordreams commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Slice 3 (final) of the SQL ORM: Many-to-Many End to End project (Linear project). Nested connect/disconnect/create through the junction + the required-payload safety rail.

⚠ DRAFT — not ready to merge. Runtime-complete, but the type-level .create disable (your non-negotiable, in-slice requirement) is deferred pending your decision — it needs a contract-.d.ts change. See Open decision below + wip/unattended-decisions.md #8. I did not make that contract-surface change unilaterally.

Stacked PR. Base tml-2786 (#680) → tml-2785 (#679) → tml-2784 (#678) → tml-2597 (#673) → tml-2729 (#667) → main. Review/merge bottom-up.

Overview

db.orm.User.update/create({ tags: (t) => t.connect/disconnect/create(...) }) now routes to the UserTag junction (INSERT / DELETE / target-insert+link), under both create() and update(). The N:M not supported yet guard is gone. Junctions with a required non-FK payload column cant be written through the sugar, so create and connect on them are disabled with a clear error (disconnect stays).

Changes (5 commits)

  • 74a778816 — runtime junction write path: partitionByOwnership gains a junctionOwned bucket (keyed on through presence); connect→INSERT, disconnect→DELETE, create→target-insert+link, both flows, composite-key AND-ed; the rejection unit test flipped positive. (getRelationDefinitions now carries through.)
  • 926bdc849 — required-payload fixture: User ↔ Role via UserRole(user_id, role_id, level NOT NULL) (canonical CLI emit).
  • 3bccd80b3 — runtime guard: nested create on a required-payload junction throws.
  • e6c641811design correction (decision Remove SQL -> Runtime dependency #9): extended the guard to connect too (connect also INSERTs a junction row it cant complete → DB NOT-NULL violation), flipped the unit test, finished the 10 write integration tests.

Integration tests (per the project standard)

mn-nested-write.test.ts — 10 tests, no skips: connect/create on the pure User.tags junction with whole-row readback via include(tags), both create()+update() flows; disconnect; connect AND create on User.roles throw the guard; disconnect on User.roles works. Whole-row toEqual, explicit .select in most, ≥1 implicit/default selection.

AC status

  • AC-1 — connect/disconnect/create route to junction DML, both flows, guard removed, rejection test flipped (unit + integration).
  • AC-3 — write integration tests per the standard.
  • AC-2 — partial. Runtime disable of create+connect on required-payload junctions: ✅ done + tested. Type-level disable: deferred — see below.

⚠ Open decision (blocks marking this slice done)

The type-level .create disable cant be built as specified: the generated contract.d.ts relation type carries only to/cardinality/on, not through — so a conditional type cant see which model is the junction or that it has a required column. requiredPayloadColumns exists only at runtime. Honouring the type-level disable needs the contract .d.ts type emitter to carry through (a contract-surface change reaching into slice-0 territory). Options (full detail in wip/unattended-decisions.md #8):

  • (a) extend the .d.ts emitter to emit through, then a follow-up adds the conditional-type disable + negative type test;
  • (b) emit a narrower requiredPayloadColumns/hasRequiredPayload marker into the typed relation;
  • (c) accept runtime-only (contradicts the in-slice non-negotiable).

I shipped the runtime guard and left this for you. Once you pick (a)/(b), a small follow-up dispatch completes the type-level disable.

Notes

connect-disabled-on-required-payload was a mid-flight spec correction (the original spec wrongly assumed connect was FK-pair-only safe). The reverse Tag.users/Role.users directions are deferred (one-directional fixture, decision #3). Refs: TML-2787.

Summary by CodeRabbit

Release Notes

  • New Features

    • Enabled nested create, connect, and disconnect operations for many-to-many relationships via junction tables.
    • Added compile-time type checking to prevent invalid operations on junction relations with required fields.
  • Improvements

    • Enhanced runtime validation with clear error guidance for missing required junction payload fields.

@coderabbitai

coderabbitai Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 1957e0ac-65b0-4316-bc62-f42d109dd655

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch tml-2787-slice-3-write

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.

@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown

size-limit report 📦

Path Size
postgres / no-emit 152.96 KB (+0.34% 🔺)
postgres / emit 121.25 KB (+0.43% 🔺)
mongo / no-emit 76.67 KB (0%)
mongo / emit 70.96 KB (0%)
cf-worker / no-emit 180.95 KB (0%)
cf-worker / emit 145.99 KB (0%)

@pkg-pr-new

pkg-pr-new Bot commented Jun 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

@prisma-next/extension-author-tools

npm i https://pkg.pr.new/@prisma-next/extension-author-tools@683

@prisma-next/mongo-runtime

npm i https://pkg.pr.new/@prisma-next/mongo-runtime@683

@prisma-next/family-mongo

npm i https://pkg.pr.new/@prisma-next/family-mongo@683

@prisma-next/sql-runtime

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

@prisma-next/family-sql

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

@prisma-next/extension-arktype-json

npm i https://pkg.pr.new/@prisma-next/extension-arktype-json@683

@prisma-next/middleware-cache

npm i https://pkg.pr.new/@prisma-next/middleware-cache@683

@prisma-next/mongo

npm i https://pkg.pr.new/@prisma-next/mongo@683

@prisma-next/extension-paradedb

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

@prisma-next/extension-pgvector

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

@prisma-next/extension-postgis

npm i https://pkg.pr.new/@prisma-next/extension-postgis@683

@prisma-next/postgres

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

@prisma-next/sql-orm-client

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

@prisma-next/sqlite

npm i https://pkg.pr.new/@prisma-next/sqlite@683

@prisma-next/extension-supabase

npm i https://pkg.pr.new/@prisma-next/extension-supabase@683

@prisma-next/target-mongo

npm i https://pkg.pr.new/@prisma-next/target-mongo@683

@prisma-next/adapter-mongo

npm i https://pkg.pr.new/@prisma-next/adapter-mongo@683

@prisma-next/driver-mongo

npm i https://pkg.pr.new/@prisma-next/driver-mongo@683

@prisma-next/contract

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

@prisma-next/utils

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

@prisma-next/config

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

@prisma-next/errors

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

@prisma-next/framework-components

npm i https://pkg.pr.new/@prisma-next/framework-components@683

@prisma-next/operations

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

@prisma-next/ts-render

npm i https://pkg.pr.new/@prisma-next/ts-render@683

@prisma-next/contract-authoring

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

@prisma-next/ids

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

@prisma-next/psl-parser

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

@prisma-next/psl-printer

npm i https://pkg.pr.new/@prisma-next/psl-printer@683

@prisma-next/cli

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

@prisma-next/cli-telemetry

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

@prisma-next/emitter

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

@prisma-next/migration-tools

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

prisma-next

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

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

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

@prisma-next/mongo-codec

npm i https://pkg.pr.new/@prisma-next/mongo-codec@683

@prisma-next/mongo-contract

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

@prisma-next/mongo-value

npm i https://pkg.pr.new/@prisma-next/mongo-value@683

@prisma-next/mongo-contract-psl

npm i https://pkg.pr.new/@prisma-next/mongo-contract-psl@683

@prisma-next/mongo-contract-ts

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

@prisma-next/mongo-emitter

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

@prisma-next/mongo-schema-ir

npm i https://pkg.pr.new/@prisma-next/mongo-schema-ir@683

@prisma-next/mongo-query-ast

npm i https://pkg.pr.new/@prisma-next/mongo-query-ast@683

@prisma-next/mongo-orm

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

@prisma-next/mongo-query-builder

npm i https://pkg.pr.new/@prisma-next/mongo-query-builder@683

@prisma-next/mongo-lowering

npm i https://pkg.pr.new/@prisma-next/mongo-lowering@683

@prisma-next/mongo-wire

npm i https://pkg.pr.new/@prisma-next/mongo-wire@683

@prisma-next/sql-contract

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

@prisma-next/sql-errors

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

@prisma-next/sql-operations

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

@prisma-next/sql-schema-ir

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

@prisma-next/sql-contract-psl

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

@prisma-next/sql-contract-ts

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

@prisma-next/sql-contract-emitter

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

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

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

@prisma-next/sql-relational-core

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

@prisma-next/sql-builder

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

@prisma-next/target-postgres

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

@prisma-next/target-sqlite

npm i https://pkg.pr.new/@prisma-next/target-sqlite@683

@prisma-next/adapter-postgres

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

@prisma-next/adapter-sqlite

npm i https://pkg.pr.new/@prisma-next/adapter-sqlite@683

@prisma-next/driver-postgres

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

@prisma-next/driver-sqlite

npm i https://pkg.pr.new/@prisma-next/driver-sqlite@683

commit: 4ea74ff

@tensordreams tensordreams force-pushed the tml-2786-slice-2-filter branch from 92d1741 to 25a3c7e Compare June 2, 2026 12:40
@tensordreams tensordreams force-pushed the tml-2787-slice-3-write branch from 7cbb4a0 to de78282 Compare June 2, 2026 12:40
@tensordreams tensordreams force-pushed the tml-2786-slice-2-filter branch from 25a3c7e to 06fc270 Compare June 2, 2026 13:07
@tensordreams tensordreams force-pushed the tml-2787-slice-3-write branch from de78282 to 3e1c908 Compare June 2, 2026 13:07
@tensordreams tensordreams marked this pull request as ready for review June 2, 2026 13:08
@tensordreams tensordreams requested a review from a team as a code owner June 2, 2026 13:08
@tensordreams tensordreams changed the title TML-2787: M:N slice 3 — nested writes through the junction (DRAFT — type-level disable pending decision) TML-2787: M:N slice 3 — nested writes through the junction Jun 2, 2026
@tensordreams

Copy link
Copy Markdown
Contributor Author

Updated: the type-level .create/.connect disable is now implemented (escalation #8 resolved via the chosen approach — the .d.ts emitter now carries through, landed in slice 0; the type gate derives required-payload purely at the type level, no any). AC-2 is closed at both the runtime and type levels. The whole stack has also been rebased onto the latest origin/main (clean — no fixture re-emit cascade). Negative type test: junction-link-write-disable.test-d.ts. No longer a draft.

@tensordreams tensordreams force-pushed the tml-2786-slice-2-filter branch from 06fc270 to bc2d4b9 Compare June 3, 2026 08:51
@tensordreams tensordreams force-pushed the tml-2787-slice-3-write branch 2 times, most recently from 308b48d to bb3e246 Compare June 3, 2026 11:32
@tensordreams tensordreams force-pushed the tml-2786-slice-2-filter branch 2 times, most recently from 3982e13 to b1dadcf Compare June 4, 2026 15:12
@tensordreams tensordreams force-pushed the tml-2787-slice-3-write branch from bb3e246 to 47cf59e Compare June 4, 2026 15:12
@tensordreams tensordreams force-pushed the tml-2786-slice-2-filter branch from b1dadcf to df900cd Compare June 4, 2026 15:41
@tensordreams tensordreams force-pushed the tml-2787-slice-3-write branch from 47cf59e to b04a59c Compare June 4, 2026 15:41
@tensordreams tensordreams force-pushed the tml-2786-slice-2-filter branch from df900cd to ee44053 Compare June 5, 2026 12:44
@tensordreams tensordreams force-pushed the tml-2787-slice-3-write branch from b04a59c to 04522c0 Compare June 5, 2026 12:44
@tensordreams tensordreams force-pushed the tml-2786-slice-2-filter branch from ee44053 to 65d4fe3 Compare June 5, 2026 14:21
@tensordreams tensordreams force-pushed the tml-2787-slice-3-write branch from 04522c0 to 4d8dad2 Compare June 5, 2026 14:21
@tensordreams tensordreams force-pushed the tml-2786-slice-2-filter branch from 65d4fe3 to bd47c00 Compare June 5, 2026 15:02
@tensordreams tensordreams force-pushed the tml-2787-slice-3-write branch from 4d8dad2 to 16dc690 Compare June 5, 2026 15:02
@tensordreams tensordreams force-pushed the tml-2787-slice-3-write branch from 16dc690 to f3a7a5c Compare June 8, 2026 10:19
@tensordreams tensordreams force-pushed the tml-2786-slice-2-filter branch from bd47c00 to 40b8cbe Compare June 8, 2026 10:19
@tensordreams tensordreams force-pushed the tml-2787-slice-3-write branch from f3a7a5c to b772b54 Compare June 8, 2026 11:19
@tensordreams tensordreams force-pushed the tml-2787-slice-3-write branch 2 times, most recently from 67fb93c to 9feb292 Compare June 9, 2026 16:37
@tensordreams tensordreams force-pushed the tml-2786-slice-2-filter branch from 97f065f to 308befd Compare June 9, 2026 16:37
@tensordreams tensordreams force-pushed the tml-2787-slice-3-write branch from 9feb292 to facaadf Compare June 9, 2026 16:39
@tensordreams tensordreams force-pushed the tml-2786-slice-2-filter branch from 308befd to e9d57ac Compare June 9, 2026 16:39

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
test/integration/test/sql-orm-client/mn-nested-write.test.ts (1)

306-317: ⚡ Quick win

Assert no partial writes after guard rejections.

Line 306 and Line 334 only verify the thrown error text. Add post-failure DB assertions to guarantee the rejected nested write didn’t persist partial parent/junction changes.

Proposed test hardening
@@
         await expect(
           users.create({
@@
           }),
         ).rejects.toThrow(/required column.*`level`/);
+
+        const userRows = await runtime.query<{ id: number }>('select id from users');
+        const roleRows = await runtime.query<{ id: string }>('select id from roles');
+        const junctionRows = await runtime.query<{ user_id: number; role_id: string }>(
+          'select user_id, role_id from user_roles',
+        );
+        expect(userRows).toEqual([]);
+        expect(roleRows).toEqual([]);
+        expect(junctionRows).toEqual([]);
@@
         await expect(
           users.create({
@@
           }),
         ).rejects.toThrow(/required column.*`level`/);
+
+        const userRows = await runtime.query<{ id: number }>('select id from users');
+        const roleRows = await runtime.query<{ id: string }>('select id from roles');
+        const junctionRows = await runtime.query<{ user_id: number; role_id: string }>(
+          'select user_id, role_id from user_roles',
+        );
+        expect(userRows).toEqual([]);
+        expect(roleRows).toEqual([{ id: ROLE_ADMIN }]);
+        expect(junctionRows).toEqual([]);

Also applies to: 334-345

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/integration/test/sql-orm-client/mn-nested-write.test.ts` around lines
306 - 317, The test currently only asserts the thrown error from users.create
with the nested roles.create and doesn't verify the DB state; after the
expect(...).rejects.toThrow(...) add assertions that the attempted
parent/junction records were not persisted (e.g. call users.findUnique({ where:
{ id: 1 } }) or users.findMany(...) and roles.findMany(...) and assert they
return null/empty), and repeat the same post-failure DB checks for the other
case referenced (the block around lines 334-345) so both rejected nested-write
scenarios confirm no partial writes occurred.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@test/integration/test/sql-orm-client/mn-nested-write.test.ts`:
- Around line 306-317: The test currently only asserts the thrown error from
users.create with the nested roles.create and doesn't verify the DB state; after
the expect(...).rejects.toThrow(...) add assertions that the attempted
parent/junction records were not persisted (e.g. call users.findUnique({ where:
{ id: 1 } }) or users.findMany(...) and roles.findMany(...) and assert they
return null/empty), and repeat the same post-failure DB checks for the other
case referenced (the block around lines 334-345) so both rejected nested-write
scenarios confirm no partial writes occurred.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 39ff8f77-4bad-45e8-96a3-49cc35485a18

📥 Commits

Reviewing files that changed from the base of the PR and between e9d57ac and facaadf.

⛔ Files ignored due to path filters (9)
  • projects/sql-orm-many-to-many/learnings.md is excluded by !projects/**
  • projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/dispatches/01-write-path.md is excluded by !projects/**
  • projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/dispatches/02-required-payload-fixture.md is excluded by !projects/**
  • projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/dispatches/03-runtime-disable.md is excluded by !projects/**
  • projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/dispatches/04-integration-tests.md is excluded by !projects/**
  • projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/dispatches/04-integration-tests.r2.md is excluded by !projects/**
  • projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/plan.md is excluded by !projects/**
  • projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/spec.md is excluded by !projects/**
  • projects/sql-orm-many-to-many/trace.jsonl is excluded by !projects/**
📒 Files selected for processing (6)
  • packages/3-extensions/sql-orm-client/src/mutation-executor.ts
  • packages/3-extensions/sql-orm-client/src/types.ts
  • packages/3-extensions/sql-orm-client/test/junction-link-write-disable.test-d.ts
  • packages/3-extensions/sql-orm-client/test/mutation-executor.test.ts
  • test/integration/test/sql-orm-client/mn-nested-write.test.ts
  • test/integration/test/sql-orm-client/runtime-helpers.ts

@tensordreams tensordreams force-pushed the tml-2787-slice-3-write branch from facaadf to 679770d Compare June 9, 2026 16:53
@tensordreams tensordreams force-pushed the tml-2786-slice-2-filter branch 2 times, most recently from 58b53c5 to 7001dbe Compare June 9, 2026 17:21
@tensordreams tensordreams force-pushed the tml-2787-slice-3-write branch from 679770d to 302e804 Compare June 9, 2026 17:21
@tensordreams tensordreams force-pushed the tml-2786-slice-2-filter branch from 7001dbe to f159866 Compare June 10, 2026 14:26
@tensordreams tensordreams force-pushed the tml-2787-slice-3-write branch from 302e804 to 100b1d5 Compare June 10, 2026 14:26
@tensordreams tensordreams force-pushed the tml-2786-slice-2-filter branch from f159866 to 2828012 Compare June 10, 2026 15:26
@tensordreams tensordreams force-pushed the tml-2787-slice-3-write branch from 100b1d5 to 91b8cc4 Compare June 10, 2026 15:26
for (const [column, value] of parentPkValues.entries()) {
junctionRow[column] = value;
}
for (const [column, value] of targetPkValues.entries()) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

For composite M:N relations where the junction reuses a column on both sides, this merge lets the target row overwrite the parent value. The fixture added in this stack has exactly that shape (parentColumns: [tenant_id, parent_id], childColumns: [tenant_id, child_id]): connecting parent (tenant_id=7, id=1) to target (tenant_id=8, id=10) would insert { tenant_id: 8, parent_id: 1, child_id: 10 }, which no longer points at the parent row we just updated, or trips FK constraints when they exist. The junction merge should detect duplicate junction columns, require the values to match, and keep the same behavior for deletes; a mismatched shared-column test would catch this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

On it. I will reject shared junction-column value mismatches when merging link values. 👍

@tensordreams tensordreams left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reviewed this PR separately against tml-2786-slice-2-filter (origin/tml-2786-slice-2-filter...origin/tml-2787-slice-3-write). I left one inline correctness comment on the junction-row merge for composite M:N relations with shared columns. Verification while reviewing: pnpm --filter @prisma-next/sql-orm-client test and git diff --check for this PR diff passed.

@tensordreams tensordreams left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reviewed this slice against projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/spec.md and the stacked diff vs tml-2786-slice-2-filter.

Well-executed slice that conforms closely to its spec: partitionByOwnership gains the third junctionOwned bucket with a proper hasThrough type guard, junction writes run after the parent PK is known in both create() and update() flows, and the required-payload disable exists at both the type level (with genuine positive and negative .test-d.ts coverage) and at runtime with a clear error naming the columns and the escape hatch. Atomicity rides the pre-existing withMutationScope (transactional when the runtime provides transaction(), sequential fallback otherwise — a pre-existing characteristic, not a regression). Production code correctly uses blindCast with reasons (no bare as, no any); runtime-guard tests bypass the type gate with test-file-exempt casts; test conventions are observed; runtime-helpers.ts extends the existing shared integration helper rather than duplicating it.

One finding I'd want resolved (or explicitly deferred with a spec note) before merge: the undefined duplicate-connect behavior, since it's a first-class user-facing path of this feature. The rest are hardening at the edges — see inline comments.

Comment on lines +840 to +859
async function insertJunctionLink(
scope: RuntimeScope,
context: ExecutionContext,
through: JunctionThrough,
parentPkValues: Map<string, unknown>,
targetPkValues: Map<string, unknown>,
): Promise<void> {
const junctionRow: Record<string, unknown> = {};
for (const [column, value] of parentPkValues.entries()) {
junctionRow[column] = value;
}
for (const [column, value] of targetPkValues.entries()) {
junctionRow[column] = value;
}

const compiled = compileInsertCount(context.contract, through.namespaceId, through.table, [
junctionRow,
]);
await executeQueryPlan<Record<string, unknown>>(scope, compiled).toArray();
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

major: insertJunctionLink compiles a plain INSERT (compileInsertCount, no ON CONFLICT), so connect-ing an already-connected row — or repeating the same criterion in one connect([...]) call — surfaces as a raw junction-PK unique-violation from the driver rather than either idempotent success or a domain error naming the relation. Duplicate-link behavior is currently undefined and untested (no unit or integration test connects the same pair twice). Please pick a behavior deliberately — idempotent insert (ON CONFLICT DO NOTHING semantics, matching Prisma's implicit-M:N connect) or a wrapped error naming the relation — and pin it with a test. If it's deferred to a later slice, a code comment + spec note would help.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

On it. I will define duplicate junction connect behavior and add coverage. 👍

Comment on lines +737 to +741
if (!mutation.criteria || mutation.criteria.length === 0) {
throw new Error(
`disconnect() nested mutation for relation "${relation.relationName}" requires criterion`,
);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

minor: Type/runtime mismatch for bare disconnect(): the RelationMutator interface still exposes the zero-arg disconnect() overload for junction relations, and for child-owned (1:N) relations it means "disconnect all" — but the junction path unconditionally throws requires criterion. So t.disconnect() on an M:N relation compiles and always fails at runtime. Either implement disconnect-all for junction relations (DELETE FROM junction WHERE parentCols = parentPk, symmetric with the 1:N behavior) or gate the no-arg overload off at the type level like you did for create/connect. If you keep the throw, the message should say criteria are required for M:N relations — "requires criterion" reads like a missing-argument bug.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

On it. I will align the bare M:N disconnect type/runtime behavior in this PR. 👍

TContract,
JunctionModel,
Through['parentColumns'][number] | Through['childColumns'][number]
>]: IsOptionalCreateField<TContract, JunctionModel, F> extends true ? never : F;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

minor: The type gate and the runtime guard disagree on execution-time create defaults. HasRequiredJunctionPayload treats a junction field as optional when IsOptionalCreateField is true, which includes HasExecutionCreateDefault (extension onCreate defaults). The runtime's requiredPayloadColumns (collection-contract.ts:371) only checks storage-level col.default — and insertJunctionLink never calls context.applyMutationDefaults (unlike insertSingleRow, line 948). Net effect: a junction payload column with an execution-only create default passes the type gate but throws the runtime guard. Suggest making insertJunctionLink apply mutation defaults for the junction table and excluding execution-defaulted columns from requiredPayloadColumns, or excluding HasExecutionCreateDefault from the type gate's optionality check so both layers agree.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

On it. I will make required junction payload checks agree on execution-time create defaults. 👍

Comment on lines +981 to +988
type ModelNameForTable<TContract extends Contract<SqlStorage>, TableName extends string> = {
[M in keyof ModelsOf<TContract> & string]: ModelsOf<TContract>[M] extends {
readonly storage: { readonly table: TableName };
}
? M
: never;
}[keyof ModelsOf<TContract> & string];

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

minor: ModelNameForTable resolves the junction model by scanning all models for a matching storage.table name, ignoring namespaces — a bare-table-name assumption the runtime side just removed (junction DML threads through.namespaceId everywhere after the TML-2841 rebase). Two models in different namespaces with the same table name would make this resolve to a union of model names, producing an unpredictable gate. The emitted through type carries namespaceId, so the fix is cheap: match on readonly storage: { readonly table: TableName; readonly namespaceId: Through['namespaceId'] } and pass the namespace through from HasRequiredJunctionPayload.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

On it. I will make the junction model lookup namespace-aware in the type gate. 👍

for (let i = 0; i < relation.through.childColumns.length; i++) {
const junctionColumn = relation.through.childColumns[i];
const targetColumn = relation.through.targetColumns[i];
if (!junctionColumn || !targetColumn) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

minor: readJunctionParentValues/readJunctionTargetValues silently continue when through.parentColumns/childColumns and their paired arrays disagree in length. For insertJunctionLink that yields a NOT-NULL violation (noisy but safe); for deleteJunctionLink a skipped pair drops a WHERE predicate, widening the DELETE to unlink more rows than requested. Contract validation should make misalignment impossible, but since the failure mode here is silent data loss rather than an error, prefer throwing on length mismatch instead of skipping.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

On it. I will make the junction paired-column readers fail fast on metadata length mismatches. 👍

// connect — create() parent flow
// ===========================================================================

it(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

minor (coverage): Failure paths for junction connect/disconnect against a nonexistent target are untested. The runtime path exists (mutation-executor.ts:768-772, did not find a matching row) but neither this file nor mutation-executor.test.ts exercises it for the junction-owned bucket (the existing not-found tests cover the parent-owned path only). Please add at least one test — e.g. users.create({..., tags: (t) => t.connect({ id: 'missing' })}) asserting the rejection, ideally also asserting no partial junction row is left behind, which would double as coverage of the rollback behavior.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

On it. I will add missing-target connect/disconnect coverage and no-partial-write assertions. 👍

const tagCriterion = { id: 'featured' } as { readonly id: NonNullable<TagCreate['id']> };

test('nested create on a relation whose junction has a required payload column is a type error', () => {
type Input = MutationCreateInput<Contract, 'User'>;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

nit: All gate assertions here go through MutationCreateInput. The gate also applies to MutationUpdateInput (both reach RelationMutator via the shared RelationMutationFields/RelationMutationCallback), but that's structural rather than asserted — a refactor that gives update its own callback type would silently lose the gate. Two cheap cases (mutator.connect forbidden, disconnect allowed, via MutationUpdateInput<Contract, 'User'>) would pin it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

On it. I will add MutationUpdateInput coverage for the junction relation mutator gate. 👍

Write slice: connect/disconnect/create through the junction + required-payload
.create disable (types+runtime). 4 dispatches (write path / required-payload
fixture / type+runtime disable / integration). Type-disable feasibility risk
pre-named with a halt.

Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
Replace the partitionByOwnership N:M rejection guard with a junctionOwned
bucket. connect/disconnect/create over a through relation now resolve to
junction INSERT/DELETE (create = target-insert then link) in both the
create() and update() graph flows, after the parent PK is known. Composite
keys are AND-ed across all parent/child column pairs; disconnect stays
update()-only. Flip the rejection unit test to a positive junction-DML
assertion and add connect/disconnect/create coverage for both flows.

Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
…umns exist

Throw a clear runtime error when a nested `.create()` targets an M:N
relation whose junction table has non-FK NOT-NULL columns with no default
(i.e. `requiredPayloadColumns` is non-empty). The error names the
relation, the offending column(s), and points to the junction model /
SQL builder as the supported alternative.

`connect` and `disconnect` are unaffected — they only touch the FK pair.
Pure junctions (no required payload) pass through the create path as before.

Adds `requiredPayloadColumns` to the local `JunctionThrough` interface and
copies it from the already-resolved `relation.through` in
`getRelationDefinitions`.

Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
…finish write integration tests

Both `connect` and `create` write a bare junction row the M:N sugar can't
complete when the junction has required non-FK columns (e.g. `user_roles.level
NOT NULL`). Extend the runtime guard in `applyJunctionOwnedMutation` to cover
`connect` in addition to `create`; `disconnect` (DELETE path) is unaffected.

Guard message is unified: "Cannot `<op>` on relation `<rel>`: its junction
`<table>` has required column(s) `<col>` the relation API can't populate.
Use the `<Model>` model directly or the SQL builder."

Unit test that previously asserted connect-on-User.roles succeeds is flipped
to assert rejection. Integration tests are fully enabled (no it.skip); the new
test asserts connect on User.roles throws the guard, while pure-junction
(User.tags) connect/create/disconnect paths continue to work end-to-end.

Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
…-2787)

Write slice artifacts. Spec corrected mid-flight (decision #9: connect also
disabled on required-payload junctions). Type-level .create disable deferred
(decision #8 — needs a contract .d.ts emitter change, operator decision).

Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
…sted-write test; re-emit fixtures with through blocks

Plain `string` constants failed the integration typecheck (tsc) because
Tag.id / Role.id are `Char<36>`.  Brand TAG_RUST, TAG_TS, ROLE_ADMIN,
ROLE_EDITOR at declaration (matching the pattern from create.test.ts).
Runtime values are unchanged.

Also commits the re-emitted contract.d.ts fixtures carrying the new
`through` blocks on the N:M relations (User.tags / User.roles).

Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
…elations at the type level

Nested `.create` on an N:M relation whose junction carries a required
non-FK payload column now resolves its argument to `never`, surfacing a
compile-time error instead of only the runtime guard in
`mutation-executor.ts`. `connect`/`disconnect` typing is unchanged.

The derivation resolves the relation `through` to its junction model,
treats any non-join column as a payload column, and disables create when
any payload column is required (not nullable, no default). `User.roles`
(junction `user_roles` with required `level`) disables create; `User.tags`
(pure junction) keeps it.

Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
Renames the `CreateDisabled` flag on `RelationMutator` to
`LinkWritesDisabled` and applies the same `never`-arg guard to both
`create` and `connect` overloads. `disconnect` is unaffected (it issues
a DELETE, not an INSERT, so no payload column is needed).

`HasRequiredJunctionPayload` is already threaded as the flag via
`RelationMutationCallback` — no change required there. The runtime
guard in `mutation-executor.ts` already blocks both operations; the
type system now matches.

Test file renamed to `junction-link-write-disable.test-d.ts`; the
former "connect remains available" positive test is replaced with a
`@ts-expect-error` assertion, and a new pure-positive disconnect test
preserves coverage of the allowed path.

Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
…ard tests

The type-level .create/.connect disable (now in dist after the main rebase)
makes those calls compile errors; cast the args to `never` to still exercise
the runtime guard on a real DB (defense-in-depth). Surfaced by rebuilding
against origin/main.

Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
…fter TML-2841 rebase

The write path gained namespace coordinates: insertSingleRow,
findRowByCriterion, toFieldName, compileInsertCount, compileDeleteCount
all take a leading namespaceId. Add namespaceId to the local
JunctionThrough, thread parentNamespaceId into applyJunctionOwnedMutation /
readJunctionParentValues, and use relation.relatedNamespaceId /
through.namespaceId for the related-row and junction-DML calls. Add the
namespaceId arg to the M:N write tests.

Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
@tensordreams tensordreams force-pushed the tml-2787-slice-3-write branch from 91b8cc4 to 4ea74ff Compare June 10, 2026 18:15
@tensordreams

Copy link
Copy Markdown
Contributor Author

On it. I will add post-failure DB assertions for the required-payload guard cases. 👍

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