Skip to content

The row lock and the advisory lock composed through the raw lane (#1155) - #1335

Merged
borisno2 merged 7 commits into
prisma-8from
claude/issue-1155-row-lock
Sep 8, 2026
Merged

The row lock and the advisory lock composed through the raw lane (#1155)#1335
borisno2 merged 7 commits into
prisma-8from
claude/issue-1155-row-lock

Conversation

@borisno2

@borisno2 borisno2 commented Sep 7, 2026

Copy link
Copy Markdown
Member

Implements #1155. Part of #1124.

.forUpdate() on a transaction-bound builder, carried by first() and all(), plus txContext.advisoryLock(key). See ADR-0047 and ADR-0062.

const result = await context.transaction(async (tx) => {
  // The lock comes BEFORE the count. `null` here is denied-or-gone.
  const slot = await tx.db.Slot.where({ id: { equals: slotId } }).forUpdate().first()
  if (slot === null) return { booked: false }

  const { taken } = await tx.db.Booking.where({ slotId: { equals: slotId } }).aggregate(
    (aggregate) => ({ taken: aggregate.count() }),
  )
  if (taken >= slot.capacity) return { booked: false }

  return { booked: true, item: await tx.db.Booking.create({ data: { slotId, holder } }) }
})

What the terminal does

  1. Runs the scoped read — operation access, Access Filter, Field Visibility, exactly as any read.
  2. Composes SELECT <pk> FROM <table> WHERE <pk> IN ($1…$n) ORDER BY <pk> LIMIT $n+1 FOR UPDATE through the contract-bound raw tag: table and key column off the contract's storage, identifiers quoted by @prisma/orm-postgres/target/sql-utils, each key bound with the identity column's own codec via param(). It runs on the transaction's executor, inside the terminal's withOrigin('engine', …).
  3. Returns only the rows the lock came back with.

Acceptance

  • The recorded lock statement matches the spellinguuid key renders $1::uuid, cuid2 (text) key renders $1, all() binds one placeholder per key with LIMIT $n+1. This suite is the one place asserting on rendered SQL; ADR-0062 grants the exception because the statement is the subject.
  • EXPLAIN on PGlite shows a lock node — the engine's own recorded statement is EXPLAINed verbatim on a connection of its own and the plan contains LockRows.
  • A row deleted between read and lock is absent — under the escape (real Postgres), with the window opened deterministically: a second connection holds the doomed row's lock, so the scoped read succeeds and the lock statement waits; the delete commits while it waits and Read Committed re-checks. PGlite blocks a concurrent delete while a transaction is open (probed), so the default harness exercises the same subset through the lock lane.
  • An empty scoped read issues no lock statementIN () is a Postgres syntax error, not an empty result.
  • An over-bound key set throws before any statement — a composed limit above ROW_LOCK_MAX_KEYS is refused with RowLockKeyLimitExceededError before the read runs; a returned key set above it is refused before the lock statement.
  • forUpdate on a non-transaction builder is a compile error — checked from a real emitted bundle (packages/cli/src/generator/types-row-lock.test.ts): error TS2339: Property 'forUpdate' does not exist on type 'ListQuery<Contract, Remainder, "Post", unknown, never, false>'.
  • Escape-only contention — a second connection's FOR UPDATE NOWAIT fails 55P03 while the lock is held and succeeds after commit.

Parked by #1154, picked up here

  • The Context API has no interactive (hook-firing) transaction — can't atomically enforce concurrency-sensitive constraints #614 capacity-gate guarantee is restored (packages/core/src/secured/capacity-gate.test.ts), re-expressed with .forUpdate() against a real database. Five racers, each on its own client, pool and connection, against a capacity-two slot admit exactly two. The racers genuinely overlap: a barrier of five inside the transactions does not release until every one of them is open, so a run in which any two did not overlap hangs and fails rather than passing with the wrong answer. A control case in the same file — the same gate without the lock — admits all five, which is the built-in demonstration that the lock is what provides the guarantee.
  • packages/core/CLAUDE.md gains the forUpdate() example, consistent with the root CLAUDE.md's description.

Constraints honoured

forUpdate() only — no forShare, no NOWAIT, no SKIP LOCKED. aggregate() and nearest() refuse the modifier rather than silently dropping it: forUpdate is a ReadPlan member, so each terminal's PlanDispositions has to say what it does with it. The engine always emits ORDER BY <pk>. A table without a single-column primary key raises RowLockIdentityError.

Surface

StackContext gains a fourth type parameter TxDB (defaulting to DB, so every existing instantiation is unchanged) and StackTransactionContext becomes a real interface carrying advisoryLock. The generator emits <List>TxList beside <List>List and a TxDB interface, and binds TransactionContext to it. UnsafeCapableClient gains readonly contract: object — the row lock reads the identity column and its codec off the contract's storage.

Verification

  • pnpm lint — 0 errors, 2 warnings (both pre-existing, in examples/blog and packages/cli/src/migration).
  • pnpm exec prettier --check . — clean. pnpm manypkg fix — clean.
  • pnpm build --force — 11/11 tasks.
  • pnpm check:prisma-error-codes — clean, allowlist unchanged.
  • Full suites (default PGlite harness): core 1640 passed / 5 skipped, cli 410 passed, ui 611, rag 456, auth 501, storage 197, storage-s3 43, storage-vercel 56, create-opensaas-app 37. Cross-package suites ran after pnpm build --force.
  • Core against a real PostgreSQL 14 (DATABASE_URL): 1608 passed / 23 skipped, with typed-read-surface.test.ts excluded — that server has no pgvector and the file fails identically on this branch's base.

Every new assertion was proved falsifiable by breaking the production code and watching it fail: dropping ORDER BY (3 spelling tests), dropping the locked-subset filter (the vanished-row test, expected [ 'doomed', 'kept' ] to deeply equal [ 'kept' ]), dropping the no-keys guard (42601 syntax error at end of input), dropping either bound (both refusal tests), running the lock on the client's executor instead of the transaction's (7 tests, all deadlocking on the pool), and dropping FOR UPDATE from the statement (the capacity gate, expected [ true, true, true, true, true ] to have a length of 2).

🤖 Generated with Claude Code

#1155)

`.forUpdate()` on a transaction-bound builder, carried by `first()` and
`all()`: the scoped read runs first — operation access, the Access Filter and
Field Visibility exactly as any read — and the engine then composes
`SELECT <pk> FROM <table> WHERE <pk> IN ($1…$n) ORDER BY <pk> LIMIT $n+1
FOR UPDATE` through the contract-bound raw tag and runs it on the
transaction's own executor, inside the terminal's engine origin. Table and
key column come off the contract's storage, identifiers are quoted by the
target package's helpers, and each key binds with the identity column's own
codec — so a `uuid` key renders `$1::uuid` and a text key `$1`. Only rows the
lock came back with are returned.

`forUpdate()` outside a transaction is a compile error, not a throw: the
generated bundle now names two faces per list (`PostList`, `PostTxList`) and
`TransactionContext` is the context over the locking one. `advisoryLock(key)`
joins it on that context, running `pg_advisory_xact_lock(hashtext($1))`.

No keys, no statement — an empty scoped read returns its empty value rather
than reaching `IN ()`, which is a Postgres syntax error. A key set over
`ROW_LOCK_MAX_KEYS` is refused with a stack-owned error before the lock
statement, and a bound over it before any statement at all. `aggregate()` and
`nearest()` refuse the modifier through the terminal's own `PlanDispositions`
rather than dropping it. No `forShare`, no `NOWAIT`, no `SKIP LOCKED`.

Restores the #614 capacity-gate guarantee #1154 had to delete, re-expressed
with `.forUpdate()` against a real database with genuinely overlapping racers,
and adds the `forUpdate()` example to `packages/core/CLAUDE.md`.

Implements #1155. Part of #1124. See ADR-0047 and ADR-0062.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 7, 2026

Copy link
Copy Markdown

Deployment failed for project stack-docs with the following error:

Resource is limited - try again in 24 hours (more than 100, code: "api-deployments-free-per-day").

Learn More: https://vercel.com/open-saas?upgradeToPro=build-rate-limit

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@changeset-bot

changeset-bot Bot commented Sep 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: fb7bbf5

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
@opensaas/stack-core Minor
@opensaas/stack-cli Minor
@opensaas/stack-auth Minor
@opensaas/stack-rag Minor
@opensaas/stack-storage Minor
@opensaas/stack-tiptap Minor
@opensaas/stack-ui Minor
@opensaas/stack-storage-s3 Minor
@opensaas/stack-storage-vercel Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Core Package Coverage (./packages/core)

Status Category Percentage Covered / Total
🟢 Lines 94.32% (🎯 65%) 3290 / 3488
🟢 Statements 93% (🎯 65%) 3696 / 3974
🟢 Functions 96.38% (🎯 62%) 720 / 747
🟢 Branches 88.26% (🎯 50%) 2528 / 2864
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/core/src/unsafe.ts 97.72% 96.66% 100% 97.29% 166
packages/core/src/context/write-pipeline.ts 97% 90.74% 100% 97.89% 299-300, 517
packages/core/src/secured/lock.ts 84.93% 71.79% 91.66% 87.69% 59-63, 231, 237, 239, 241, 301, 330, 334-337, 346-349
packages/core/src/secured/read.ts 90.39% 82.18% 93.18% 90.35% 250-254, 327, 418, 792-799, 813-821, 873, 915-918, 967-970, 1157
Generated in workflow #2123 for commit fb7bbf5 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for UI Package Coverage (./packages/ui)

Status Category Percentage Covered / Total
🔵 Lines 78.45% 244 / 311
🔵 Statements 77.95% 251 / 322
🔵 Functions 69.81% 74 / 106
🔵 Branches 66.94% 160 / 239
File CoverageNo changed files found.
Generated in workflow #2123 for commit fb7bbf5 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for CLI Package Coverage (./packages/cli)

Status Category Percentage Covered / Total
🔵 Lines 71.69% 1672 / 2332
🔵 Statements 71.45% 1792 / 2508
🔵 Functions 79.12% 288 / 364
🔵 Branches 59.26% 828 / 1397
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/cli/src/generator/types.ts 100% 97.05% 100% 100%
Generated in workflow #2123 for commit fb7bbf5 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Auth Package Coverage (./packages/auth)

Status Category Percentage Covered / Total
🔵 Lines 91.2% 280 / 307
🔵 Statements 89.94% 313 / 348
🔵 Functions 96.05% 73 / 76
🔵 Branches 82.38% 262 / 318
File CoverageNo changed files found.
Generated in workflow #2123 for commit fb7bbf5 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Storage Package Coverage (./packages/storage)

Status Category Percentage Covered / Total
🔵 Lines 80.21% 227 / 283
🔵 Statements 81.61% 253 / 310
🔵 Functions 91.35% 74 / 81
🔵 Branches 77.46% 220 / 284
File CoverageNo changed files found.
Generated in workflow #2123 for commit fb7bbf5 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for RAG Package Coverage (./packages/rag)

Status Category Percentage Covered / Total
🔵 Lines 91.29% 556 / 609
🔵 Statements 90.58% 606 / 669
🔵 Functions 97.39% 112 / 115
🔵 Branches 84.26% 375 / 445
File CoverageNo changed files found.
Generated in workflow #2123 for commit fb7bbf5 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Storage S3 Package Coverage (./packages/storage-s3)

Status Category Percentage Covered / Total
🔵 Lines 100% 40 / 40
🔵 Statements 100% 40 / 40
🔵 Functions 100% 9 / 9
🔵 Branches 100% 19 / 19
File CoverageNo changed files found.
Generated in workflow #2123 for commit fb7bbf5 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Storage Vercel Package Coverage (./packages/storage-vercel)

Status Category Percentage Covered / Total
🔵 Lines 100% 68 / 68
🔵 Statements 100% 71 / 71
🔵 Functions 100% 15 / 15
🔵 Branches 97.87% 46 / 47
File CoverageNo changed files found.
Generated in workflow #2123 for commit fb7bbf5 by the Vitest Coverage Report Action

@borisno2 borisno2 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Code review — PR #1335 (effort: high)

Verdict: REQUEST CHANGES. The lock mechanism itself is sound and I could not break it: the SQL composition is safe, the transaction binding is real, and the contention proof is genuine. The blocking item is #1 — the flagship example the docs teach reads a column off the locked row, and that value is the pre-lock snapshot. Everything else is minor.

Reviewed by checking out the branch, installing, pnpm build --force, running the suites on both harnesses, and mutating production code to confirm each claim is falsifiable. All of the PR description's verification numbers reproduced exactly (core 1640/5 skipped; cli 410; Postgres 1608/23 skipped; lint 0 errors + 2 pre-existing warnings; prettier clean).


The three headline risks, answered

1. Raw SQL composition — clean. Every attacker-influenceable value is a bound parameter; the only text in the statement is fixed clause text plus contract-derived identifiers, and there is exactly one composition site (lockStatement) with no branch that bypasses quoting.

  • Keys are wrapped in param(key, { codecId })ParamRef. I traced Prisma's tag: resolveInterpolation (@prisma/orm-family-sql) turns a bare string/number into ParamRef.of(...) too, so advisoryLock(key) — the one genuinely caller-supplied string in the file — is bound, not interpolated. Recorded params confirm it.
  • quoteIdentifier (@prisma/orm-target-postgres) doubles embedded " and rejects null bytes/empty. I drove lane.lock() over a hostile synthetic contract (table Slot" FOR UPDATE; DROP TABLE users; --, column id" , (SELECT 1) AS "x, namespace pub"lic). The result stays inside one valid quoted identifier — no breakout — and both keys came through as param-ref.

3. Transaction binding — real, and the tests catch its loss. createRowLockLane(client.raw, client.contract, _unsafeTransaction) passes the transaction's scope on both construction paths. I mutated both to client.runtime() (the pooled executor): the capacity gate fails with timeout exceeded when trying to connect — it goes looking for a second connection against each racer's max: 1 pool — and 6 further tests in lock.test.ts fail on PGlite. That is a real experiment, not a shape argument.

The compile-error half also holds, and the type-level proof is exercised rather than decorative. I flipped RowLockKey<Tx> to Tx extends true ? 'forUpdate' : 'forUpdate'; core's own typed-read-surface.test.ts:148,150 fails to compile. That assertion — Exact<Exclude<PromisedLockingMember, PromisedQueryMember>, 'forUpdate'> — is stronger than needed for this PR and is exactly what will catch a future forShare/skipLocked being added to the surface.

4. The concurrency test genuinely proves contention — verified by mutation. I removed ' FOR UPDATE' from the composed statement and ran against real PostgreSQL 14: expected [ true, true, true, true, true ] to have a length of 2 but got 5. All five racers admitted means they really do overlap in time — a test whose racers had serialised would still have answered 2 and passed for the wrong reason. The barrier is inside the callback and client.transaction issues BEGIN before invoking it, so all five transactions are open before any locks. Each racer has its own client, pool and connection.

Also confirmed the harness is adequate: the suite is describe.skipIf(escape.kind !== 'postgres'), and .github/workflows/test.yml:28 sets DATABASE_URL to Postgres for any PR whose base is not main — so this does run in CI on this PR rather than silently skipping.

The remaining risk areas

  • 2. Locked ⊆ readable — holds. The scoped read resolves access first (resolvePlannull short-circuits), keys come only from rows it returned, and locked() filters to keys the lock statement came back with. A vanished row is absent, not an error (lock.test.ts:247). The Note list in the fixture proves the access filter scopes before the lock: an unreadable row yields [] and zero raw statements.
  • 5. Excluded capabilities — genuinely excluded. No forShare, NOWAIT or SKIP LOCKED anywhere on the surface, not even as an internal option: lockStatement hardcodes ' FOR UPDATE' with no mode parameter, and RowLockKey<Tx> admits the single member. NOWAIT appears only inside the test's own second-connection probe, which is a measurement, not a capability.
  • 6. Refusals before side effects — verified. Both refusal tests assert emptiness, and the over-bound one asserts recorder.statements (all lanes, not just raw) is [], so the scoped read never ran either.
  • 7. Type integrity — clean. No any, no casts, no @ts-ignore in the diff (every "any" hit is the English word in a comment). unknown appears only as parameter types on the internal createRowLockLane; the public entry point exports just the three error classes and the constant.
  • 8. Base is prisma-8; changeset is minor for both packages. Correct.

Findings

1. Medium (blocking) — a locked row carries its pre-lock column values, and the documented example depends on them.

locked() (packages/core/src/secured/read.ts:914) returns the row objects from statement 1; statement 2 re-validates only the key set. Under Read Committed each statement takes its own snapshot, so a column mutated and committed between the two reaches the caller stale.

Proved on PostgreSQL 14. A blocker holds the row lock; our transaction's statement 1 reads capacity = 2; statement 2 blocks; the blocker sets capacity = 0 and commits; statement 2 acquires and returns the id:

CAPACITY SEEN BY LOCK HOLDER: 2
CAPACITY ACTUALLY COMMITTED:  0

For contrast, a single-statement SELECT * FROM … FOR UPDATE in the same race returns 0 — Postgres re-evaluates after acquiring (EvalPlanQual). So .forUpdate() diverges from what FOR UPDATE means in SQL, in the direction a reader will not expect.

ADR-0047:32 already says the lock is "a mutex token, not protection for the parent's own data" — but the flagship example in the PR body, packages/core/CLAUDE.md, the root CLAUDE.md, .changeset/mellow-gates-hold.md and capacity-gate.test.ts:160 all read parent.capacity off the locked row and use it as the gate threshold. That is precisely relying on the parent's own data. The gate's main invariant is still safe (the Booking count is a separate statement issued after the lock, so it is fresh); it is the threshold that can be stale, and any app with an admin UI that edits capacity inherits a latent race.

Either re-read the needed columns in statement 2 and return those, or state plainly in the TSDoc and both CLAUDE.mds that only the identity is post-lock and change the example so it does not read a mutable column off the locked row.

2. Medium — a hook inside context.transaction() cannot take a row lock, and is told it is not in a transaction.

buildDbDelegate (packages/core/src/context/index.ts:1166) calls populateDbDelegate with no lock argument, so the lane is dropped on the contexts it rebuilds — write-pipeline.ts:262 (bindContextToTransaction) and access/field-visibility.ts:127. forUpdate is still installed as a member, so the call composes and then throws. Confirmed with a beforeOperation hook running inside context.transaction():

RowLockUnavailableError: all().forUpdate() needs a transaction … Compose the read inside
`context.transaction(async (tx) => …)` and reach it through `tx.db`.

It is already doing exactly that. This fails closed, which is the right direction — it refuses rather than taking a lock that would be released immediately — so it is not a security issue. But either thread the lane through, or give this case its own message.

3. Low — lock() has no zero-key guard of its own; at arity 0 the template is malformed.

With keys = [], lockStatement yields strings.length === 3 against values.length === 1. The tag never consumes the trailing fragment, emitting … IN ($1) ORDER BY "id" LIMIT FOR UPDATE dropped and LIMIT left dangling, i.e. a Postgres syntax error. The only guard is if (keys.length === 0) return [] in locked() (read.ts:912), a module away. lock() is a member of the public RowLockLane and the suite drives it directly (lock.test.ts:262), so the guard belongs on both sides. Unreachable through the terminal today.

4. Low — RowLockUnavailableError conflates "no transaction" with "the lane could not be built".

createRowLockLane returns undefined when isRawLane/isContract reject the client, and every downstream refusal then asserts the caller is outside a transaction. A client whose raw/contract shape drifts (an rc bump, a hand-built double) inside a genuine transaction gets a message pointing the wrong way; advisoryLock() inherits the same read-oriented wording ("Compose the read inside…"). Worth distinguishing.

5. Low — tests/prisma8-double.ts was not updated for the new required contract member.

unsafe.ts adds readonly contract: object as required, but prisma8Double returns a literal typed UnsafeCapableClient without it. Confirmed a real error:

tests/prisma8-double.ts(45,3): error TS2741: Property 'contract' is missing in type
'{ sql: {}; raw: {}; orm: …; runtime: …; transaction: … }' but required in type 'UnsafeCapableClient'.

Nothing catches it because packages/core/tsconfig.json has "include": ["src/**/*"], so tests/ is never type-checked. Runtime degrades gracefully (contract undefined → isContract false → lane absent → fail-closed), but the type is wrong today.

Nit

lockLane's doc says a bound "the terminal will not honour" is refused before any statement, which is accurate for a composed .limit(). An unbounded .forUpdate().all() over a very large scoped set still materialises every row before lock() refuses — inherent, since the key count is not knowable earlier, but a defensive limit(ROW_LOCK_MAX_KEYS + 1) on the read would bound it.


Falsifiability spot-checks all passed: removing FOR UPDATE, moving the executor off the transaction, and opening the type gate each produced a failing test. This is well-tested work; the mechanism is right.

…n take one

Review findings on #1335.

The locked row carries its columns as of BEFORE the lock — the terminal reads
first and locks second, and each statement takes its own snapshot — so the
flagship capacity gate, which read `capacity` off the locked row and used it as
the gate's limit, taught a pattern that is stale under contention. Both
`CLAUDE.md`s, the changeset, `forUpdate()`'s own TSDoc and the gate suite now
read the threshold in its own statement after the lock, the way the count
already was, and each states the semantics. A new escape-only test holds the
window open with a blocker and proves the two values differ.

A hook running inside `context.transaction()` can now take a row lock: the lane
travels on `AccessContext._rowLock`, so every path that rebuilds the `db`
delegate hands it back. A write that opened its OWN transaction still drops it —
the lane is a different transaction's.

`lock()` guards arity zero itself, where the template is malformed rather than
empty. `RowLockLaneUnavailableError` separates "this client cannot compose the
statement" from `RowLockUnavailableError`'s "there is no transaction". The
Prisma 8 test double declares the `contract` its type has required since #1155.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
stack-docs Ready Ready Preview Sep 8, 2026 2:40am UTC

@borisno2

borisno2 commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Addressed all five findings on 0b98a47a. No new PR, nothing merged; the changeset stays mellow-gates-hold.md at minor.

1. BLOCKING — the flagship example taught a stale threshold

You are right, and the mechanism is behaving as designed — so this is a documentation fix, not a terminal change. ADR-0047 deliberately chose the two-statement shape, so the terminal still does not re-read; what changed is that the example no longer depends on the half of its answer that is pre-lock.

The gate now reads both of its sides after the lock, each in its own statement:

const held = await tx.db.Slot.where({ id: { equals: slotId } }).forUpdate().first()
if (held === null) return { booked: false }

// `held.capacity` is the row as of before the lock; this re-read is not,
// because no one else can commit an update to a row this transaction holds.
const slot = await tx.db.Slot.where({ id: { equals: slotId } }).first()
const { taken } = await tx.db.Booking.where({ slotId: { equals: slotId } }).aggregate(
  (aggregate) => ({ taken: aggregate.count() }),
)
if (slot === null || taken >= slot.capacity) return { booked: false }

Corrected in all five places you named — packages/core/CLAUDE.md, the root CLAUDE.md, .changeset/mellow-gates-hold.md, capacity-gate.test.ts, and the PR body's example is the same text as the changeset's. The semantics are now stated plainly rather than implied, in forUpdate()'s own TSDoc (secured/read.ts) and as a bullet in packages/core/CLAUDE.md: the lock is a mutex on the row, the columns come back as of before it, only the identity is post-lock, and this diverges from a single-statement SELECT … FOR UPDATE which Postgres re-evaluates after acquiring.

Test. capacity-gate.test.ts gains "the gate compares against the threshold read after the lock, not the one on the locked row", built on your own deterministic-window shape: a blocker holds the slot's lock, the gate's scoped read succeeds, its lock statement waits, and capacity goes 2 → 0 and commits while it waits. It asserts both values the gate could have compared against, so the divergence is the subject rather than a side effect.

Mutation — the one line back to Number(held.capacity), on PostgreSQL 14:

AssertionError: expected 2 to be +0 // Object.is equality
 ❯ src/secured/capacity-gate.test.ts:264  expect(outcome.afterLock).toBe(0)

2. MEDIUM — a hook inside a transaction can now take the lock

Threaded, rather than reworded. The lane travels on AccessContext._rowLock, so buildDbDelegate hands it back on every path that rebuilds the delegate — bindContextToTransaction and deriveResolveOutputContext both.

One boundary is deliberate and documented: bindContextToTransaction carries the lane only when tx is the handle the context already had (the joined-write shape). A write that opened its own transaction gets a tx on a different connection, and a lock taken through the outer lane would be held by the wrong transaction — so it is dropped and refused, and RowLockUnavailableError's advice ("compose the read inside context.transaction") is exactly the fix in that case.

Mutation — reverting buildDbDelegate to drop the lane reproduces your report verbatim:

- "keys": ["a", "b"]
+ "error": RowLockUnavailableError { "message": "all().forUpdate() needs a transaction: …" }
 ❯ src/secured/lock.test.ts:404  expect(lockedByHook).toEqual({ keys: ['a', 'b'] })

3. LOW — zero-key guard on lock()

if (keys.length === 0) return [] now sits on the lane too, not only a module away in locked(). Test drives lock() directly at arity 0 and asserts no raw statement is issued.

Mutation — removing the guard, on PGlite:

Serialized Error: { kind: 'sql_query', sqlState: '42601', … }
Caused by: error: syntax error at end of input

4. LOW — the two causes are separated

New RowLockLaneUnavailableError (exported) covers "inside a transaction, but this client carries no usable raw lane and contract to compose the statement through". RowLockUnavailableError keeps only "there is no transaction". The seat is filled by a refusing lane rather than left empty, so undefined means exactly one thing; the refusal still lands before any statement, because identity() resolves ahead of the scoped read. advisoryLock() gets its own member name in the message.

Mutation — dropping the refusing lane:

AssertionError: expected RowLockUnavailableError … to be an instance of RowLockLaneUnavailableError

5. LOW — the test double

contract: {} added to prisma8Double. I did not touch what gets type-checked (filed separately), but confirmed the error under a throwaway config that includes tests/:

tests/prisma8-double.ts(45,3): error TS2741: Property 'contract' is missing in type
'{ sql: {}; raw: {}; orm: …; runtime: …; transaction: … }' but required in type 'UnsafeCapableClient'.

— present with the line removed, gone with it there. Runtime behaviour is unchanged and now explicit: raw: {} fails isRawLane, so a double inside a transaction gets the refusing lane and the accurate message from finding 4.

Verification

  • pnpm install --prefer-offline, pnpm build --force — 11/11 tasks
  • pnpm lint — 0 errors, 2 pre-existing warnings; pnpm format clean; pnpm manypkg fix clean
  • pnpm check:prisma-error-codes — clean, 17 literals across 5 allowlisted files (allowlist unchanged)
  • packages/core on PGlite — 1644 passed | 6 skipped (85 files)
  • packages/cli410 passed (43 files)
  • packages/core on PostgreSQL 14 — 1613 passed | 37 skipped, one file failing: typed-read-surface.test.ts on CREATE EXTENSION vector (no pgvector locally, typed-read-surface's test needs pgvector but does not guard for it, so the Database escape goes red #1336)
  • capacity-gate.test.ts — 3/3 on Postgres, including the new one

The nit (a defensive limit(ROW_LOCK_MAX_KEYS + 1) on the scoped read) is untouched — it changes what the terminal issues, which reads as its own decision rather than a fix.

@borisno2 borisno2 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Re-review — PR #1335 (follow-up, head 0b98a47a)

Verdict: APPROVE. Posted as a Comment review because the gh identity here is the PR author and GitHub blocks a self-Approve event — treat this as an approval.

Re-verified by checking out 0b98a47a, pnpm install, pnpm build --force (11/11), running the suites on both harnesses, and mutating production code to break each new assertion. Every number in the response comment reproduced exactly: core PGlite 1644 passed / 6 skipped; core on PostgreSQL 14 1613 passed / 37 skipped with only typed-read-surface.test.ts failing on CREATE EXTENSION vector (#1336, not this PR); cli 410; ui 611; rag 456; auth 501; storage 197 / s3 43 / vercel 56; create-opensaas-app 37. pnpm lint 0 errors + the 2 pre-existing warnings, prettier --check clean, manypkg check clean, check:prisma-error-codes clean with the allowlist unchanged.


The priority: can a lock ever be held by the wrong transaction?

No. I could not construct a path that takes a lock on a connection other than the one the surrounding work runs on, and the boundary the response describes turns out to be stronger than stated.

The pairing is structural. context._rowLock's executor is always _unsafeTransaction, and a transaction-bound context.ormHandle is always derived from the same opened object (transactionOpenerForormHandleFor(config, tx.orm), which mints a fresh models object per transaction, so handle identity is a faithful proxy for transaction identity). I walked every path that can reach the lane after a rebind:

  • A hook writing through a handed context (bindContextToTransaction, write-pipeline.ts:263). opener = existingOwner ? undefined : context._transactionOpener, and runInTransaction only yields a tx different from args.ormHandle when opener is defined. Every WritePipelineArgs is constructed by populateDbDelegate, where ormHandle and context.ormHandle are the same value by construction — I checked all three runWritePipeline call sites.
  • deriveResolveOutputContext (field-visibility.ts:127) spreads the context and rebuilds db against context.ormHandle, so it cannot break the pairing.
  • The sudo surface and session substitution re-enter getContext with the same ormHandle, client and _unsafeTransaction, so they build a fresh lane over the same transaction scope. transactionFace wraps base.sudo() / base.withSession() with the enclosing lock, itself over that scope.
  • A nested transaction. With _transactionOwner set, transaction() runs fn(transactionFace(returned, lock)) on this context's own lane. In runTransactionBody, all three branches derive handle and scope together: child(opened.ormHandle, opened.unsafe) alongside rowLockSeat(client, opened.unsafe), and child(ormHandle, _unsafeTransaction) for the join. The $transaction branch yields no scope, so no lane and a refusal — the documented Known-limits branch, unreachable today.

And the invariant is stronger than the guard. _rowLock is set iff client && _unsafeTransaction; _transactionOpener is set iff client && !_unsafeTransaction (transactionOpenerFor returns undefined when already inside a transaction). They are mutually exclusive, so opener !== undefined ⟹ context._rowLock === undefined, and the drop branch of tx === context.ormHandle ? … : undefined is never reached with a lane present.

Two experiments confirm that rather than leaving it as reasoning:

  • Replacing the guard with a throw on exactly that condition and running the whole core suite on Postgres: the probe never fired (1613 passed / 37 skipped, only the pgvector file).
  • Removing the guard entirely and running the whole core suite on Postgres: nothing changed — same 1613/37.

And the harness genuinely detects a wrong-connection lock. Mutating rowLockSeat to build the lane over client.runtime() (the pooled executor) instead of the transaction scope fails 11 tests, including the new hook test and advisoryLock, with timeout exceeded when trying to connect and the capacity gate breaking. So a regression that moved the lock off the transaction's connection would not pass CI.

No capability regression on the other side either. The suite covers the write-pipeline hook path but nothing else, so I wrote a throwaway probe against real Postgres for the paths it does not: tx.sudo().db.X.forUpdate().all(), tx.withSession(s).db.X.forUpdate().all(), tx.sudo().advisoryLock(k), a nested tx.transaction(inner => inner.db.X.forUpdate().all()), and a field-level resolveOutput hook taking a lock (the deriveResolveOutputContext rebuild). All five take the lock, each issuing exactly one raw FOR UPDATE on the transaction's lane. Nothing is withheld where it should work.

The documentation fix — the argument holds, and I tested it

"this re-read cannot be stale, because no one else can commit an update to a row this transaction holds."

True, for every case the example covers. Every writer of that row — UPDATE and DELETE alike — must take the same row-level exclusive lock, so nobody can commit a change to it between our acquisition and our commit; and under Read Committed each new statement's snapshot includes everything committed before it started, so a change committed while our lock statement waited is visible to the re-read. Verified directly on PostgreSQL 14 with two connections:

  • A competitor that had already read before the lock was taken (the case named in review): it read 2, we then took the lock, our re-read returned 2, its UPDATE blocked for as long as we held the lock, and its value landed only after our commit. That is correct serialisation — we committed first — not staleness.
  • A competitor that committed while our lock statement waited: statement 1 saw 5, the blocker committed 9, our post-lock re-read returned 9. Not the stale value.

One boundary worth knowing, and not a defect: at REPEATABLE READ the re-read would use the transaction snapshot, but the terminal's own lock statement raises first — I confirmed 40001: could not serialize access due to concurrent update. So it fails loudly rather than returning stale data, and ADR-0042 / types/context.ts:84 already pin the level as the connection default. The docs' "under Read Committed" wording is accurate.

The semantics are now stated in the changeset, both CLAUDE.mds, capacity-gate.test.ts and SecuredQuery.forUpdate's TSDoc, including the divergence from a single-statement SELECT … FOR UPDATE. The new escape-only gate test is a real characterisation test — it asserts onLockedRow === 2 and afterLock === 0 through the real terminal against real Postgres, so it would fail if the terminal ever started re-reading columns post-lock. All 3 gate tests pass on PostgreSQL 14.

The remaining fixes — each verified by breaking it

Fix Verified
Zero-key guard on lock() (lock.ts:363, ahead of the ROW_LOCK_MAX_KEYS check) Removing it fails the new arity-0 test with 42601 syntax error at end of input
RowLockLaneUnavailableError + unusableRowLockLane() seat Removing the ?? unusableRowLockLane() fails the new test: expected RowLockUnavailableError … to be an instance of RowLockLaneUnavailableError. The refusal lands in lockLane() via identity(), ahead of collection.all() in both runAll and runFirst — before any statement, as claimed
contract: {} on prisma8Double Removing it reproduces TS2741: Property 'contract' is missing under an isolated tsconfig that includes tests/. Still uncovered by any checked config (packages/core/tsconfig.json includes src/**/* only) — filing that separately is the right call

Nothing from the clean findings regressed

forShare / NOWAIT / SKIP LOCKED appear nowhere in production code; nowait survives only inside the test's own second-connection probe, which is a measurement. RowLockKey<Tx> still admits the single member, and typed-read-surface.test.ts:150's Exact<Exclude<PromisedLockingMember, PromisedQueryMember>, 'forUpdate'> is still falsifiable — flipping RowLockKey to always yield 'forUpdate' fails to compile at :148 and :150. The compile-time refusal outside a transaction still holds from a real emitted bundle (types-row-lock.test.ts, in the green 410). No any, no casts, no @ts-ignore in the diff; @ts-expect-error appears only where it is the assertion. unknown stays internal — parameter types on createRowLockLane and the two type guards; the public entry point exports the four error/constant names alone. Base is prisma-8, changeset minor for both packages.


Findings

Low — one place the semantics is still missing, and it is the one a consumer's editor shows.

packages/core/src/types/secured-list.ts:521–533 is the forUpdate() TSDoc on the typed generated surface: <List>TxList extends SecuredList<Contract, Remainder, 'List', true>, and the member comes from the RowLock mapped type. That is the docblock a project developer sees when hovering tx.db.Slot.…forUpdate(). It still carries only "the locked set is a subset of the readable one" and the vanished-row rule; the pre-lock-columns paragraph went into SecuredQuery.forUpdate in read.ts, which is core's own untyped engine view and not what a consumer hovers. By this repo's own comment rule — public API docblocks exist because they are what the consumer's editor shows — this is the single place the new semantics most needs to be stated and is not. Worth a short paragraph mirroring the read.ts one.

Low — the boundary foregrounded in the response is defensive, not live, and the test named for it does not exercise it.

Per the analysis above, _rowLock and _transactionOpener are mutually exclusive, so bindContextToTransaction's tx === context.ormHandle check never actually drops a lane that exists. The new test "the same hook under a write that opened its own transaction refuses" runs on database.context(anonymous) — a context that never had a lane — so it passes identically with the guard deleted (confirmed: whole core suite green with the guard removed). The guard is correct and I would keep it; it just guarantees an invariant that a different mechanism already guarantees. Worth one clause in the bindContextToTransaction docblock saying so, and a test name that does not imply it is load-bearing.

On the declined nit

I agree with declining the defensive limit(ROW_LOCK_MAX_KEYS + 1). It would change the statement the terminal issues — which this suite deliberately pins and which ADR-0059's stamping observes — and it degrades the refusal's diagnostic, since RowLockKeyLimitExceededError would always report 1001 rather than the true key count. On an unordered read it also silently picks an arbitrary 1001 rows, and it has to be reasoned about against offset/cursor. Same outcome for every compliant read, real trade-offs otherwise: a decision, not a fix. A follow-up issue is the right home if the unbounded materialisation cost turns out to matter.


The mechanism is right, the fixes are real and falsifiable, and the two remaining items are documentation. Ship it.

borisno2 and others added 2 commits September 8, 2026 10:55
The pre-lock-columns paragraph landed on `SecuredQuery.forUpdate`, which is
the engine's own untyped view. `tx.db.Slot.…forUpdate()` resolves to the
`RowLock` mapped type in `types/secured-list.ts`, and that is the docblock a
project developer's editor shows — so the semantics goes there too.

Also stops the write-pipeline lane test from claiming a guard it does not
exercise. `_rowLock` and `_transactionOpener` are set on mutually exclusive
conditions, so `bindContextToTransaction`'s `tx === context.ormHandle` check
never drops a lane that exists; the test runs on a context that never had
one. Kept the guard, named the test for what it checks, and said in both
places that the check is defence in depth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings in #1330, which stops a directly-written foreign-key column being
silently dropped. The two sides overlap in `context/index.ts` and
`context/write-pipeline.ts` only, in disjoint functions — `serverAction` and
`runWriteInTransaction` on the base's side, the lane construction and
`bindContextToTransaction` on this branch's.

Nothing here depended on the old behaviour: `capacity-gate.test.ts` declares
its `slotId` as a plain `text()` column deliberately, and `lock.test.ts`'s
`Booking.slot` relationship is never written. The interaction runs the other
way — a probe against a REAL relationship shows the capacity-gate example
this PR documents only works after #1330; before it, the written `slotId`
came back undefined.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@borisno2

borisno2 commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Both residual findings addressed, plus the base merge (head 821dc651)

Finding 1 — the semantics now lives where a consumer hovers it

types/secured-list.ts's RowLock mapped type carries the pre-lock-columns paragraph, mirroring SecuredQuery.forUpdate in read.ts: the two statements each take their own snapshot under Read Committed, so the row's columns arrive as of before the lock, only the identity is post-lock, the lock is a mutex token rather than protection for the row's own data, and this diverges from a single-statement SELECT … FOR UPDATE, which Postgres re-evaluates after acquiring — with the consequence stated (read a gate's threshold in its own statement after the lock, as its count already is).

That is the docblock tx.db.Slot.…forUpdate() resolves to, so the guidance now reaches the editor rather than only core's own engine view. No changeset edit: mellow-gates-hold.md already states these semantics in full, and nothing about the guidance changed — it was only missing a location.

Finding 2 — the test no longer claims a guard it does not exercise

I kept the guard and fixed the two things that misrepresented it.

I could not write an honest test that exercises it, and I checked rather than assumed. Reaching the drop branch with a lane present needs _rowLock and _transactionOpener set at once, and getContext derives them from the same two inputs on mutually exclusive conditions (rowLockSeat needs _unsafeTransaction; transactionOpenerFor returns undefined when one is present), so no argument combination produces it. Forcing the state from a test would mean either assigning _rowLock on a StackContext, which does not declare it and so needs a cast this repo bans, or hand-building WritePipelineArgs with a fabricated WriteStrategy and WriteCollection — a shape test standing in for the thing it claims to check, which is the failure mode the finding is about.

So it is renamed to what it does check — "the same hook under a write outside a transaction has no lane, and refuses" — with a comment saying plainly that the refusal comes from the lane's absence, not from the guard, and that the guard is defensive. bindContextToTransaction's docblock gained the matching clause: the drop is defence in depth, the two flags are mutually exclusive, deleting the check changes no test.

Both claims are verified, not asserted:

  • The renamed name is load-bearing. Turning lockLane's throw new RowLockUnavailableError(member) into return undefined fails it: - "error": Any<RowLockUnavailableError> / + "keys": ["a"] at lock.test.ts:422 — the hook silently read unlocked instead of refusing.
  • The guard really is dead. Replacing it with a plain _rowLock: context._rowLock and running the whole core suite on PostgreSQL 14: 1634 passed / 37 skipped, byte-for-byte the same as with it, same single unrelated failure.

Base merge — verified rather than assumed

git merge origin/prisma-8 (9026738, #1330) merged with no conflicts. Both sides touch context/index.ts and context/write-pipeline.ts, but in disjoint functions: the base changed serverAction's relationship actions and runWriteInTransaction's new relation-lowering phase, this branch the lane construction and bindContextToTransaction. #1330 itself is confined to filterWritableFields, entirely on the write path.

Nothing here depended on the old behaviour: capacity-gate.test.ts declares its slotId as a plain text() column deliberately (the fixture says so), so it never went through foreignKeyOwners; lock.test.ts's Booking.slot relationship is declared but never written.

The interaction runs the other way, and it is in our favour. A throwaway probe on real Postgres ran this PR's documented gate against a real slot: relationship({ ref: 'Slot.bookings' }), writing slotId directly the way CLAUDE.md and the changeset teach. Post-merge it passes and the row carries the key. Reverting field-access.ts to its pre-merge version and rerunning, it fails at expect(booked?.slotId).toBe(slotId) with undefined — the column was silently dropped. So the example this PR ships only actually worked after #1330 landed. Probe deleted; not committed.

Every changeset on origin/prisma-8 survives the merge byte-identical (blob hashes compared across all 59 entries), and mellow-gates-hold.md survives unchanged and still minor.

Verification

Check Result
pnpm build --force 11/11 successful
pnpm lint 0 errors, 2 pre-existing warnings
prettier --check . clean; manypkg fix no changes
check:prisma-error-codes clean, 17 literals / 5 files — allowlist unchanged
stack-core test:coverage 1665 passed / 6 skipped, exit 0 — no per-file threshold tripped (src/context 94.51% stmt / 96.18% line; write-pipeline.ts 97%)
stack-core on PostgreSQL 14 1634 passed / 37 skipped — only typed-read-surface.test.ts, on CREATE EXTENSION vector (#1336, unrelated)
stack-cli 411 passed
stack-ui / stack-auth / stack-rag 628 / 501 (80 skipped) / 456 passed

Working tree clean after every mutation experiment; each restored file re-verified against the suite.

The base moved again during verification (#1332). Three conflicts, all
purely additive: `_config` and `_rowLock` are separate optional members that
each side appended to `AccessContext`, to `getContext`'s context literal and
to `bindContextToTransaction`'s. Both are kept in each.

`deriveResolveOutputContext` needed nothing — it spreads the context, so it
carries both new members already.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@borisno2

borisno2 commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Follow-up: the base moved again mid-verification (head 6c579155)

prisma-8 advanced to 3138401 (#1332, the RAG plugin-owned-column write) between my merge of 9026738 and the push, so the PR briefly showed CONFLICTING. Merged that too; the PR is MERGEABLE again.

Three conflicts, all purely additive. #1332 adds _config to AccessContext and sets it in getContext's context literal and in bindContextToTransaction's — the same three places this branch adds _rowLock. They are independent optional members, so both are kept in each; no behaviour on either side was dropped or reconciled. deriveResolveOutputContext needed nothing, since it spreads the context and so carries both already.

Re-verified everything on the new merge rather than trusting the earlier run:

Check Result
pnpm build --force 11/11 successful
pnpm lint 0 errors, 2 pre-existing warnings
prettier --check . clean
check:prisma-error-codes clean, 17 literals / 5 files — allowlist unchanged
stack-core test:coverage 1689 passed / 6 skipped, exit 0src/context 95.02% stmt / 96.56% line, no threshold tripped
stack-core on PostgreSQL 14 1658 passed / 37 skipped — only typed-read-surface.test.ts on CREATE EXTENSION vector (#1336, unrelated)
stack-cli 411 passed
stack-rag 470 passed

All 61 changeset entries on origin/prisma-8 survive byte-identical (blob hashes compared against the index), and mellow-gates-hold.md is unchanged at 8f62b5d — the same blob it has on 0b98a47a — still minor for both packages.

borisno2 and others added 2 commits September 8, 2026 12:28
The blog case timed out on CI under coverage at vitest's 5 s default
(#1291). Measured, the case costs ~80 ms uninstrumented and ~260 ms under
coverage, so the failure was scheduling, not work: it runs alongside the
tsc-spawning compile tests in this package, two of which this branch
added. All eight fixtures get 30 s, with assertions unchanged.

Also pins the seam between this branch's row-lock lane and the junction
edge creation that landed in #1329. Both extend the context, both were
merged into it silently, and the transaction rebind lists its members by
hand (#1345). The new test drives both through every build path — plain,
sudo(), withSession(), the transaction rebind and its derived contexts —
and fails if either is dropped on any of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@borisno2

borisno2 commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Merged origin/prisma-8 (6143ea29, #1329) in as a merge commit, fast-forward push, no rebase.

The seam with #1329

Both this branch and #1329 extend getContext in packages/core/src/context/index.ts — this branch adds the row-lock lane, #1329 adds junction edge creation via serverAction's addRelated. Git merged them with no textual conflict, which is exactly the case worth distrusting, so I verified by running code rather than reading it.

New test: packages/core/src/context/row-lock-junction-seam.test.ts. It drives both lanes through every path getContext rebuilds itself on — plain, sudo(), withSession(), the transaction rebind, and the contexts derived inside a transaction (tx.sudo(), tx.withSession(), a nested tx.transaction()).

All paths carry both. No member is dropped.

I checked the test actually has teeth rather than trusting a green result, by breaking the merged code deliberately:

Deliberate break Result
rowLockSeat(client, unsafeTransaction)undefined in the transaction rebind 1 test fails
serverAction replaced with a stub in returned 4 tests fail

The first break is worth calling out: my initial version of the test passed with the lock dropped from the rebind. forUpdate() reaches its lane through the db delegate, which the child getContext builds from its own rowLockSeat call — the lane handed to transactionFace feeds only advisoryLock(). So a version of this test that exercises forUpdate() alone does not cover the rebind's hand-listed lane at all. The committed test asserts advisoryLock() on each derived context for that reason. Relevant to #1345.

The lock still cannot be taken on the wrong connection. PGlite serialises transactions, so the contention cases skip on the default harness. Run against a real Postgres 14 via DATABASE_URL, on the merged tree:

  • a second connection cannot take the row while the lock is held, and can after — passes
  • five racers against a capacity-two slot admit exactly two — passes
  • the same gate without the lock over-admits, which is what the lock is for — passes (the negative control still fails without the lock, so the property is being measured, not assumed)
  • 28 passed, 0 skipped under the escape

#1329's own purpose holds. junction-edge.test.ts passes, and the UI suite it extended is 641/641.

The contract-equivalence timeout

I measured before changing the budget, and the causal story in the brief does not survive measurement — reporting it rather than quietly raising the number.

packages/cli/tests/contract-equivalence.test.ts "blog: identical contract JSON", on the merged tree:

Condition Observed (3 runs)
File alone, no coverage 83 / 80 / 78 ms
Full package suite, --coverage 165 / 258 / 209 ms

Against a 5000 ms default that is a 20–60x gap, so the CI failure was a scheduling stall, not work. Two corrections to the diagnosis:

  1. This branch's types.ts change does not feed this test. The test renders through generator/contract-module.ts, which has no dependency on generator/types.ts — the emitted per-list type and transaction-scoped DB interface never reach it.
  2. The base's Tag/PostTag addition does not feed it either. The blog fixture is blogConfig from packages/core/tests/fixtures/contract-configs.ts, a standalone in-repo fixture, not derived from examples/blog. This test will not get heavier from Adding an edge across a junction is a create of the junction row under its own access #1329.

What this branch did add is packages/cli/src/generator/types-row-lock.test.ts — two tsc-spawning compile tests at { timeout: 300_000 } each. Those are heavy CPU consumers sharing the runner, and the contract-equivalence case is in-process, is first in its file, and pays jiti's cold start on top. That is the plausible mechanism: more tsc subprocesses competing under coverage instrumentation.

So contract generation is not dramatically slower — it is unchanged on this path. Budget set to { timeout: 30_000 } on all eight fixtures: ~115x headroom over the worst measured coverage run, and 10x tighter than the 300 s the tsc-spawning tests in this package already carry. Assertions unchanged, nothing skipped.

Verification on the merged tree

Check Result
pnpm lint 0 errors, 2 pre-existing warnings
pnpm build --force 11/11 tasks
pnpm check:prisma-error-codes 17 literals across 5 allowlisted files — allowlist did not grow
@opensaas/stack-core test:coverage 1718 passed, 6 skipped (90 files) — stmts 92.87%, branches 88.19%
@opensaas/stack-cli test:coverage 411 passed (43 files) — stmts 71.45%, branches 59.34%
@opensaas/stack-ui test 641 passed (57 files)
@opensaas/stack-rag test 470 passed (20 files)

pglite-absence.test.ts (#1291) did not flake in these runs.

Changesets: every changeset on origin/prisma-8 survives byte-identical (file-list diff shows additions only), and .changeset/mellow-gates-hold.md is intact and still minor.

@borisno2
borisno2 merged commit 8af4296 into prisma-8 Sep 8, 2026
6 checks passed
@borisno2
borisno2 deleted the claude/issue-1155-row-lock branch September 8, 2026 02:50
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