Optimistic Locking

A row that two callers update concurrently can lose one write — both read balance = 100, both compute balance + 10, both write balance = 110, and the bank is short ten dollars. Optimistic locking detects this at the database boundary and rejects the second write with 412 Precondition Failed, leaving the row untouched.

Schema attribute

Add @version to one Int field per model:
model Ledger {
  id Int @id
  label String
  balance Int
  version Int @version
}
Constraints enforced at parse time:
  1. exactly one @version field per model
  2. type must be required Int
  3. cannot also be the primary key
The framework reads the column as i64 at runtime.

Update flow

Every successful update emits version = version + 1 in the same SQL statement that writes the new state. The generated REST router:
  1. returns ETag: "<version>" on GET /resource/<id> and on the response of any successful mutation
  2. requires If-Match: "<version>" on PATCH /resource/<id> and on DELETE for soft-delete models
  3. responds 412 Precondition Failed when If-Match is missing
  4. responds 412 Precondition Failed when the supplied version is stale
  5. distinguishes “stale version” from “row not found” by probing the read policy after the update fails
PATCH /ledgers/3 HTTP/1.1
If-Match: "0"
Content-Type: application/json

{"balance": 42}

HTTP/1.1 200 OK
ETag: "1"
Content-Type: application/json

{"id": 3, "balance": 42, "version": 1}

Programmatic use

Internal Rust callers thread the expected version through if_match:
let updated = cool
    .ledger()
    .update(3)
    .set(UpdateLedgerInput { balance: Some(42), ..Default::default() })
    .if_match(0)
    .run(&ctx)
    .await?;
Omitting if_match on a versioned model returns CoolError::PreconditionFailed before any SQL runs. Banks treat the version check as a contract, not a hint — there is no “force update” escape hatch on the generated path.

Input filtering

@version is excluded from both Create<Model>Input and Update<Model>Input:
  1. clients cannot seed the initial version on create — the framework sets it to 0
  2. clients cannot replay or skip a version through a PATCH body — the column is bumped server-side
If a future change re-added the field, the generated Rust input struct would no longer compile against existing call sites — that’s the primary line of defence.

Interaction with @@soft_delete

Soft-delete tombstones bump the version column too, so callers that re-read after a delete observe a fresh ETag. Live updates against a tombstoned row match zero rows and return the same “precondition failed” shape.

When to use it

Add @version to:
  1. balances, ledger entries, transfers, holds, reservations
  2. any row a workflow reads, decides on, then writes back
  3. any row a webhook can update concurrently with a user-facing flow
Skip it for:
  1. append-only event tables
  2. rows updated by exactly one writer (lookup tables, configuration)
  3. denormalised counters that already use SQL-level atomic increments
  1. Idempotency — protects against duplicate execution; complements lost-update protection
  2. Transaction isolation — closes the read-write skew window inside the same transaction
  3. Field attributes — full list of supported field attributes