Conflict preview & resolution
While a session holds staged changes, the live documents keep moving — the head, the live document as it stands right now, advances underneath the staged view. The session’s job is to make that visible before commit, not to surprise you at commit time.
Conflicts are derived, not stored
Section titled “Conflicts are derived, not stored”A stage stores only its
patches and base sequence — no
snapshot of the document. Each staged replace or remove carries an RFC 6902
test guard asserting the prior value at
the path it changes — an add has no prior value to assert and so
carries no guard.
Detection is a fold: the staged patches are applied in order, guards included,
onto the live head. A clean fold is the auto-merge — disjoint upstream
changes need no attention. A failed fold — a guard that no longer matches, or
an operation that no longer applies — marks the stage blocked.
The fold needs nothing but the head and the patches, so it gives the same answer on every client, and it’s recomputed rather than persisted: a head change that turns out to be disjoint un-blocks the stage by itself. One deliberate strictness: a staged path that was edited and then reverted still carries its guards, so an upstream edit to it conflicts — the session expressed intent about that path even though the net change is nothing.
The three-way preview
Section titled “The three-way preview”A blocked stage is available as data — for a diff UI, or for an agent to reason about:
- base — the document as it was when staging began,
- ours — base with the session’s changes applied,
- theirs — the live document now.
getConflictPreview(docId) returns a promise, because the base value is
resolved rather than stored: it’s instant in the common cases (the live head
still sits at the stage’s base sequence, or this client has watched the stage
since it opened), and on a cold client it’s reconstructed by replaying the
document’s event log up to the base
sequence. The replay is online-only — a cold, offline client’s preview waits
for reconnect. Detection and commit gating never wait; they need no base. The
session’s read view degrades the same way, but only where it must: on a real
overlap, where no coherent head-plus-stack view exists, it serves the last
coherent one (base plus the staged changes), and a cold client sees the live
head un-overlaid until the replayed base lands.
The resolution verbs
Section titled “The resolution verbs”- Amend a staged change — rewrite a pending change in place (fix the agent’s typo before committing). Async for the same reason as the preview: it re-derives the change against the state it was authored on.
- Drop a staged change (
dropStagedChange), or a whole stage (dropStage) — its staged edits, rich-text copies, rename, delete or create — discarding part of the staged work and leaving the session’s other stages alone. - Rebase a stage — adopt the current head as the new base and restage on top of it. The embedded guards are rewritten against the new base; a staged change the upstream edit already made identical is dropped, and a stage left with no surviving work is removed.
- Discard the session — walk away; live documents were never touched.
commit() is the gate: it refuses while any stage is blocked, and writes all
stages atomically once none are.
When the document moves under you
Section titled “When the document moves under you”A changeset’s onUpstreamAdvance policy decides where the line between merge
silently and surface it falls. A hosted changeset carries it, so every
session over that host applies the same policy: autoMerge unless the host
document was created with
session: emptySessionChangeset({ onUpstreamAdvance: "block" }). An in-memory
session takes it as an openStagingSession option:
autoMerge— the default, and everything above. Upstream changes that still fold cleanly merge without a word; only a failed fold blocks the stage.block— blocks the stage whenever the document advanced upstream at all, even when the staged patches would still apply.
The reason the second policy exists is that a clean fold proves structural
independence, not semantic independence. A staged change guards the paths it
wrote, not the paths it read. An agent that reads a whole document and
stages a one-field edit to summary produces a patch that folds cleanly over
an upstream edit to body — and a summary that now describes text nobody can
find. Disjoint on paper, stale in fact.
So reach for block when a staged change was derived from more of the document
than it touched: agent-authored edits, or a review gate where “somebody else
moved this while you were staging” is itself something the reviewer should see.
Because it keys off the document’s sequence rather than the guards, it also
catches what the guards structurally cannot — two authors adding the same new
key. You pay for all this in false positives: on a busy document, every
upstream touch blocks and wants a rebase. That’s why autoMerge is the
default.
A stage blocked this way reports reason: "upstreamAdvance" rather than
physicalConflict — nothing clashed, the patches still apply. It keeps reading
head plus your staged changes, which is exactly what commit would write; only a
real overlap falls back to base plus stack. And when a stage both overlaps
and advanced, the overlap wins the reason: physicalConflict is the more
actionable of the two.
Both policies are live: staged work follows the head as it moves, and both ignore Yjs-only upstream movement (rich-text deltas merge onto any later state, so they conflict with nothing — an “advance” here means the JSON lane’s sequence, which they never touch). Neither ever auto-resolves an overlap — that stays a decision for you or the agent.
When the schema moves under you
Section titled “When the schema moves under you”A migration is not a conflict. When a type’s schema gains a rename, remove or remap while a stage on one of its documents is open, the server brings the document forward — which advances its sequence and moves its shape out from under patches authored against the old one. Read naively, that is a stack that no longer folds.
So staged work records the shape it was authored in: each change carries the
schemaSequence of its type’s sys:schema:<type> document at staging time,
and the stage carries the one its base is at — the
conformedSchemaSequence
the live document itself names, which is also how the session knows a head is
still a frame behind a schema change that just landed. Whenever the schema has moved
since, the session reads the stack brought forward — every staged patch,
and the base or head it folds onto, replayed through the migrations stamped
since with the same engine the server uses for
pending writes.
The conflict check, the overlaid read, the three-way preview, amend, rebase
and commit all see that one form: an edit to title staged before a
title → name rename reads, previews and commits as an edit to name. What
still fails to fold after that is a real overlap, and a stack that folds but
fails the new shape (a field it now requires) is schemaInvalid as ever.
The stored changeset is not rewritten. A stage is shared by every author
on the host document, so rewriting it from each client would be a burst of
identical writes and a window of mixed forms; the brought-forward form is a
pure function of the stored stack and the schema’s append-only migration log,
so every client derives the same one — and the same verdict. stagedChanges on
a stage lists the patches as commit would fold them. A change whose every
operation a remove migration deleted lists an empty patch, folds as nothing,
and drains with its stage. rebaseStage and amendStagedChange, which rewrite
patches anyway, write them at the current shape and re-stamp them.
The same holds for a migration staged in the session. While a session stages a schema edit with migrations, it reads every document of that type — staged or not — as the migrations will leave it, so content is authored and validated in the shape it commits into, and changes staged before the schema edit are carried through it. Commit drains the schema document first, then waits for each target’s migration to arrive before folding its content.
Content staged under such a migration is in a shape no schema sequence names
yet: the live schema plus migrations that have not committed. The server
stamps a migration with the sequence of the write that commits it, and any
other schema write landing first takes the number a guess would have used. So
the change records what is known — the live schemaSequence it sat on, and
stagedMigrations, the keys of the staged migrations it was authored after.
A reader takes those keys out of what the change is owed, wherever the
migrations ended up: still staged, committed behind someone else’s schema
write, or committed before an offline author’s change under them reached the
changeset.
A change staged between two staged migrations is carried through the second
only. A document the session shows carried through staged migrations names no
conformedSchemaSequence for the same reason.
A schema’s migration log is a record keyed by each migration’s
key, so a staged migration is an entry of its
own: when another author commits a migration first, the two sit side by side in
the log and replay in key order. What does not merge is reported rather than
merged silently: a staged key that another author already used for a different
migration, or one that sorts before a key committed meanwhile (the server
only accepts new keys past the committed range). The patches still fold and the
merged schema still validates, so neither is an overlap — but the live log
already says the server would refuse the write, and the schema stage reports
reason: "migrationKeyRefused", with the key in migrationKey and the
server’s refusal in message. It keeps reading head plus your staged changes,
like an upstreamAdvance, and outranks one.
Resolve it with rekeyStagedMigration(schemaDocId, from, to), which gives the
staged migration a later key. Do not amend the schema stage by hand instead:
every change staged under the migration records its key, and a change naming a
key that no longer exists is read as owed the migration again under the new
one — a value a chained remap already moved is moved a second time. The
session operation rewrites the schema stage and those records in one write;
when the key was taken by another author, their entry stays and yours moves.
A client that has not heard of the committed key yet can still commit into the
refusal; the schema stage then fails and stays staged, to be rekeyed once the
conflict shows. If the schema stage fails at commit, content staged
under its migrations is held back with it and stays staged: written early, it
would be migrated a second time once the schema did commit. One case is not
detected: two authors migrating the same field. Content staged under your
migration is carried through theirs afterwards; when the two do not commute,
review the merged schema before committing (the block policy flags the schema
stage for exactly that).
A type validated against a bundled static schema has no migration log, so its staged work records no sequence and is read as authored.
When the document is replaced
Section titled “When the document is replaced”A stage records the generation of the
document it was opened against. If that document is purged and a new one is
created under the same id, the new one is a different generation — even when
its sequence happens to match — and every stage on it reports
reason: "targetReplaced": staged edits, deletes, renames and staged
rich-text copies alike — including a copy opened before the replacement and
first edited after it. Nothing staged belongs to the new document, so the
session shows it as it is: its reads and index ignore a staged delete or
rename, getConflictPreview returns null (there is no base to merge), rebase
refuses, and commit refuses while the stage remains. The only
resolution is dropping the stage with dropStage(docId).