IndexedDB-backed persistence for object graphs using dirty tracking and automatic serialization.
Strvct's persistence system stores object graphs in the browser's IndexedDB. Rather than requiring explicit save/load calls, it monitors slot changes and automatically commits dirty objects at the end of the event loop. The system is built from three layers:
SvObjectPool — Manages an in-memory cache of objects indexed by persistent unique IDs (puuids). Tracks dirty objects and handles serialization, deserialization, and garbage collection.SvPersistentAtomicMap — An IndexedDB wrapper that loads the entire database into memory on open, provides synchronous read/write to the cache, and batches writes into atomic IndexedDB transactions on commit.SvStorableNode — A node base class that hooks slot changes into the dirty tracking system.A node class opts into persistence by calling setShouldStore(true) in its initPrototype() method:
(class MyNode extends SvStorableNode {
initPrototypeSlots () {
{
const slot = this.newSlot("name", "");
slot.setSlotType("String");
slot.setShouldStoreSlot(true);
}
{
const slot = this.newSlot("score", 0);
slot.setSlotType("Number");
slot.setShouldStoreSlot(true);
}
{
const slot = this.newSlot("cachedResult", null);
// Not stored — transient, recomputed at runtime
}
}
initPrototype () {
this.setShouldStore(true);
this.setShouldStoreSubnodes(true);
}
}.initThisClass());Two flags control what is persisted:
setShouldStore(true) on the node — enables persistence for the node itself. Without this, the node and all its slots are ignored by the storage system.setShouldStoreSlot(true) on individual slots — marks that slot's value for inclusion in the serialized record. Slots without this flag are transient and will be lost on reload.A third flag controls whether child nodes are stored:
setShouldStoreSubnodes(true) — persists the node's subnodes array. Used for collection nodes where children are the primary data. When false, subnodes are transient and must be recreated on load (typically via setFinalInitProto() on slots with setIsSubnodeField(true)).Storage capability begins at SvStorableNode in the class hierarchy:
ProtoClass → SvNode → SvTitledNode → SvInspectableNode → SvViewableNode → SvStyledNode → SvStorableNodeClasses above SvStorableNode cannot be stored. Application model classes typically extend SvStorableNode or one of its subclasses like SvSummaryNode.
Every stored object has a puuid — a 10-character random string (A-Za-z0-9) that serves as its key in the object pool. Puuids are generated on first access via crypto.getRandomValues() and stored as a non-enumerable _puuid property on the object.
Puuids serve two purposes:
{ "*": "puuid" } rather than inlining the referenced object. This allows the storage system to trace the object graph for garbage collection.Each stored object is serialized as a JSON record with a type field and an entries array:
{
"type": "MyNode",
"entries": [
["name", "Alice"],
["score", 42],
["inventory", { "*": "aB3xK9mQ2p" }]
]
}Primitive values (strings, numbers, booleans, null) are stored inline. Object references are stored as { "*": "puuid" } pointers. The type field identifies the class so the correct constructor can be used on deserialization.
recordForStore(aStore) — iterates all slots with shouldStoreSlot() == true, calls aStore.refValue() on each value to handle object references, and returns the record.loadFromRecord(aRecord, aStore) — iterates the record's entries, calls aStore.unrefValue() to resolve references back to live objects, and sets each slot value.The persistence system uses automatic dirty tracking so application code never needs to call save explicitly.
SvStorableNode.didUpdateSlot() checks whether the node and slot are both marked for storage.didMutate(), which notifies the SvObjectPool via a mutation observer.addDirtyObject(obj) to add the object to its dirty set.addDirtyObject() calls scheduleStore(), which uses SvSyncScheduler to defer the commit.commitStoreDirtyObjects() runs, serializing all dirty objects and writing them to IndexedDB in a single atomic transaction.Because the commit is deferred, multiple slot changes within the same event loop — even across different objects — are batched into a single IndexedDB transaction. This keeps persistence efficient even when many properties change in rapid succession.
commitStoreDirtyObjects() begins an IndexedDB transaction via SvPersistentAtomicMap, then iterates the dirty set. For each dirty object, it calls recordForStore() to serialize it and writes the JSON string to the map keyed by puuid. After all objects are stored, the transaction is committed atomically. If any object becomes dirty again during the commit (because serialization triggers side effects), the cycle repeats until the dirty set is empty.
SvPersistentAtomicMap wraps SvIndexedDbFolder with a synchronous in-memory cache. On open, it loads the entire database into a JavaScript Map. All reads are served from memory. Writes go to an in-memory write cache and are flushed to the underlying store in batched transactions on commit. SvIndexedDbFolder is the environment abstraction point — it uses native IndexedDB in the browser and LevelDB (via classic-level) in Node.js, with the same async API in both cases. See Headless Execution for details on the storage abstraction.
This design means:
promiseBegin() — snapshots the current state into a write cache.atPut, removeKey) modify the write cache.promiseCommit() — applies all changes to IndexedDB in a single transaction.The application opens the pool once at startup:
const pool = SvPersistentObjectPool.sharedPool();
await pool.promiseOpen();
const root = await pool.rootOrIfAbsentFromClosure(() => {
return MyRootNode.clone();
});promiseOpen() opens the IndexedDB database, loads all records into memory, and runs garbage collection. rootOrIfAbsentFromClosure() loads the existing root object from storage, or creates a new one using the provided closure if no root exists yet.
Loading the root object triggers a cascade: as each object is deserialized, its slot values that contain { "*": "puuid" } references cause those referenced objects to be loaded in turn. After all objects in the reachable graph have been loaded, finalInit() is called on each to re-establish object relationships, followed by afterInit().
Renaming a stored class would normally break deserialization: existing IndexedDB records still reference the old class name in their type field, and classForName() would fail to find the renamed class. To avoid a forced data migration, the pool supports a conversion map that routes old class names to their new names at load time.
Before opening the pool, register the old→new mappings:
const store = this.defaultStore();
store.addClassNameConversion("OldName", "NewName");
// or for many at once:
store.addClassNameConversionTuples([
["OldName", "NewName"],
["AnotherOldName", "AnotherNewName"]
]);
await store.promiseOpen();classForName() checks the map first — if an entry exists, it looks up the new name instead. Stored records are left unchanged on disk; next time each object is saved, it's written with the new class name, so the database gradually migrates itself.
extend SvStorableNode (directly or indirectly) have their names embedded in records, so a rename without a conversion mapping will strand that data.The conversion map only affects record deserialization. It does not rewrite code, JSDoc, or string literals elsewhere in the codebase — those must be updated directly (see the codemod pattern in ClassRenames.json).
The pool uses mark-and-sweep garbage collection to remove unreachable objects:
{ "*": "puuid" } references in stored records, marking each visited puuid.Garbage collection runs automatically when the pool opens. It ensures that objects which are no longer reachable from the root — for example, nodes removed from a collection — are cleaned up from IndexedDB.
Blob garbage collection runs separately via SvBlobPool (see Local and Cloud Blob Storage).
SvSubObjectPool is an in-memory variant of SvObjectPool used for cloud sync rather than local persistence. It uses a plain SvAtomicMap instead of SvPersistentAtomicMap (no IndexedDB) and does not auto-schedule commits. Instead, it provides explicit methods for cloud upload with delta optimization. See Cloud Object Pools for details.
| Class | Purpose |
|---|---|
SvObjectPool | Base pool: object cache, dirty tracking, serialization, GC |
SvPersistentObjectPool | Singleton SvObjectPool backed by IndexedDB |
SvPersistentAtomicMap | IndexedDB wrapper with synchronous in-memory cache |
SvStorableNode | Node base class that hooks slot changes to dirty tracking |
SvSubObjectPool | In-memory pool for cloud sync (no IndexedDB) |