State Management
Zustand stores, localStorage persistence, and the background document sync engine.
State Management
VeriWorkly's Studio is local-first: the browser is the primary source of truth, and the backend is an optional persistence and processing layer.
Objectives
- Client-centricity — document data is persisted locally, so the editor works offline and before login.
- Optimistic updates — UI interactions apply immediately; no network round-trip blocks typing.
- Decoupled synchronisation — pushing to the cloud is a background process, and can be turned off entirely per account or per document.
- Data integrity — schema normalisation runs on every load, and a revision counter guards against silent cross-device overwrites.
Stores
Studio uses Zustand without persistence middleware. Persistence is explicit: stores expose
saveToStorage / hydrateFromStorage actions that call dedicated service modules, so writes are
deliberate rather than a side effect of every state change.
| Store | Location | Holds |
|---|---|---|
useResumeStore | features/resume/store/resume-store.ts | The single active ResumeData document plus the currently selected section. |
useCoverLetterStore | features/cover-letter/store/cover-letter-store.ts | The single active CoverLetterDocument plus the currently selected section. |
useUserStore | store/useUserStore.ts | The session user, login state, and loading flag. |
useResumeStore and useCoverLetterStore hold one document at a time — not a collection. The multi-document library is not
store state; it is read from and written to localStorage through
features/documents/services/document-library.ts.
interface ResumeStoreState {
resume: ResumeData;
selectedSection: ResumeSectionId;
setResume: (resume: ResumeData) => void;
hydrateFromStorage: () => void;
saveToStorage: (options?: SaveResumeOptions) => SaveResumeResult;
resetResume: () => void;
emptyResume: () => void;
// Section-level mutators
setSectionVisibility: (section: ResumeSectionId, visible: boolean) => void;
reorderSections: (fromIndex: number, toIndex: number) => void;
updateSectionColumn: (sectionId: ResumeSectionId, column: "left" | "right") => void;
// Content mutators (basics, summary, links, skills, experience,
// education, projects, and custom sections) …
}Every mutator runs the result through normalizeResumeData and stamps an updatedAt timestamp, so
malformed or partially-migrated data is repaired on the way in rather than persisted.
Persistence
All document persistence targets the browser's localStorage.
Keys follow a versioned scheme owned by
features/documents/services/storage-keys.ts:
v3 Storage Layout (Per-Document Keys + Metadata Index)
The v2 storage scheme stored every document of a type inside a single JSON blob. That made every write scale with total library size and caused cross-tab clobbering. The v3 scheme isolates every document and decouples metadata indexing:
| Key | Contents |
|---|---|
veriworkly:docs:v3:doc:{type}:{id} | Individual document data. Reads and autosaves are constant time O(1). |
veriworkly:docs:v3:index | Metadata index (id, type, title, updatedAt, sync status, revision) used for fast library listing. |
veriworkly:docs:v2:active | TYPE:id pointer to the currently active document (deliberately preserved across migrations). |
veriworkly:sync-outbox | Pending background sync work items and retry queue. |
veriworkly:sync-telemetry | Last attempt / success / error timestamps and error diagnostics. |
veriworkly:master-profile | Local cached master profile facts record. |
veriworkly:workspace-settings | Local workspace and editor preferences. |
(Note: veriworkly:docs:v2:{type} is legacy — read once during automatic migration to v3, then cleaned up).
Two custom DOM events (veriworkly:docs-storage-updated and
veriworkly:sync-outbox-updated) let the autosave layer and the sync layer notify each other without
importing one another — this avoids a circular dependency between them.
Resilience
- Self-healing reads — a corrupted JSON value is cleared and replaced with a safe fallback rather than throwing. An uncaught throw inside the fire-and-forget sync tick would silently stop syncing until a page reload.
- Schema validation — document collections are validated on load and malformed entries are dropped instead of being carried forward in an obsolete format.
- Quota handling — when the browser's storage quota is reached, the user is notified and offered cloud offloading or document cleanup.
The sync engine
features/documents/services/sync-engine.ts implements a retrying outbox.
Sync states
local-only → pending → syncing → synced
↘ conflicted| State | Meaning |
|---|---|
local-only | The document is deliberately excluded from sync (keepLocalOnly). |
pending | Local changes are queued for upload. |
syncing | An upload is in flight. |
synced | Local and cloud agree. |
conflicted | The server rejected the write because the cloud revision moved on. |
Outbox items
Each queued document carries an attempts count and a nextAttemptAt timestamp, so failed uploads
back off rather than retrying in a tight loop. Conflicted items are excluded from automatic retry —
they wait for the user to resolve them in the conflict details view under /settings.
Conflict detection
Every Document row carries a revision integer. The client sends the revision it last saw; if the
server's revision has advanced, the write is rejected and the local document is marked
conflicted rather than overwritten. Failures are classified by reason (conflict, auth,
forbidden, not-found, network, unknown) so the UI can respond appropriately — a network blip
retries, an auth failure prompts a re-login, a conflict prompts resolution.
Controls
- Auto-sync toggle — an account-level setting (
autoSyncEnabled, default on) exposed at/settings, plus a manual Sync Now action. - Keep local only — a per-document opt-out that pins a document to
local-onlyand never uploads it.
Editing during an in-flight sync is safe
The sync service captures a snapshot of the document when an upload starts, and by the time the
server responds the user may have typed further and autosaved. The completion handler therefore
never writes that snapshot back wholesale: document-sync-service.ts re-patches only the
sync sub-object onto whatever is currently in storage, via localStorage.patchSync(id, …).
Interim keystrokes survive the round trip.
Portfolio state
The portfolio builder implements the same idea independently. Its editor autosaves every 12
seconds while dirty, warns on beforeunload if there are unsaved changes, and merges a guest local
draft against the cloud draft by comparing updatedAt timestamps so neither side clobbers newer
data. It does not share Studio's sync engine.
For the underlying data shapes, see Resume Schema and Database Schema.