Commiq Docs
Usage Patterns

Composing Plugins

Initialize multiple plugins on a single store with correct ordering and cleanup.

Commiq plugins — devtools, effects, persist, OpenTelemetry — each attach to a store independently. As an application grows, a single store may use two or three at once. Getting the order right and disposing of them correctly avoids leaked listeners and silently inert instrumentation.

Plugin initialization order

Create and configure the raw store

Register context extensions first, then command and event handlers on the raw StoreImpl:

const _store = createStore<AppState>(initialState, { onError: reportError })
  .useExtension(withGuard<AppState>())
  .addCommandHandler(AppCommand.load, handleLoad);

_store.addEventHandler(AppEvent.Loaded, handleLoaded);

addCommandHandler and useExtension return the store, so they chain. addEventHandler returns an Unsubscribe and therefore ends a chain — call it as a separate statement.

Extensions must be registered before the first command is processed; useExtension throws on an already-active store. See context extensions.

Seal the store

export const appStore = sealStore(_store);

sealStore returns a facade exposing state, queue, flush, suspend, openStream, and closeStream. It narrows the surface consumers see; it does not deep-freeze anything the store has not already frozen.

Attach plugins

Each plugin takes the store and subscribes to its event stream internally:

const devtools = createDevtools({ maxEvents: 500 });
devtools.connect(_store, "app");

const effects = createEffects(appStore, { onError: reportEffectError });

Effects, persist, and OpenTelemetry accept the sealed store. Devtools' connect takes anything with state and openStream/closeStream, so either the raw or the sealed store works — pass the sealed one unless you have a reason not to.

Devtools

createDevtools(options) creates the collector. Stores attach afterwards with connect(store, name) — there is no stores option on the factory.

stores/search.ts
import { createDevtools } from "@naikidev/commiq-devtools-core";
import { searchStore } from "./search";
import { inventoryStore } from "./inventory";

export const devtools = createDevtools({
  maxEvents: 500,
  logToConsole: false,
});

devtools.connect(searchStore, "search");
devtools.connect(inventoryStore, "inventory");

DevtoolsOptions is { transport?, maxEvents?, maxSnapshots?, snapshotMode?, detectAliasedState?, logToConsole?, onError? }. Every store you want in the timeline needs its own connect call; the name you pass is the label used by getTimeline(storeName), getStateHistory(storeName), and disconnect(storeName).

A store that is never connected produces no timeline entries and no error. If devtools shows an empty timeline, check that connect ran — and that the module containing it was actually imported.

Devtools + Effects

The common combination. Devtools observes every event, including those caused by effects; effects react to events emitted by command handlers.

stores/search.ts
import { createCommand } from "@naikidev/commiq";
import { createDevtools } from "@naikidev/commiq-devtools-core";
import { createEffects } from "@naikidev/commiq-effects";
import { searchStore, SearchEvent } from "./search";

const devtools = createDevtools({ maxEvents: 500 });
devtools.connect(searchStore, "search");

const effects = createEffects(searchStore, {
  onError: (report) => console.error("[effects]", report.source, report.error),
});

effects.on(
  SearchEvent.Completed,
  (data, ctx) => {
    ctx.queue(createCommand("search:add-recent", data.query));
  },
  { debounce: 300 },
);

Commands queued by the effect appear in the timeline with a causedBy link back to the event that triggered them, so getChain(correlationId) reconstructs the whole cause-and-effect path. See effects and cancellation.

OpenTelemetry

The otel package exports instrumentStore(store, options). There is no plugin factory and no serviceName option — the service name belongs to the OpenTelemetry Resource, which you configure when you set up the tracer provider.

telemetry.ts
import { createTraceRegistry, instrumentStore } from "@naikidev/commiq-otel";
import { orderStore } from "./stores/order";
import { paymentStore } from "./stores/payment";

const registry = createTraceRegistry();

const instrumentations = [
  instrumentStore(orderStore, { storeName: "order", registry }),
  instrumentStore(paymentStore, { storeName: "payment", registry }),
];

InstrumentOptions requires storeName and optionally takes tracerName, tracerVersion, registry, maxCommandDurationMs, maxPendingCommands, recordCorrelationIds, and sanitizeError. Sharing one registry across stores is what links a command on one store to the event that caused it on another — without it, each store's spans form an unrelated trace.

Full composition

stores/order.ts
import { createStore, sealStore } from "@naikidev/commiq";
import { createDevtools } from "@naikidev/commiq-devtools-core";
import { createEffects } from "@naikidev/commiq-effects";
import { instrumentStore, createTraceRegistry } from "@naikidev/commiq-otel";
import { OrderEvent } from "./events";

const _store = createStore<OrderState>(initialState, {
  onError: (report) => reportToService(report.error, { source: report.source }),
});
// ... handlers ...

export const orderStore = sealStore(_store);

const devtools = createDevtools({ maxEvents: 500 });
devtools.connect(orderStore, "order");

const effects = createEffects(orderStore, {
  onError: (report) => reportToService(report.error, { source: report.source }),
});

effects.on(OrderEvent.Placed, async (data) => {
  await sendConfirmationEmail(data.orderId);
});

const registry = createTraceRegistry();
const otel = instrumentStore(orderStore, { storeName: "order", registry });

Each plugin operates independently. They share the same event stream and do not interfere with each other. Note that the store's onError and the effects' onError are separate channels: the store reports failures inside command handlers, event handlers, stream listeners, and extensions; effects report failures inside effect handlers.

Cleanup

Every plugin conforms to core's Disposable — a destroy(): void method. Dispose in reverse initialization order so nothing queues work into a torn-down store:

otel.destroy();      // stop spans (also callable directly: otel())
effects.destroy();   // abort running effects, drop the stream subscription
devtools.destroy();  // clear the timeline, disconnect every store
_store.destroy();    // last: clears queue, handlers, listeners, extensions

instrumentStore returns a value that is both callable and has .destroy(); the two are equivalent, and destroy() is preferred for consistency with the other plugins.

store.destroy() settles every outstanding CommandHandle as discarded and resolves any pending flush(), so awaiting a handle across a teardown will not hang.

In tests, dispose in afterEach to keep listeners from leaking between cases:

order.test.ts
import { afterEach, beforeEach } from "vitest";
import type { Effects } from "@naikidev/commiq-effects";

let effects: Effects<OrderState>;

beforeEach(() => {
  effects = createEffects(orderStore);
});

afterEach(() => {
  effects.destroy();
});

effects.on() also returns an Unsubscribe if you want to remove one effect without destroying the instance.

Writing plugin-friendly stores

Keep the raw store private. Export only the sealed store — see store file structure. Register all handlers before sealing.

Emit events for meaningful transitions. Effects and the event bus react to events. A handler that only calls setState is invisible to them: the only event produced is BuiltinEvent.StateChanged, which carries prev/next but no domain meaning. Emit an explicit event for any transition other parts of the system should observe.

Install onError once, at store creation. It is the only place that sees failures from every source — command handlers, event handlers, stream listeners, and extension hooks. Outside production the default reporter logs to the console; in production the default is silent, so an explicit reporter is required if you want production visibility.

On this page