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 |
upsert() (ON CONFLICT path) | SET version = version + 1 only when the existing row's version = $expected | 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 |
For upsert(), the version guard only applies on the ON CONFLICT (update)
branch. The pure-INSERT branch (no existing row) starts at version = 1 and
needs no guard — there is nothing to conflict with.
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 match = $match LIMIT 1 on the error path only (the rare case, so the
extra round-trip is acceptable):
- 0 rows → the row is gone → throw
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 three stable error codes for optimistic concurrency control.
They are shared between PostgreSQL (as SQLSTATE values via RAISE EXCEPTION)
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 ... USING ERRCODE = 'ERR01') | 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) | 404 Not Found |
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.