Skip to content

Commit 512ab30

Browse files
committed
docs(psl-relation-syntax): slice 4 spec + plan (implicit M:N synthesis)
Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
1 parent b52910d commit 512ab30

2 files changed

Lines changed: 90 additions & 0 deletions

File tree

  • projects/psl-relation-syntax/slices/04-implicit-mn-synthesis
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Slice 4 — implicit M:N synthesis — Dispatch plan
2+
3+
**Slice spec:** `projects/psl-relation-syntax/slices/04-implicit-mn-synthesis/spec.md`
4+
**Linear:** [TML-2943](https://linear.app/prisma-company/issue/TML-2943)
5+
6+
Two dispatches. M1 front-loads the feasibility halt; M2 proves it downstream.
7+
8+
## M1 — Detection + synthesis of the model-less junction (feasibility-gated)
9+
10+
- **Outcome:** an implicit M:N (both ends bare, no junction model) emits a contract carrying a synthesised `_<A>To<B>` table (cols `A`/`B`, composite PK, FKs to the two model ids) + `N:M`/`through` on both ends, round-tripping `validateContract`; D5 precedence preserved.
11+
- **Builds on:** slice 2's `through` lowering shape (the descriptor it emits) + slice 3 (no conflict).
12+
- **Hands to:** the synthesised junction contract the migration + runtime consume.
13+
- **Focus:** **first confirm the contract IR can hold a model-less storage table** (the § Feasibility halt) — read the storage-table IR + how `interpreter.ts` builds tables from models; if synthesis can't be injected cleanly within slice scope, HALT and surface. Then: detect the synthesise case in the bare-list path; inject the `_AToB` table (decision #7 naming) + emit the `through` on both ends; reuse the slice-2 `through`-descriptor machinery. Diagnostics for the edge cases (no `@id`; two implicit M:N between the same pair; name collision with a real table).
14+
- **Completed when:**
15+
- [ ] `pnpm --filter @prisma-next/sql-contract-psl test` green with: an implicit-M:N lowering test (synthesised table + `N:M`/`through`, `toEqual` on `Contract` + `validateSqlContractFully`); the D5-precedence control (both-bare-with-junction-model → recognised, **not** synthesised); the no-`@id` / ambiguous / collision diagnostics.
16+
- [ ] `cd packages/2-sql/2-authoring/contract-psl && pnpm typecheck` + `lint` clean.
17+
- **Halt conditions:**
18+
- The contract IR / validator can't accept a model-less storage table within slice scope → **HALT + surface** (re-scope signal).
19+
- Two implicit M:N between the same pair collide on the synthesised name → diagnostic (don't silently clobber).
20+
21+
## M2 — Migration DDL + runtime integration
22+
23+
- **Outcome:** the synthesised junction is created by `migrate` (postgres + sqlite), and `db.orm.<Model>.include(<m2n>)` over an implicit M:N returns the related rows.
24+
- **Builds on:** M1's synthesised contract.
25+
- **Hands to:** implicit M:N parity with Prisma (the slice's downstream DoD).
26+
- **Focus:** confirm the migration pipeline emits `CREATE TABLE _AToB` + composite PK + the two FKs for postgres **and** sqlite (it should, as a normal contract table — if it needs threading, that's the real work here); a PSL fixture authored as an implicit M:N (both bare, no junction), emitted + migrated; an `include` integration test (whole-row, ≥1 implicit; PGlite). `pnpm build` before integration/`fixtures:check`.
27+
- **Completed when:**
28+
- [ ] A migration/DDL test shows the synthesised table created on postgres + sqlite.
29+
- [ ] The implicit-M:N `include` integration test passes (PGlite).
30+
- [ ] `pnpm fixtures:check` clean (after `pnpm build`).
31+
- **Halt conditions:**
32+
- The migration pipeline rejects / can't create the model-less table → surface (may need its own dispatch).
33+
34+
## Hand-off completeness
35+
36+
M1 (synthesis into the contract) + M2 (migration creates it, runtime walks it) compose to the slice-DoD: implicit M:N authored as bare lists works end-to-end, with D5 precedence intact. The feasibility halt on M1 is the guard against the slice being bigger than scoped.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Slice 4: implicit M:N — synthesise a model-less junction
2+
3+
_Parent project: `projects/psl-relation-syntax/`. Linear: [TML-2943](https://linear.app/prisma-company/issue/TML-2943). Builds on slice 2's `through:` lowering. Design: `design-notes.md` decision **D5** (case 3); operator decisions #5 (kept in-project), #7 (naming)._
4+
5+
## At a glance
6+
7+
When both navigable list ends are **bare** (no `through:`) **and no junction model links them**, the framework **synthesises** a model-less junction table and lowers the relation to `cardinality:'N:M'` + a `through` descriptor over that synthesised table — Prisma's "implicit many-to-many." The table is created by the migration system (postgres + sqlite) and walked by the ORM, exactly like an authored junction.
8+
9+
```prisma
10+
model Post { id Uuid @id @default(uuid()); tags Tag[] }
11+
model Tag { id Uuid @id @default(uuid()); posts Post[] }
12+
// no junction model → framework synthesises `_PostToTag(A, B)` and an N:M through over it
13+
```
14+
15+
Per D5, the precedence is preserved: `through:` on one end → use the named junction; both bare **and** a junction model exists → recognise it (slice-5 behaviour, slices 2's path); both bare **and no** junction → **synthesise** (this slice).
16+
17+
## Chosen design
18+
19+
- **Detection (resolver).** In the bare-list M:N path (`contract-psl` `findJunctionFkPairs` / the backrelation resolution), when both ends are bare and no junction model is found for the pair, branch to **synthesis** instead of the orphaned-backrelation diagnostic. The two terminal models must each have a single-column (or composite) `@id` to reference.
20+
- **Synthesis (lowering).** Inject a storage table into the contract IR: name `_<A>To<B>` (terminal storage names, alphabetically ordered — decision #7), columns `A` (FK → first model's id) and `B` (FK → second model's id), composite identity `(A, B)`, the FK types matched to the referenced id columns. Emit the `N:M` + `through { table, parentColumns, childColumns, targetColumns, namespaceId }` descriptor over it on **both** navigable ends. The contract is **additive** (a table with no PSL model).
21+
- **Migration/DDL.** The synthesised table is a normal contract storage table, so the migration system should create it (postgres + sqlite `CREATE TABLE` + the composite PK + the two FKs) **without special-casing** — confirm this; if the migration pipeline rejects a model-less table or needs threading, that is the slice's real work (and a feasibility signal).
22+
- **Runtime.** The `through` descriptor is the same shape the sibling runtime already consumes — the ORM `include` walks the synthesised junction unchanged.
23+
24+
## Feasibility halt (front-loaded)
25+
26+
This slice injects a table the user never authored into the contract IR and the migration pipeline. **S4·M1 must first confirm the contract IR + migration can cleanly accept a synthesised (model-less) storage table.** If they cannot within slice scope (e.g. the IR assumes every storage table has an authoring model, or the migration keys off the model set), **HALT and surface** — the slice may need re-scoping or its own design pass, and that is a load-bearing finding, not something to force.
27+
28+
## Scope
29+
30+
**In:** detection of the synthesise case (both bare, no junction model); synthesis of the `_AToB` junction table + `N:M`/`through` into the contract; migration `CREATE TABLE` for it (postgres + sqlite); runtime `include` parity; round-trip `validateContract`; `fixtures:check`.
31+
32+
**Out:** the explicit `through:` paths (slices 2–3); arrow-path (S5); a synthesised-junction naming **override** / `@@map` on the implicit junction (follow-up if wanted); implicit M:N **writes** beyond what the sibling runtime already supports; nullable/non-`@id` target keys (sibling slice 7 territory).
33+
34+
## Pre-investigated edge cases
35+
36+
| Edge case | Disposition |
37+
|---|---|
38+
| Both bare **and** a junction model exists | recognise it (D5 case 2 — slice 2's path), do **not** synthesise — preserve slice-5 behaviour |
39+
| `through:` on one end | use the named junction (slice 2), not synthesis |
40+
| terminal model lacks an `@id` | cannot synthesise an FK target → actionable diagnostic |
41+
| two implicit M:N between the same pair of models | ambiguous for a synthesised name → diagnostic (explicit junction or `through:` required); do not silently collide table names |
42+
| name collision: a real table already named `_AToB` | diagnostic, don't clobber |
43+
44+
## Slice-specific done conditions
45+
46+
- [ ] An implicit M:N (both ends bare, no junction model) emits a contract carrying the synthesised `_<A>To<B>` table + `N:M`/`through` on both ends, round-tripping `validateContract`.
47+
- [ ] The synthesised table is created by `migrate` (postgres + sqlite) — DDL/migration test.
48+
- [ ] `db.orm.<Model>.include(<m2n>)` over an implicit M:N returns the related rows — integration (PGlite, project standard).
49+
- [ ] D5 precedence intact: both-bare-with-junction-model still recognises (does not synthesise); `through:` still uses the named junction.
50+
51+
## References
52+
53+
- Project: `spec.md`, `design-notes.md` (D5 case 3); `wip/unattended-decisions.md` #5, #7.
54+
- Surfaces: `contract-psl/src/psl-relation-resolution.ts` + `interpreter.ts` (detection + synthesis injection); the contract storage-table IR; the migration/DDL pipeline (postgres + sqlite); the sibling M:N runtime (unchanged).

0 commit comments

Comments
 (0)