ETag / Optimistic Concurrency Contracts
These guarantees apply to resources configured with etag in useResource(table, { etag: {...} }). Without the config, no ETag headers are emitted and conditional headers are ignored.
Guarantees
ETag Emission
- Mutating and single-item responses carry ETags:
POST /,GET /:id,PATCH /:id, andPUT /:idresponses include anETagheader reflecting the returned representation - Tag derivation precedence:
versionField(if set and present on the item) →updatedAtField+idField(timestamp-id pair) → MD5 hash of the serialized item - Weak by default: Tags are weak (
W/"...") unlessalgorithm: "strong"is configured - Deterministic: The same item state always produces the same ETag
Conditional Writes (If-Match)
- Enforcement on mutation:
If-Matchis checked against the current stored item onPATCH /:id,PUT /:id, andDELETE /:idbefore the mutation executes - 412 on mismatch: A non-matching
If-Matchfails with412 Precondition Failed(PRECONDITION_FAILED, RFC 7807 body includingcurrentETagin details) and the mutation is not applied - Compare-and-swap: When
If-Matchis present, thePATCH/PUT/DELETEstatement carries a CAS predicate on the version/updated-at field, so the validated version must still match at write time. If a concurrent writer changed the row between the read and the write, zero rows match and the request fails with412instead of silently losing the update — exactly one of N concurrentIf-Matchwriters wins, the rest get412 - Wildcard:
If-Match: *matches any current state (the write proceeds if the item exists) - Multiple tags:
If-Matchmay contain a comma-separated list; the check passes if any tag matches - Strong comparison (RFC 7232 §3.1):
If-Matchuses the strong comparison function - No header, no check: Requests without
If-Matchare unconditional (last write wins, no CAS predicate)
Conditional Reads (If-None-Match)
- 304 on match:
GET /:idwith a matchingIf-None-Matchreturns304 Not Modifiedwith an empty body and the currentETagheader - Fresh data otherwise: A non-matching tag returns
200with the full representation and currentETag
Version Auto-Increment (Optimistic Locking)
- Increment on every update: When
versionFieldis configured, the field is incremented by 1 on everyPATCH /:idandPUT /:id, starting from the current stored value (missing/non-numeric values are left untouched) - Client override: If the request body explicitly sets the version field, that value is used instead of auto-increment
- Lost-update protection: Two clients that read version N and both write with
If-Match— the CAS predicate guarantees exactly one write lands; the other matches zero rows and receives 412 and must refetch
Non-Guarantees
- ❌ List ETags:
GET /(list) responses do not carry per-item ETags - ❌ Batch conditional writes:
If-Matchis not enforced on/batchoperations, and batch updates do not auto-increment the version field - ❌ CAS without a comparison field: The compare-and-swap predicate requires a
versionFieldorupdatedAtField. With neither configured (hash-only ETags), the If-Match check is still enforced before the write but is not atomic with it, so an interleaving writer could theoretically slip between check and write - ❌ Hash stability across versions: The fallback (hash-based) tag format may change between minor versions; treat ETags as opaque
- ❌ Strong validator semantics: Weak tags (default) do not guarantee byte-for-byte identity of representations
Failure Modes
If-Match Mismatch
- Returns
412with problem details:code: "PRECONDITION_FAILED",details.currentETagset to the item's current tag, and a suggestion to refetch - The stored item is unchanged
Malformed ETag in Header
- Tags that are not
"value"orW/"value"never match → conditional writes fail with 412, conditional reads return 200
Item Not Found
- Conditional requests against a missing id return
404(the conditional check is not reached)
Test Coverage
tests/concurrency/etag-race.test.ts- ETag emission, If-Match enforcement,*wildcard, concurrent optimistic locking, delete