Commiq Docs
API Reference

Commands & Events

createCommand, createCommandDef, createEvent, handledEvent, matchEvent, sealStore and createEventBus.

Commands & Events

A command is an intent — one handler, may change state. An event is a fact — zero or many handlers, may only queue more commands.

createCommandDef<D>(name)

Creates a command definition that carries both the command name and its payload type. This is the preferred way to declare a command: addCommandHandler and queue share one D, so a wrong payload or an unregistered name is a compile error rather than a runtime invalidCommand.

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

export const addTodo = createCommandDef<{ text: string }>("addTodo");
export const clearTodos = createCommandDef("clearTodos");

D defaults to void, so a definition with no payload is queued with no second argument:

store.addCommandHandler(addTodo, (ctx, cmd) => {
  ctx.setState({ ...ctx.state, todos: [...ctx.state.todos, cmd.data.text] });
});

store.queue(addTodo, { text: "Buy milk" });
store.queue(clearTodos);

CommandPayloadArgs<D> is the helper that makes the payload argument required for a real D and optional when D includes undefined or void. You rarely reference it directly.

createCommand(name, data, options?)

Creates a command object. Still fully supported, and the right choice when the name is computed at runtime or when you need to set causedBy by hand.

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

const cmd = createCommand("addTodo", { text: "Buy milk" });
// { name: "addTodo", data: { text: "Buy milk" }, correlationId: "", causedBy: null }

correlationId is empty until the command is queued — store.queue() assigns a nanoid. options.causedBy links the command into an existing causal chain, and an explicit value takes precedence over the ambient correlation ID of whatever is currently being processed.

const followUp = createCommand("notify", { id }, { causedBy: parentCorrelationId });

queue() clones the command it is given, so the object you pass is never modified and can be reused.

createEvent<D>(name)

Creates an event definition holding a fresh symbol id. Events match by symbol identity, not by string, so two definitions with the same name are two different events.

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

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

Emit it from a command handler, and subscribe from an event handler:

store.addCommandHandler(createUser, (ctx, cmd) => {
  ctx.setState({ ...ctx.state, user: cmd.data });
  ctx.emit(userCreated, { name: cmd.data.name });
});

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

addEventHandler returns an Unsubscribe. It does not return the store, so it cannot be chained.

EventDef<D> is branded

EventDef carries a phantom __data member, so EventDef<{ name: string }> is not assignable to a bare EventDef (whose payload is unknown). For a parameter that accepts any event definition, make it generic or use EventDef<never>:

function describe<D>(def: EventDef<D>): string {
  return def.name;
}

function cancelSource(def: EventDef<never>): void { /* ... */ }

handledEvent(commandName)

Returns the event definition for the auto-notify convention "<commandName>:handled". Symbols are interned by name, so every call for the same command name returns the same definition — which is what makes it match a notify: true command.

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

store.addCommandHandler(increment, incrementHandler, { notify: true });

store.addEventHandler(handledEvent("increment"), (ctx, event) => {
  // runs after every successful "increment"
});

In commiq 1.x this never worked. handledEvent minted a fresh symbol on each call and the notify path minted another, so the handler registered above was never invoked — with no error. If you are upgrading, see the migration guide: the handler body has never run and may be stale.

The payload is { command }. handledEvent is the only interned event factory — createEvent is deliberately not interned.

matchEvent(event, eventDef)

Type guard narrowing a StoreEvent to its payload type by symbol identity. Needed in raw openStream listeners, where data is unknown.

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

store.openStream((event) => {
  // without matchEvent — requires an unsafe cast
  if (event.id === BuiltinEvent.CommandHandlingError.id) {
    const { command, error } = event.data as { command: Command; error: unknown };
  }

  // with matchEvent — inferred
  if (matchEvent(event, BuiltinEvent.CommandHandlingError)) {
    const { command, error } = event.data;
  }
});

Works the same for your own events:

const orderPlaced = createEvent<{ orderId: string }>("order:placed");

store.openStream((event) => {
  if (matchEvent(event, orderPlaced)) {
    console.log(event.data.orderId); // string
  }
});

Typed APIs — addEventHandler, bus.on, effects.on — already infer the payload from the EventDef. Reach for matchEvent only in raw openStream listeners.

sealStore(store)

Returns a frozen facade over a StoreImpl, exposing exactly:

MemberType
stateDeepReadonly<S> (getter)
queueQueueFn
flush() => Promise<void>
suspend() => Unsubscribe
openStream(listener: StreamListener) => Unsubscribe
closeStream(listener: StreamListener) => void
import { sealStore } from "@naikidev/commiq";

export const counterStore = sealStore(store);

counterStore.state.count;
counterStore.queue(increment);
await counterStore.flush();

Registration is absent from the SealedStore type, so a consumer reaching for it does not compile:

counterStore.addCommandHandler(increment, handler);
// Property 'addCommandHandler' does not exist on type 'SealedStore<CounterState>'

This is a compile-time absence, not a runtime undefined check — the facade object simply has no such property, and Object.freeze prevents one being added.

What sealing does and does not guarantee

state is DeepReadonly<S>, and outside production every state object is deep-frozen, so a write throws a TypeError:

counterStore.state.count = 999; // type error; TypeError at runtime outside production

What it does not cover:

  • Production builds do not freeze. Freezing is skipped when the environment is production, so in a production bundle the types are the only guard and a plain-JS caller can still write.
  • Map, Set, class instances and functions are not frozen at any depth, in any environment. They are typed readonly, and that is all.
  • A cast defeats it. (sealed.state as CounterState).count = 1 compiles. In development it throws; in production it silently mutates state with no event and no devtools entry.

Sealing is a boundary that makes the mutation path obvious and cheap to review. It is not a sandbox.

In commiq 1.x sealStore was documented as a read-only proxy in five places while sealed.state.count = 999 mutated the real store with no event and no type error. If your code has defensive copies added because of that, they can go.

createEventBus()

Routes events between stores without coupling them. The bus subscribes to each connected store's stream and dispatches to handlers registered by event definition.

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

const bus = createEventBus();

const disconnectA = bus.connect(storeA);
const disconnectB = bus.connect(storeB);

const off = bus.on(userCreated, (event) => {
  storeB.queue(greet, { name: event.data.name });
});

off();
disconnectA();
MethodReturnsDescription
connect(store)UnsubscribeSubscribe the bus to a store's stream. Refcounted
disconnect(store)voidDecrement the refcount; unsubscribes when it reaches zero
on(eventDef, handler)UnsubscribeRegister a handler for one event
off(eventDef, handler)booleanExplicit form of the returned unsubscribe
destroy()voidUnsubscribe from every store and drop every handler

connect accepts anything matching Streamable{ openStream, closeStream } — which includes both StoreImpl and SealedStore.

Connections are refcounted, so connect(store) twice needs two disconnects. In 1.x the second connect installed a duplicate listener but overwrote the tracked entry, so a single disconnect silenced the bus while the caller believed one connection remained. Prefer the Unsubscribe returned by connect over calling disconnect yourself.

A bus handler that throws is caught and logged to the console; it does not stop the other handlers. The bus has no onError option — for structured error reporting, use the store's own error channel.

On this page