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.
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.
@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.
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:
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:
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} — {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.
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:
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.
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:
| Helper | Import | When to use |
|---|---|---|
memory() | @swirls/looms/projectors | Unit tests and single-process development |
sqlite(opts) | @swirls/looms/projectors/sqlite | Local file path or existing db. Defaults to :memory: |
postgres(opts) | @swirls/looms/projectors/postgres | Production servers. Pass { url } or an existing postgres.js client |
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:
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.