Guide

Optimistic concurrency with `_rev`

Brainy 7.31.0 adds a per-entity revision counter so multiple writers can coordinate without a global lock or external coordinator. The pattern is the same one CouchDB, PouchDB, and ETag-based HTTP caches use: read the current revision, do your work, write back with ifRev: <whatRevYouSaw>. If the revision moved, your write is rejected and you retry against the latest state.

What gets added

Surface Behavior
entity._rev: number Returned on every get(), find(), search(). Initialized to 1 on add(). Bumped by 1 on every successful update(). Pre-7.31.0 entities without _rev are surfaced as 1.
update({ id, ..., ifRev: number }) If the persisted _rev does not equal ifRev, throws RevisionConflictError. Omitting ifRev keeps the prior (unconditional) update behavior.
RevisionConflictError Carries { id, expected, actual } for principled recovery.
add({ id, ifAbsent: true }) By-ID idempotent insert. Returns the existing id if one is already present; no throw, no overwrite.
addMany({ items, ifAbsent: true }) Applies ifAbsent to every item. Per-item ifAbsent overrides the batch flag.

_rev is the per-entity counter. Its store-wide counterpart is the generation counter behind the Db API: brain.transact(ops, { ifAtGeneration }) is CAS over the whole store, update({ ifRev }) (and ifRev on transact() update operations) is CAS over one entity.

The lock pattern

Every distributed-job scheduler eventually wants this exact loop:

import { Brainy, RevisionConflictError } from '@soulcraft/brainy'

const LOCK_ID = '...uuid for this job slot...'

// Bootstrap the lock document once (idempotent).
await brain.add({
  id: LOCK_ID,
  type: NounType.Document,
  data: { owner: null, expiresAt: 0 },
  ifAbsent: true
})

async function tryAcquireLock(workerId: string, ttlMs: number) {
  const lock = await brain.get(LOCK_ID)
  if (!lock) throw new Error('lock document missing')

  const state = lock.data as { owner: string | null; expiresAt: number }
  const now = Date.now()

  // Only take the lock if it's free or expired.
  if (state.owner && state.expiresAt > now) return false

  try {
    await brain.update({
      id: LOCK_ID,
      data: { owner: workerId, expiresAt: now + ttlMs },
      ifRev: lock._rev
    })
    return true
  } catch (err) {
    if (err instanceof RevisionConflictError) {
      // Another worker grabbed it between our read and write.
      return false
    }
    throw err
  }
}

No external lock service, no Redis SETNX, no Cloud Tasks. The CAS check is the lock.

Read-modify-write with retry

The other common shape is "update a counter / config object" with bounded retries on conflict:

async function incrementCounter(id: string, by: number) {
  for (let attempt = 0; attempt < 5; attempt++) {
    const entity = await brain.get(id)
    if (!entity) throw new Error('counter does not exist')
    const current = (entity.data as { value: number }).value
    try {
      await brain.update({
        id,
        data: { value: current + by },
        ifRev: entity._rev
      })
      return
    } catch (err) {
      if (err instanceof RevisionConflictError) continue   // refetch + retry
      throw err
    }
  }
  throw new Error('counter update conflict after 5 attempts')
}

The retry bound matters — without one, two unlucky writers can ping-pong forever.

Idempotent bootstrap with `ifAbsent`

For singletons (config rows, well-known seed entities, job-state documents) where the natural ID is deterministic:

await brain.add({
  id: 'config:singleton',
  type: NounType.Document,
  data: { tenantQuota: 1000 },
  ifAbsent: true
})
  • First caller writes; gets back 'config:singleton'.
  • Every subsequent caller short-circuits at the pre-read; gets back the same 'config:singleton' without touching the existing entity.
  • _rev is not bumped on the no-op path (no write happened).

ifAbsent is only meaningful when you supply an id. With no id, Brainy generates a fresh UUID that can never collide, so the flag is silently ignored.

addMany({ items, ifAbsent: true }) applies the flag to every item. Mixing per-item overrides with the batch flag works as you'd expect: per-item ifAbsent: false opts an individual row out, per-item ifAbsent: true opts it in.

Why no `addIfMissing({ match, add })` for attribute-based dedup?

You may want "create if no entity with this email exists" (lookup by attribute, not by ID). That's a different operation:

// What we DID NOT ship in 7.31.0 — the attribute-based variant.
await brain.addIfMissing({                       // ← not a real API
  match: { type: 'Person', where: { email: 'x@y.com' } },
  add:   { data: '...', metadata: { email: 'x@y.com' } }
})

It's race-prone as a plain read-then-write: two concurrent imports both see "not found," both insert, you get duplicates. Without a unique-index primitive (which Brainy doesn't have today), close the race with whole-store CAS — read at a pinned generation, then commit only if nothing moved:

import { GenerationConflictError } from '@soulcraft/brainy'

async function addIfMissingByEmail(email: string, data: string) {
  for (let attempt = 0; attempt < 5; attempt++) {
    const db = brain.now()
    try {
      const existing = await db.find({
        type: NounType.Person,
        where: { email },
        limit: 1
      })
      if (existing.length > 0) return existing[0].id

      const committed = await brain.transact(
        [{ op: 'add', type: NounType.Person, subtype: 'customer', data, metadata: { email } }],
        { ifAtGeneration: db.generation }   // rejects if ANYTHING committed since the read
      )
      return committed.receipt!.ids[0]
    } catch (err) {
      if (err instanceof GenerationConflictError) continue   // world moved — re-read + retry
      throw err
    } finally {
      await db.release()
    }
  }
  throw new Error('addIfMissingByEmail conflict after 5 attempts')
}

ifAtGeneration is deliberately coarse — any committed write invalidates it — so keep the retry bound. When you control the ID, ifAbsent stays the cheaper tool.

How `_rev` relates to generations

Brainy 8.0 has exactly two write-coordination counters, at two granularities:

Counter Scope What it tracks CAS surface Conflict error
_rev One entity Per-entity write count, bumped on every successful update update({ ifRev }), { op: 'update', ifRev } in transact() RevisionConflictError
Generation Whole store One tick per committed transact() batch or single-operation write transact(ops, { ifAtGeneration }) GenerationConflictError

They compose: a transact() batch can carry per-entity ifRev checks and a whole-store ifAtGeneration; any failed check rejects the entire batch before anything is staged. Generations also power snapshots and time travel (brain.now(), brain.asOf(), db.persist()) — see the consistency model and Snapshots & Time Travel.

A snapshot or historical view captures each entity including its _rev at that moment, so reading the past and writing back with ifRev against the live state works exactly as you'd hope: the write fails if the entity moved since the state you copied from.