Skip to content

Changesets & lanes

A session’s persisted state is a changeset: a structured value stored in a field of the host document. Its shape mirrors the hybrid change model of datadata itself.

For every document touched by the session, the changeset holds a stage:

  • the document’s base sequence — the version the edits were computed against,
  • the document type, whether the stage creates or deletes the document, and a pending rename if there is one.

That’s all — no copy of the document’s data travels with the stage. Each staged patch embeds its own RFC 6902 test guards (the values its author saw), which is what conflict detection runs on: the patches plus the live head, no stored base. Storing no base keeps the changeset proportional to the staged edits, not to the size of the documents being staged — a session touching large documents is still a small host document. When a resolution surface needs the base value (the three-way preview), it is resolved on demand — from the live head, an in-memory pin, or a replay of the document’s event log.

Staged edits accumulate in two parallel lanes — and, deliberately, the lanes have different shapes:

  • changes — JSON Patch operations against structured data: a flat record set ordered by fractional index, the same ordered-record pattern used for collections everywhere in datadata. Each record is addressable — a change can be amended or dropped by id, not just wholesale — and carries the generation of the document it was authored against, and the schema sequence its patch was authored at.
  • yjsCopies — staged Yjs content: one live-seeded host sub-document copy per target Y.Doc, recorded as { docId, yjsId, copyRef, stage }. The record stores no update bytes — the copy holds the content as a real, synced sub-document, not a stored delta.

The asymmetry is the point. Structured edits are addressable, so the JSON lane is a record set a review can amend or drop from by id. Collaborative text isn’t — it merges as a whole document — so the Yjs lane is a forked copy instead. A prepared document’s getYDoc hands back a copy seeded from the live target, but stages nothing: the first edit promotes it to a yjsCopies entry anchored by a deterministic copyRef, so merely opening an editor leaves the changeset untouched. Editors edit the copy directly, and live sharing, cursors, offline editing and state-vector catch-up ride the host document’s ordinary Yjs sync for free. The copy also tracks the live target during the session, so upstream edits surface to the editor as they happen rather than at commit.

Both lanes converge under concurrent authorship, by different means. Patch records from two authors interleave by fractional index. The Yjs copy needs nothing extra: copyRef is deterministic — a stable id derived from the target’s docId, yjsId and generation — so concurrent sessions on the same target resolve to the same single copy. They collaborate on it natively, with no last-writer race over which copy is canonical.

The generation in that id is what keeps two incarnations of a target apart. A copy forked before the target was purged and recreated under its id belongs to the document that no longer exists, so it resolves to a different sub-document than a copy forked after — the vanished document’s content can never mix into the one the recreated document’s authors are editing.

That generation, and the one on each changes record, are also what make the targetReplaced block survive concurrent authorship. The stage record carries a generation too, but it is one path in the changeset: an author whose view of it predates a sibling’s staging writes it whole, marker included, so the sibling’s stale work would go unblocked and commit into the document that reused the id. Staged work is judged by the generation it carries itself — per change, and per copy — which no other author’s write can replace.

For the same reason, every changes and yjsCopies record also notes the stage it was written under: the document type and base sequence. Whether a stage still exists is decided by whoever last touched it. When one author commits or drops a stage, it’s removed once the work they can see is gone. By then another author may have added work under that stage that hasn’t synced yet, for example an edit made offline. When that work arrives, it rebuilds its stage from the record instead of being dropped, so it shows up as staged work for everyone to review. If it was written against a create that has since been committed, it names no generation of the new document, so its stage shows up blocked as targetReplaced.

copyRef is a yjsRef-typed value, so it’s reference-counted like any other Yjs reference. Its presence in the changeset anchors the copy sub-document against the host document’s orphan GC; clearing it reaps the sub-document. There’s no session-specific Yjs cleanup — committing or discarding just drops the entry, and the general reference GC reclaims the copy.

One more record set rides the changeset: commitIntents, written by commit() itself just before it drains. Each intent records — per stage — which staged change ids are draining under which pre-minted write event ids. It’s the changeset’s crash-safety ledger: with offline persistence enabled, a commit that dies with its page leaves journaled writes that replay on the next boot, and the intent is how a resumed session recognizes them as its own commit already in flight — it waits for their outcome and finishes the commit’s bookkeeping instead of re-applying the staged work. You never write this lane yourself; it appears during a commit and clears when the commit’s writes settle.

Stages leave the changeset one by one, each as its own write is acknowledged — not all together when the commit ends. In between, a stage’s changes are in the live document and still listed as staged, so the intent also tells a reader which changes to leave out: a session never folds a change its commit has already put into the live document it holds. The committing client knows from its own write — pending, or remembered as applied the moment its acknowledgement arrives, so a listener notified by that acknowledgement, or by a refusal’s rollback, reads the stage right. Every other client subscribed to the target knows from the target’s patch: it carries the write’s event id, the same one the intent records, so an observer reads the stage as landed from that patch on, even when the host’s patch that clears it arrives late. For a schema document every client can tell from the document alone — the intent records the keys its staged migrations add to the log, and finding them in the live log is the landed write. An observer whose copy of an ordinary target never saw the patch (it subscribed, or reloaded, between the target’s write and the host’s) has no such evidence, and may read a non-idempotent staged op (an append into an array) twice until the host’s patch arrives.

A staged change carries only its id, order and patch — no per-change author or originating-turn metadata. Staging is transient: the changeset is cleared once every stage drains at commit (or the session is discarded), so anything stamped on a staged change is visible only for the window the change sits staged — not where a durable record would matter. Durable authorship lives on the event log instead: every committed event records the writing principal (subject and actor). An app that wants to group staged changes by conversation turn keeps its own map keyed by staged change id — turns and tool calls are the app’s vocabulary, not the library’s.