spaces: version history that lives inside the file - #440
Open
nyblnet wants to merge 1 commit into
Open
Conversation
The timeline was in IndexedDB, in one browser. Mail the space and its past stayed behind. `doc.revisions` puts it in the document, so it travels with every copy — and an encrypted space gets history for the first time, because the field is inside the same envelope as the pages it describes rather than a plaintext artefact beside the ciphertext. A revision covers the whole space, not one page: a page here has a parent, can be the home, and is linked to by id, so one restored alone lands in a tree that no longer matches it. It stores only what CHANGED since the one before it, at page and block granularity — 269 B for an ordinary save of the starter space against 37,572 B for a whole snapshot. Capped at 128 KB and sixty entries, and past either the oldest two entries fold into one, so the distant past gets coarser while this afternoon keeps every save. The fold re-derives from the state those two produce rather than merging patches, which is what makes "every surviving revision still restores exactly what it did" true by construction. Restoring is exact on the serialized bytes, and it is an edit rather than a rewind: docId, assets and the timeline itself survive it, and ⌘Z walks it back. The Changes view diffs by WORD for the reason type/src/redline.ts argues — a line diff calls a reflowed paragraph wholly rewritten, a character diff marks 30 → 60 as one glyph nobody can see. That file is cited, not imported. History remembers deleted pages, and a document you mail carries that with it, so the dialog says so, Clear history is first-class, and a page extract is stripped of the parent's revisions outright. New logic is confined to spaces/src/history.ts; model.ts declares the field, main.ts records on save, store.ts keeps the log out of undo snapshots (and puts it back the way it already did for assets), portable.ts strips it from an extract. Additive: absent key is the old behaviour, no revisions means no key, and a `revisions` this build cannot read is left alone. 35 assertions in scripts/test-spaces-model.ts (968/968), 14 sabotages, all caught. Browser-verified over http on a built shell: save/restore byte-exact through the shipped paths, the word diff rendered, the envelope decrypted, and the pre-history shell round-tripping the field untouched.
Build size
Updated: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Version history for bento/spaces that lives inside the document, under
doc.revisions. Today's timeline iskernel/src/autosave.ts→ IndexedDB →that browser only: mail the space and its past stays behind. This is the half
that survives being sent, which is a thing a hosted app can only do on its
server and a file you email otherwise simply cannot have.
New logic is confined to
spaces/src/history.ts. The diffs inmodel.ts,main.ts,store.ts,portable.tsandabout.tsare small and localized —nine sibling branches are in flight against those files.
The four calls, and why
A revision covers the WHOLE SPACE, not one page. A page here has a
parent, can be thehome, and is linked to by id, so a page restored on itsown lands in a tree that no longer matches it. Covered:
title,home,theme,pages. Deliberately not:assets(orders of magnitude larger andcontent-addressed —
store.tsalready excludes them from undo snapshots forthe same reason),
collab(bearer capabilities, a live room), and the file'sidentity fields. Restoring is an EDIT, never "become a different file".
Following
type/src/model.ts'sRevision {id, at, label, body}, with twostated divergences. The body is a PATCH at page-and-block granularity rather
than a whole snapshot — the budget, below, is the reason. And
labelisOPTIONAL, present only when a person typed one: a label minted by the app would
be a sentence frozen in one author's UI language and then shown to every reader
of a file that opens in eight, so the summary is derived from the patch at
render time and only counts are stored.
The budget TIERS rather than fails, following
PREVIEW_BUDGET.HISTORY_BUDGET128 KB against a ~278 KB shell,HISTORY_MAX60 entries. Pasteither, the oldest two fold into one — RESOLUTION is what is dropped and it is
dropped from the distant past first, while this afternoon keeps every save. The
fold re-derives the replacement from the state those two produce rather than
merging two patch objects, which is what makes "every surviving revision still
restores exactly what it did" true by construction. A space whose content alone
exceeds the budget keeps none, and the dialog says so.
An encrypted space keeps history, and it is not a loophole in slides' rule.
Slides refuses a preview to an encrypted deck because a plaintext title slide
BESIDE the ciphertext is the leak; autosave is refused an encrypted space
because a recovery snapshot is plain JSON on disk. Neither describes this:
revisionsis a field of the document, inside the samebento/encenvelope,encrypted by the same pass over the same JSON as the pages it describes. So an
encrypted space has history for the first time — it had none at all before,
because the only mechanism was the one that must refuse it.
Byte cost, measured
Starter space: 14 pages, 142 blocks, 37,572 B of content.
An ordinary save is 140× cheaper than the whole-snapshot design this
replaces. Shell cost of the feature: 277,769 → 285,969 B (+8,200, +2.95%),
measured by building
origin/mainbeside this branch; split by swapping the oldpacked catalog back in — code+CSS +4,092 B, language +4,108 B (16 strings ×
8 locales).
The invariant
The equivalent of
redline.ts'saccept(all)/reject(all):asserted on the serialized bytes, not on a count of blocks — in the rig and
again in a real browser through the shipped ⌘S and Restore paths. The word diff
carries redline's own pair (everything that is not an insertion reconstructs the
old text; everything that is not a deletion, the new one). Granularity is WORDS
for the reason that file argues;
redline.tsis cited, not imported —importing bento/type's runtime would put another app's code in this shell and
couple two zones for forty lines of LCS.
Disclosure, said out loud
History remembers deleted pages, and what is different about a document you MAIL
is that the disclosure travels. So: Clear history is a first-class control,
the dialog says it in words, and
portable.tsstripsrevisionsfrom a pageextract outright — an extract of one page has no business carrying the deleted
history of pages that did not travel with it.
Verification
node scripts/test-spaces-model.ts— 968/968 (+35 for this feature),including 200 randomised edit sequences (rename/edit/create/delete/reorder/
resurrect) verified at every revision.
assertion goes red: dropping the stored order (1, 3), storing partial page
metadata (2), folding by keeping the newer patch instead of re-deriving (4),
tier 3 keeping a history it cannot honour (5), recording a no-op save (6),
reading a foreign
revisionsas ours (7), diffing by character (8) or by line(13), dropping the diff's common tail (9), an extract carrying the parent's
history (10), reporting every block as changed (11), storing whole snapshots
(12), and rebuilding the document on restore instead of editing it (14).
node scripts/test-spaces.mjs— all 8 rigs green under the full TZ matrix;--manifestok;test-sync-spaces.ts3/3.tsc -bclean ·build:single·shell-gate.mjsok ·build-spaces-i18n.mjs --check613 × 8, all complete, zero all-zero rowsin
packed.ts.http://127.0.0.1:5197: three saves →three revisions through the real ⌘S path; the serialized
#bento-doccarriesthem; the dialog renders the timeline, the derived summaries and the word diff
(red
del/ greenins); Restore brings the document back in the DOM as wellas the model; ⌘Z walks the restore back and the timeline survives it; three
further save→restore cycles byte-identical on the serialized content; the
encrypted file's envelope decrypts to a document carrying its revisions while
no page text, current or historical, appears in the clear; Clear history
removes the key from model and file. And the shell built from
origin/main—the build that never heard of the field — round-trips 3 revisions
byte-identically and renders none of them.
Flags
scripts/test-spaces-model.tsis ops surface. The 35 assertions areappended at the tail. A new rig file would have needed a
ci.ymlstep, andthe contract says not to add one while the queue is contended.
complete a file picker) — the envelope was decrypted in-page with its own
parameters instead.
about.ts'ssetPwuses a nativeprompt(), whichfreezes the automation harness; that one call was stubbed and nothing else.
revisionsis a top-level field and the CRDT syncs pages and blocks, so it mints no ops.
Documented in
history.tsrather than half-built.