refactor(web): route browser persistence through one validated seam - #3651
Conversation
localStorage is the web host's single persistence layer, but the getItem -> parse -> guard -> stringify -> setItem boilerplate was hand-rolled across five per-device stores. Consolidate all access into web-local-store.ts: - createRecordStore(key, entrySchema) for the Record<string, Entry> registries (workspaces, archive, task-metadata); readValidated(key, schema, fallback) for the browser-tabs snapshot; raw readJson/writeJson/removeKey for the auth session/preferences; rawLocalStorage for the zustand backend. - Every persisted store is now validated against a Zod schema on read and drops what no longer fits (per-entry for registries), so evolving a shape is a schema edit rather than a hand-written localStorage migration. Reuse the canonical workspaceSchema / tabsSnapshotSchema from @posthog/shared; derive the two web-local types (WebArchivedTask, TaskMetadata) from their schemas via z.infer. IndexedDB is left untouched: it holds only the non-extractable auth cipher key (web-auth-adapters.ts), which localStorage cannot store without exposing its raw bytes. Documented the web-host build story and this storage policy in AGENTS.md, and fixed a stale web-container.ts comment referencing a nonexistent test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
😎 Merged successfully - details. |
|
React Doctor found no issues in the changed files. 🎉 Reviewed by React Doctor for commit |
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 1 · PR risk: 0/10 |
|
…ackend Review flagged that the seam's best-effort swallow was applied where the original direct writes let errors propagate, turning failures into silent successes: - Auth session/preferences writes and the logout clear now use strict variants (writeJsonStrict / removeKeyStrict) that propagate. A swallowed clearCurrent() would report logout complete while the session stayed in localStorage, recoverable on reload. - rawLocalStorage (zustand persist backend) lets setItem/removeItem throw again; the renderer persistence layer already awaits and logs failed writes, so swallowing reported dropped drafts/settings/layout writes as success. Best-effort writeJson/removeKey stay for the rebuildable per-device caches, where a dropped write only costs cross-reload persistence. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed the review feedback in c9428ab. All three comments were the same root issue — the seam's best-effort swallow was applied where the original direct writes let errors propagate, turning failures into silent successes. Fixed by splitting the seam into two tiers:
The best-effort |
|
/trunk merge |
Why
The web host uses localStorage as its single persistence layer, but the
getItem → JSON.parse → guard → JSON.stringify → setItemboilerplate was hand-rolled across five per-device stores. This consolidates all browser-storage access into one seam and adds Zod-validated versioning so future shape changes don't require hand-written migrations.What
One seam —
apps/web/src/web-local-store.ts. All localStorage access routes through it (grep confirms zero directwindow.localStorageelsewhere):createRecordStore(key, entrySchema)— theRecord<string, Entry>registries (workspaces, archive, task-metadata). Validates per entry on load and drops only stale rows.readValidated(key, schema, fallback)— the browser-tabs snapshot (single object; drops to empty + re-seeds if incompatible).readJson/writeJson/removeKey— auth session/preferences (record types owned by core, no local schema).rawLocalStorage— the zustand persist backend.Versioning via Zod, not migrations. Every persisted store is a discardable per-device cache validated on read; invalid data is shed and rebuilt from the server. Evolving a shape is now a schema edit. Reuses the canonical
workspaceSchema/tabsSnapshotSchemafrom@posthog/shared(no drift from the desktop service), and derives the two web-local types (WebArchivedTask,TaskMetadata) from their schemas viaz.infer.IndexedDB left untouched. It holds only the non-extractable AES-GCM auth cipher key (
web-auth-adapters.ts), which localStorage physically cannot store without exposing its raw bytes — the property that keeps a stolen token dump undecryptable offline. This is documented as a deliberate exemption, not a second app-state store.Docs. Adds a "Web Host" section to
AGENTS.md(build story + storage policy) and fixes a staleweb-container.tscomment that referenced a nonexistent test.Behavior / risk
No behavioral change for valid data. On first load after this lands, any persisted cache that fails validation is dropped and rebuilt (these are per-device sidebar/tab caches, not durable data). No async ripple into host-agnostic
core/ui— reads stay synchronous.Testing
pnpm --filter @posthog/web typecheck✅biome check✅ (also enforced by pre-commit)🤖 Generated with Claude Code