Skip to content

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.

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.

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.

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

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.

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.

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