Skip to main content

Persist

@oimdb/persist is the storage-agnostic persistence engine for OIMDB collections, objects, and manual indexes. It contains the engine only — the persistor lifecycle, resources, source adapters, and the versioned codec. The concrete backends ship as separate packages, so you only bundle the storage you actually use:

PackageStorage
@oimdb/persist-memoryin-memory Map (tests, SSR, server-side fill)
@oimdb/persist-localstoragelocalStorage
@oimdb/persist-idbIndexedDB
@oimdb/persist-jsonplain JSON dump — SSR dehydrate/hydrate transport
@oimdb/persist-async-kvasync key-value — React Native AsyncStorage, Cordova native storage

Each backend exposes a create<Backend>Persistor() factory plus the builder sugar (.collection(c), .object(o), .setIndex(i), …). The persistor instance API below is identical across backends — only the import of the factory changes. Install the engine plus the backend(s) you need:

npm install @oimdb/persist @oimdb/persist-localstorage @oimdb/core
# or @oimdb/persist-memory / @oimdb/persist-idb

Roles

  • Persistor: owns storage config and a resource registry.
  • Resource: pairs a source with a strategy.
  • Strategy: backend-specific read/write/clear. Locations (storageKey, tableName, bucketName) live here.

Memory

import { createMemoryPersistor } from '@oimdb/persist-memory';

const persistor = createMemoryPersistor({});

persistor.collection(users).records({ bucketName: 'users' });

await persistor.hydrate();
persistor.start();

LocalStorage One-Key Example

import { createLocalStoragePersistor } from '@oimdb/persist-localstorage';

const persistor = createLocalStoragePersistor({});

persistor.collection(users).entry({ storageKey: 'app:users' });

await persistor.hydrate();
persistor.start();

LocalStorage Nested Path Example

persistor.collection(users).path({
storageKey: 'app',
path: ['collections', 'users'],
});

persistor.object(settings).path({
storageKey: 'app',
path: ['settings'],
});

Multiple resources on the same root key are merged in one read and one write per flush.

IndexedDB Table And Row Example

import { createIndexedDbPersistor } from '@oimdb/persist-idb';

const persistor = createIndexedDbPersistor({
databaseName: 'app-db',
});

persistor.collection(users).entry({
tableName: 'entities',
primaryKey: { entityType: 'users' },
});

IndexedDB Records Example

persistor.collection(users).records({ tableName: 'users' });

Objects And Indexes

persistor.object(settings).entry({ tableName: 'entities', primaryKey: { entityType: 'settings' } });

persistor.setIndex(usersByRole).entry({ tableName: 'indexes', primaryKey: { index: 'usersByRole' } });
persistor.arrayIndex(recentItems).entry({ tableName: 'indexes', primaryKey: { index: 'recentItems' } });
persistor.orderedArrayIndex(queue).entry({ tableName: 'indexes', primaryKey: { index: 'queue' } });

Manual indexes persist keys and primary keys only, not entity values.

Custom Strategy

persistor.collection(users).using({
async read(p) {
return p.storage.entries.get('entities:users') as
| { records: Array<{ pk: string; value: User }> }
| undefined;
},
async write(p, snapshot) {
p.storage.entries.set('entities:users', snapshot);
},
async clear(p) {
p.storage.entries.delete('entities:users');
},
});

Codec

persistor.collection(users).entry(
{ storageKey: 'app:users' },
{
encode(snapshot) {
return snapshot.records.map(r => r.value);
},
decode(stored: User[]) {
return { records: stored.map(value => ({ pk: value.id, value })) };
},
}
);

Autosave And Queue

Without a queue each source change triggers an immediate write. With a queue all dirty resources are flushed once per AFTER_FLUSH — one render cycle, one write. For IndexedDB that means one transaction across all stores.

import { OIMEventQueue } from '@oimdb/core';

const persistor = createIndexedDbPersistor({
databaseName: 'app-db',
queue, // from OIMEventQueue or OIMReactiveCollection
});

persistor.collection(users).records({ tableName: 'users' });
persistor.setIndex(usersByRole).entry({ tableName: 'indexes', primaryKey: 'usersByRole' });

await persistor.hydrate();
persistor.start();

JSON Backend (SSR transport)

@oimdb/persist-json stores everything as a single plain, JSON-serializable object keyed by storageKey, so the whole registered state can be dehydrated to a string on the server and seeded back on the client. createJsonPersistor({ initial }) takes the inlined blob, and persistor.dehydrate() returns the JSON-serializable dump.

import { createJsonPersistor } from '@oimdb/persist-json';

// Server: fill, persist, dump.
const persistor = createJsonPersistor();
persistor.collection(questions).entry({ storageKey: 'questions' });
await persistor.persist();
const json = JSON.stringify(persistor.dehydrate());

// Client: seed from the blob, then hydrate.
const client = createJsonPersistor({ initial: window.__OIM__ });
client.collection(questions).entry({ storageKey: 'questions' });
await client.hydrate();

See the Server-Side Rendering guide for the full server + client flow and merging an SSR pre-state with a durable local cache.

Hydration Merge Hook (onHydrate + byPk)

By default hydrate() replaces whatever the collection holds with the stored snapshot — the last hydrate wins (this is the original, backward-compatible behavior). When two sources feed the same collection — for example an SSR pre-state plus a durable local cache — you can reconcile the incoming hydrate against the current contents instead via .onHydrate(reconcile):

import { byPk } from '@oimdb/persist';

persistor
.collection(questions)
.entry({ storageKey: 'questions' })
.onHydrate(
byPk((question, answer) =>
question ? { ...question, answer: answer.answer } : answer
)
);

reconcile(current, incoming) => snapshot receives the whole collection snapshot: current is what the collection holds now (e.g. the SSR pre-state), incoming is what this hydrate() brings (e.g. the IndexedDB data). byPk(...) lifts a per-entity resolver (current, incoming, pk) => entity | undefined to a collection-level reconcile — it walks the union of primary keys and calls your resolver for each. The exported type is TOIMPersistHydrateReconcile.

This is the foundation of the SSR + durable-cache flow. See the Server-Side Rendering guide for the full recipe.

Cleanup

persistor.stop();
persistor.destroy();

removeResource(resource) stops a resource and removes it from the registry. It does not delete persisted data. Use persistor.clearPersisted() to delete stored data.