Persistence
SQLRooms persistence is designed for local-first analytics apps where the host application owns durable storage. The storage might be a DuckDB table, a project file, IndexedDB, a server endpoint, or another workspace-specific location.
The persistence API separates three concerns:
- What part of room state is durable.
- How durable snapshots are loaded, saved, and removed.
- When a changed snapshot should be treated as dirty and flushed.
SQLRooms room state is built from composable slices on top of a Zustand room store. Persistence uses the same store and slice model: slice configs define the durable shape, and the persistence helpers connect that shape to host-owned storage.
Most apps should start with createRoomStorePersistence(). It composes the lower-level controller with Zustand persist middleware storage and room-store subscription glue.
API Layers
Use the layers this way:
createPersistHelpers()maps slice config schemas to a typed persisted state shape and merges persisted configs back into runtime state.createRoomStorePersistence()is the usual app integration point. It exposesstoragefor Zustand persist, save status throughcontroller, and flush helpers for close or navigation events.createPersistenceController()is the storage-agnostic save policy. Use it directly only when you are not integrating a Zustand room store.
Recommended Room Store Setup
Define the slice configs to save and provide your storage callbacks. persistSliceConfigs() handles config extraction and merging automatically; the persistence helper uses the same schemas to track saved state.
import {
createPersistHelpers,
createRoomStore,
createRoomStorePersistence,
persistSliceConfigs,
} from '@sqlrooms/room-store';
import {BaseRoomConfig} from '@sqlrooms/room-config';
import {LayoutConfig} from '@sqlrooms/layout-config';
const sliceConfigSchemas = {
room: BaseRoomConfig,
layout: LayoutConfig,
};
const {partialize} = createPersistHelpers(sliceConfigSchemas);
const persistence = createRoomStorePersistence({
partialize: (state: RoomState) => partialize(state),
autosaveDelayMs: 300,
load: loadWorkspaceState,
save: (snapshot, metadata) => saveWorkspaceState(snapshot, metadata?.reason),
});
export const {roomStore, useRoomStore} = createRoomStore<RoomState>(
persistSliceConfigs(
{
name: 'workspace-state',
sliceConfigSchemas,
storage: persistence.storage,
onRehydrateStorage: persistence.onRehydrateStorage,
},
(set, get, store) => ({
// Compose room slices here.
}),
),
);loadWorkspaceState() returns a saved JSON string or null; saveWorkspaceState() writes that string. Both callbacks return promises. The RoomState annotation lets TypeScript infer the persisted config shape separately from the full runtime state.
Keep onRehydrateStorage: it marks the restored state as saved after merging. If you customize config extraction, also pass persistence.partialize to persistSliceConfigs() so saving and dirty tracking use the same shape.
Hydration Flow
When using Zustand persist, hydration has two phases: the durable snapshot is loaded by SQLRooms persistence, then Zustand merges the persisted state into the runtime store.
onRehydrateStorage() matters because the runtime state after merge might not be byte-for-byte identical to the loaded snapshot. Defaults, migrations, and schema normalization can all change the runtime shape. Marking the post-merge state as saved prevents the app from treating hydration as a user edit.
Save Flow
Zustand persist calls storage.setItem() when its persisted state changes. The storage adapter does not write immediately. It hands the serialized snapshot to the controller, which marks it dirty and schedules a save when autosave is enabled.
Call persistence.flush('final-flush') before unload, close, project switch, or any other operation where pending state must be durable before continuing.
Direct Store Subscription
Some hosts want persistence to observe the room store directly rather than only responding to Zustand persist storage calls. Pass store when creating persistence, or call bindStore() later.
const persistence = createRoomStorePersistence({
store: roomStore,
partialize,
autosaveDelayMs: 300,
load,
save,
});Direct binding is useful when:
- You are not using Zustand persist middleware.
- You need explicit control over initial binding.
- You want
shouldPersistChangeguards for host lifecycle states.
Initial Binding
By default, an initially-bound store snapshot is treated as already saved:
createRoomStorePersistence({
store,
partialize,
load,
save,
markInitialSnapshotSaved: true,
});Set markInitialSnapshotSaved: false when the initial state should be persisted as a new durable snapshot.
If persistence is created before store.getState() returns the completed initial state, pass initialState:
const persistence = createRoomStorePersistence({
store,
initialState,
partialize,
load,
save,
});Skipping Changes
Use shouldPersistChange when a store change should update the last observed snapshot but should not be saved.
const persistence = createRoomStorePersistence({
store,
partialize,
load,
save,
shouldPersistChange: (state) => state.room.initialized,
});This is useful during startup, teardown, restore flows, or failed initialization states.
Snapshot Equivalence
The controller compares snapshots by strict equality by default. This is ideal for JSON string snapshots. For structured snapshots, provide either compareSnapshots or getSnapshotRevision.
createRoomStorePersistence({
partialize,
load,
save,
serialize: (state) => ({
revision: state.revision,
payload: state,
}),
deserialize: (snapshot) => snapshot.payload,
getSnapshotRevision: (snapshot) => snapshot.revision,
});Use compareSnapshots when revision equality is not enough:
createRoomStorePersistence({
partialize,
load,
save,
compareSnapshots: (next, previous) =>
next.contentHash === previous.contentHash &&
next.layoutHash === previous.layoutHash,
});Low-Level Controller
Use createPersistenceController() directly when you need persistence policy without Zustand.
import {createPersistenceController} from '@sqlrooms/room-store';
const controller = createPersistenceController({
autosaveDelayMs: 300,
getSnapshot: () => serializeCurrentWorkspace(),
adapter: {
load: () => loadWorkspaceSnapshot(),
save: (snapshot, metadata) =>
saveWorkspaceSnapshot(snapshot, metadata?.reason),
},
});
const snapshot = await controller.hydrate();
restoreWorkspace(snapshot);
controller.markSnapshotSaved(serializeCurrentWorkspace());
controller.markDirty('manual');
await controller.flush('final-flush');The controller owns:
hydrating,dirty,saving, andpendingSavestate.- Autosave scheduling.
- Final flush behavior.
- Coalescing in-flight saves so the latest snapshot wins.
- Error reporting through
controller.getState().errorand subscribers.
It does not own:
- Which fields are durable.
- How snapshots are serialized.
- Where snapshots are stored.
- How loaded snapshots are merged into runtime state.
Remove Flow
Remove flow is for deleting or clearing the durable workspace snapshot, not for ordinary edits. It is relevant for workflows such as deleting a project, resetting a saved workspace, clearing local app data, or replacing a workspace with a freshly imported one.
If you pass remove, persistence.storage.removeItem(name) first flushes pending state so the host does not lose an in-flight save. It then pauses dirty tracking, calls your remove adapter, and marks the saved snapshot as null.
const persistence = createRoomStorePersistence({
partialize,
load,
save,
remove: async (name) => {
await deleteWorkspaceState(name);
},
});Without a remove option, removeItem() throws. This makes accidental durable deletes explicit.
Practical Checklist
When integrating persistence in an app:
- Decide the durable state shape. Prefer persisted slice configs first.
- Define Zod schemas for persisted slice configs.
- Use
persistSliceConfigs()for schema-based config extraction and merging. - Use
createRoomStorePersistence()for storage, dirty tracking, autosave, and final flush. - Pass
persistence.storageandpersistence.onRehydrateStoragetopersistSliceConfigs(). Passpersistence.partializetoo if you customize config extraction. - Register
persistence.flush('final-flush')for unload, close, or project switch. - Use
createPersistenceController()directly only for non-Zustand persistence flows.