Skip to content
Looms
Documentation
On this page

Projections & projectors

The append-only event log is the only source of truth. Product surfaces (chat, approval queues, ledgers, admin indexes) are projected from that log via @swirls/looms/react or cross-run projectors. Scoped projections share types with the host; see Type safety.

ProjectionsOne event stack fans out into three surfaces: a conversation, a ledger, and an analytical chart. The same events fold into chat, ledgers, and analytics. Each view is derived from the log and can be rebuilt from it.
One history. Many useful views.The same events fold into chat, ledgers, and analytics. Each view is derived from the log and can be rebuilt from it.
Run stream (truth)→Pure fold→UI projections→DB projectors

Behavioral vs projection reducers#

Thread kinds use behavioral reducers ((state, event) → { state, effects }). Projections use projection reducers ((state, event) → state): pure observation, no side effects. Full event/effect boundary: Events & effects.

Projections are portable; React is an adapter. The same definition folds in-memory on the host, reactively in the browser via @swirls/looms/react, or into SQLite, Postgres, or ClickHouse.

Building custom UIs#

The UI subscribes to the run event stream. When events land, client-side projections re-fold.

Step 1: Define your domain projection#

The snippets in this walkthrough use application-specific order events and an ordersModule scope, defined with the module API. They illustrate integration rather than a standalone application; the quickstart provides a complete runnable example. Define a pure read model using ordersModule.projection from your module's scope (or standalone defineProjection from @swirls/looms/core). With scope.projection, event types and payloads are automatically narrowed per case based on the module's registered catalogs. Providing a Zod (or Standard Schema) shape infers the state type automatically without an explicit generic. Export it so both server and client can use it.

ts
import { z } from 'zod'import { ordersModule } from './scope'export const OrderSchema = z.object({  orderId: z.string().nullable(),  items: z.array(z.string()),  total: z.number(),  status: z.enum(['draft', 'awaiting_approval', 'processing', 'completed', 'failed']),  approvalId: z.string().nullable(),  paymentId: z.string().nullable(),  history: z.array(z.object({ step: z.string(), timestamp: z.number() })),})export type OrderState = z.infer<typeof OrderSchema>export const orderTracker = ordersModule.projection({  name: 'orderTracker',  shape: OrderSchema,  initialState: {    orderId: null,    items: [],    total: 0,    status: 'draft',    approvalId: null,    paymentId: null,    history: [],  },  reduce(state, event) {    // state is inferred from shape; event is narrowed per case by scope    switch (event.type) {      case 'orders.created':        return {          ...state,          orderId: event.payload.orderId,          items: event.payload.items,          total: event.payload.total,          status: 'draft',          history: [...state.history, { step: 'Order Created', timestamp: event.ts }],        }      case 'approval.requested':        return {          ...state,          status: 'awaiting_approval',          approvalId: event.payload.approvalId,          history: [...state.history, { step: 'Approval Requested', timestamp: event.ts }],        }      case 'approval.decided':        return {          ...state,          status: event.payload.outcome === 'approve' ? 'processing' : 'failed',          history: [...state.history, { step: `Approval ${event.payload.outcome}d`, timestamp: event.ts }],        }      case 'payments.charge.authorized':        return {          ...state,          status: 'completed',          paymentId: event.payload.chargeId,          history: [...state.history, { step: 'Payment Authorized', timestamp: event.ts }],        }      default:        return state    }  },})

Step 2: Provide the host connection#

Wrap your React tree or page with LoomsProvider from @swirls/looms/react. Pass the Looms host URL; the client opens /runs/:id/events on that host:

tsx
import { LoomsProvider } from '@swirls/looms/react'import { OrderDashboard } from './order-dashboard'export function App({ runId }: { runId: string }) {  return (    <LoomsProvider endpoint="http://localhost:8787">      <OrderDashboard runId={runId} />    </LoomsProvider>  )}

Step 3: Subscribe reactively with useProjection#

In your component, call useRunStore(runId) to connect to the run's event stream, then pass it to useProjection:

tsx
import { useRunStore, useProjection } from '@swirls/looms/react'import { orderTracker } from './projections'export function OrderDashboard({ runId }: { runId: string }) {  const store = useRunStore(runId)  const order = useProjection(store, orderTracker)  return (    <div className="order-card">      <h2>Order {order.orderId ?? 'Initiating...'}</h2>      <div className="badge">{order.status}</div>      <p>Total: ${order.total}</p>      <h3 id="timeline">Timeline<a className="heading-anchor" href="#timeline" aria-label="Link to this section">#</a></h3>      <ul>        {order.history.map((h, i) => (          <li key={i}>{h.step} &mdash; {new Date(h.timestamp).toLocaleTimeString()}</li>        ))}      </ul>    </div>  )}

useProjection uses React's useSyncExternalStore with an incremental projection cache. When new events append to the run log on the server, the SSE connection receives them and folds only the newly appended events into the cached state, coalescing updates at animation frame rates. Rendering cost still depends on the projection and the components that subscribe to it.

Step 4: Dispatch user interactions via signals#

User actions do not mutate state directly; they post events to the log using store.commit or client.signal:

This excerpt is for a trusted internal client behind a gateway that authorizes the run and validates allowed signals. For a public browser UI, send a decision intent to an application endpoint that authenticates the reviewer and constructs the event. See authentication and tenancy. Add pending and error states before using these controls in a product.

tsx
import { decision } from '@swirls/looms/approval'import { useRunStore, useProjection } from '@swirls/looms/react'import { orderTracker } from './projections'export function OrderControls({ runId }: { runId: string }) {  const store = useRunStore(runId)  const order = useProjection(store, orderTracker)  const handleApprove = () => {    if (order.approvalId) {      store.commit(decision(order.approvalId, 'approve'))    }  }  const handleReject = () => {    if (order.approvalId) {      store.commit(decision(order.approvalId, 'reject'))    }  }  if (order.status !== 'awaiting_approval') return null  return (    <div className="actions">      <p>Human approval required for orders over $100</p>      <button onClick={handleApprove}>Approve Order</button>      <button onClick={handleReject}>Reject Order</button>    </div>  )}

When store.commit runs, the signal lands on the server, wakes the parked thread, and streams back to all subscribers, updating all projections automatically.

Step 5: Time-travel debugging in the client#

Because projections are pure folds over the event array, you can fold any prefix of the log to render a historical UI state:

tsx
import { useState } from 'react'import { foldProjection } from '@swirls/looms/core'import { useRunStore } from '@swirls/looms/react'import { orderTracker } from './projections'export function TimeTravelSlider({ runId }: { runId: string }) {  const store = useRunStore(runId)  const events = store.events()  const [step, setStep] = useState(events.length)  // Reconstruct state at event #step with zero backend round-trips:  const pastState = foldProjection(orderTracker, events.slice(0, step))  return (    <div>      <input        type="range"        min={0}        max={events.length}        value={step}        onChange={(e) => setStep(Number(e.target.value))}      />      <span>Viewing step {step} of {events.length}</span>      <pre>{JSON.stringify(pastState, null, 2)}</pre>    </div>  )}

Server-Side Projectors (Cross-Run Storage)#

While client-side projections fold the log of a single run, projectors run on the host to watch the entire event store across all runs. Use them to maintain queryable tables, full-text search, or fan out to webhooks and Slack.

Attach projectors to the host#

Wrap the local execution store with withProjectors from @swirls/looms/projectors. Add createProjectorDelivery with a persistent cursor store when projection must survive a process restart. Delivery advances its cursor only after an idempotent projector accepts the batch.

ts
import { bunSqliteEventStore } from '@swirls/looms/core/bun-sqlite'import { withProjectors } from '@swirls/looms/projectors'import { sqlite } from '@swirls/looms/projectors/sqlite'import { s2Projector, s2ConfigFromEnv } from '@swirls/looms/s2'// Local SQLite is the execution store. Index + S2 lake are projectors.const store = withProjectors(bunSqliteEventStore({ path: './run.sqlite' }), [  sqlite({ path: './looms-index.db' }),  s2Projector(s2ConfigFromEnv(process.env)),], {  onError: (error, projector) => {    console.warn(projector.name, error.message)  },})

On Cloudflare Durable Objects, supply projectors via configure().projectors (see Hosting & storage). Projectors can populate relational databases, emit webhooks, or replicate events to a centralized data lake via s2Projector. Looms stores projector cursors in the object's SQLite database, retries failures with durable backoff, and moves exhausted work to a dead letter state. Inspect lag and requeue failed delivery through the projector operations API.

Built-in index helpers#

Looms includes built-in projectors for Memory, SQLite, and Postgres to keep a searchable run index and an approval index up to date:

HelperImportWhen to use
memory()@swirls/looms/projectorsUnit tests and single-process development
sqlite(opts)@swirls/looms/projectors/sqliteLocal file path or existing db. Defaults to :memory:
postgres(opts)@swirls/looms/projectors/postgresProduction servers. Pass { url } or an existing postgres.js client
ts
import { sqlite } from '@swirls/looms/projectors/sqlite'const index = sqlite({ path: './looms.db' })// Query cross-run indexes from your admin routes:const run = await index.getActor(runId)const pendingReviews = await index.listReviews(runId)

Custom webhook and fan-out projectors#

Any object implementing project(events) satisfies the Projector interface:

ts
import type { Projector } from '@swirls/looms/projectors'export function approvalsWebhook(url: string): Projector {  return {    name: 'approvals-webhook',    version: '1',    project: async (events) => {      for (const event of events) {        if (event.type !== 'approval.requested') continue        await fetch(url, {          method: 'POST',          headers: { 'content-type': 'application/json' },          body: JSON.stringify({            runId: event.runId,            approvalId: event.payload.approvalId,            title: event.payload.title,          }),        })      }    },  }}

Next: see complete working examples of custom projections in Examples, host runs on Durable Objects in Hosting & storage, or browse packages in Modules.