First slice of a restructuring of how the SQL ORM client executes
writes. Every write method is to build a **mutation graph**
(database-level nodes joined by edges) and hand it to one runner,
replacing the per-method statement code and, later, the nested-write
executor. This PR adds the graph and moves the six update and delete
methods onto it.
## Changes
All under `packages/3-extensions/sql-orm-client/src/mutation-graph/`
unless noted.
- **Nodes** (`node.ts`, `find.ts`, `update.ts`, `delete.ts`): a frozen
class per statement kind. A node holds its statement as SQL AST
(`SelectAst`, `UpdateAst`, `DeleteAst`) and has `execute(inputs, run)`,
which applies its inputs to the AST and runs it. A statement that
returns columns runs as a row stream, one that returns nothing as an
affected-row count.
- **Edges** (`edge.ts`, `filter-data.ts`, `after.ts`): `FilterData` has
`output(sourceRow)`, which turns one row of its source into a condition
on its target. `After` is order only.
- **Graph** (`graph.ts`): a bidirectional adjacency list with stable
positions. `add(node, inputs)` is typed by the node's input slots and
returns a branded `NodeId`; adding a data edge makes its source return
the columns the edge reads; `after(from, to)` adds an order edge.
- **Runner** (`run-graph.ts`): a loop over the nodes in position order:
resolve each input edge per source row, call `execute`, keep the result.
It puts the caller's annotations on every statement, opens a transaction
when the graph has more than one node, and shapes the result node's rows
for the caller with the existing mapping, include-loading and
read-consumer code.
- **Collection methods** (`collection.ts`, 3,385 → 3,120 lines):
`update` (without relation callbacks), `updateAll`, `updateAndCount`,
`delete`, `deleteAll`, `deleteAndCount` build a graph
(`collection-graphs.ts`) and call the runner. `update()` with relation
callbacks, the create methods, `upsert` and `mutation-executor.ts` are
unchanged.
- **Shared with existing code**: the AST-building parts of the update,
delete and select compile functions and the row mapping of
`dispatchMutationRows` are extracted so the graph and the remaining
callers use one implementation.
Graphs are asserted in tests as text, through a printer that lives in
the test directory:
```
n1 Find users where name = 'Ada' order by id asc limit 1
n2 Update users set email = 'ada@example.com' <- FilterData n1 (id->id)
result: n2 first row
```
## Behaviour changes
- **`update({})` resolves the first matching row and `updateAll({})`
yields the matching rows.** On main they resolve `null` and no rows. A
write with nothing to set is a `Find` on the same rows, so a call
returns what it matches whether or not it sets anything.
`updateAndCount({})` still resolves `0`. Two integration tests asserted
the old results and were changed; the port of Prisma 7's
`extended-where` › "update with where 1 unique (PK)" now passes and left
the failing ledger.
- The matching read of `update()` / `delete()`, the read of
`deleteAll()` / `delete()` with includes, and the reload read of a write
with includes carry the caller's annotations.
- `delete()` with includes issues two statements (main: three).
- `update()` maps fields and applies update defaults before the matching
read, so a default generator runs even when no row matches.
- `update()` / `delete()` after `limit(0)` open no transaction.
## Why
The nested-write executor issues statements while it walks the input,
once per relation layout, and the plain and nested paths of `create()`
and `update()` differ in annotations, variant handling and returned
selection. A graph between the input and the statements can be tested
and reordered by its edges alone, and a new kind of node or edge is a
class, not a change to the runner. The design, the reason for each
decision and the rejected alternatives are in
`projects/nested-mutations/mutation-graph.md`; this slice's contract is
`projects/nested-mutations/slices/graph-core/spec.md`.
## Verification
- Package tests: 1,546 pass; typecheck, lint, `lint:deps`,
`check:upgrade-coverage` pass.
- Integration, run locally for the directories that reach the ORM's
write methods (`test/sql-orm-client`, `test/ports`,
`test/temporal-defaults`, `test/value-objects`, `test/cross-package`,
the namespaced-accessors test): 453 files, 2,524 passed, 86 expected
failures, none failed. The whole integration suite was not run locally.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: Steven McClankerton <tatarintsev@prisma.io>
Co-authored-by: Steven McClankerton <tatarintsev@prisma.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>