Concurrency + expiry
.version() for optimistic locking and expires() for time-limited rows — what each guarantees, on which dialect.
Two column-level tools for questions a schema cannot otherwise answer: which write is newest, and when does this row stop counting.
.version() — optimistic locking
Two clients read the same row and both write it. Without a version the second silently wins, and the first user's change is gone with no trace. That is the shape of every "my edit disappeared" report.
export const documents = table('documents', {
id: id(),
title: text(),
version: integer().version(),
})From then on the store increments version on every update, and an update that carries an expectation fails when the row has moved on:
// The client sends the version it read.
yield* ctx.store.update('documents', input.id, { title: input.title, version: input.version })
// → VersionConflict { expected: 3, actual: 7 } when four writes landed in betweenVersionConflict is a typed error, so it reaches the client typed and a UI can offer reload and re-apply instead of showing a crash. It carries both numbers, because "someone else changed it" is not actionable while "you had 3, it is now 7" is.
Why not updatedAt. A timestamp cannot do this job. Two writes in the same millisecond are indistinguishable, and across replicas the clocks disagree — a comparison that looks correct in a test loses rows under load. An integer the database owns is totally ordered and needs no clock. (.version() therefore rejects a text() or timestamp() column at declaration.)
What it does not do. It is not a history — it records that a row changed, not what to; use plugin-row-history for that. It is not a lock: a conflict is reported, never queued or merged, because merging two intents is a decision only your application can make. And it is not a retry — "re-apply my change on top of theirs" is correct for some changes and wrong for others, so you write it.
Three details worth knowing:
- The version a caller sends is an expectation, never a write. It is stripped from the patch, so a client cannot pin its own version and win every race.
- An update with no expectation is still last-write-wins — the default does not change — but the version still advances. A version that moved only for careful writers would be worse than none: it would sit still while a careless write changed the row.
- A row deleted underneath you is a conflict too, with
actual: null. That is how you tell "deleted" from "changed".
expires() — a row with an end date
export const inviteLinks = table('inviteLinks', {
id: id(),
email: text(),
}).with(expires())
await ctx.store.insert('inviteLinks', {
email,
expiresAt: new Date(Date.now() + 24 * 3_600_000),
})After that instant the row is not returned by reads. expiresAt is nullable and null means never, so adding the mixin to an existing table does not make its rows vanish.
Opt out for a deliberate read — an admin view, a grace-period check:
ctx.store.select('inviteLinks').includeExpired()Read this before you rely on it
Visibility and storage are two different guarantees, and only one of them holds everywhere.
| where | when | |
|---|---|---|
| Invisible to reads | every dialect | immediately, the instant it passes |
| Physically deleted | every dialect | eventually, by the retention sweep |
So an expired row is invisible immediately and still present in the database for a while. That is the right trade — making visibility depend on the sweep would mean a row that vanished on postgres and kept serving on MariaDB — but it matters: do not treat an expired row as unreachable. If the value must actually be gone, delete it, or do not store it in a row at all.
The second row of that table used to say postgres only, and it was accurate: the sweep was registered behind a dialect !== 'postgres' early return, so on mariadb, mysql, mssql and sqlite nothing was ever deleted and the boot printed no armed-policies line to say so. What is postgres-specific is the fast set-based DELETE, not the policy — the sweep falls back to a bounded read plus one set-based delete per batch elsewhere. Fixed in 0.33.0; on those dialects the first boot after upgrading will have a backlog to work through.