Vivek Jalondhara. All writing

Backend

Reject a Stale CRM Edit with One Conditional Update

By Vivek Jalondhara · 5 min read
Published

Use a record revision to detect stale CRM edits atomically, then help the user resolve the conflict without losing their draft.

Two people open the same CRM record. One corrects its title while the other is still editing the older copy. If both saves simply replace the stored title, the second request erases the first correction without explaining that anything happened.

This educational SmackDab scenario focuses on one record update. It develops the concurrency boundary beyond a general frontend/backend architecture discussion, without claiming that the private CRM uses this exact database schema.

Send back the revision the editor actually saw

Return a revision alongside the editable record and preserve it with the draft. The save request contains the intended change and that original revision. Do not silently replace the draft’s revision when a background refresh arrives: doing so would claim the user edited information they never reviewed.

A simple integer revision works when every relevant write increments it. Define what relevant means. If changing a hidden internal field should not conflict with a title edit, decide whether the record needs a narrower revision boundary. Starting with one revision is easier to explain, but can produce conflicts between otherwise independent changes.

Put the comparison inside the write

The SQL example combines identity, tenant, and expected revision in the UPDATE predicate, then increments the revision in the same statement. A preliminary SELECT followed by an unconditional UPDATE does not provide this protection; another request can change the record between those statements.

For this simple row update under PostgreSQL Read Committed, a concurrent updater waits for the first updater and rechecks the condition against the updated row. After the first save increments the revision, the second request no longer matches. More restrictive isolation levels can instead raise serialization errors, which need a separate documented handling policy.

Interpret zero changed rows carefully

One returned row means the conditional write succeeded. Zero rows does not prove a conflict: the record may have been deleted, the tenant may differ, or the expected revision may be stale. Do not turn every miss into a response that confirms another tenant’s record exists.

Resolve the outcome through the application’s authorised lookup rules. Where the caller can still read the record, offer a conflict response containing permitted current fields and their revision. Otherwise preserve the existing not-found or access-denied policy. The returned shape should reveal only what the user is allowed to see now.

Keep validation and permission checks in the operation

A revision is a concurrency token, not a permission token. Validate the proposed title, restrict the fields this endpoint can change, and enforce the record’s edit policy on the server. Tenant matching alone does not establish that a particular user can edit every record in that tenant.

If permission changes must be coordinated atomically with the edit, include the necessary policy predicates or transaction locking in the write path. A permission decision cached indefinitely before the update cannot provide that guarantee. The short example deliberately isolates stale-write detection; it does not stand in for the application’s authorisation design.

Give the conflict a usable interface

Keep the user’s unsaved title visible alongside the permitted current value. Let them discard their draft or make a deliberate new edit based on the latest revision. Automatically retrying the old title with the new revision defeats the safeguard by converting a rejected overwrite into an accepted one.

A lost response creates another ambiguity: the first save may have succeeded even though the client still holds the old revision. An explicit request-replay contract can identify that attempt. Without one, compare the authorised current state and explain uncertainty; do not invent certainty by repeatedly sending fresh revision numbers.

Verify the competing saves

Use two database connections in a real integration test. Both load the same revision, then attempt distinct titles. Exactly one should change the row for that revision, and the other must preserve its draft. Also test deletion, an unrelated tenant, a denied field, and a writer that bypasses the usual endpoint.

Keep revision serialization exact across the API. If the database uses bigint, avoid a JavaScript Number conversion beyond its safe integer range. The mechanism stays small only when all writers follow its contract. Document the invariant beside imports and administrative tools, because one unversioned write can quietly undermine every careful editor.

Change the title only if the viewed revision still matches

Educational SQL for crm_records with a unique tenant/id pair and a non-null revision. Inputs are bound parameters. Enforce record and field authorisation in the protected write path before treating this as an endpoint.

Illustrative example · SQL · Simplified for explanation

UPDATE crm_records
SET title = $4,
    revision = revision + 1
WHERE tenant_id = $1::uuid
  AND id = $2::uuid
  AND revision = $3::bigint
RETURNING id, title, revision;

-- One row: the versioned change was accepted.
-- Zero rows: resolve stale/deleted/unauthorized via safe lookup.
-- All writers affecting this revision must increment it.
How the example fits together
  1. Load the permitted record and revision
  2. Keep the revision with the draft
  3. Validate and conditionally update
  4. Return success or a recoverable conflict

Conceptual flow for the example above.

Tradeoff to keep in mind

This prevents a lost update on the guarded record. It does not provide request idempotency, field authorisation, or atomic consistency across unrelated records.

Related project: SmackDab CRM →

References & further reading

Related posts