Skip to content

Low-level state management primitives for SQLRooms, built on Zustand.

Use this package when you want to build custom room state from scratch.
If you want DuckDB + layout + room shell out of the box, use @sqlrooms/room-shell.

Installation

bash
npm install @sqlrooms/room-store

What this package provides

  • createRoomStore() and createRoomStoreCreator()
  • base lifecycle slice: createBaseRoomSlice()
  • generic slice helper: createSlice()
  • React context/hooks: RoomStateProvider, useBaseRoomStore, useRoomStoreApi
  • persistence helpers: persistSliceConfigs(), createPersistHelpers()
  • room-store persistence glue: createRoomStorePersistence()
  • persistence controller: createPersistenceController()

Quick start

tsx
import {
  BaseRoomStoreState,
  createBaseRoomSlice,
  createRoomStore,
  createSlice,
  type StateCreator,
} from '@sqlrooms/room-store';

type CounterSliceState = {
  counter: {
    value: number;
    increment: () => void;
  };
};

function createCounterSlice(): StateCreator<CounterSliceState> {
  return createSlice<CounterSliceState>((set, get) => ({
    counter: {
      value: 0,
      increment: () =>
        set((state) => ({
          counter: {
            ...state.counter,
            value: get().counter.value + 1,
          },
        })),
    },
  }));
}

type RoomState = BaseRoomStoreState & CounterSliceState;

export const {roomStore, useRoomStore} = createRoomStore<RoomState>(
  (set, get, store) => ({
    ...createBaseRoomSlice()(set, get, store),
    ...createCounterSlice()(set, get, store),
  }),
);

React integration

tsx
import {RoomStateProvider} from '@sqlrooms/room-store';
import {roomStore} from './store';

export function App() {
  return (
    <RoomStateProvider roomStore={roomStore}>
      <Dashboard />
    </RoomStateProvider>
  );
}
tsx
import {useRoomStore} from './store';
import {Button} from '@sqlrooms/ui';

function Dashboard() {
  const value = useRoomStore((state) => state.counter.value);
  const increment = useRoomStore((state) => state.counter.increment);

  return <Button onClick={increment}>Count: {value}</Button>;
}

Imperative access

Use roomStore.getState() for non-reactive code (events, timers, async jobs).

ts
import {roomStore} from './store';

export function incrementLater() {
  setTimeout(() => {
    roomStore.getState().counter.increment();
  }, 500);
}

Guarded command invocation

External and agent-facing integrations must use invokeCommandWithPolicy() instead of calling roomStore.getState().commands.invokeCommand() directly. The guarded helper re-checks that the command exists and is enabled immediately before execution. It also blocks high-risk or requiresConfirmation commands unless the caller supplies confirmation obtained from the user.

ts
import {invokeCommandWithPolicy} from '@sqlrooms/room-store';

const result = await invokeCommandWithPolicy(
  roomStore,
  'workspace.refresh',
  undefined,
  {
    surface: 'mcp',
    actor: 'assistant',
    traceId: requestId,
    metadata: {clientName: 'Example client'},
    signal: abortController.signal,
  },
  {confirmed: false},
);

Set confirmed: true only after explicit user confirmation. Omitting it, or passing false, fails closed with command-confirmation-required when the command requires confirmation. createCommandCliAdapter() and createCommandMcpAdapter() use the same guard and therefore have the same execution semantics.

Persistence

For a Zustand room store with host-owned storage, prefer createRoomStorePersistence(). It composes createPersistHelpers() with a controller-backed PersistStorage, rehydrate saved-snapshot marking, optional room-store subscription, autosave, and final flush helpers. This is the default entry point for SQLRooms apps that persist room state to DuckDB, files, or another project-owned store. See the Persistence developer guide for the full integration model, data flow, and examples.

persistSliceConfigs() defaults to browser localStorage. If browser storage is null, cannot be accessed, or a raw storage operation fails, the room state continues to work in memory and persistence is skipped. Malformed persisted JSON and read failures from an explicit custom storage adapter still propagate through Zustand's onRehydrateStorage callback so hosts can distinguish a failed load from an empty store; custom write and removal failures are logged and skipped.

Use the lower-level createPersistenceController() only when you need the same persistence policy outside a room store or Zustand persist. The controller is storage-agnostic: hosts provide load() and save() adapter functions, while SQLRooms handles hydration state, dirty tracking, scheduled saves, final flush, in-flight save coalescing, and observable save status.

createPersistHelpers() still only handles schema-based partialization and rehydrate merging. Let createRoomStorePersistence() combine those helpers with save policy unless you have a custom integration that does not fit the room-store helper.

ts
import {createRoomStorePersistence} from '@sqlrooms/room-store';

const persistence = createRoomStorePersistence({
  partialize: (state) => ({room: state.room.config}),
  autosaveDelayMs: 300,
  load: async () => loadProjectSnapshot(),
  save: async (snapshot, metadata) => {
    await saveProjectSnapshot(snapshot, metadata?.reason);
  },
});

await persistence.hydrate();
await persistence.flush('final-flush');

Inside components, useRoomStoreApi() gives you the raw store API:

tsx
import {useRoomStoreApi} from '@sqlrooms/room-store';
import {Button} from '@sqlrooms/ui';

function ResetButton() {
  const store = useRoomStoreApi();
  return (
    <Button
      onClick={() => {
        // Example: imperative read from store
        const current = store.getState().room.initialized;
        console.log('initialized', current);
      }}
    >
      Inspect store
    </Button>
  );
}