Persist Utility -- Architecture
Internal design documentation for the persist() utility in @codebelt/classy-store/utils.
Design Principles
-
Standalone utility, not middleware.
persistis a plain function that consumes two existing public APIs (subscribeandsnapshot) and nothing else. No changes tocore.ts,snapshot.ts, oruseStore.tswere needed. -
Tree-shakeable.
persistlives in a separate entry point (@codebelt/classy-store/utils). Applications that don't use persistence pay zero cost -- the code is never bundled. -
Storage-agnostic. The
StorageAdapterinterface accepts sync (localStorage,sessionStorage) and async (AsyncStorage,localForage) implementations through the same type.persisthandles both transparently viaawait. -
Class-aware. The utility understands class stores: getters and methods are automatically excluded from persistence. Only source-of-truth data properties are saved. This is a key differentiator from libraries like Zustand where
partializerequires manual exclusion.
How It Hooks Into the Existing Architecture
persist sits beside the existing three-layer architecture as an independent consumer:
The key insight: writing proxy[key] = value during hydration flows through the existing SET trap in core.ts, which handles child proxy cleanup, version bumping, and notification scheduling automatically. The persist utility doesn't need to know anything about the proxy internals.
File Structure
src/
index.ts # Existing barrel (unchanged)
utils/
index.ts # Barrel: persist, devtools, subscribeKey, withHistory
persist/
persist.ts # persist(), types, and all logic
persist.test.ts # tests with mock storage adapters
package.json # "./utils" export entry
tsdown.config.ts # 'src/utils/index.ts' in entry array
Internal State
Each persist() call creates a closure with the following internal state:
| Variable | Type | Purpose |
|---|---|---|
disposed | boolean | Guards against writes after unsubscribe() |
debounceTimer | ReturnType<typeof setTimeout> | null | Active debounce timer (cancelled on save/unsubscribe) |
hydratedFlag | boolean | Synchronous hydration status |
expiredFlag | boolean | Set to true when hydration encounters expired data |
resolveHydrated | () => void | Resolves the hydrated promise |
rejectHydrated | (error) => void | Rejects the hydrated promise on error |
resolvedProps | Array<{key, transform?}> | Normalized property list with optional transforms |
transformMap | Map<string, PropertyTransform> | Fast lookup from key to transform |
propKeys | string[] | Flat list of property keys to persist |
No global mutable state. Multiple persist() calls on different stores (or even the same store with different keys) are fully independent.
Save Flow
Triggered by subscribe(proxy, callback) firing after each batched mutation:
1. subscribe callback fires
2. if disposed → return (guard)
3. if debounce > 0:
cancel existing timer
schedule new timer → goto step 4 after delay
else:
goto step 4 immediately
4. snapshot(proxy) → frozen immutable copy
5. for each key in propKeys:
read value from snapshot
if transform exists → call transform.serialize(value)
assign to state object
6. wrap in envelope: { version, state }
7. if expireIn is set → stamp expiresAt = Date.now() + expireIn
8. JSON.stringify(envelope)
9. await storage.setItem(name, json)
Property Resolution
On initialization, resolveProperties() determines which keys to persist:
- If
propertiesis provided: each entry is either a string key (used as-is) or aPropertyTransformdescriptor (key extracted, transform stored intransformMap). - If
propertiesis omitted: takes a snapshot of the store, iteratesObject.keys(), and excludes:- Getters: detected via
findGetterDescriptor()walking the prototype chain withObject.getOwnPropertyDescriptor. - Methods: detected via
typeof value === 'function'on the proxy.
- Getters: detected via
This resolution happens once at persist() call time, not on every save.
Storage Envelope
Data is stored as a JSON string with a version wrapper:
{
"version": 1,
"state": {
"todos": [{"text": "Buy milk", "done": false}],
"filter": "all"
},
"expiresAt": 1700000000000
}
The version field is always present (default 0). The state field contains only the selected properties, post-serialization-transform. The expiresAt field is a Unix epoch timestamp (milliseconds) and is only present when expireIn is set. It is refreshed on every write. Envelopes without expiresAt are treated as "never expires".
Restore Flow (Hydration)
Triggered in three scenarios:
- Auto-hydration on init (unless
skipHydration: true) - Manual rehydrate via
handle.rehydrate() - Cross-tab sync via
window.storageevent
All three call the same applyPersistedState(raw) function:
1. JSON.parse(raw) → envelope
2. Validate envelope shape (object with .state)
if invalid → return silently (corrupted data)
3. if envelope.expiresAt exists AND Date.now() >= expiresAt:
set expiredFlag = true
if clearOnExpire → storage.removeItem(name)
return (skip hydration)
4. if envelope.version !== options.version AND migrate exists:
state = migrate(state, envelope.version)
if transform exists → state[key] = transform.deserialize(state[key])
6. Build currentState from snapshot (only propKeys)
7. Apply merge strategy:
'shallow' / 'replace': { ...currentState, ...persistedState }
custom function: merge(persistedState, currentState)
8. for each key in propKeys:
if key in mergedState → proxy[key] = mergedState[key]
(goes through SET trap → reactivity → batched notification)
9. Set hydratedFlag = true, resolve hydrated promise
Merge Strategies
| Strategy | Behavior |
|---|---|
'shallow' (default) | { ...current, ...persisted } -- persisted keys overwrite, new keys keep defaults |
'replace' | Same as shallow at top level. Conceptually signals full replacement for nested objects. |
Custom fn | fn(persisted, current) -- full control. Enables deep merge or selective overrides. |
Both 'shallow' and 'replace' produce the same result for flat stores. The distinction is semantic and affects how consumers think about nested object handling.
Error Handling
- Corrupted JSON in storage (
JSON.parsethrows): caught silently, hydration completes with no state changes. - Invalid envelope (missing
.stateor not an object): detected by shape check, skipped silently. - Async errors during
storage.getItem(): caught, hydrated promise rejects,hydratedFlagstill set totrue.
Cross-Tab Sync
When syncTabs is enabled (default for localStorage), the utility listens for the window.storage event:
Key properties of window.storage:
- Fires in other same-origin tabs only (not the tab that wrote). This prevents infinite loops.
- Only works with
localStorage-- notsessionStorage, not async adapters. - The event payload includes
key,oldValue,newValue, andstorageArea.
Auto-detection
If syncTabs is not explicitly set:
truewhen the storage adapter is literallyglobalThis.localStorage(checked via identity comparison).falsefor everything else (sessionStorage,AsyncStorage, custom adapters).
Cleanup
handle.unsubscribe() performs three cleanup steps:
1. Set disposed = true (guards all future writes)
2. Cancel pending debounce timer (if any)
3. Call unsubscribe from store mutations (remove subscribe callback)
4. Remove window 'storage' event listener (if syncTabs was active)
After unsubscribe():
- Store mutations no longer trigger writes to storage.
- Cross-tab changes to the storage key are ignored.
save()becomes a no-op.clear()andrehydrate()still work (they operate on storage directly, not on subscriptions).
PersistHandle API
| Method/Property | Behavior |
|---|---|
unsubscribe() | Full cleanup: unsubscribe + cancel debounce + remove storage listener. Idempotent. |
hydrated | Promise that resolves when initial hydration completes. Rejects on async storage errors. |
isHydrated | Getter returning boolean. Becomes true after hydration resolves or rejects. |
isExpired | Getter returning boolean. true when the last hydration found expired data (requires expireIn). Re-evaluated on every rehydrate() call. |
save() | Cancel pending debounce, write current state immediately. No-op if disposed. |
clear() | Call storage.removeItem(name). Works even after unsubscribe. |
rehydrate() | Re-read from storage, apply to store. Also resolves hydrated if not yet resolved (for skipHydration flows). |
Dependency Graph
persist has no dependency on useStore.ts, types.ts, or collections.ts. It only uses:
subscribe()-- to listen for store mutationssnapshot()-- to create an immutable copy for serializationfindGetterDescriptor()-- to detect class getters during property resolution
Performance Characteristics
| Operation | Complexity | Notes |
|---|---|---|
| Property resolution | O(keys) | Once at init, not per-save |
| Save (per mutation batch) | O(propKeys) | Snapshot + pick + serialize + JSON.stringify |
| Debounced save | O(1) per mutation | Timer reset; actual save is O(propKeys) when timer fires |
| Hydration | O(propKeys) | JSON.parse + deserialize + merge + assign |
| Cross-tab sync | O(propKeys) | Same as hydration, triggered by storage event |
save() | O(propKeys) | Same as auto-save, synchronous trigger |
unsubscribe() | O(1) | Flag + timer cancel + listener removal |
Design Decisions
Why a function, not a decorator or middleware?
Decorators require class modifications and don't compose well with the existing createClassyStore() wrapping. Middleware requires an interception layer that doesn't exist in the architecture (the proxy IS the middleware). A standalone function that takes a proxy and returns a handle is the simplest, most composable API.
Why per-property transforms instead of global serialize/deserialize?
Global serialize/deserialize (as in Zustand's createJSONStorage) operates on the entire state blob. This makes it awkward when only one property needs special handling (e.g., a single Date field among ten string fields). Per-property transforms are more ergonomic for class-based stores where each property has a known type.
Why exclude getters automatically?
Class getters are derived/computed values that recompute from persisted data properties. Persisting a getter result would be redundant and risk restoring a stale value that contradicts the actual data. For example, if get remaining() returns 3 but the persisted todos array has 5 incomplete items, the restored getter value would be wrong until something triggers a recompute. By excluding getters, we persist only the inputs and let the outputs recompute naturally.
Why a hydrated Promise instead of lifecycle hooks?
Zustand uses onRehydrateStorage (a callback), Pinia uses beforeHydrate/afterHydrate hooks. A Promise is more composable: it works with async/await, can be passed to React Suspense boundaries, and doesn't require a registration pattern. The isHydrated boolean covers the synchronous check case.
Why debounce instead of throttle?
Debounce waits for a quiet period after the last mutation. Throttle writes at regular intervals. For persistence, debounce is more appropriate: it coalesces bursts of mutations (like typing) into a single write, while throttle would write mid-burst with potentially incomplete state. save() provides the escape hatch for "write right now" scenarios.
| Group | What's covered |
|---|---|
| Basic round-trip | Save/restore, versioned envelope, getter exclusion, method exclusion |
| Properties option | Persist specific properties, restore only specified properties |
| Per-property transforms | Date round-trip, ReactiveMap round-trip |
| Debounce | Coalescing, save bypass |
| Version migration | Migration call, version match skip |
| Merge strategies | Shallow, replace, custom function |
| skipHydration | No auto-hydrate, manual rehydrate, promise resolution |
| Unsubscribe | Stop writes, cancel debounce |
| Flush/clear/rehydrate | Immediate write, remove data, in-memory preservation, re-read |
| Hydration state | Promise instance, resolution timing |
| Async storage | Async write, async hydrate |
| Cross-tab sync | Correct key, wrong key, post-unsubscribe, disabled |
| expireIn / TTL | Normal hydration, expired skip, clearOnExpire, default leave-in-storage, cross-tab reject, no-expiresAt envelope, TTL reset on write, rehydrate re-check |
| Edge cases | Empty storage, corrupted JSON, invalid envelope, no-storage error, multiple persists |