docs

Understanding any

The record CRDT

any-sync delivers signed, encrypted changes; any-store holds documents. The record CRDT is the layer between them: it defines a change as a batch of per-record operations on a named dataset and applies it to rows through a per-path version gate, so that every peer holding the same changes — in any order — ends with identical rows.

Datasets, records, ops

object (one any-sync tree)
└── dataset  "chat_messages"          named record collection; one handler
    └── record  { id, …fields, _ver } anyenc document
        └── op   $set / $unset / $addToSet / $pull / $inc / $incGated / delete

One change belongs to exactly one dataset and may touch many records. Each record change carries an id, an upsert flag and an ordered list of ops. Ops address dotted paths; $set and $unset also have a multi-field form whose payload is an object of path: value entries, each applied as its own gated write.

There is no insert. A record is created by an upsert: true change whose ops populate it; a strict (default) modify on an absent id is a no-op, so a typo cannot conjure a record. Reserved names — id and anything starting with _ — are rejected at validation, before apply (Writing data).

Versions and _ver

Every peer's any-sync assigns each change it holds a versionId: an opaque string that sorts lexicographically in DAG order for that peer's view of that tree. It is peer-local — two peers may spell the same change differently — but the relative order of any two changes converges once both peers hold both.

Each record carries _ver, a map from field path to the versionId of the last write that landed there:

{ "id": "m1", "text": "hello", "edited": true,
  "_ver": { "id": "!A0", "text": "!C4", "edited": "!C4" } }

The apply rule is one comparison per path:

if incoming.versionId > record._ver[path]:  write the value, _ver[path] = incoming.versionId
else:                                       skip this path

_ver is a tree. A write to a.b stamps exactly that path; a whole-object $set of a collapses the subtree to one string, and a * entry holds the default for keys a broad write claimed without enumerating. A broad write over finer entries merges per leaf — newer leaves survive, older ones are replaced — so "replace the whole object" and "edit one key" race to the same result in every delivery order.

_ver.id is special: it is the creation marker, the versionId of the earliest upsert that targeted the id, updated only downward (min). That is what makes sort: ["-_ver.id"] a stable creation order on every peer (System fields).

Content-addressed record ids

A record change with an empty id gets one derived from the enclosing change:

DeriveRecordId(changeId) = base58(xxh3-64(changeId))     ≈ 11 chars

The first empty-id record in a batch takes that value; later ones append :1, :2, …. Because changeId is a CID, the id is globally unique with no coordination, and it is the same on every peer. It is why recordIds[0] in a write reply is the id of the message or block you just created, and why property ids (propId) look the way they do.

Handlers

Every dataset has a handler — either a compiled-in one (chat, editor blocks, the objects row, spaceIndex, …) or the generic schema handler compiled from a runtime dataset declaration. Hooks run inside the apply transaction on every peer, identically:

Hook Runs Typical rule
BeforeCreate before a record's first materialization required fields, id rules, stamp creator / createdAt from the change envelope
BeforeModify per op field pinned, author-only edit (ctx.Before.creator == ctx.Change.Creator), value shape, bump modifiedAt
BeforeDelete per record delete author-only delete

A handler writes through a sink: Derive queues a same-record op that lands with the change's own versionId (this is how server-stamped fields exist without a server), Project queues a sibling write to another dataset on the same object. Hooks may read only what converges — the change envelope and the record's pre-op state — never replica-local data, so their verdicts are the same everywhere.

Rejections are op-granular and never fatal on the apply path: an op that fails validation is dropped and recorded, the rest of the change still commits, and the writer's own pre-check is the only place a whole change is refused. Which fields a peer may write is also declared, as a scope on the field or property: synced rides the DAG, derived is handler-only, local never leaves the device, account travels through the tech space. An inbound DAG op addressing a local or account path is dropped per-op on every peer.

Merge rules by op

Op Gated by _ver? Updates _ver? Behaviour under concurrency
$set, $unset yes yes last writer in DAG order wins, per path
$addToSet, $pull yes (against a newer $set) no commutative — concurrent adds all land
$inc yes (against a newer $set) no commutative counter
$incGated yes yes LWW on the post-increment value; not convergent under arbitrary order
delete collapses to {id, "*"} wins absolutely; the tombstone is sticky

Two concurrent creates of the same id merge per field. A delete on an absent id writes a tombstone so it still wins against a create it has not seen. Every later modify on a tombstone is dropped — and reported to the local writer as a rejection, never silently.

Worked example: two concurrent edits

Devices A and B both hold message m1 with _ver.text = "!B2", then go offline.

A:  $set text  = "meet at 10"       → change cA, parents [B2]
B:  $set pinned = true              → change cB, parents [B2]

A applies its own change first (cA → local version !C3), then receives cB (!C4). B does the reverse. Each write touches a different path, so both gates pass on both devices:

A: text ← "meet at 10" (!C3)    then pinned ← true (!C4)
B: pinned ← true (!C3)          then text ← "meet at 10" (!C4)

both:  { "text": "meet at 10", "pinned": true }      ✓ identical rows

The _ver strings differ between A and B (they are peer-local), the rows do not. Now suppose B had instead written $set text = "meet at 11". Both changes hit the same path; the DAG order — the same on every peer once both changes are present — picks one, and the other is gone everywhere with no error to either author. Design records so that the fields people edit at the same time are different fields (CRDTs and consistency).

Tombstones and counters

A deleted record stays as a tombstone { id, _deletedAt, _ver: { id, "*" } } so late writes have something to lose against. Queries and subscriptions skip tombstones; the change feed used by indexers can ask for them (IncludeDeleted) to evict documents.

Three peer-local counters ride the apply path, each answering one question:

Counter Where Answers
versionId _ver on each record which write landed last on this path
addSeq _addSeq on each record; per-object watermark is any-store caught up with any-sync (the restore watermark)
applySeq _applySeq; the Changes() feed is a consumer (the search indexer) caught up with any-store — it also counts local and account writes, which never enter the DAG

All three are local to a peer. None is a timestamp, and none should be compared across devices.

The DataVersion gate

A handler registration stamps a DataVersion on every change it writes (chat_messages-v2; a runtime dataset stamps its type's schema state). A receiving peer applies a change only if it carries a handler for that dataset at that version; otherwise the change is parked — persisted in the tree, kept in a _detached collection, invisible to queries — and drained when the registration appears. For runtime datasets that means "schema first, then data" holds in either arrival order: a record written against a field definition you have not yet received waits for the definition instead of being applied wrongly or dropped.

Why there is no rollback and no total order

  • No total order. Concurrent branches have no "before" and "after" until they meet; DAG order gives every peer the same tie-break, not a wall clock. Version history is therefore a walk over a DAG with a cursor, and timestamp is display-only (Version history).
  • No rollback. A change that entered the tree is signed and content-addressed; other peers may already have built on it. Correction is another change. Validation drops ops on apply rather than refusing the change, precisely so a tree can always be replayed to the same state.
  • No conflict surface. Every merge is decided by rule. The one thing reported back is a tombstone rejection — a write that was dropped whole — and even that is not a conflict, just a fact about a record that no longer exists.

Why it matters. The CRDT is deliberately narrow: per-path LWW plus a few commutative ops. That is enough to make "apply this set of changes in any order" a pure function — which is what lets a device be offline for a week, a handler be re-run over history to rebuild rows (Versioning and re-index), and a second device restore an account from the DAG alone.