Declarative read models over an event log — "PostgREST for your projections".
Define a projection as data (a _projections row), and the engine folds
events into a queryable table with zero service code. Define a select policy
(_policies) and a generic POST /v1/query/:name endpoint serves filtered,
sorted, paged, column-projected reads — row-scoped to the caller. New table =
new definition row, no new endpoints, no new code.
append(event) ──▶ applyEvent() ──rules──▶ rm_<name> table ──▶ execQuery() ──▶ rows
(mode A fold) (policy AND-ed)
Storage-agnostic (SqlClient = any libsql-shaped client) and event-source-
agnostic (EventLike = { type, streamId, tenant, payload } — structurally
compatible with @baseworks/eventstore's StoredEvent).
- Mode A (this package): fold rules are data —
upsert(payload paths / literals),inccounters,delete. Covers ~80% of read models. Rebuild is fully generic: the host can rebuild any projection from the log on its own. - Mode B (not this package): real domain folds (cross-aggregate walks, invariants). The owning service computes the row and pushes it. This engine must never learn domain logic — if a rule can't express it, it's mode B.
import { createClient } from '@libsql/client'
import { Registry, applyEvent, execQuery, rebuildProjection } from '@baseworks/readmodel'
const db = createClient({ url: process.env.DB_URL! })
const registry = new Registry(db)
await registry.init()
// 1. Projection = data. Registering it creates rm_notes AND whitelists "notes".
await registry.saveProjection({
name: 'notes',
columns: { owner: 'text', text: 'text', created_at: 'integer' },
on: {
NoteAdded: { op: 'upsert', set: { owner: '$.owner', text: '$.text', created_at: '$.createdAt' } },
NoteDeleted: { op: 'delete' },
},
})
// 2. Policy = data. Deny-by-default; this grants "everyone sees their own rows".
await registry.savePolicy({ name: 'notes', role: '*', using: 'owner = :auth_uid' })
// 3. On every append, fold inline (read-your-writes):
await applyEvent(db, registry, storedEvent)
// 4. Serve the generic query endpoint:
const result = await execQuery(db, registry, 'notes', requestBody, {
tenant: ctx.tenant, uid: ctx.userId, role: ctx.role,
})Query body (POST /v1/query/notes — reads are POSTs by design: rich nested
filters, no URL limits):
{
"select": ["id", "text", "created_at"],
"where": { "and": [ { "text": { "like": "%demo%" } }, { "created_at": { "gt": 100 } } ] },
"sort": ["-created_at"],
"limit": 50, "offset": 0
}Operators: eq ne gt gte lt lte like in, nested and / or. eq: null → IS NULL.
- Names resolve only through the registry —
_projections,_policies,_rpcand the event log are structurally unreachable. - Deny-by-default — no policy for
(name, role)(nor a*fallback) → 403. tenant = ?is AND-ed structurally into every query, never via policy text.- Client JSON never reaches SQL as text: columns are whitelisted against the
definition, values are bound parameters. Policy
usingfragments are operator-authored config (written in migrations, like schema) whose:auth_*placeholders bind from the verified auth context. allow/denycolumn lists apply to select, where, and sort alike.
Read models are disposable; the log is the truth:
await rebuildProjection(db, registry, 'notes', tenant, eventsInGlobalSeqOrder)Hosts typically expose this as POST /admin/rebuild.
:auth_uid, :auth_role, :auth_email, :auth_tenant, :auth_claim_<key> —
bound from the AuthCtx the host builds from its verified JWT. A policy that
references a value the caller lacks denies (never widens).
_rpcnamed parametric queries — table reserved, engine v2.- Mode B push API, lookup enrichment, catch-up checkpoints — the hosting service's concern (e.g. eventstore-service) or later iterations.