Skip to content

spaces: version history that lives inside the file - #440

Open
nyblnet wants to merge 1 commit into
mainfrom
spaces-history
Open

spaces: version history that lives inside the file#440
nyblnet wants to merge 1 commit into
mainfrom
spaces-history

Conversation

@nyblnet

@nyblnet nyblnet commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Version history for bento/spaces that lives inside the document, under
doc.revisions. Today's timeline is kernel/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 in model.ts,
main.ts, store.ts, portable.ts and about.ts are 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 the home, and is linked to by id, so a page restored on its
own lands in a tree that no longer matches it. Covered: title, home,
theme, pages. Deliberately not: assets (orders of magnitude larger and
content-addressed — store.ts already excludes them from undo snapshots for
the same reason), collab (bearer capabilities, a live room), and the file's
identity fields. Restoring is an EDIT, never "become a different file".

Following type/src/model.ts's Revision {id, at, label, body}, with two
stated divergences.
The body is a PATCH at page-and-block granularity rather
than a whole snapshot — the budget, below, is the reason. And label is
OPTIONAL, 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_BUDGET 128 KB against a ~278 KB shell, HISTORY_MAX 60 entries. Past
either, 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:
revisions is a field of the document, inside the same bento/enc envelope,
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.

bytes
base revision (the whole space) 39,502
20 further saves +5,378 total · 269 average · 438 worst
whole history after 21 saves 44,880 (budget 131,072)

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/main beside this branch; split by swapping the old
packed catalog back in — code+CSS +4,092 B, language +4,108 B (16 strings ×
8 locales).

The invariant

The equivalent of redline.ts's accept(all)/reject(all):

restore(N)  ==  the content that was saved at N

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.ts is 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.ts strips revisions from a page
extract 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.ts968/968 (+35 for this feature),
    including 200 randomised edit sequences (rename/edit/create/delete/reorder/
    resurrect) verified at every revision.
  • 14 sabotages, 14 caught. Each breaks one mechanism and the SPECIFIC
    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 revisions as 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;
    --manifest ok; test-sync-spaces.ts 3/3.
  • tsc -b clean · build:single · shell-gate.mjs ok ·
    build-spaces-i18n.mjs --check 613 × 8, all complete, zero all-zero rows
    in packed.ts.
  • Browser, uniquely-named copy over http://127.0.0.1:5197: three saves →
    three revisions through the real ⌘S path; the serialized #bento-doc carries
    them; the dialog renders the timeline, the derived summaries and the word diff
    (red del / green ins); Restore brings the document back in the DOM as well
    as 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.ts is ops surface. The 35 assertions are
    appended at the tail. A new rig file would have needed a ci.yml step, and
    the contract says not to add one while the queue is contended.
  • Not verified: an encrypted file reopened from disk (the harness cannot
    complete a file picker) — the envelope was decrypted in-page with its own
    parameters instead. about.ts's setPw uses a native prompt(), which
    freezes the automation harness; that one call was stubbed and nothing else.
  • Under collaboration each replica keeps its own file-local history: revisions
    is a top-level field and the CRDT syncs pages and blocks, so it mints no ops.
    Documented in history.ts rather than half-built.

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.
@github-actions

Copy link
Copy Markdown

Build size

main (0100083) → spaces-history (9cacc5a)

app base PR change
bento/slides 677.9 KiB 677.9 KiB 0.0 KiB (0.00%)
bento/spaces 271.3 KiB 279.3 KiB +8.0 KiB (+2.95%)
bento/dash 424.1 KiB 424.1 KiB 0.0 KiB (0.00%)

Updated: 2026-09-10T00:25:16Z

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant