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>
21 files changed,
+3933
−113
core/src/abi.rs+93 −1core/src/lib.rs+14 −3core/src/snapshot.rs+118 −0core/tests/snapshot.rs+189 −0docs/superpowers/plans/2026-09-01-snapshots.md+1056 −0docs/superpowers/specs/2026-09-01-snapshots-design.md+449 −0fixtures/editor-entry.js+59 −18fixtures/live-updates.mjs+52 −7fixtures/live-updates.test.mjs+40 −4internal/core/core.go+166 −30internal/core/core_test.go+124 −0internal/core/testdata/docs_core.wasmbinaryinternal/docs/cursor_test.go+136 −0internal/docs/handlers.go+69 −8internal/docs/migrations/0004_doc_snapshots.sql+27 −0internal/docs/models.go+23 −0internal/docs/snapshots.go+143 −0internal/docs/snapshots_test.go+929 −0internal/docs/static/editor.3e44c12d757809a4.js+0 −14internal/docs/static/editor.6029d6f8d990c8a4.js+14 −0internal/docs/store.go+232 −28
CI passed — run details
core/src/abi.rsLoading diff…
core/src/lib.rsLoading diff…
core/src/snapshot.rsLoading diff…
core/tests/snapshot.rsLoading diff…
docs/superpowers/plans/2026-09-01-snapshots.mdLoading diff…
docs/superpowers/specs/2026-09-01-snapshots-design.mdLoading diff…
fixtures/editor-entry.jsLoading diff…
fixtures/live-updates.mjsLoading diff…
fixtures/live-updates.test.mjsLoading diff…
internal/core/core.goLoading diff…
internal/core/core_test.goLoading diff…
internal/core/testdata/docs_core.wasmLoading diff…
internal/docs/cursor_test.goLoading diff…
internal/docs/handlers.goLoading diff…
internal/docs/migrations/0004_doc_snapshots.sqlLoading diff…
internal/docs/models.goLoading diff…
internal/docs/snapshots.goLoading diff…
internal/docs/snapshots_test.goLoading diff…
internal/docs/static/editor.3e44c12d757809a4.jsLoading diff…
internal/docs/static/editor.6029d6f8d990c8a4.jsLoading diff…
internal/docs/store.goLoading diff…