Skip to content
54 changes: 54 additions & 0 deletions .changeset/mellow-gates-hold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
'@opensaas/stack-core': minor
'@opensaas/stack-cli': minor
---

Add the row lock and the advisory lock to the transaction-bound context

A capacity gate is the case a stricter isolation level used to cover, and `context.transaction(fn)` no longer takes one. `.forUpdate()` is the replacement: a row lock on the contended parent, taken **before** the count, so every racer takes the same token on the same row and the count cannot go stale under a booking a racer that got there first has already committed.

```typescript
const result = await context.transaction(async (tx) => {
// The lock comes BEFORE both reads below. `null` here is denied-or-gone —
// either way there is no gate to run.
const held = await tx.db.Slot.where({ id: { equals: slotId } })
.forUpdate()
.first()
if (held === null) return { booked: false }

// BOTH sides of the gate are read after the lock, each in its own statement.
// `held`'s own columns are the row as of BEFORE the lock was granted, so
// `held.capacity` can be stale; this re-read cannot be, 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 }

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

**The lock is a mutex on the row, not a fresh read of it.** Each of the two statements takes its own snapshot under Read Committed, so a column another transaction committed between the read and the lock reaches the caller as its **pre-lock** value — only the row's identity is post-lock, and this differs from a single-statement `SELECT … FOR UPDATE`, which Postgres re-evaluates after acquiring. Read whatever the gate compares against in its own statement after the lock, the way the count above is.

`.forUpdate()` is on the transaction-bound builder and nowhere else. A lock taken outside a transaction is released at the end of the statement that took it, so it would compile, run, return rows and guard nothing — `context.db.Slot.forUpdate()` is a compile error rather than a throw. The generated bundle names two faces per list for it, `SlotList` and `SlotTxList`, and `TransactionContext` is the context over the locking one.

The terminal runs two statements. The scoped read goes first and resolves operation access, the Access Filter and Field Visibility exactly as any read does; the engine then composes `SELECT <pk> FROM <table> WHERE <pk> IN ($1…$n) ORDER BY <pk> LIMIT $n+1 FOR UPDATE` over the keys it returned and runs it on the transaction's own connection. So the locked set is provably a subset of the readable set, and **a terminal never returns a row it did not lock**: a row deleted between the two statements locks nothing, `first()` yields `null` and `all()` the surviving subset. `null` now means denied-or-vanished.

`first()` and `all()` carry the modifier. `aggregate()` and `nearest()` refuse it rather than drop it — an aggregate returns no primary keys to lock, and a ranking is not a gate. There is no `forShare`, no `NOWAIT` and no `SKIP LOCKED`: a skipped locked row would be indistinguishable from an access-denied one, which would make silent failure mean two things at once. The engine always emits `ORDER BY <pk>`, so acquisition order is the same in every session.

A list whose table has no single-column primary key cannot be locked (`RowLockIdentityError`), and one terminal binds at most `ROW_LOCK_MAX_KEYS` keys (`RowLockKeyLimitExceededError`) — a cost limit, fail-closed, well under Postgres's bind-parameter ceiling, where the failure is a corrupted bind rather than a clean refusal.

`tx.advisoryLock(key)` joins it on the transaction context, for an invariant that is not a row:

```typescript
await context.transaction(async (tx) => {
await tx.advisoryLock(`checkout:${cartId}`)
// …
})
```

It runs `pg_advisory_xact_lock(hashtext($1))` and releases when the transaction ends, whichever way it ends. It sits on the context rather than on `db` because it locks a number and belongs to no list. `hashtext` is 32-bit, so two distinct keys can collide — a collision costs spurious serialisation, never a missed lock.

See ADR-0047 and ADR-0062.
30 changes: 22 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -688,17 +688,31 @@ const myPosts = await context.db.Post.where({ authorId: 'user-123' }).all()

**Interactive transactions:** Use `context.transaction(async (txContext) => { … })` to run several access-checked, hook-firing `context.db.*` operations atomically in one transaction. Unlike a raw transaction on the unsafe surface (which bypasses access control and hooks), `txContext.db.*` keeps the security/validation boundary. The transaction takes no options — there is no isolation level to select, so a concurrency-sensitive invariant (e.g. a capacity gate) is expressed as a **row lock** on the contended parent: `.forUpdate()`, available only on a transaction-bound builder. See ADR-0012, ADR-0042 and ADR-0047, and `packages/core/CLAUDE.md`.

The lock is a **mutex on the row, not a fresh read of it**: the terminal reads
first and locks second, so the columns it hands back are the row as of _before_
the lock. Everything the gate compares — the threshold as much as the count — is
therefore read in its own statement _after_ the lock.

```typescript
const result = await context.transaction(async (tx) => {
// Take the lock BEFORE counting. Every racer takes the same lock on the same
// parent row, so the count below cannot go stale under a concurrent booking.
// Take the lock BEFORE reading either side of the gate. Every racer takes the
// same lock on the same parent row, so the reads below cannot go stale under
// a concurrent booking.
// `null` here means denied or gone — either way there is no gate to run.
const slot = await tx.db.Slot.where({ id: slotId }).forUpdate().first()
if (!slot) return { booked: false }

const count = await tx.db.Booking.where({ slotId }).aggregate({ count: true })
if (count >= capacity) return { booked: false }
return { booked: true, item: await tx.db.Booking.create({ slotId }) }
const held = await tx.db.Slot.where({ id: { equals: slotId } })
.forUpdate()
.first()
if (held === null) return { booked: false }

// `held.capacity` is the value as of before the lock; this re-read is not,
// because no one else can commit an update to a row we hold.
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 }

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

Expand Down
118 changes: 118 additions & 0 deletions packages/cli/src/generator/types-row-lock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { OpenSaasConfig } from '@opensaas/stack-core'
import { checkbox, relationship, text } from '@opensaas/stack-core/fields'
import {
CONSUMER_PRELUDE,
emitTypeFixture,
type TypeFixture,
} from '../../tests/emit-type-fixture.js'

/**
* The row lock on the surface a generated project actually gets (ADR-0047).
*
* `forUpdate()` outside a transaction has to be a compile error rather than a
* throw — a lock taken outside one is released at the end of the statement
* that took it, so it would compile, run, return rows and guard nothing. That
* is a claim about the emitted bundle, not about core's internals, so it is
* checked here: `.opensaas/types.ts` names two faces per list and the
* transaction context is the one over the locking face.
*/

const config: OpenSaasConfig = {
db: { provider: 'postgresql', timestamps: true },
lists: {
User: {
fields: { name: text({ validation: { isRequired: true } }) },
},
Post: {
fields: {
title: text({ validation: { isRequired: true } }),
published: checkbox({ defaultValue: false }),
author: relationship({ ref: 'User' }),
},
},
Settings: {
isSingleton: true,
fields: { siteName: text({ validation: { isRequired: true } }) },
},
},
}

describe('the row lock over the emitted contract', () => {
let fixture: TypeFixture

beforeAll(async () => {
fixture = await emitTypeFixture('row-lock', config)
}, 300_000)

afterAll(() => {
fixture?.cleanup()
})

it('carries forUpdate() inside a transaction and nowhere else', { timeout: 300_000 }, () => {
const output = fixture.check(`${CONSUMER_PRELUDE}
import type { Context } from './.opensaas/types.ts'

declare const context: Context
declare const slotId: string

async function run() {
await context.transaction(async (tx) => {
// The documented gate: lock the contended parent, then count.
const post = await tx.db.Post.where({ id: { equals: slotId } }).forUpdate().first()
assertType<Exact<NonNullable<typeof post>['title'], string>>()

// The modifier composes in any position and keeps the row type.
const many = await tx.db.Post.forUpdate().where({ published: { equals: true } }).all()
assertType<Exact<(typeof many)[number]['title'], string>>()

// It survives a projection, which the row type still honours exactly.
const projected = await tx.db.Post.select('title').forUpdate().all()
assertType<Exact<keyof (typeof projected)[number], 'id' | 'createdAt' | 'updatedAt' | 'title'>>()

await tx.advisoryLock('checkout:' + slotId)
})

// @ts-expect-error a lock outside a transaction guards nothing, so it is not on this surface
await context.db.Post.where({ id: { equals: slotId } }).forUpdate().first()

// @ts-expect-error nor after any other composition
await context.db.Post.select('title').forUpdate().all()

// @ts-expect-error advisoryLock is the transaction context's seat alone
await context.advisoryLock('checkout')
}

void run
`)

expect(output).toBe('')
})

it('keeps the lock off a singleton and off the reducing terminals', { timeout: 300_000 }, () => {
const output = fixture.check(`${CONSUMER_PRELUDE}
import type { Context } from './.opensaas/types.ts'

declare const context: Context

async function run() {
await context.transaction(async (tx) => {
// @ts-expect-error a singleton carries no composed read, so no lock either
tx.db.Settings.forUpdate

// The transaction context is still a full context: sudo and withSession
// answer in the same face, so a lock survives either.
await tx.sudo().db.Post.forUpdate().all()
await tx.withSession({ userId: 'u1' }).db.Post.forUpdate().all()

// A nested transaction joins this one and hands back the same face.
await tx.transaction(async (inner) => inner.db.Post.forUpdate().all())
})
}

void run
`)

expect(output).toBe('')
})
})
14 changes: 14 additions & 0 deletions packages/cli/src/generator/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ describe('the generated types file', () => {
expect(types).toContain(
`export interface ${name}List extends Stack$SecuredList<Stack$Contract, Remainder, '${name}'>`,
)
// The transaction-bound face, whose reads carry `forUpdate()` (ADR-0047).
expect(types).toContain(
`export interface ${name}TxList extends Stack$SecuredList<Stack$Contract, Remainder, '${name}', true>`,
)
}
expect(types).toContain('export interface Context<TSession extends Stack$Session')
expect(types).toContain('export interface BaseContext<TSession extends Stack$Session')
Expand All @@ -127,6 +131,16 @@ describe('the generated types file', () => {

it('keys the db surface by each list’s PascalCase list name', () => {
expect(types).toContain('export interface DB {\n User: UserList\n Post: PostList')
expect(types).toContain('export interface TxDB {\n User: UserTxList\n Post: PostTxList')
})

it('binds the transaction context to the locking surface', () => {
expect(types).toContain(
'extends Stack$StackContext<DB, TSession, Stack$PluginServices, TxDB> {}',
)
expect(types).toContain(
'extends Stack$StackTransactionContext<TxDB, TSession, Stack$PluginServices, TxDB> {}',
)
})

it('writes no per-list args, payload, select, include or where type', () => {
Expand Down
34 changes: 28 additions & 6 deletions packages/cli/src/generator/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,14 +141,21 @@ function imported(name: string): string {
}

/** Every name the module declares that is not derived from a list name. */
const MODULE_NAMES = ['Remainder', 'DB', 'BaseContext', 'Context', 'TransactionContext'] as const
const MODULE_NAMES = [
'Remainder',
'DB',
'TxDB',
'BaseContext',
'Context',
'TransactionContext',
] as const

/**
* The suffixes a list name is decorated with. Two distinct list names can still
* land on one binding — `Post` and `PostList` both want `PostList` — which is
* what {@link claimNames} refuses.
*/
const LIST_SUFFIXES = ['', 'StoredRow', 'CreateInput', 'UpdateInput', 'List'] as const
const LIST_SUFFIXES = ['', 'StoredRow', 'CreateInput', 'UpdateInput', 'List', 'TxList'] as const

/**
* Refuse a config whose lists and computed fields would have the module declare
Expand Down Expand Up @@ -195,19 +202,33 @@ function generateListInterfaces(listName: string): string {
`export interface ${listName}CreateInput extends ${imported('CreateInput')}${key} {}`,
`export interface ${listName}UpdateInput extends ${imported('UpdateInput')}${key} {}`,
`export interface ${listName}List extends ${imported('SecuredList')}${key} {}`,
// The transaction-bound face of the same list: `forUpdate()` and nothing
// else (ADR-0047).
`export interface ${listName}TxList extends ${imported('SecuredList')}<${imported('Contract')}, Remainder, '${listName}', true> {}`,
].join('\n')
}

function generateDbType(config: OpenSaasConfig): string {
const listNames = Object.keys(config.lists)
const lines: string[] = []
lines.push('/**')
lines.push(' * The access-controlled `db` surface, one member per list.')
lines.push(' */')
lines.push('export interface DB {')
for (const listName of Object.keys(config.lists)) {
for (const listName of listNames) {
lines.push(` ${listName}: ${listName}List`)
}
lines.push('}')
lines.push('')
lines.push('/**')
lines.push(' * The same surface inside `context.transaction()`, where a read can take a')
lines.push(' * row lock. `forUpdate()` is the only difference between the two.')
lines.push(' */')
lines.push('export interface TxDB {')
for (const listName of listNames) {
lines.push(` ${listName}: ${listName}TxList`)
}
lines.push('}')
return lines.join('\n')
}

Expand All @@ -227,13 +248,14 @@ export interface BaseContext<TSession extends ${session} = ${session}>
* \`BaseContext\` carries plus \`sudo()\`, \`withSession()\` and \`transaction()\`.
*/
export interface Context<TSession extends ${session} = ${session}>
extends ${imported('StackContext')}<DB, TSession, ${services}> {}
extends ${imported('StackContext')}<DB, TSession, ${services}, TxDB> {}

/**
* The context inside \`context.transaction()\`.
* The context inside \`context.transaction()\`: the same surface over \`TxDB\`,
* whose reads carry \`forUpdate()\`, plus \`advisoryLock()\`.
*/
export interface TransactionContext<TSession extends ${session} = ${session}>
extends ${imported('StackTransactionContext')}<DB, TSession, ${services}> {}`
extends ${imported('StackTransactionContext')}<TxDB, TSession, ${services}, TxDB> {}`
}

/**
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/tests/contract-equivalence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,15 @@ const fixtures: { name: string; config: OpenSaasConfig; packs?: PrismaContractPa

describe('renderContractModule — the rendered module and the in-process derivation agree', () => {
for (const { name, config, packs } of fixtures) {
test(`${name}: identical contract JSON`, async () => {
// Each case transpiles and evaluates the rendered module through jiti, and
// the first to run pays jiti's cold start on top. That is a toolchain cost
// this file does not control, and it runs alongside the `tsc`-spawning
// compile tests in this package under a coverage-instrumented CI runner,
// where it has been observed to stall past vitest's 5 s default (#1291).
// Locally it costs ~80 ms uninstrumented and ~260 ms under coverage, so
// this budget is orders of magnitude of headroom for scheduling while
// still failing well short of the 300 s the tsc tests take.
test(`${name}: identical contract JSON`, { timeout: 30_000 }, async () => {
const data = deriveContract(config)
const source = renderContractModule(data)

Expand Down
Loading
Loading