Skip to content

dotlabshq/baseworks-readmodel

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@baseworks/readmodel

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).

The two modes (and what this package refuses to do)

  • Mode A (this package): fold rules are data — upsert (payload paths / literals), inc counters, 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.

Quick start

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: nullIS NULL.

Security invariants (each is tested)

  1. Names resolve only through the registry — _projections, _policies, _rpc and the event log are structurally unreachable.
  2. Deny-by-default — no policy for (name, role) (nor a * fallback) → 403.
  3. tenant = ? is AND-ed structurally into every query, never via policy text.
  4. Client JSON never reaches SQL as text: columns are whitelisted against the definition, values are bound parameters. Policy using fragments are operator-authored config (written in migrations, like schema) whose :auth_* placeholders bind from the verified auth context.
  5. allow / deny column lists apply to select, where, and sort alike.

Rebuild

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 context

: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).

What's deliberately out (for now)

  • _rpc named 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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors