Commiq Docs
API Reference

Store

Store API

The store is the heart of Commiq. It holds state, routes commands to handlers, and broadcasts events.

createStore<S>(initialState: S)

Creates a new store with the given initial state.

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

const store = createStore({ count: 0 });

store.addCommandHandler(name, handler, options?)

Registers a handler for a named command. Returns this for chaining.

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

Handler Context (CommandContext<S>)

PropertyTypeDescription
stateSCurrent state (updated live)
setState(next: S) => voidReplace the state
emit(eventDef, data) => voidEmit a custom event
signalAbortSignal | undefinedAbort signal (present only for interruptable handlers)

Options

OptionTypeDefaultDescription
notifybooleanfalseAuto-emit a <commandName>:handled event
interruptablebooleanfalseEnable cancellation on re-queue

store.addEventHandler(eventDef, handler)

Registers a handler that reacts to a specific event. Event handlers can queue new commands.

import { createEvent, createCommand } from "@naikidev/commiq";

const userCreated = createEvent<{ name: string }>("userCreated");

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

Event Handler Context (EventContext<S>)

PropertyTypeDescription
stateSCurrent state (readonly)
queue(command: Command) => voidQueue a new command

store.queue(command)

Adds a command to the queue. Commands are processed sequentially. Each queued command is automatically assigned a unique correlationId and its causedBy is set based on the current execution context (the correlation ID of the parent command/event, or null if queued from user code).

store.queue(createCommand("increment", undefined));

store.flush()

Returns a promise that resolves when all queued commands have been processed.

store.queue(createCommand("increment", undefined));
await store.flush();
console.log(store.state.count); // 1

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

Subscribe/unsubscribe to all events emitted by the store.

const listener = (event) => console.log(event.name, event.data);
store.openStream(listener);
// later...
store.closeStream(listener);

Every event received by stream listeners includes instrumentation metadata:

PropertyTypeDescription
timestampnumberDate.now() when the event was emitted
correlationIdstringUnique identifier for this event
causedBystring | nullCorrelation ID of the parent command/event

store.useExtension(ext)

Registers a context extension that adds custom properties to CommandContext and/or EventContext. Returns this with an expanded type, so extensions compose via chaining.

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

type AppState = { count: number };

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

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

After useExtension(ext), all handlers registered on the store see the extra properties on ctx.

ContextExtensionDef<S, T>

PropertyTypeDescription
command(ctx: CommandContext<S>, command: Command) => TBuilder called per command execution
event(ctx: EventContext<S>, event: StoreEvent) => TBuilder called per event handling
afterCommand() => void | Promise<void>Lifecycle hook called after each command handler completes (including on error)
afterEvent() => void | Promise<void>Lifecycle hook called after each event handler completes (including on error)

All fields are optional. An extension that only provides command will not appear in event handler contexts. The afterCommand and afterEvent hooks run in a finally block — they execute even when the handler throws, and errors from the hooks themselves are swallowed.

The builder receives the base context — extensions can use ctx.setState, ctx.emit, or ctx.queue to build utilities that wrap built-in behavior.

Constraints

  • No overriding built-in keys. Extension keys that collide with state, setState, emit, signal (command context) or state, queue (event context) cause a commandHandlingError event at runtime.
  • No duplicate keys across extensions. If two extensions produce the same key, a commandHandlingError event is emitted.
  • Locked after first command. useExtension throws if called after the first command has been queued. Register all extensions before queuing.

Multiple extensions

Chain multiple useExtension calls. The type accumulates across calls:

const store = createStore<AppState>({ count: 0 })
  .useExtension(loggerExt)
  .useExtension(metaExt)
  .addCommandHandler("test", (ctx) => {
    ctx.log("hello");     // from loggerExt
    ctx.meta.commandName; // from metaExt
  });

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

store.replaceState(next)

Directly replaces the store's state without going through a command handler. This is an advanced API available only on StoreImpl — it is not exposed on SealedStore.

Emits stateChanged (with { prev, next }) and stateReset events. No-op if next is the same reference as the current state.

// Hydrate state from persistence
const saved = JSON.parse(localStorage.getItem("app-state")!);
store.replaceState(saved);

Use cases: persistence rehydration, undo/redo, state synchronization.

Interruptable Commands

Mark a handler as interruptable to enable automatic cancellation when the same command is re-queued.

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

When a new command with the same name is queued:

  1. Any queued (not-yet-started) instances are removed — a commandInterrupted event is emitted with phase: "queued" for each.
  2. If an instance is currently running, its AbortController is aborted — a commandInterrupted event is emitted with phase: "running" when it completes.

The handler receives ctx.signal (an AbortSignal) to pass to cancellable APIs like fetch. Non-interruptable handlers have ctx.signal set to undefined.

If an interruptable handler throws due to abort (e.g. AbortError from fetch), commandInterrupted is emitted instead of commandHandlingError.

Builtin Events

These events are emitted automatically by the store:

EventDataWhen
stateChanged{ prev, next }State was updated by a handler or replaceState
commandStarted{ command }A command is about to be handled
commandHandled{ command }A command was successfully handled
invalidCommand{ command }No handler registered for the command
commandHandlingError{ command, error }A handler threw an error
commandInterrupted{ command, phase }An interruptable command was cancelled
stateResetvoidState was replaced via replaceState

Access them via matchEvent for type-safe narrowing:

import { BuiltinEvent, matchEvent } from "@naikidev/commiq";

store.openStream((event) => {
  if (matchEvent(event, BuiltinEvent.StateChanged)) {
    // event.data is typed as { prev: unknown; next: unknown }
    console.log("State changed:", event.data.prev, "→", event.data.next);
  }
});

On this page