Skip to main content

Persistence

NodalMerge persistence is split between node history and blob payloads. This allows you to combine different storage backends without changing sync correctness.

Persistence model

At the server layer, persistence is composed from:
  • Node persistence for room DAG history
  • Blob persistence for content-addressed binary payloads
Durability is treated as the combined property of both halves for operational decisions such as safe room eviction.

Built-in persistence modes

No persistence (default)

Without --store, server uses in-memory persistence only.
  • Fast startup
  • No restart durability
  • Suitable for local dev and ephemeral demos only

Directory persistence (--store)

With --store <root>, server enables durable local storage. Built-in on-disk layout includes:
  • <root>/nodalmerge.db for persisted nodes
  • <root>/blobs/<sanitized_room>/<hash> for blob files
  • <root>/blob-tombstones/<sanitized_room>/<hash> for GC tombstones
  • Topology sidecar stores under the same root (when enabled)

On-disk behavior details

Directory persistence uses SQLite plus file blobs. Key durability behaviors:
  • Node writes use INSERT OR IGNORE semantics for idempotent replays
  • Batch node persist path reduces commit overhead for accepted packs
  • Blob writes use write-to-temp then rename pattern
  • Blob load verifies hash integrity and skips mismatched payloads
Room IDs are sanitized for filesystem-safe blob/tombstone paths.

Hydration and runtime recovery

When durable mode is active, room creation hydrates from persisted data:
  1. Load persisted nodes
  2. Apply to in-memory graph
  3. Load persisted blobs into memory blob store
  4. Resume runtime sync with hydrated state
This allows restart recovery while preserving deterministic replay semantics.

Backend composition model

Persistence surfaces are designed for composition:
  • Use one backend for nodes
  • Use another backend for blobs
  • Expose as one combined server persistence surface
This is useful for patterns like:
  • SQL/document store for nodes
  • S3/R2/object storage for blobs
The architecture keeps transport and merge semantics stable regardless of backend pairing.

Backup strategy

For durable directory persistence, treat the entire store root as backup scope. At minimum, include:
  • SQLite node database
  • Blob object directories
  • Blob tombstone metadata
  • Topology sidecar stores (if used)
Backup policy should align with your replay/audit requirements, not only “latest state” expectations.

Restore expectations

After restore:
  • Rooms hydrate from restored persistence state
  • Missing/extra data may surface as deterministic replay differences
  • Blob integrity checks can reveal corrupted payload files
Always run post-restore validation with known test rooms before serving production traffic.

Data lifecycle and GC interaction

Blob persistence participates in GC lifecycle:
  • Live-set derived from room references
  • Two-phase tombstone -> delete behavior
  • Grace windows prevent premature deletion during state churn
Do not treat GC as a storage-only concern; it is tightly coupled to room reachability semantics.

Operational checks

For any durable deployment, verify regularly:
  • Persisted node count increases under write load
  • Blob files appear for expected hash references
  • Restart recovers known room state
  • Blob GC behavior matches configured interval/grace
  • Storage write latency metrics stay within SLO

SDK peer-local persistence (browser clients)

The server persistence described above handles server-side durability. Separately, the JavaScript SDK supports optional peer-local graph persistence in browser clients using IndexedDB. This allows clients to hydrate from local storage on page reload without waiting for a full server sync.

IndexedDB adapter setup

Off by default. Enable by passing persistence to createNodalMergeSdk:
const sdk = await createNodalMergeSdk({
  wsUrl: "ws://your-host/ws/runtime",
  roomId: "my-room",
  persistence: {
    enabled: true,
    adapter: "indexeddb",
    dbName: "nodalmerge-peer-local",  // default
    dbVersion: 1,                      // default
    debounceMs: 500                    // default auto-save interval
  }
});

// Hydration runs during initialize(), before connect().
await sdk.room.connect();
On initialize(), the SDK:
  1. Opens the IndexedDB database
  2. Loads the room’s node pack and blobs into the WASM SyncStore
  3. Reports hydration via sdk.persistence.hydrateReport()
Room data is scoped by roomId (nodes store) and roomId:hash (blobs store), so multiple rooms can safely share one database name.

Outbox persistence (separate from graph persistence)

offline.persistenceKey controls outbox persistence in localStorage — queued writes that haven’t been flushed to the server yet. This is independent of persistence.*:
const sdk = await createNodalMergeSdk({
  wsUrl: "...",
  roomId: "my-room",
  offline: {
    persistenceKey: "nodalmerge:my-room:outbox"
  }
});
sdk.offline.outboxDepth() returns the count of unsent ops. sdk.offline.flush() sends them immediately. sdk.offline.clearPersisted() wipes the localStorage queue.

Recovery patterns

After hydration, inspect the report to decide if recovery is needed:
const report = sdk.persistence.hydrateReport();
// { roomId, nodesPack, blobsRestored, canonicalHash }

if (!report || !report.canonicalHash) {
  // No local state — will sync fresh from server
}
If local persistence is corrupt or mismatched:
// Re-hydrate from the last good persisted snapshot
const freshReport = await sdk.persistence.recover();

// Wipe all local state for this room and start clean
await sdk.persistence.clearRoom();
Call sdk.persistence.flush() before page unload or navigation to ensure the latest graph state is written to IndexedDB. Flush is also invoked automatically on sdk.room.disconnect().

Migration from app-managed storage

If your app previously managed its own IndexedDB nodes/blobs storage:
  1. Remove hand-rolled idbPut('nodes', 'all', …) and blob cursor hydration.
  2. Pass persistence: { enabled: true } to createNodalMergeSdk.
  3. If you stored data under legacy demo key formats (nodes/all, flat blob hashes), pass persistence: { migrateLegacyDemo: true } during the first release to migrate in place.
  4. Keep offline.persistenceKey if you still need disconnected outbox replay.

Custom persistence adapters

Implement PeerLocalPersistenceAdapter to use a different storage backend (e.g., OPFS, a custom cache):
import { createNodalMergeSdk, PeerLocalPersistenceAdapter } from "nodalmerge-sdk-js";

const myAdapter: PeerLocalPersistenceAdapter = {
  kind: "my-storage",
  isAvailable: () => true,
  open: async () => { /* open connection */ },
  hydrate: async (store, roomId) => { /* load and return report */ },
  schedulePersist: (store, roomId) => { /* debounce a save */ },
  flush: async (store, roomId) => { /* immediate save */ },
  recover: async (store, roomId) => { /* reload from last good state */ },
  clearRoom: async (roomId) => { /* wipe room data */ }
};

const sdk = await createNodalMergeSdk({
  wsUrl: "...",
  roomId: "my-room",
  persistence: { enabled: true, adapter: myAdapter }
});
For server-side pod equivalents, use NODALMERGE_HEADLESS_BACKEND=file or embedded with a mounted data directory — not IndexedDB.

Common pitfalls

  • Assuming --store is optional for production durability
  • Backing up only DB but not blobs (or vice versa)
  • Enabling room eviction on non-durable persistence
  • Treating object-store direct I/O as replacement for persistence correctness
  • Ignoring hash mismatch warnings during blob load