Skip to content

Commit 7cbb4a0

Browse files
committed
docs(sql-orm-many-to-many): slice 3 specs/briefs/trace/learnings (TML-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>
1 parent e6c6418 commit 7cbb4a0

8 files changed

Lines changed: 235 additions & 4 deletions

File tree

projects/sql-orm-many-to-many/learnings.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ This harness exposes no `SendMessage`/resume for spawned subagents — the `Agen
1010

1111
`pnpm fixtures:check` fails at `fixtures:emit` in this sandbox (CLI not on PATH / "Failed to load config" for sql-builder + sql-orm-client emit scripts) — pre-existing, not introduced (matches the TML-2729 gotcha). Additivity is verified instead via a direct golden git-diff (`git diff -- ':(glob)**/contract.json' …`); CI runs the real gate. Don't treat the local `fixtures:check` red as a dispatch failure.
1212

13+
**Correction (slice 3):** the canonical CLI emit **does** work in this sandbox — run it **from the repo root**: `node packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/integration/test/sql-orm-client/fixtures/prisma-next.config.ts`, then `pnpm --filter @prisma-next/sql-orm-client emit` (package-local copy + pgvector strip). The earlier "config-load failure" was from running with the wrong cwd (`test/integration`). **Prefer the canonical emit from root over a `tsx` bypass** — no golden-stability risk.
14+
1315
## PGlite/WASM JIT flakiness on broad integration runs
1416

1517
Running the whole sql-orm-client integration suite at once (`cd test/integration && pnpm test test/sql-orm-client/`) can crash with V8 `jit_page.has_value()` (WASM JIT) failures — **pre-existing PGlite/Node env flakiness**, reproduces on the parent branch, not introduced by M:N work. Targeted reruns (per-file, or the same suite again) pass cleanly. Verify integration blast radius with targeted per-file runs; don't trust a single broad-run red.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Brief: S3-D1 — runtime junction write path
2+
3+
## Task
4+
5+
Lift the M:N nested-mutation guard and route nested writes through the junction. Today `partitionByOwnership` (`packages/3-extensions/sql-orm-client/src/mutation-executor.ts:~351`) throws `'N:M nested mutations are not supported yet'`. Replace that with a third bucket — **`junctionOwned`** (relations carrying `through`) — and execute junction mutations in the `create()` and `update()` graph flows **after the parent row exists** (parent PK known):
6+
7+
- **`connect({criteria})`** → resolve the target row(s) by criteria, then `INSERT INTO junction (parentColumns…, childColumns…) VALUES (parentPk…, targetPk…)` per resolved target.
8+
- **`disconnect({criteria})`**`DELETE FROM junction WHERE parentColumns = parentPk AND childColumns = targetPk`. (Keep `disconnect` gated to the `update()` flow — the existing rule.)
9+
- **`create(data)`** → insert the target row, then INSERT the junction link. (For THIS dispatch, the junction is the pure `User↔Tag` one — no required payload; the required-payload guard is D3. Don't build the guard here.)
10+
11+
Composite keys: INSERT/DELETE across all `parentColumns`/`childColumns` pairs. Use slice 0's `ResolvedRelation.through`.
12+
13+
**Flip the rejection unit test** (`mutation-executor.test.ts`, currently `.rejects.toThrow(/N:M nested mutations are not supported yet/)`) to a **positive** assertion that the M:N nested mutation now produces the expected junction write. Add unit tests for connect/disconnect/create junction DML (both flows).
14+
15+
## Scope
16+
17+
**In:** `partitionByOwnership` + the `junctionOwned` execution branch in the `create()`/`update()` graph flows (`mutation-executor.ts`); composite-key junction INSERT/DELETE; flip + extend the rejection unit test.
18+
19+
**Out:** the required-payload guard (D3); the required-payload fixture (D2); integration tests (D4); `set`/`connectOrCreate`/nested-`update` kinds (TML-2781). Don't regress FK (parent/child-owned) nested writes.
20+
21+
## Completed when
22+
23+
- [ ] `connect`/`disconnect`/`create` over the pure M:N relation route to junction INSERT/DELETE (create = target-insert + link), under both `create()` and `update()`; the `'not supported yet'` guard is gone.
24+
- [ ] The rejection unit test is flipped to a positive assertion; unit tests cover connect/disconnect/create junction DML (composite-key AND-ed).
25+
- [ ] FK nested writes unchanged (existing mutation-executor tests pass).
26+
- [ ] Gate: `pnpm --filter @prisma-next/sql-orm-client typecheck` + `test` green.
27+
28+
## Standing instruction
29+
30+
Stay focused on the junction write routing. Mirror the existing parent/child-owned flow structure (ordering relative to the parent insert). No bare `as` casts (use `castAs`/`blindCast` or a type predicate — siblings were bounced for bare casts; a `hasThrough` predicate already exists in `model-accessor.ts` if useful). Implement → unit-test → gate; don't over-explore (sibling write/judgment dispatches truncated from over-exploration).
31+
32+
## References
33+
34+
- Slice spec: `projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/spec.md`.
35+
- `mutation-executor.ts`: `partitionByOwnership` (~338), the `create()` graph flow (~159-200) + `update()` flow (~206-290) — the parent/child-owned execution to mirror.
36+
- Slice 0 `ResolvedRelation.through`; slice 2's `hasThrough` type predicate (`model-accessor.ts`).
37+
38+
## Operational metadata
39+
40+
- **Model tier:** **opus** — complex routing across two graph flows + composite keys; the slice's main runtime judgment.
41+
- **Branch:** `tml-2787-slice-3-write`. Explicit staging + `-s` sign-off. **Do not push.**
42+
- **Time-box:** ~90 min. Commit when the gate is green even if you'd like to polish — bank the work.
43+
- **Halt + surface to me:** if junction writes can't be ordered correctly within the existing graph-flow structure (parent PK not available where needed); if completing the routing requires touching the required-payload guard (that's D3) or an out-of-scope kind.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Brief: S3-D2 — required-payload-junction fixture
2+
3+
## Task
4+
5+
Add a second M:N relation to the integration fixture whose junction has a **required non-FK payload column**, so D3 can test the `.create` disable. Add to the fixture **source** (`test/integration/test/sql-orm-client/fixtures/contract.ts`):
6+
7+
- A `Role` model (`id`, `name`) — mirror the existing `Tag` shape.
8+
- A `UserRole` junction with `userId`, `roleId`, **and a required non-FK column** — e.g. `level` (`int`/`text`, **NOT NULL, no default**) — composite PK `(userId, roleId)`, table `user_roles`.
9+
- `User.roles = rel.manyToMany(() => Role, { through: () => UserRole, from: 'userId', to: 'roleId' })` (the `User.roles` direction only — the reverse is unnecessary, consistent with the project's one-directional convention).
10+
11+
Re-emit `contract.json` + `contract.d.ts`. After emit, `resolveModelRelations` must resolve `User.roles`'s `through.requiredPayloadColumns` to **`['level']`** (the NOT-NULL no-default non-FK column) — this is what D3's disable keys on.
12+
13+
## Scope
14+
15+
**In:** the fixture source (`Role` + `UserRole` w/ required `level` + `User.roles`); the re-emitted `contract.json`/`contract.d.ts`.
16+
17+
**Out:** the disable logic (D3); the write path (D1, done); integration tests (D4); the existing `User.tags` relation. Don't hand-edit generated files except as the emitter produces them.
18+
19+
## Completed when
20+
21+
- [ ] The fixture declares `User.roles` M:N via `UserRole(user_id, role_id, level NOT NULL no-default)`; emits `cardinality:'N:M'` + populated `through`.
22+
- [ ] For `User.roles`, `resolveModelRelations(...).through.requiredPayloadColumns` resolves to `['level']` (verify with a tiny scratch assertion or by inspecting the junction storage in the emitted `contract.json``level` is NOT NULL, no default, not a FK col).
23+
- [ ] Re-emitted `contract.json`/`.d.ts` committed; additive (existing models + `User.tags` unchanged).
24+
25+
## Standing instruction
26+
27+
Add exactly the `Role`/`UserRole`/`User.roles` shapes with one required payload column (`level`). No reverse relation, no extra columns beyond the required one. Same emit approach as slice 1's fixture dispatch.
28+
29+
## References
30+
31+
- Slice spec: `projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/spec.md`.
32+
- Slice 1 fixture dispatch (commit `fcecac5b3`) added `User.tags`/`UserTag` + re-emitted the same way — mirror it (incl. the `tsx`-bypass emit; CLI `contract emit` fails on the known config-load env issue).
33+
- Slice 0 `requiredPayloadColumns` derivation (NOT NULL ∧ no default ∧ not FK) in `collection-contract.ts`.
34+
35+
## Operational metadata
36+
37+
- **Model tier:** sonnet — schema authoring + re-emit (mechanical, mirrors slice 1).
38+
- **Branch:** `tml-2787-slice-3-write`. Explicit staging + `-s` sign-off. **Do not push.** Commit the re-emit (don't leave uncommitted).
39+
- **Time-box:** ~50 min.
40+
- **Halt + known-env:** local `fixtures:emit`/CLI fails on the pre-existing config-load issue — emit via the same `tsx`-bypass slice 1 used; verify the generated `contract.json` by inspection + that `requiredPayloadColumns` resolves to `['level']`; note it. If re-emit is genuinely impossible in-sandbox, surface to me (don't hand-fabricate). Halt if adding the relation forces touching the disable logic (D3) or trips a type regression in existing tests (describe it).
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Brief: S3-D3 — runtime `.create` disable on required-payload junctions (runtime half only)
2+
3+
> **Scope note (orchestrator decision, unattended):** the **type-level** disable is **deferred** — it requires the contract `.d.ts` type emitter to carry `through` (it currently doesn't), which is a contract-surface decision for the operator (see `wip/unattended-decisions.md` #8). This dispatch does the **runtime** disable only. Do **not** attempt the type-level/conditional-type disable; do **not** change the contract `.d.ts` emitter.
4+
5+
## Task
6+
7+
In the `junctionOwned` `create` branch added in S3-D1 (`mutation-executor.ts`, `applyJunctionOwnedMutation` / the create path), guard against nested `.create` when the junction has required payload columns. The resolved relation's `through.requiredPayloadColumns` (slice 0, runtime) lists them. When a nested `create` targets an M:N relation whose `requiredPayloadColumns` is non-empty:
8+
9+
- **Throw a clear, actionable error** naming the relation + the offending column(s), and pointing the user to the junction model's own relations / the SQL query builder (the supported path for payload-bearing junctions). E.g. *"Cannot nest `create` on relation `roles`: its junction `user_roles` has required column(s) `level` the relation API can't populate. Use the `UserRole` model directly or the SQL builder."*
10+
- **`connect` and `disconnect` are unaffected** — they only touch the FK pair, never the payload columns; they must still work on a required-payload junction.
11+
12+
Write a **runtime** test: nested `.create` on `User.roles` (the required-payload junction from S3-D2) throws the guard error; `connect`/`disconnect` on `User.roles` succeed (compile the expected junction DML). The pure `User.tags` junction's nested `create` is unaffected (still works).
13+
14+
## Scope
15+
16+
**In:** the runtime required-payload guard in the junction `create` branch (`mutation-executor.ts`); a unit/runtime test for the throw + connect/disconnect-still-work.
17+
18+
**Out:** the **type-level** disable (deferred — operator decision; do not touch `.d.ts` emission or add conditional types); the write path (D1, done); integration tests (D4); other kinds.
19+
20+
## Completed when
21+
22+
- [ ] Nested `.create` on a required-payload junction (`User.roles`) throws a clear error naming the relation + required column(s) + the recommended alternative; uses runtime `requiredPayloadColumns`.
23+
- [ ] `connect`/`disconnect` on the required-payload junction still produce correct junction DML (unaffected by the guard).
24+
- [ ] Nested `.create` on the pure junction (`User.tags`, no required payload) is unaffected.
25+
- [ ] Gate: `pnpm --filter @prisma-next/sql-orm-client typecheck` + `test` green.
26+
27+
## Standing instruction
28+
29+
Runtime guard only. Do NOT attempt the type-level disable (it's blocked on an operator contract decision — see the scope note). No bare `as` casts.
30+
31+
## References
32+
33+
- Slice spec: `projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/spec.md` (note the type-level disable is deferred per decision #8).
34+
- S3-D1 junction write path (`applyJunctionOwnedMutation`, commit `74a778816`); slice 0 `through.requiredPayloadColumns` (`collection-contract.ts`).
35+
- S3-D2 fixture: `User.roles` via `UserRole` w/ required `level` (commit `926bdc849`).
36+
37+
## Operational metadata
38+
39+
- **Model tier:** sonnet — bounded runtime guard + test.
40+
- **Branch:** `tml-2787-slice-3-write`. Explicit staging + `-s` sign-off. **Do not push.** Commit when green.
41+
- **Time-box:** ~40 min.
42+
- **Halt + surface to me:** if the runtime guard can't access `requiredPayloadColumns` at the create branch (it should — `RelationDefinition.through` carries it after D1); if anything pulls you toward the type-level disable.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Brief: S3-D4 — M:N nested-write integration tests (operator standard)
2+
3+
## Task
4+
5+
Prove M:N nested writes work end-to-end against the database, following the project's **integration-test standard**. D1 added the junction write path; D2 added the `User.roles` required-payload fixture; D3 added the runtime `.create` disable. Add integration tests under `test/integration/test/sql-orm-client/` (PGlite, `withCollectionRuntime`). Reuse slice 1's `seedTags`/`seedUserTags` + add `Role`/`UserRole` seeds as needed.
6+
7+
**Cases (all required):**
8+
- **`connect`**`db.orm.User.update({ ... tags: (t) => t.connect({ id }) })`; read back via `include('tags')` → the tag is linked. Also exercise `connect` in the **`create()`** parent flow.
9+
- **`disconnect`** — link then `disconnect`; read back → gone. (`update()` flow — disconnect is update-only.)
10+
- **`create`** (pure junction `User.tags`) — nested `create` inserts the target Tag + the junction link; read back → present.
11+
- **Runtime disable** — nested `create` on `User.roles` (required-payload junction) **throws** the D3 guard error (`expect(...).rejects.toThrow(/required column.*level/)` or similar); `connect`/`disconnect` on `User.roles` **succeed** + read back.
12+
- Cover **both `create()` and `update()`** parent flows for connect/create.
13+
14+
**Standard (all three):** (1) whole-row `toEqual` on the readback (via `include('tags')`); (2) explicit `.select(...)` in most tests; (3) **≥1** implicit/default-selection readback.
15+
16+
## Scope
17+
18+
**In:** new integration test file under `test/integration/test/sql-orm-client/`; `Role`/`UserRole` seed helpers if needed (extend `runtime-helpers.ts`).
19+
20+
**Out:** production changes (D1/D3 own the write path + guard — if a test reveals a write bug, surface it, don't patch production here); the **type-level** disable (deferred — only the **runtime** throw is testable now). Don't modify the fixture contract (D2 owns it).
21+
22+
## Completed when
23+
24+
- [ ] Integration tests pass on PGlite: connect (both flows) / disconnect / create (pure junction) with whole-row readback via `include('tags')`; the runtime `.create` disable on `User.roles` throws; connect/disconnect on `User.roles` succeed.
25+
- [ ] Most tests explicit `.select`; **≥1** implicit/default-selection readback.
26+
- [ ] Gate: `cd test/integration && pnpm test test/sql-orm-client/<your-file>` green.
27+
28+
## Standing instruction
29+
30+
Match the existing integration corpus. The type-level disable is NOT testable here (deferred) — only assert the **runtime** throw for required-payload `create`. If a write returns the wrong state on readback, **surface it to me** (a D1/D3 bug — must-fix), don't patch the test around it.
31+
32+
## References
33+
34+
- Slice spec: `projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/spec.md` (note: type-level disable deferred per `wip/unattended-decisions.md` #8).
35+
- Slice 1's `mn-include.test.ts` (readback-via-include pattern) + `runtime-helpers.ts` seeds; slice 2's `mn-filter.test.ts`.
36+
- D1 write path (`74a778816`), D3 runtime guard (`3bccd80b3`), D2 fixture (`926bdc849`).
37+
38+
## Operational metadata
39+
40+
- **Model tier:** sonnet.
41+
- **Branch:** `tml-2787-slice-3-write`. Explicit staging + `-s` sign-off. **Do not push.** Commit when green.
42+
- **Time-box:** ~75 min — core connect/disconnect/create readback first, then both-flows + the runtime-disable + implicit-selection; don't over-explore.
43+
- **Halt + surface to me:** if the harness can't run in-sandbox (PGlite spin-up failure unrelated to your tests — describe it, don't fake green); if a nested write produces the wrong readback state (D1/D3 bug).

0 commit comments

Comments
 (0)