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>)
| Property | Type | Description |
|---|---|---|
state | S | Current state (updated live) |
setState | (next: S) => void | Replace the state |
emit | (eventDef, data) => void | Emit a custom event |
signal | AbortSignal | undefined | Abort signal (present only for interruptable handlers) |
Options
| Option | Type | Default | Description |
|---|---|---|---|
notify | boolean | false | Auto-emit a <commandName>:handled event |
interruptable | boolean | false | Enable 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>)
| Property | Type | Description |
|---|---|---|
state | S | Current state (readonly) |
queue | (command: Command) => void | Queue 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); // 1store.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:
| Property | Type | Description |
|---|---|---|
timestamp | number | Date.now() when the event was emitted |
correlationId | string | Unique identifier for this event |
causedBy | string | null | Correlation 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>
| Property | Type | Description |
|---|---|---|
command | (ctx: CommandContext<S>, command: Command) => T | Builder called per command execution |
event | (ctx: EventContext<S>, event: StoreEvent) => T | Builder 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) orstate,queue(event context) cause acommandHandlingErrorevent at runtime. - No duplicate keys across extensions. If two extensions produce the same key, a
commandHandlingErrorevent is emitted. - Locked after first command.
useExtensionthrows 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:
- Any queued (not-yet-started) instances are removed — a
commandInterruptedevent is emitted withphase: "queued"for each. - If an instance is currently running, its
AbortControlleris aborted — acommandInterruptedevent is emitted withphase: "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:
| Event | Data | When |
|---|---|---|
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 |
stateReset | void | State 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);
}
});