Skip to content

mattfletcher94/strata

Repository files navigation

@mattfletcher94/strata

Predictable state management for TypeScript. Three tiers, pure commands, reactions for I/O, structural enforcement.

license


Why Strata?

Strata is built around four commitments:

  • Declarative by default. Commands describe state changes as event lists. Reactions describe side effects as event subscribers. Live-query sources describe streams as (data) => events[] mappers. There is no dispatch(), no mutate(), no imperative state-change call site anywhere in consumer code.
  • Predictable state, debuggable end-to-end. Every command mints a correlation ID; every event, projection, and reaction it triggers (transitively) carries it. Trace one user action through the entire system via onCommand / onEvent / onReaction filtered by corrId. Concurrent invocations of the same command never race their results.
  • TypeScript inference in the TanStack / tRPC / zod tradition. Branded event creators, intersection-only command call signatures, phantom payload types. What looks plain at the call site is doing real work to give you full autocomplete with zero string literals — declare deps once, inferred everywhere.
  • Opinionated structure. One right way to do a side effect. One right way to subscribe to a stream. One right way to await a server confirmation. The DAG precludes circular imports by construction. The opinionatedness pays off twice: humans don't reinvent patterns, and coding agents stop inventing hacks the type system would reject.

The view layer becomes thin: state and logic live in Strata, components bind queries and call commands. No reducers, thunks, providers, context, or selectors-with-equality-fns.

Powered by @vue/reactivity internally. Framework-agnostic externally — runs on the frontend, backend, workers, or anywhere TypeScript runs.

When to use Strata

Coming from… Strata's analogue
Redux Toolkit Stores + projections replace slices + reducers; commands replace thunks
TanStack Query / RTK Query / Convex Live queries — refcounted, consumer-driven lifecycle, identity by (name, args)
Zustand / Jotai SubscribableQuery<T> reads, with a structural architecture around them
Vuex / Pinia Native fit — Strata uses @vue/reactivity under the hood
Event-sourcing systems ES-shaped vocabulary (events, projections, reactions), not an ES system — no event log, no persistence, no replay

Strata isn't trying to be the smallest state library. It's trying to be the one that keeps large applications coherent over time.

Mental model

There's a small learning curve. Once you've internalised:

  • Events are the only state-change primitive
  • Queries are the only read primitive
  • Reactions and live queries are the only side-effect primitives
  • Commands are pure functions returning event lists

…the rest of the API is affordances on top. Until that clicks, it can feel like a lot of pieces. Read the Three Invariants section twice.

Features

Architecture

  • Three tiers — defineStore, defineService, defineOrchestrator — enforced at the type level and at runtime
  • No circular imports by construction (DAG resolution; check:circular script gates the import graph)
  • Structural guarantees — stores see nothing, commands have no service access, reactions can't mutate state directly

Developer experience

  • Branded event creators and command refs — full autocomplete, no string literals
  • Typed deps — declare once, inferred through every callback
  • Synchronous commands with thenable handles — supports optimistic (.data) and authoritative (await) patterns from the same call site
  • Awaitable commands with automatic correlation across concurrent invocations; rejects with CommandUnresolvedError if a declared result never fires
  • Reactions with parallel / serial / switch concurrency, plus per-key forms (search-per-topic, save-per-document)
  • Live queries — refcounted long-lived I/O with consumer-driven lifecycle and useLiveQuery for one-line Vue binding
  • Reactive queries — SubscribableQuery<T> with .subscribe(), lazy evaluation, LRU-cached parameterised queries
  • Built for AI coding agents — opinionated structure, one right way to do most things, machine-readable reference at llms.txt

Observability

  • Trace hooks — onCommand, onEvent, onReaction, onLiveQuery, all carrying corrId end-to-end
  • Static introspection — inspectDAG(), toMermaid()
  • Runtime introspection — $inspectLiveQueries(), $inspectReactions() with concurrency mode and in-flight counts
  • Reaction-cycle detection — capped per-(reaction, corrId), surfaces as a failed trace rather than crashing
  • Async, ordered disposal — await $dispose() drains reactions, tears down live queries, stops reactivity, runs service teardowns in reverse

Integrations

  • Vue — useQuery(), useLiveQuery() via @mattfletcher94/strata/vue
  • Framework-agnostic core — adapters for React/Solid/Svelte are thin; the patterns live at the SubscribableQuery level

Quick Start

npm install @mattfletcher94/strata
import {
  defineStore,
  defineService,
  defineOrchestrator,
  defineReaction,
  createStrata,
  type Resolved,
} from "@mattfletcher94/strata";

interface Todo {
  readonly id: string;
  readonly title: string;
  readonly completedAt: number | null;
}

// 1) State + projections + queries
const todoStore = defineStore({
  name: "todoStorage",
  state: { items: {} as Record<string, Todo> },
  projections: {
    todoCreated: (state, todo: Todo) => ({
      ...state,
      items: { ...state.items, [todo.id]: todo },
    }),
    todoSynced: (state, { id, serverId }: { id: string; serverId: string }) => ({
      ...state,
      items: { ...state.items, [id]: { ...state.items[id], serverId } },
    }),
    todoPersistFailed: (state, { id, error }: { id: string; error: string }) => ({
      ...state,
      items: { ...state.items, [id]: { ...state.items[id], error } },
    }),
  },
  queries: (state) => ({
    list: () => Object.values(state.items),
    count: () => Object.keys(state.items).length,
    byId: (id: string) => () => state.items[id],
  }),
});

// 2) I/O
const apiService = defineService({
  name: "restApi",
  setup: (config: { baseUrl: string }) => ({
    save: async (todo: Todo) =>
      fetch(`${config.baseUrl}/todos`, { method: "POST", body: JSON.stringify(todo) }).then((r) =>
        r.json(),
      ),
  }),
});

// 3) Coordination — commands compute events; reactions subscribe to events
const todosOrch = defineOrchestrator({
  name: "todos",
  deps: { storage: todoStore },
  commands: (deps) => ({
    create(input: { title: string }) {
      const todo: Todo = { id: crypto.randomUUID(), title: input.title, completedAt: null };
      return {
        events: [deps.storage.todoCreated(todo)],
        data: todo.id,
      };
    },
  }),
  reactions: (deps, services: { readonly api: Resolved<typeof apiService> }) => ({
    persist: defineReaction({
      on: deps.storage.todoCreated,
      run: (todo) => services.api.save(todo),
      onSuccess: (todo, server: { id: string }) => [
        deps.storage.todoSynced({ id: todo.id, serverId: server.id }),
      ],
      onFailure: (todo, error) => [
        deps.storage.todoPersistFailed({ id: todo.id, error: error.message }),
      ],
    }),
  }),
});

// 4) Compose
const app = createStrata({
  name: "todo-app",
  responsibility: "Main app graph.",
  services: { api: apiService.with({ baseUrl: "/api" }) },
  stores: { storage: todoStore },
  orchestrators: { todos: todosOrch },
});

// Pattern A — optimistic / fire-and-forget. State updates synchronously; persistence runs in background.
const id = app.todos.create({ title: "Buy milk" }).data;
console.log(app.storage.byId(id)());

// Pattern B — authoritative. Declare `result:` on the command to make `await` resolve with the
// matching event's payload, correlated automatically to this invocation. See "Awaitable commands" below.
//   const todo = await app.todos.create({ title: "Buy milk" });

await app.$dispose();

Three Invariants

  1. All mutable state lives in stores. Projections are the only mutation path.
  2. All I/O lives in services. Reactions and live queries are the only callers.
  3. Orchestrators coordinate. No state, no direct I/O. Commands compute events; reactions subscribe.

Three Tiers

Tier Sees Owns Used for
Store nothing state, projections, queries data
Service nothing external I/O wrappers HTTP, DB, sockets
Orchestrator stores + other orchestrators (deps) queries, commands, reactions, live queries coordination

Reactions live in orchestrators. They subscribe to events (or commands) and call services.


defineOrchestrator

const todosOrch = defineOrchestrator({
  name: "todos",
  deps: { storage: todoStore },
  queries: (deps) => ({
    active: () => deps.storage.list().filter((t) => !t.completedAt),
  }),
  commands: (deps) => ({
    create(input: { title: string }) {
      const todo = { id: crypto.randomUUID(), title: input.title, completedAt: null };
      return {
        events: [deps.storage.todoCreated(todo)],
        data: todo.id,
      };
    },
  }),
  reactions: (deps, services, commands) => ({
    persist: defineReaction({
      on: deps.storage.todoCreated,
      run: (todo) => services.api.save(todo),
      onSuccess: (todo, server) => [deps.storage.todoSynced({ id: todo.id, serverId: server.id })],
      onFailure: (todo, error) => [
        deps.storage.todoPersistFailed({ id: todo.id, error: error.message }),
      ],
    }),
  }),
  liveQueries: (deps, services) => ({
    byId: defineLiveQuery({
      query: (id: string) => deps.storage.byId(id),
      source: (id, { on }) =>
        services.ws.subscribe(id, { onMessage: on((todo) => [deps.storage.todoCreated(todo)]) }),
    }),
  }),
});
  • deps — required. Stores and other orchestrators. Types flow into every callback.
  • queries — pure derivations from deps. Same shape as store queries.
  • commands — pure functions returning { events?, data?, result? }. No services.
  • reactions — event subscribers. Receive (deps, services, commands). Each reaction declares on:, run, optional onSuccess/onFailure, optional concurrency.
  • liveQueries — long-lived I/O bound to a query. Receive (deps, services).

Reactions

A reaction subscribes to one or many events (or commands) and runs a handler when triggered.

defineReaction({
  on:          deps.todos.todoCreated,           // single source
  // on:       [deps.todos.todoCreated, deps.notes.noteCreated],   // multi-source: payload is union
  // on:       commands.create,                  // command-derived (synthetic event)
  run:         async (payload, ctx) => { ... },  // ctx.abort, ctx.trigger
  onSuccess:   (payload, result) => readonly EventDescriptor[],
  onFailure:   (payload, error)  => readonly EventDescriptor[],
  concurrency: "parallel" | "serial" | "switch"
               | { mode: "switch" | "serial", key: payload => string },
})

Concurrency

  • parallel (default) — each trigger spawns an independent run.
  • serial — triggers queue, run one at a time in arrival order.
  • switch — new trigger aborts the in-flight run via ctx.abort.
  • Per-key forms{ mode, key: payload => string } scopes the policy by a derived key (search per topic, save per document).
// search-per-topic: switch within topic, parallel across topics
fetch: defineReaction({
  on: deps.search.queryChanged,
  concurrency: { mode: "switch", key: ({ topic }) => topic },
  run: async ({ topic, q }, ctx) => services.api.search(topic, q, { signal: ctx.abort }),
  onSuccess: (input, results) => [deps.search.resultsReceived({ topic: input.topic, results })],
}),

Awaitable commands

Declare result: on the command and await resolves with the matching event's payload — correlated automatically to the invocation.

commands: (deps) => ({
  create(input: { title: string }) {
    return {
      result: { ok: deps.todos.todoCreated, fail: deps.todos.todoCreateFailed },
    };
  },
}),
reactions: (deps, services, commands) => ({
  persist: defineReaction({
    on: commands.create,
    run: (input) => services.api.create(input),
    onSuccess: (input, todo) => [deps.todos.todoCreated(todo)],
    onFailure: (input, err)  => [deps.todos.todoCreateFailed({ tempId: input.tempId, error: err.message })],
  }),
}),

// consumer
const todo = await app.todos.create({ title: "Buy milk" });
router.push(`/todos/${todo.id}`);

If result: is a single creator: await resolves with that event's payload, rejects with Error. If result: is { ok, fail }: rejects with the fail event's payload. If no result: is declared: await resolves with the data field once all triggered reactions settle. If result: is declared but no reaction emits the event: the await rejects with CommandUnresolvedError.

Correlation

Every command call generates an internal corrId. Events the command dispatches (and events emitted by reactions transitively triggered) inherit it. The handle's await resolves only on result events bearing the matching corrId — concurrent calls never race.

onCommand, onEvent, onReaction traces all carry corrId. Events outside any command (live-query dispatch, manual dispatch) have corrId === null.


CommandHandle

Every command returns a CommandHandle: thenable, with a synchronous .data field.

// Synchronous data — optimistic / client-id pattern
const id = app.todos.create({ id: crypto.randomUUID(), title: "Buy milk" }).data;
router.push(`/todos/${id}`);

// Awaitable result event — authoritative / server-id pattern
const todo = await app.todos.create({ title: "Buy milk" });

// Wait for every triggered reaction to settle (not just the result event)
const handle = app.todos.create({ title: "Buy milk" });
await handle;
await handle.allSettled();

.then, .catch, .finally are all available. Unobserved rejections do not surface as unhandledRejection.


Live Queries

Live queries are queries whose value is kept fresh by a long-lived I/O source. Identity is (name, stableJson(args)). Acquired by consumers via useLiveQuery (Vue) or acquire() (programmatic). Refcounted — the source opens on first acquire and tears down on the last release.

liveQueries: (deps, services) => ({
  byId: defineLiveQuery({
    query: (id: string) => deps.storage.byId(id),
    source: (id, { on, fail }) =>
      services.ws.subscribe(id, {
        onMessage: on((todo) => [deps.storage.todoCreated(todo)]),
        onError: (e) => fail(e),
      }),
    onError: (id, e) => [deps.storage.streamFailed({ id, error: e.message })],
  }),
});

Source patterns

Standard (callback-based) — source emits via callbacks. Use query + source + on() to wire callbacks into events. Projections route into a store; queries read from the store. Use whenever projections do real work or commands also write the same data.

Pass-through — when the source is already a SubscribableQuery (RxDB queries, custom reactive layers), return it from query directly with a no-op source. Strata detects the shape, refcounts the entry, and shares identity across consumers.

liveQueries: (_, services: { readonly notes: NotesService }) => ({
  byId: defineLiveQuery({
    query: (id: string) => services.notes.subscribableById(id),
    source: () => () => {},
  }),
});

createStrata

const app = createStrata({
  name: "todo-app",
  responsibility: "Main app graph.",
  services: { api: apiService.with({ baseUrl: "/api" }) },
  stores: { storage: todoStore },
  orchestrators: { todos: todosOrch },
  onCommand: (t) => log("cmd", t),
  onEvent: (t) => log("evt", t),
  onReaction: (t) => log("rxn", t),
  onLiveQuery: (t) => log("lq", t),
});

// ... use it ...

await app.$dispose();

Resolution order: services → stores → orchestrators (three-phase: command-ref stubs first, then reactions, then real command invokers). Synchronous if all services are sync; returns a Promise if any service is async.

$dispose() is async: drains in-flight reactions, tears down live queries, stops reactivity, then calls service teardowns in reverse order.

Environments

Strata has no DOM or browser dependencies. The same graph runs in:

  • Browsers — bind via useQuery / useLiveQuery
  • Node / Bun / Deno — drive commands from HTTP handlers, queue workers, schedulers
  • Web / service workers — same surface, different services
  • Tests — build a graph with fake services, drive it through commands, assert via queries

Vue is an optional peer dependency, only required if you import @mattfletcher94/strata/vue.


Command Execution Flow

1. Command runs (pure, sync) -> { events?, data?, result? }
2. Each event tagged with the command's corrId
3. Events batched per store, dispatched to projections (one shallowRef per store)
4. Synthetic command-invocation event dispatched (so commands.X-subscribed reactions fire)
5. CommandHandle returned (data accessible synchronously)
6. Reactions run in background (parallel/serial/switch, plus per-key forms)
   - On success: onSuccess events -> dispatched to stores (corrId inherited)
   - On failure: onFailure events -> dispatched to stores (corrId inherited)
7. await handle resolves when:
   - result.ok event fires (resolves with payload), OR
   - result.fail event fires (rejects with payload), OR
   - no result declared and all reactions settled (resolves with data), OR
   - all reactions settled without firing result (rejects with CommandUnresolvedError)

Live-query lifecycle is independent: triggered by consumer acquire/useLiveQuery. Events dispatched by live queries have corrId === null; reactions still fire.


Trace Hooks

Every trace carries a corrId. Filter your trace stream by one corrId to see exactly what one user action did — the command that started it, every event it dispatched, every projection that ran, every reaction it triggered, and every transitively-emitted event from those reactions. This is the debugging story: bugs in a Strata graph are reproducible because state transitions are pure and causally linked.

createStrata({
  // ...
  onCommand: ({ module, command, args, corrId, timestamp }) => {},
  onEvent: ({ store, event, payload, corrId, timestamp }) => {},
  onReaction: ({ module, reaction, trigger, corrId, status, duration, result, error }) => {},
  onLiveQuery: ({ module, liveQuery, args, status, duration, error }) => {},
});

onReaction fires startedsucceeded / failed / aborted for each invocation. Synthetic command-invocation events do NOT appear in onEventonCommand already covers them.


Introspection

inspectDAG(app); // structured graph
toMermaid(inspectDAG(app)); // mermaid diagram
app.$inspectLiveQueries(); // active live-query entries
app.$inspectReactions(); // every registered reaction with concurrency mode and inFlight count

Vue Integration

import { useQuery, useLiveQuery } from "@mattfletcher94/strata/vue";

const list = useQuery(app.notes.list); // plain query
const todo = useQuery(() => app.notes.byId(props.id)); // parameterised
const note = useLiveQuery(() => app.notes.byId(props.id)); // live, refcounted
const pinned = useLiveQuery(app.notes.pinned); // live, zero-arg
const maybe = useLiveQuery(() => (props.id ? app.notes.byId(props.id) : null));

Two useLiveQuery calls with the same args share one underlying I/O source.


Patterns

Canonical patterns for common problems. Each pattern is a prescription: the Strata-shaped way, not one of several options. Reach for these before inventing your own. Each links to a scenario test you can read and copy.

Optimistic mutations → derive from an overlay, never snapshot

The Strata pattern for optimistic updates is derived state via an overlay, not snapshot-and-restore. Items in the cache reflect the server. A separate "pending mutations" overlay (e.g. pendingRemovals: string[]) is what queries filter against. Rollback is overlay-clear; commit is items mutation + overlay-clear. Items[] is never touched until the server confirms.

state: {
  items: [] as Todo[],
  pendingRemovals: [] as string[], // overlay
},
projections: {
  itemRemoveStarted: (state, { id }) => ({
    ...state,
    pendingRemovals: [...state.pendingRemovals, id],
  }),
  itemRemoveConfirmed: (state, { id }) => ({
    ...state,
    items: state.items.filter((t) => t.id !== id),
    pendingRemovals: state.pendingRemovals.filter((x) => x !== id),
  }),
  itemRemoveRolledBack: (state, { id }) => ({
    ...state,
    pendingRemovals: state.pendingRemovals.filter((x) => x !== id),
  }),
},
queries: (state) => ({
  list: () => state.items.filter((t) => !state.pendingRemovals.includes(t.id)),
}),

Why overlays beat snapshots: A list fetch returning during a pending removal can't resurrect the deleted item — the query keeps filtering by the overlay until the mutation settles. With snapshots, that same race silently overwrites the optimistic state.

See: test/scenarios.data-fetching.test.ts

Loading / error state → projections in the store

Loading state is just data. Don't track isLoading in component state or refs alongside Strata — model fetch lifecycle as projections. Queries derive the boolean.

state: { listStatus: "idle" as "idle" | "fetching" | "error", listError: null as string | null },
projections: {
  listFetchStarted: (state) => ({ ...state, listStatus: "fetching", listError: null }),
  listFetched: (state, { todos }) => ({ ...state, items: todos, listStatus: "idle" }),
  listFetchFailed: (state, { error }) => ({ ...state, listStatus: "error", listError: error }),
},
queries: (state) => ({
  isListLoading: () => state.listStatus === "fetching",
  listError: () => state.listError,
}),

The reaction's onSuccess and onFailure dispatch the corresponding events. Components bind useQuery(app.todos.isListLoading) and re-render automatically.

See: test/scenarios.data-fetching.test.ts

Cache invalidation → re-dispatch the trigger event

Strata has no separate invalidate primitive. Calling the fetch command again is invalidation — the reaction has switch concurrency, so the new fetch aborts the old one and updates state on completion. For discoverability, expose an alias command:

fetchList() {
  return { events: [deps.todoCache.listFetchStarted({})] };
},
invalidateList() {
  return { events: [deps.todoCache.listFetchStarted({})] };
},

Auto-invalidation after a mutation is just an event in the reaction's onSuccess:

runRemove: defineReaction({
  on: deps.todoCache.itemRemoveStarted,
  run: ({ id }) => services.api.remove(id),
  onSuccess: ({ id }) => [
    deps.todoCache.itemRemoveConfirmed({ id }),
    deps.todoCache.listFetchStarted({}), // refetch the list
  ],
}),

See: test/scenarios.data-fetching.test.ts

Awaitable mutations → declare result, don't reach for allSettled()

When a command needs to await a server response (e.g. to redirect to a server-created id), declare result: { ok, fail }. The await resolves with the matching event's payload, correlated automatically.

create(input: { title: string }) {
  return {
    events: [deps.todos.todoOptimisticAdded(input)],
    result: { ok: deps.todos.todoCreated, fail: deps.todos.todoCreateFailed },
  };
},

// consumer
const todo = await app.todos.create({ title: "Buy milk" });
router.push(`/todos/${todo.id}`); // server-issued id

handle.allSettled() is the escape hatch for "wait for everything, no specific completion event." Use it sparingly — most commands have a meaningful "done" event worth declaring.

Long-running streams → live queries

Server-pushed streams (websockets, SSE, observers) are live queries. Identity is (name, args) with refcounting; concurrent acquirers share one underlying source.

liveQueries: (deps, services) => ({
  byId: defineLiveQuery({
    query: (id: string) => deps.todos.byId(id),
    source: (id, { on, fail }) =>
      services.ws.subscribe(id, {
        onMessage: on((todo) => [deps.todos.todoUpdated(todo)]),
        onError: (e) => fail(e),
      }),
  }),
})

// consumer
const todo = useLiveQuery(() => app.todos.byId(props.id))

See: test/live-queries.test.ts and test/scenarios.chat-app.test.ts

Cross-resource coordination → cross-orchestrator command refs

When orchestrator A needs to react to commands in orchestrator B, subscribe to the command ref via the commands parameter:

// In orchestrator A
reactions: (deps, services, commands) => ({
  // commands.B is exposed because B is in deps
}),

// Better: reactions in orchestrator B subscribe to its own commands
reactions: (deps, services, commands) => ({
  trackEvent: defineReaction({
    on: commands.create, // subscribes to the command-invocation event
    run: (input) => services.analytics.track("create", input),
  }),
}),

This is how cross-cutting concerns (analytics, logging, audit, replication) attach without modifying the command itself. One reaction, one job.

Tests → build a real graph with fake services

No mock runtime, no shallow rendering, no test-specific Strata mode. Build a real graph with fake services that satisfy the same interface as production services. await app.$flush() to wait for all reactions to settle, then assert via queries.

const apiService = defineService({
  name: "api",
  setup: () => ({ save: vi.fn(async (todo) => todo) }),
});
const app = createStrata({ services: { api: apiService }, stores: {...}, orchestrators: {...} });

app.todos.create({ title: "Buy milk" });
await app.$flush();
expect(app.todos.list()).toHaveLength(1);

await app.$dispose();

$flush() waits for ALL in-flight reactions across the graph to settle (versus handle.allSettled() which waits only for one command's reactions). For per-command awaiting, prefer declaring result: { ok, fail }.

Scenarios index

Every scenario test demonstrates a pattern. Read these first when designing a new feature.


Testing

Build a real graph with fake services, drive it through commands, assert via queries.

import { describe, it, expect, vi } from "vitest";
import { defineService, createStrata } from "@mattfletcher94/strata";

it("creating a todo updates state synchronously", async () => {
  const apiService = defineService({
    name: "api",
    setup: () => ({ save: vi.fn(async (_t: Todo) => {}) }),
  });

  const app = createStrata({
    name: "test",
    responsibility: "Test graph.",
    services: { api: apiService },
    stores: { storage: storageStore },
    orchestrators: { todos: todosOrchestrator },
  });

  app.todos.create({ title: "Buy milk" });
  expect(app.storage.count()).toBe(1);

  await app.$dispose();
});

Asserting on service calls — hoist the spy outside setup, then await handle.allSettled() to wait for triggered reactions:

const saveSpy = vi.fn(async (_t: Todo) => {});
const apiService = defineService({ name: "api", setup: () => ({ save: saveSpy }) });
// ...
const handle = app.todos.create({ id: "t1", title: "Buy milk" });
await handle.allSettled();
expect(saveSpy).toHaveBeenCalledOnce();

Awaiting commands with declared result:

const todo = await app.todos.create({ title: "Buy milk" }); // resolves with todoCreated payload
await expect(app.todos.create({ title: "" })).rejects.toThrow(); // rejects with todoCreateFailed payload or CommandUnresolvedError

Trace hooks as test seams — use when asserting what events fired in what order. For what state resulted, queries are clearer:

const events: EventTrace[] = [];
const app = createStrata({ ..., onEvent: (t) => events.push(t) });
app.todos.create({ title: "Buy milk" });
expect(events.map((e) => e.event)).toEqual(["todoCreated"]);

Always await app.$dispose() at test end. Long-lived graphs leak watchers across tests.


Common Mistakes

  • Don't put I/O in projections, queries, or commands. Use reactions or live queries.
  • Don't put mutable state in orchestrators. All state belongs in stores.
  • Don't access services from commands. Commands get (deps) only.
  • Don't await a fire-and-forget command if you don't need the result — the handle resolves asynchronously when reactions settle.
  • Don't await a command expecting a live query to have connected. Live queries are not reactions.
  • Don't put functions, Maps, or Sets inside live query args. They serialise to identical or empty registry keys and break refcount sharing.
  • Use Record<string, T> not Map. Use arrays not Set. Projections use spread.
  • If await app.x.cmd() hangs and eventually rejects with CommandUnresolvedError, your command declared a result but no reaction emitted the success or failure event.

License

MIT

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors