Commiq Docs
API Reference

Store

createStore, handler registration, the command queue, state reads, suspension, lifecycle and the builtin event set.

Store API

A store holds one state object, routes commands to handlers sequentially, and publishes events. createStore returns a StoreImpl — the full surface, including registration. Wrap it in sealStore to hand consumers a read-and-dispatch view.

createStore<S>(initialState, options?)

import { createStore } from "@naikidev/commiq";

type CounterState = { count: number };

const store = createStore<CounterState>({ count: 0 }, {
  onError: (report) => console.error(report.source, report.error),
});

StoreOptions

OptionTypeDefaultDescription
onErrorErrorReporterconsole.error outside production, no-op in productionReceives a StoreErrorReport for every failure the library catches
suspendWarningMsnumber5000Reports once through onError when a suspend() gate is held longer than this. 0 disables

store.state

Typed DeepReadonly<S>. Reading it is always a read of the current state — there is no snapshot to go stale.

store.state.count; // number

Outside production each new state object is deep-frozen, so a plain-JS write throws a TypeError instead of silently succeeding. The freeze covers plain objects and arrays only — Map, Set, class instances and functions in state are typed readonly but not frozen. In a production build freezing is off entirely and the types are the only guard.

store.queue(command) / store.queue(def, data?)

Adds a command to the queue and returns a CommandHandle. Commands are processed sequentially on a microtask, so state does not change on the same tick as the call.

import { createCommandDef } from "@naikidev/commiq";

const increment = createCommandDef("increment");

store.queue(increment);
await store.flush();
store.state.count; // 1

Each queued command is cloned, then assigned a fresh correlationId (a nanoid). Its causedBy is the explicit value you passed to createCommand, if any; otherwise the correlation ID of the command or event currently being processed, or null when queued from application code.

store.queue is a stable property, so destructuring it is safe: const { queue } = store.

CommandHandle

type CommandHandle = Promise<CommandResult> & {
  readonly command: Command;
  readonly correlationId: string;
};

type CommandResult = {
  status: "handled" | "failed" | "interrupted" | "invalid" | "discarded";
  command: Command;
  error?: unknown;
};

A handle never rejects, so ignoring it can never produce an unhandled rejection. Awaiting it is how you find out whether a command succeeded:

const result = await store.queue(increment);
if (result.status === "failed") {
  reportToUser(result.error);
}
StatusMeaning
handledThe handler resolved
failedThe handler threw. Also reported through onError and published as commandHandlingError
interruptedRemoved from the queue or aborted mid-flight by a newer instance of an interruptable command
invalidNo handler is registered for that name
discardedThe store was destroyed before or during processing

A handle settles when that command settles. Events it published are dispatched to event handlers afterwards, and commands those handlers queue run later still. await queue(...) is not quiescence — use flush().

store.flush()

Resolves when the queue is empty and no events are pending. Never rejects, including when a handler threw.

store.queue(increment);
await store.flush();

flush() throws synchronously when called from inside a command or event handler. In 1.x it deadlocked. The guard is based on synchronous handler depth, so a handler that awaits something first and then calls flush() escapes the guard and still deadlocks — the queue cannot drain while that handler is unresolved. Do not call flush() from handler code.

store.addCommandHandler(nameOrDef, handler, options?)

Registers the handler for one command name and returns the store, so command registrations chain. Exactly one handler per name — registering a second replaces the first and reports a duplicateHandler error through onError.

store.addCommandHandler(increment, (ctx) => {
  ctx.setState({ count: ctx.state.count + 1 });
});

Passing a CommandDef instead of a string ties the handler's cmd.data to the def's payload type. See createCommandDef.

CommandContext<S>

PropertyTypeDescription
stateDeepReadonly<S>Live getter — re-reads current state on every access, including after an await
setState(next: S | (prev: DeepReadonly<S>) => S) => voidReplaces state and publishes stateChanged immediately
emit<D>(eventDef: EventDef<D>, data: D) => voidPublishes a custom event immediately
signalAbortSignal | undefinedPresent only when the handler is registered interruptable

setState accepts a value or an updater:

store.addCommandHandler(increment, (ctx) => {
  ctx.setState((prev) => ({ count: prev.count + 1 }));
});

Every setState call publishes its own stateChanged, so intermediate states are observable:

store.addCommandHandler(search, async (ctx, cmd) => {
  ctx.setState({ ...ctx.state, isLoading: true });   // published
  const results = await fetchResults(cmd.data);
  ctx.setState({ ...ctx.state, isLoading: false, results }); // published
});

The context is disposed once the handler settles. A late setState or emit — from a callback that outlived the handler — is rejected and reported with source: "disposedContext".

CommandHandlerOptions

OptionTypeDefaultDescription
notifybooleanfalseAlso publish handledEvent(name) — the interned "<name>:handled" event — when the command is handled
interruptablebooleanfalseCancel earlier instances of the same command when a new one is queued, and supply ctx.signal
rollbackOnInterruptbooleanfalseOn interruption, restore the state the store had when the handler started. Published as its own stateChanged

rollbackOnInterrupt only applies to a handler that is also interruptable. It restores the state object captured at handler entry, so any setState the aborted handler already applied is undone:

store.addCommandHandler(
  search,
  async (ctx, cmd) => {
    ctx.setState({ ...ctx.state, isLoading: true });
    const res = await fetch(`/api/search?q=${cmd.data}`, { signal: ctx.signal });
    ctx.setState({ ...ctx.state, isLoading: false, results: await res.json() });
  },
  { interruptable: true, rollbackOnInterrupt: true },
);

Without rollbackOnInterrupt the aborted run leaves isLoading: true behind for the next instance to clear.

store.removeCommandHandler(nameOrDef)

Unregisters the handler. Returns true if one was registered. Commands queued for that name afterwards settle as invalid.

store.addEventHandler(eventDef, handler)

Registers a handler for one event and returns an Unsubscribe. Zero or many handlers per event. Event handlers cannot change state — they can only queue commands.

const off = store.addEventHandler(userCreated, (ctx, event) => {
  ctx.queue(sendWelcome, { name: event.data.name });
});

off(); // or store.removeEventHandler(userCreated, handler)

This used to return the store. Chaining off it — store.addCommandHandler(...).addEventHandler(...).addCommandHandler(...) — no longer compiles.

EventContext<S>

PropertyTypeDescription
stateDeepReadonly<S>Current state
queueQueueFnQueue a command. Returns a CommandHandle

Handlers are dispatched from a snapshot of the handler list and isolated from each other: one that throws publishes eventHandlingError, reaches onError, and does not stop the others or suppress commandHandled.

store.removeEventHandler(eventDef, handler)

Explicit form of the returned unsubscribe. Returns true if the handler was registered.

store.openStream(listener) / store.closeStream(listener)

openStream subscribes to every event the store publishes and returns an Unsubscribe. Listeners are called synchronously at publish time.

const off = store.openStream((event) => {
  console.log(event.name, event.correlationId, event.causedBy);
});

off(); // or store.closeStream(listener)

Every StoreEvent carries instrumentation metadata:

PropertyTypeDescription
idsymbolIdentity of the event definition. Compare with matchEvent
namestringHuman-readable name, for logging and serialization
dataunknown at the stream boundaryNarrow with matchEvent
timestampnumberDate.now() at publish
correlationIdstringUnique ID for this event
causedBystring | nullCorrelation ID of the command or event that caused it

A listener that throws is caught, reported with source: "streamListener", and additionally published as unhandledError. Other listeners are unaffected.

store.suspend()

Pauses command execution and returns a release. The gate is counted, not boolean: every suspender must release before processing resumes. release() is idempotent.

const release = store.suspend();
try {
  const saved = await loadFromIndexedDb();
  store.replaceState(saved);
} finally {
  release();
}
  • queue() still accepts commands while suspended and returns real handles. They run in order once the gate opens.
  • Event dispatch is not gated. Publishing, stream listeners, event-handler dispatch and replaceState all keep working — that is what makes plugin hydration possible. An event handler's queue() is accepted immediately but executed after release.
  • flush() resolves on quiescence: immediately if the queue and pending events are already empty, otherwise once the gate opens and the queue drains.
  • A gate held longer than suspendWarningMs reports once through onError with source: "suspendedQueue". It does not publish an event.

store.isSuspended reports whether any gate is currently held.

store.replaceState(next)

Replaces state without going through a command handler. Available on StoreImpl only — not on SealedStore. Publishes stateChanged and then stateReset. No-op when next is the same reference as the current state.

store.replaceState(hydratedState);

Intended for persistence rehydration, undo/redo and state synchronization. Take a suspend() gate around it if commands might be queued while you load.

store.useExtension(ext)

Registers a context extension that adds properties to CommandContext and/or EventContext. Returns the store with widened types, so extensions chain.

import { createStore, createCommandDef } from "@naikidev/commiq";
import type { ContextExtensionDef } from "@naikidev/commiq";

type AppState = { count: number };
type LogProps = { log: (msg: string) => void };

const logger: ContextExtensionDef<AppState, LogProps, LogProps> = {
  command: () => ({ log: (msg) => console.log(`[cmd] ${msg}`) }),
  event: () => ({ log: (msg) => console.log(`[evt] ${msg}`) }),
};

const increment = createCommandDef("increment");

const store = createStore<AppState>({ count: 0 })
  .useExtension(logger)
  .addCommandHandler(increment, (ctx) => {
    ctx.log("incrementing");
    ctx.setState({ count: ctx.state.count + 1 });
  });

ContextExtensionDef<S, TCommand, TEvent>

PropertyTypeDescription
command(ctx: CommandContext<S>, command: Command) => TCommandBuilder run per command execution
event(ctx: EventContext<S>, event: StoreEvent) => TEventBuilder run per event handling
afterCommand() => void | Promise<void>Runs after each command handler settles, including on error
afterEvent() => void | Promise<void>Runs after each event handler settles, including on error
destroy() => voidTeardown, run once by removeExtension or store.destroy()

All fields are optional. TCommand and TEvent default to {} and are widened separately — an extension that defines only command is a compile error if referenced from an event handler, where in 1.x it type-checked and was silently skipped at runtime.

afterCommand / afterEvent run in a finally block. A throwing hook is reported with source: "contextExtension", published as unhandledError, and does not abort the remaining hooks.

Constraints

  • No overriding builtin keys. Keys colliding with state, setState, emit, signal (command context) or state, queue (event context) are reported through onError.
  • No duplicate keys across extensions. Two extensions producing the same key are reported.
  • Locked once active. useExtension throws if called after the first command has been queued.

store.removeExtension(ext)

Detaches by identity and runs the extension's destroy(). Returns true if it was registered. It removes the runtime hooks but cannot narrow the already-widened context types, so handlers registered earlier still appear to have the extra properties at compile time.

See @naikidev/commiq-context for pre-built extensions.

store.destroy()

Satisfies Disposable. Resets the suspension gate, aborts interrupt controllers, clears the queue, pending events, stream listeners and all handlers, runs each extension's destroy(), settles every outstanding handle as discarded, and resolves pending flush() callers.

After destroy(), queue() and registration calls are no-ops that report with source: "destroyedStore".

Interruptable commands

const search = createCommandDef<string>("search");

store.addCommandHandler(
  search,
  async (ctx, cmd) => {
    const res = await fetch(`/api/search?q=${cmd.data}`, { signal: ctx.signal });
    ctx.setState({ ...ctx.state, results: await res.json() });
  },
  { interruptable: true },
);

When a new command with the same name is queued:

  1. Queued-but-not-started instances are removed. Each publishes commandInterrupted with phase: "queued" and its handle settles as interrupted.
  2. A running instance has its AbortController aborted. commandInterrupted with phase: "running" is published when it completes.

Pass ctx.signal to cancellable APIs. Non-interruptable handlers have ctx.signal set to undefined. If an interruptable handler throws because of the abort, commandInterrupted is published rather than commandHandlingError.

The error channel

Nothing thrown inside the library is swallowed. onError receives a StoreErrorReport:

type StoreErrorReport = {
  error: unknown;
  source: StoreErrorSource;
  command?: Command;
  event?: StoreEvent;
};

StoreErrorSource is one of "commandHandler", "eventHandler", "streamListener", "contextExtension", "disposedContext", "queueProcessor", "duplicateHandler", "destroyedStore", "suspendedQueue".

Failures with no dedicated event channel — stream listeners, extension hooks, disposed contexts — additionally publish unhandledError.

A throwing handler does not propagate out of queue() or flush(). Neither rejects. try { await store.flush() } catch {} never fires. The three ways to observe a failure are the CommandHandle result, onError, and the commandHandlingError / eventHandlingError events.

Builtin events

BuiltinEvent holds the event definitions; BuiltinEventName holds their string names for comparisons against event.name.

EventDataWhen
stateChanged{ prev, next }Every ctx.setState(), plus replaceState and interrupt rollback
commandStarted{ command }A command is about to be handled
commandHandled{ command }A command's handler resolved
invalidCommand{ command }No handler is registered for that name
commandHandlingError{ command, error }A command handler threw
commandInterrupted{ command, phase }phase is "queued" or "running"
eventHandlingError{ event, error }An event handler threw
unhandledErrorStoreErrorReportA failure with no other event channel
stateResetvoidState was replaced via replaceState
import { BuiltinEvent, BuiltinEventName, matchEvent } from "@naikidev/commiq";

store.openStream((event) => {
  if (matchEvent(event, BuiltinEvent.StateChanged)) {
    console.log(event.data.prev, "→", event.data.next);
  }
  if (event.name === BuiltinEventName.CommandInterrupted) {
    // string comparison, when you only need the name
  }
});

BuiltinEvent.StateChanged is declared as StateChangedData<unknown>, so a generic stream listener sees prev and next as unknown. Cast to your own state type at the boundary where you know it, or read state from store.state instead.

Exported handler types

Use these when extracting a handler into its own module.

TypeShape
CommandHandler<S, D, Ctx>(ctx: CommandContext<S> & Ctx, cmd: Command<string, D>) => void | Promise<void>
EventHandler<S, D, Ctx>(ctx: EventContext<S> & Ctx, event: StoreEvent<D>) => void | Promise<void>
StreamListener(event: StoreEvent) => void
QueueFnThe queue signature — accepts a Command, or a CommandDef plus its payload
Unsubscribe() => void
Disposable{ destroy(): void }
import type { CommandHandler } from "@naikidev/commiq";

export const handleIncrement: CommandHandler<CounterState> = (ctx) => {
  ctx.setState({ count: ctx.state.count + 1 });
};

D and Ctx default to unknown and {} respectively.

On this page