User journey · verified · persistence & recovery

Where Does Your Work Live?

A user makes a change in the editor. If their browser crashes, their session expires, they switch devices, they switch from parent to child — what survives, where?

Last verified 2026-04-18 against code
Slug persistence-map
Storage tiers 4 (memory · sessionStorage · IndexedDB · server)
Autosave none

§1 The big idea

FuzzyCode has no autosave. A user's in-progress work lives in four layers with very different lifetimes, scopes, and recovery paths. Understanding which layer holds what is the difference between "I lost my work" and "my work is fine, I just need to refresh."

§2 The four storage layers

1. projectVersionHistory · in-memory JS global per-tab

Lives in
browser memory, one JS object
What
every version's { html, prompt, diff, provenance, pii_attestation } — the editor's authoritative view
Written by
saveVersion() — only on user action (send, swipe, paste, code edit, metadata, etc.)
Read by
every iframe swap, every diff, every publish
TTL
until tab close / hard refresh
Survives
nothing on its own — relies on sessionStorage restore
Evidence
FuzzyCode/static/script.js:2865
authoritative source for whatever is "in the editor right now" — derived from below on restore

2. sessionStorage · fast restore per-tab, per-session

Lives in
browser sessionStorage
What
whitelisted globals including projectVersionHistory, input values, last-selected version
Written by
window_tab_saveState() — after every saveVersion + on unload
Read by
window_tab_state_restorer on page load
Key scope
scoped by projectId + user/child context — household switches don't collide
TTL
until tab closes
Evidence
window_tab_state_restorer.js:255-319
↕ restores memory layer on tab refresh

3. IndexedDB · fuzzycode_recent_tabs / tab_state per-browser, per-child

Lives in
browser IndexedDB
DB / store
fuzzycode_recent_tabs / tab_state
Key shape
child:<childId>:project:<pid>partitioned per child
Written
after each saveVersion + on unload
Retention
360 days, cleaned up at restore
Survives
tab close · browser close · JWT expiry · cookie clear · network loss
Doesn't survive
clear site data · different browser · different device
User-facing
/local_recent_history popup with Active vs Legacy buckets (after context switches)
Evidence
FuzzyCode/static/fuzzy-blocks/tab_state_storage.js
↕ survives browser restarts · the cross-device gap

4. Redis · temp_page:* keys server, per-child

Lives in
server Redis
Key shape
temp_page:<hash> — keyed by content hash
Scope
ownership by child_id / user_id / username; _owns_temp_page gate
Written
when the editor needs a preview URL (iframe re-render with helper scripts, sharing)
Retention
30 days TTL
User-facing
/user_projects (cross-device list) · /project_history/<pid> (diff timeline) · iframe via usercontent.fuzzycode.dev Worker
Gotcha
parents without a selected child get 403 CHILD_CONTEXT_REQUIRED on /temp_page/*
Evidence
FuzzyCode/quart_server/blueprints/temp_pages.py:181
↕ cross-device recovery (for 30 days)

5. Pages DB + S3 · published rows canonical, forever

Lives in
pages Postgres row + screenshot in S3 (aws.fuzzycode.dev/uploaded/<hash>.webp)
Only via
Publish — attest-publish + @pages/submit
Retention
forever (soft-delete on /delete_page)
User-facing
pages.fuzzycode.dev/page/<hash> public URL · share tokens for unlisted
Evidence

§3 Survival matrix — what survives what?

Columns = the four in-progress layers (Publish always survives once done). Rows = recovery events a user might experience. Read a row to see what happens to their work.

Recovery event Memory
projectVersionHistory
sessionStorage IndexedDB
(per-child)
Redis
temp_page:*
Tab refresh (same tab) lost restored → memory unaffected unaffected
Tab close, reopen later lost lost user sees in /local_recent_history visible in /user_projects (30d)
Browser close + reopen lost lost survives survives
Clear cookies still in RAM if tab open scoped-keys may survive IndexedDB unaffected 401 on /temp_page/* until re-login
Clear site data lost lost lost survives on server (30d)
JWT expires mid-edit intact intact intact next write → 401 until refresh
Switch to a different device n/a n/a does not cross devices /user_projects shows last 30d
Parent → Child context switch cleared; new child's history loaded key-scoped — doesn't collide per-child key namespace; other child's records shown as "Legacy / unassigned" server re-scopes by child_id
Publish succeeds unaffected unaffected unaffected new pages row takes over as canonical

§4 The three user-facing "history" surfaces

Each uses a different backing store. Users need to understand which one they're looking at.

Local Recent History

/local_recent_history
IndexedDB per-browser 360d

Shows every project the user has touched in this browser. Opens as a popup. Partitioned per-child — switching to another child shows their own list; prior child's projects appear as "Legacy / unassigned" bucket.

Doesn't cross devices.

My Projects

/user_projects
Redis cross-device 30d

The cross-device project list — whatever the user has touched on any device in the last 30 days. Redis-backed, so fast but server-filtered by child scope.

Any project the user hasn't touched in 30 days disappears from this list — but is still retrievable if it was published.

Project Version History

/project_history/<pid>
Redis + server-rendered per-project

A timeline view of all versions of a single project, with diffs. Used for review and audit, not for authoring.

§5 Fork / duplicate — what exists, what doesn't

There is no in-editor fork button. Users "duplicate" by loading a shared URL or a published Pages page, which creates a fresh projectVersionHistory[newProjectId] with referencePageID set to the source.

flowchart LR classDef src fill:#eaf3ff,stroke:#1d58b1,color:#111 classDef action fill:#fff4d6,stroke:#a47a3a,color:#111 classDef new fill:#e8f6ec,stroke:#2f7a3e,color:#111 P[Existing project or published page]:::src P -->|user shares URL| U[Copy shared URL]:::action P -->|user opens /page/<hash>| V[View published page]:::src V -->|"Make a copy" link opens editor with ?fromURL=| L1[Load HTML into new
projectVersionHistory]:::action U -->|paste into editor clipboard| L2[loadHtmlFromUrl or
paste_as_new_project]:::action L1 --> N[New project id minted
referencePageID = source]:::new L2 --> N N --> E[User iterates normally]:::new E -->|Publish with duplication_strategy=duplicate| DP[New Pages row]:::new E -->|Publish with duplication_strategy=update
AND hash matches| DP2[⚠ silent overwrite of original]:::action
Publish with update strategy can silently overwrite the original. If the user duplicated from an existing page, edited it, and chose "update" on publish — if the hash happens to still match, the original page row is replaced. No confirmation dialog on the first Publish that opts into update.

§6 Metadata page — what it persists

The "Metadata" button opens a popup that introspects the current HTML (via /get_metadata) and lets the user edit extracted fields (title, description, images, sounds, controls). Metadata has no separate store — edits produce a new version with lastAction=update_metadata, saved to projectVersionHistory like any other change.

sequenceDiagram participant U as 👤 User participant E as Editor participant MP as Metadata popup
/metadata_page participant S as Server
/get_metadata participant IF as Iframe U->>E: click puzzle icon #openMetadataBtn E->>MP: open popup MP->>S: GET /get_metadata (current HTML) S-->>MP: { title, description, images[], sounds[], controls[] } MP-->>U: editable form U->>MP: edits fields, clicks save MP->>E: postMessage metadata changes E->>E: saveVersion('update_metadata', merged HTML) E->>IF: re-render

No separate metadata DB. The source of truth for metadata is what's embedded in the page's HTML.

§7 Gotchas

No autosave. Prompt text the user is typing lives only in sessionStorage. The instant they close the tab without firing an action that calls saveVersion, it's gone.
First-send creates the project id. Until a user hits Send or Paste, there is no project id. Until there is a project id, nothing persists to server.
Parent without a selected child → no /temp_page writes. /temp_page/* returns 403 CHILD_CONTEXT_REQUIRED. This also breaks iframe preview for unselected-parent sessions.
Local history shows "Legacy" buckets after context switches. Projects from a previously-active child appear in a separate "Legacy / unassigned" section until context is switched back.
No per-project delete in local history UI. Users can only wait for 360-day expiry or clear all IndexedDB.
referencePageID is mutated on past versions at publish time. This means a version created before publish may have referencePageID=null and, after publish, show the new page's ID. Diagnostics that assume this field is immutable will be misled.
window_tab_inclusion_list gates which globals survive reload. Only whitelisted names are written to sessionStorage. New globals added to script.js need explicit inclusion.
Child-scoped key space is strict. IndexedDB keys include child:<childId>. Parent acting as parent vs acting as Child A vs acting as Child B all see separate project lists.

§8 Verification pointers