Skip to content

docs(projects): shape affected-row-counts (spec + plan) (TML-3166) - #29895

Closed
StevenMcClankerton wants to merge 1 commit into
mainfrom
tml-3166-shape-affected-row-counts
Closed

docs(projects): shape affected-row-counts (spec + plan) (TML-3166)#29895
StevenMcClankerton wants to merge 1 commit into
mainfrom
tml-3166-shape-affected-row-counts

Conversation

@StevenMcClankerton

Copy link
Copy Markdown
Contributor

Shaping artifacts for the affected-row-counts project — spec and three-slice plan, per the project lifecycle in projects/README.md. No code changes.

What this project does

updateAndCount and deleteAndCount run two statements: a SELECT of every matching primary key, then the write — returning the read's row count and discarding whatever the write reported. Three consequences:

  • Not atomic. Outside a transaction, a concurrent insert gets updated but not counted; a concurrent delete gets counted but not updated. The returned number can be wrong.
  • Paid for twice. The filter is evaluated twice, and every matching primary key is materialised in JS purely to call .length on it.
  • Two WHERE builders that can drift. The count SELECT goes through compileSelect, the write through buildCountMutationWhere. They already diverged once for MTI variants (CLI: Rename Prisma 2 to Prisma Framework #940).

Meanwhile the count already exists and is discarded — Postgres reports it in the CommandComplete tag, SQLite in sqlite3_changes64() via StatementSync.run(). The gap is structural: RuntimeScope.execute() returns a row stream, which has nowhere for statement metadata to live, and every layer between driver and caller consumes its source with for await.

After this project the driver SPI splits along the question being asked, named the way every prior art names it (JDBC executeQuery/executeUpdate, ADO.NET ExecuteReader/ExecuteNonQuery, Go Query/Exec):

query<Row>(req): AsyncIterable<Row>          // rows
execute(req):    Promise<SqlStatementStats>  // { affectedRows: number }

affectedRows is not optional — absence is not a state either engine has for the statements execute() exists to serve, so nothing downstream branches on undefined. Because statistics never travel through a row stream, the seven for await re-wrap sites stop being a hazard: there is nothing in flight for them to drop. Prepared-ness rides on the request rather than doubling the method surface, so four driver methods become two.

Plan shape

Three slices — a stack of two plus an independent docs slice:

  1. query-execute-split (TML-3167) — reshape the driver SPI and the runtime's execution paths. No ORM-visible behaviour change; the pre-SELECT is still in place at the end of this slice.
  2. count-terminals (TML-3168) — the terminals consume execute(); the pre-SELECT is deleted rather than left dormant.
  3. count-semantics (TML-3169, parallel) — amend ADR 210; document each target's definition of "affected".

Scope boundaries

Streaming write terminals, createAndCount, and new targets are out of scope. Count semantics are deliberately not unified across targets — Postgres's command tag counts matched rows, SQLite's sqlite3_changes64() counts modified rows, Mongo's modifiedCount excludes no-op writes. The project documents the difference rather than reconciling it; no translation layer, no normalized definition.

Decision provenance

Three questions were settled with the operator at spec time and moved into the spec body: the execution shape (an earlier single-execute() design yielding row/metadata frames was considered and reversed — it gave statistics a home inside the stream at the cost of making every layer demux), the naming falling out of that shape, and per-driver count semantics.

The project amends ADR 210 — Prepared Statements rather than adding a new ADR: every principle it states survives (opaque slot, lazy synchronous allocation, a driver may ignore the slot, preparedStatements: false leaves it unset) — only the shape they were expressed through changes. ADR 210's stale-retry contract requires ADAPTER.PREPARE_FAILED, currently emitted nowhere in the codebase; closing that is in scope for slice 1 rather than deferred, since rewriting the surrounding code while leaving a known violation of the ADR being amended would be incoherent.

Refs: TML-3166

🤖 Generated with Claude Code

Shaping artifacts for the **affected-row-counts** project — spec and
three-slice plan, per the project lifecycle in `projects/README.md`.

## What this project does

`updateAndCount` and `deleteAndCount` run two statements: a `SELECT` of
every matching primary key, then the write — returning the *read's* row
count and discarding whatever the write reported. That is not atomic
(outside a transaction a concurrent insert is updated but not counted),
it evaluates the filter twice and materialises every matching key in JS
purely to call `.length` on it, and it builds the two `WHERE` clauses
through different code paths that already drifted once for MTI variants
(#940).

The count already exists and is thrown away — Postgres reports it in the
`CommandComplete` tag, SQLite in `sqlite3_changes64()` via
`StatementSync.run()`. The gap is structural: `RuntimeScope.execute()`
returns a row stream with nowhere for statement metadata to live.

After this project the driver SPI splits along the question being asked,
named the way every prior art names it (JDBC, ADO.NET, Go):

    query<Row>(req): AsyncIterable<Row>          // rows
    execute(req):    Promise<SqlStatementStats>  // { affectedRows: number }

`affectedRows` is not optional — absence is not a state either engine has
for the statements `execute()` exists to serve. Statistics never travel
through a row stream, so the seven `for await` re-wrap sites between
driver and caller stop being a hazard. Prepared-ness rides on the request
rather than doubling the method surface, so four driver methods become
two.

## Scope boundaries

Streaming write terminals, `createAndCount`, and new targets are out.
Count semantics are deliberately *not* unified across targets —
Postgres's command tag, SQLite's `sqlite3_changes64()`, and Mongo's
`modifiedCount` each mean something different, and the project documents
the difference rather than reconciling it.

## Decision provenance

Three questions were settled with the operator at spec time and moved
into the spec body: the execution shape (an earlier single-`execute()`
frame-yielding design was considered and reversed), the naming falling
out of that shape, and per-driver count semantics. Spec § Open Questions
records the reversal.

The project amends ADR 210 — Prepared Statements rather than adding a new
ADR: every principle it states survives, only the shape they were
expressed through changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Steven McClankerton <tatarintsev@prisma.io>
@StevenMcClankerton
StevenMcClankerton requested a review from a team as a code owner August 5, 2026 15:17
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Review was skipped due to path filters

⛔ Files ignored due to path filters (2)
  • projects/affected-row-counts/plan.md is excluded by !projects/**
  • projects/affected-row-counts/spec.md is excluded by !projects/**

CodeRabbit blocks several paths by default. You can override this behavior by explicitly including those paths in the path filters. For example, including **/dist/** will override the default block on the dist directory, by removing the pattern from both the lists.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro Plus

Run ID: dfd0b9d8-0ac5-4f52-9f16-2b88e834ec78

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

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.

@StevenMcClankerton

Copy link
Copy Markdown
Contributor Author

Closing — opened in error. The spec + plan commit rides slice 1's PR (TML-3167) instead; no separate planning PR.

@SevInf
SevInf deleted the tml-3166-shape-affected-row-counts branch August 5, 2026 15:20
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.

2 participants