fix(lifeboard): harden state persistence — audit findings - #320
Open
ivanmkc wants to merge 1 commit into
Open
Conversation
An audit of the persistence mechanism turned up five defects. Each is confirmed by a test that fails without the fix. A SECOND TAB LOST ITS WRITES. Sequence numbers were minted from the in-memory tail, so two connections to the same database produced the same key, IndexedDB rejected the duplicate, and the write vanished -- a tick in the second tab reverted itself with no explanation. Reading and appending now happen in one transaction, and a tab absorbs whatever another wrote before deciding what its own change means, so an update against a record it had never seen is applied rather than dropped. A NEW BUILD NEVER REACHED ANYONE. app.js is not content-hashed and the service worker was cache-first, so a deploy was invisible until someone bumped the cache name by hand -- which nothing enforced. The shell is network-first with a cache fallback now: online you get the current build, offline you get the last one that worked. Content-hashed chunks stay cache-first, where that is free and correct. The e2e serves a changed build mid-run and asserts it arrives. THE APP DID NOT START WITHOUT STORAGE. A private window rejects the open, and the rejection was `undefined` because request errors were passed through unchecked. The result was a blank screen with nothing in the console. There is a backend seam now, with an in-memory fallback and a `durable` flag; the app boots and says plainly that it is not saving anything. TRANSCRIPT WRITES COULD REJECT WITH NOBODY LISTENING. Three console writes were fire-and-forget, so a full disk became an unhandled rejection. Losing a line of narration is survivable; losing it silently while the page logs an uncaught error is not. THE COST OF A WRITE GREW WITH THE AGE OF THE HOUSEHOLD. Every append re-folded the entire log and structuredClone'd every record: 0.4 ms per write at 200 entries, 4.8 ms at 3,000, and every tick writes twice. Entries now fold into the existing view incrementally. Also handles onblocked and onversionchange, which could otherwise leave the open promise neither resolved nor rejected -- an app hanging on a blank screen with no error at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Sep 2, 2026
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.
The stack
localStoragefor the test suite on Node 25@ivanmkc/termchart-canvas; injectable interact transportEvery branch in the stack was checked out on its own and verified independently:
npm install,tsc --noEmitfor every package,npm testacross the workspace, and the offline e2e where it exists. All nineteen are green — no branch depends on a later one to build or pass. Full viewer e2e (11 suites, 106 assertions) was run on #312 as the behaviour-preservation evidence for the extraction.Stack position: 11 of 12. Base:
feat/lifeboard-foundation.An audit of the state persistence mechanism —
log.ts,store.ts,transcript.ts,prefs.ts,sw.js— turned up five defects. Each is fixed here with a test that fails without the fix. The full write-up, including the three findings left open, is indocs/lifeboard/persistence-audit.md(added in the next PR).A second tab's writes were silently lost. Sequence numbers were minted from the in-memory tail, so two connections to the same database produced the same key, IndexedDB rejected the duplicate, and the entry never landed. What a person saw: a tick in the second tab reverting itself a moment after being tapped, with no explanation. Reproduced before the fix:
Reading and appending now happen in one transaction, and a tab absorbs whatever another wrote before deciding what its own change means — so an update against a record it had never seen is applied rather than dropped.
A new build never reached anyone.
app.jsis not content-hashed and the service worker was cache-first for every same-origin GET, so a deploy was invisible until someone bumpedCACHEinsw.jsby hand — which nothing enforced. The shell is network-first with a cache fallback now; content-hashed chunks stay cache-first, where that is free and correct. The e2e serves a changed build mid-run and asserts it arrives.The app did not start without storage. A private window rejects the open, and the rejection was literally
undefinedbecause request errors were passed through unchecked — a blank screen with nothing in the console. There is aBackendseam now, with an in-memory fallback and adurableflag; the app boots and says plainly that it is not saving anything.prefs.tsalready degraded this way; the store now matches it.Transcript writes could reject with nobody listening. Three console writes were fire-and-forget, so a full disk became an unhandled rejection. Losing a line of narration is survivable; losing it silently while the page logs an uncaught error is not.
The cost of a write grew with the age of the household. Every append re-folded the entire log and
structuredCloned every record — 0.39 ms per write at 200 entries, 1.57 at 1,000, 4.79 at 3,000, and every tick writes twice. Entries fold into the existing view incrementally now.Also handles
onblockedandonversionchange, which could otherwise leave the open promise neither resolved nor rejected — an app hanging on a blank screen with no error at all.Left open, and argued in the audit: no export or backup (the largest remaining risk, and the cheapest to fix — the durable state is a JSON array); no schema version on entries; no log compaction.
Verified: lifeboard 350 tests (7 new), offline e2e 18/18, all packages green.
🤖 Generated with Claude Code