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
timestampis 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.