Optimistic Locking & Concurrency
When two users edit the same record at the same time, the last writer normally wins — silently overwriting the first writer's changes. This is the lost update problem, and it is the single biggest correctness risk in any collaborative editing system.
Primebrick DAL solves it with optimistic concurrency control (optimistic
locking): every write carries a version number, and the database rejects the
write if the row's current version no longer matches the version the client
read. No locks are held during the user's "thinking time"; the check happens
atomically at write time inside a single SQL statement.
The losing writer receives a 409 Conflict and must re-read, re-merge, and retry. The winning writer is never blocked. This is the foundation that field-level collaboration and visual merge build on top of.
Automatic for @AuditTrail entities — zero per-entity configuration
Optimistic locking is on by default for every entity decorated with
@AuditTrail(). There is no per-entity @OptimisticLock() flag, no
enableVersioning: true option, and no migration to opt in. If the entity has
an audit trail, it has a version column and the version guard is enforced on
every write.
Code
The DAL detects the @AuditableField(AuditableFieldType.VERSION) column from
entity metadata at runtime — there is no separate "is this entity
optimistically locked?" flag. If the version column exists, the guard is
applied; if it doesn't, writes pass through unguarded (legacy / non-auditable
entities keep working unchanged).
The version column
Every auditable entity gets an integer version column:
| Property | Value |
|---|---|
| PG type | integer |
| Default | 1 (applied on INSERT) |
| Increment | + 1 on every write (update, upsert ON CONFLICT, delete, restore, hardDelete) |
| Read-only | clients must send the version they read, but never compute it themselves |
The increment is emitted as a SET clause in the same UPDATE statement —
version = version + 1 — so it is atomic with the version guard. There is no
window where the version has been bumped but the guard has not yet run.
How the intrinsic optimistic lock works
The version guard is built into the four mutating write operations. In every
case the guard is a single WHERE ... AND version = $expected clause appended
to the same statement that performs the write — no second round-trip, no
advisory locks, no SELECT ... FOR UPDATE.
| Operation | Version guard | On zero rows |
|---|---|---|
update() | WHERE match = $match AND version = $expected, SET version = version + 1 | disambiguate → ERR01 or ERR03 |
delete() (soft) | WHERE match = $match AND version = $expected, SET version = version + 1 | disambiguate → ERR01 or ERR03 |
restore() | WHERE match = $match AND version = $expected, SET version = version + 1 | disambiguate → ERR01 or ERR03 |
hardDelete() | WHERE match = $match AND version = $expected | disambiguate → ERR01 or ERR03 |
updateMany() / deleteMany() / restoreMany() | temp-table expected_version column joined as target.version = tmp.expected_version per row | one pg_raise listing ≤10 offenders → ERR01 (stale) / ERR03 (all vanished); missing version → ERR02; whole tx rolls back |
The single-row upsert() was removed; upsertMany() is parked (commented
out) pending a guarded-vs-sync semantics decision — its conflict branch used
to overwrite rows unconditionally.
Guard flow
The guard, the version increment, and the write all happen in a single SQL
statement — there is no window where the version has been bumped but the guard
has not yet run. The disambiguation SELECT runs only on the error path (the
rare case), so the extra round-trip is acceptable.
Disambiguation: ERR01 vs ERR03
When a guarded write matches zero rows, the DAL cannot tell from the row count alone whether:
- the row exists but the version doesn't match (a real concurrency
violation →
ERR01), or - the row was hard-deleted by another writer between the client's read and
write (the record is simply gone →
ERR03).
To distinguish the two, the DAL runs a single disambiguation SELECT 1 FROM t WHERE <all identity conditions> LIMIT 1 on the error path only (the rare
case, so the extra round-trip is acceptable):
- 0 rows → the full identity matches nothing. If a row exists under ANY
SINGLE supplied identity field (e.g. correct
uuid+ wrongid), the identity is incoherent →IdentityConflictError(ERR10); otherwise the row is gone →RecordVanishedError(ERR03). - 1 row → the row exists but the version didn't match → execute
RAISE EXCEPTION ... USING ERRCODE = 'ERR01'so the error surfaces with the stable SQLSTATE code.
Error codes
The DAL defines stable error codes for optimistic concurrency control and
bulk write failures. They are shared between PostgreSQL (as SQLSTATE values
via RAISE EXCEPTION / pg_raise) and TypeScript (as DalError.code
values), so consumers can branch on the string literal regardless of where
the error originated.
| Code | Meaning | Origin | HTTP status |
|---|---|---|---|
ERR01 | Concurrency violation — the row exists but version does not match | PostgreSQL (RAISE EXCEPTION/pg_raise) | 409 Conflict |
ERR02 | Missing version field on an auditable-entity write | TypeScript (MissingVersionError) | 400 Bad Request |
ERR03 | Record vanished — the row was hard-deleted between read and write | TypeScript (RecordVanishedError) / pg_raise in bulk | 404 Not Found |
ERR04 | Unique-constraint conflict against a live row | PostgreSQL (pg_raise, add/addMany/update conflict CTE) | 409 Conflict |
ERR05 | Unique conflict where every conflicting row is soft-deleted — restore instead | PostgreSQL (pg_raise) | 409 Conflict |
ERR06 | Bulk operation exceeded its wall-clock budget (BulkOptions.timeoutMs / DalConfig.bulkTimeoutMs) — whole tx rolled back | TypeScript (BulkTimeoutError) | 408 Request Timeout |
ERR07 | PG 57014 query_canceled — a statement was killed by statement_timeout | PostgreSQL 57014 → logical ERR07 at the HTTP boundary | 500 (typed) |
ERR08 | Unique violation NOT identifiable via @Unique metadata — a raw 23505 from a manual, deferred, or partial constraint. Signals a missing @Unique declaration | PostgreSQL 23505 → logical ERR08 at the HTTP boundary | 409 Conflict |
ERR09 | Illegal match selector — matchBy prop not @Unique/@Key, composite group incomplete, or no identity field in the payload | TypeScript (MatchSelectorError, pre-SQL) | 422 Unprocessable Content |
ERR10 | Identity incoherence — supplied identity fields (e.g. id+uuid) do not converge on one row, or the match resolved to >1 rows (guard abort, nothing written) | TypeScript (IdentityConflictError) / pg_raise guard CTE | 412 Precondition Failed |
Bulk raises carry a jsonb detail — { entity, table, stale|missing|conflicts, rows[≤10] } — which the HTTP boundary surfaces as extra.issues for the
frontend error dialog. The pg_raise function must exist on every target
database (init SQL / setupTestSchema).
The ERR + 2 digits convention places the codes outside PostgreSQL's
SQL-standard SQLSTATE classes (00–99), so PostgreSQL accepts them as
custom codes without colliding with built-in error classes.
How PostgreSQL raises ERR01
When the disambiguation step confirms the row exists but the version doesn't match, the DAL executes:
Code
PostgreSQL propagates this through node-postgres as a DatabaseError whose
code property is the string "ERR01". The DAL does not catch and
re-throw this as a TS class in the happy/conflict path — the PG-originated
error reaches the consumer directly, carrying the stable code. The
OptimisticLockError TS class exists only for ergonomic instanceof
normalization at a consumer boundary (see below).
DalError and the error classes
All DAL errors extend the abstract DalError class, which adds a stable
code: string property to the standard Error. The DAL itself never imports
HTTP or NATS types — it is framework-agnostic. Consumers map the code to the
appropriate boundary response at their own layer.
Code
The three optimistic-locking error classes:
Code
Typical update flow with version
The client reads a record (which includes its current version), lets the
user edit, then sends the updated fields plus the original version back
to the DAL. The DAL strips version out of the SET clause, uses it in the
WHERE guard, and bumps it by 1.
Code
If the caller forgets to send version on an auditable entity, the DAL throws
MissingVersionError (ERR02) before any SQL is issued — a 400 Bad Request,
not a silent unguarded write.
Code
The 409 conflict scenario
When two writers race, the second writer's WHERE version = $expected matches
zero rows. The DAL disambiguates, confirms the row still exists, and raises
ERR01 from PostgreSQL. The consumer sees a DatabaseError with
code === "ERR01".
Code
A consumer that wants uniform instanceof handling can normalize the
PG-originated error into OptimisticLockError at its boundary:
Code
Mapping errors to HTTP status codes
The DAL is framework-agnostic and never imports HTTP types. The backend (BE)
maps the stable code values to HTTP status codes at the controller boundary:
err.code | Error class | HTTP status | Meaning |
|---|---|---|---|
ERR01 | OptimisticLockError (PG-originated) | 409 Conflict | Version mismatch — another writer got there first |
ERR02 | MissingVersionError (TS-originated) | 400 Bad Request | Client forgot to send version on an auditable write |
ERR03 | RecordVanishedError (TS-originated) | 404 Not Found | The row was hard-deleted between read and write |
Code
The 409 response typically includes the current version of the record so the frontend can re-render the merge UI without an extra round-trip.
Retry pattern
When a writer receives ERR01 (409), the standard recovery is: re-read the
current row, re-apply the user's edits on top of it, and retry the write with
the new version. A bounded retry loop prevents infinite loops under sustained
contention.
Code
The applyEdits function is a pure function of the current row — this is what
makes the retry safe. If the row changed between attempts, the edit is
re-applied on top of the new state, not the stale state.
Bulk operations
The version guard applies per-row inside bulk operations too:
| Operation | Version guard behavior |
|---|---|
addMany | No guard — pure INSERT, all rows start at version = 1 |
upsertMany | Guard applies on the ON CONFLICT (update) branch only; the pure-INSERT branch starts at version = 1 |
updateMany | Guard applies per row — each row in the batch must include its version, and the TEMP TABLE UPDATE emits WHERE match = $match AND version = $expected per row |
deleteMany | Guard applies per row — each soft-delete emits WHERE match = $match AND version = $expected |
Code
If partial success is required (some rows succeed, some conflict), split the
batch into individual update calls and catch ERR01 per row — the TEMP TABLE
strategy is all-or-nothing by design (atomicity is the point).
Express error-handling example
A complete Express middleware that maps the three optimistic-locking codes to HTTP responses, including the 409-with-current-record pattern:
Code
Next steps
- Audit trail — the
@AuditTrail()decorator that turns the version column on, and the audit log that records every version bump. - Repository — the
update/upsert/delete/restore/hardDeletesignatures. - Architecture — the error-handling philosophy and stable code design.
- Connections & transactions — how
statement_timeoutinteracts with the guard.