oficina / docs Public

Snapshots, so a document stops going read-only

maxRetainedUpdates was a cliff rather than a budget: at 4096 updates
Handle.Apply refused everything further and the document stopped
accepting edits, permanently, with no warning — about two hours of
continuous typing at the current debounce. Snapshots remove that, and
separately make a shorter debounce affordable, which is the next change
and the one anyone will actually feel.

A snapshot is one ordinary Yjs update encoding a whole document, which
is what keeps this cheap: Store.replay and the browser's applyServerLog
both already know how to apply concatenated updates. The Rust core gains
a state export and an in-core verifier (ABI v4); the store writes a
snapshot every 256 updates and replays snapshot-then-tail; the update
endpoint serves the same shape, with raw=1 as the escape hatch for a
snapshot that will not decode. Replaying 4000 rows: 3.55ms with a
snapshot against 71.10ms without.

The snapshot is taken from the replayed state BEFORE the incoming update
is applied, and that placement is the correctness argument rather than a
style choice. Taken after a successful apply it can record a state
missing a row a concurrent POST committed — the replay runs outside the
transaction, and the verification cannot catch it because it compares
the candidate against the same stale handle. It also never rescues a
document already at the cap, since Apply refuses before any update
succeeds, which is the case the whole feature exists for.

Reviewed per task and then as one branch by Fable. Five times a test
turned out to be incapable of failing against the mistake it guarded —
four of them in the plan's own code — and each fix was verified by
breaking the implementation and watching the test fail. The spec carries
the decisions, including D8's mid-replay re-seed, which keeps a long
document readable when its snapshot is invalidated by a future ABI bump.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 6bb0a03a5b0de4aa8c6f8b7912cad4923861a865 parent fecd95c
21 files changed, +3933 −113
  • core/src/abi.rs +93 −1
  • core/src/lib.rs +14 −3
  • core/src/snapshot.rs +118 −0
  • core/tests/snapshot.rs +189 −0
  • docs/superpowers/plans/2026-09-01-snapshots.md +1056 −0
  • docs/superpowers/specs/2026-09-01-snapshots-design.md +449 −0
  • fixtures/editor-entry.js +59 −18
  • fixtures/live-updates.mjs +52 −7
  • fixtures/live-updates.test.mjs +40 −4
  • internal/core/core.go +166 −30
  • internal/core/core_test.go +124 −0
  • internal/core/testdata/docs_core.wasm binary
  • internal/docs/cursor_test.go +136 −0
  • internal/docs/handlers.go +69 −8
  • internal/docs/migrations/0004_doc_snapshots.sql +27 −0
  • internal/docs/models.go +23 −0
  • internal/docs/snapshots.go +143 −0
  • internal/docs/snapshots_test.go +929 −0
  • internal/docs/static/editor.3e44c12d757809a4.js +0 −14
  • internal/docs/static/editor.6029d6f8d990c8a4.js +14 −0
  • internal/docs/store.go +232 −28

CI passed — run details

core/src/abi.rs

Loading diff…

core/src/lib.rs

Loading diff…

core/src/snapshot.rs

Loading diff…

core/tests/snapshot.rs

Loading diff…

docs/superpowers/plans/2026-09-01-snapshots.md

Loading diff…

docs/superpowers/specs/2026-09-01-snapshots-design.md

Loading diff…

fixtures/editor-entry.js

Loading diff…

fixtures/live-updates.mjs

Loading diff…

fixtures/live-updates.test.mjs

Loading diff…

internal/core/core.go

Loading diff…

internal/core/core_test.go

Loading diff…

internal/core/testdata/docs_core.wasm

Loading diff…

internal/docs/cursor_test.go

Loading diff…

internal/docs/handlers.go

Loading diff…

internal/docs/migrations/0004_doc_snapshots.sql

Loading diff…

internal/docs/models.go

Loading diff…

internal/docs/snapshots.go

Loading diff…

internal/docs/snapshots_test.go

Loading diff…

internal/docs/static/editor.3e44c12d757809a4.js

Loading diff…

internal/docs/static/editor.6029d6f8d990c8a4.js

Loading diff…

internal/docs/store.go

Loading diff…