An append-only, multi-tenant event store over libSQL/SQLite (with a Postgres schema too). Streams, optimistic concurrency, and a global order — the source of truth an event-sourced service reads and rebuilds from.
append(event) ──▶ events table (append-only, per-tenant, globally ordered)
│
readStream / readAll ──▶ fold(projector) ──▶ derived state
Pairs with @baseworks/readmodel: eventstore is the log;
readmodel folds it into queryable read models. Zero framework lock-in — it's
a repo over any drizzle-shaped client; the same interface is available over HTTP
(createEventStoreHttpClient) so a service can front it.
- Event envelope — an event grows through three shapes:
NewEvent(what a caller records),SealedEvent(+ id, version, time),StoredEvent(+globalSeq, assigned by the store on write). - Stream — the append-only history of one entity, addressed by its id.
Within a stream
versionis strictly increasing (1, 2, 3…). - Optimistic concurrency —
appendtakes theexpectedVersionthe caller believes the stream is at; if it moved on, the append is rejected withConcurrencyError. AUNIQUE(tenant, stream_id, version)index is the ultimate backstop. - Global order —
globalSeqorders events across all streams, so the whole tenant log replays deterministically. - Tenant isolation — every row and every read is scoped by
tenant.
import { createClient } from '@libsql/client'
import { drizzle } from 'drizzle-orm/libsql'
import { createEventStoreRepo, events, SQLITE_SCHEMA_SQL } from '@baseworks/eventstore'
const client = createClient({ url: process.env.DB_URL! })
await client.executeMultiple(SQLITE_SCHEMA_SQL) // apply the schema on boot
const store = createEventStoreRepo(drizzle(client, { schema: { events } }), { events })
// append (optimistic concurrency: expectedVersion 0 = a new stream)
await store.append('acme', 'order-1', 0, [
{ type: 'OrderPlaced', streamId: 'order-1', actor: 'u1', payload: { total: 42 } },
])
// read
await store.readStream('acme', 'order-1') // one entity's history
await store.readAll('acme', { fromGlobalSeq: 0 }) // the whole tenant log, in ordercreateEventStoreRepo(db, { events }) → EventStoreRepo:
| method | purpose |
|---|---|
append(tenant, streamId, expectedVersion, events) |
append; → { events, version }, throws ConcurrencyError on conflict |
readStream(tenant, streamId, { fromVersion? }) |
one stream's events |
readAll(tenant, { fromGlobalSeq? }) |
every event in the tenant, in global order |
readByCorrelation(tenant, correlationId) |
events sharing a correlation id (lineage) |
streamVersion(tenant, streamId) |
current version of a stream |
Projections (projection.ts) — fold events into derived state:
import { fold, rebuildStream, rebuildAll, type Projector } from '@baseworks/eventstore'
const projector: Projector<State> = { initial, apply }
const state = await rebuildStream(store, projector, 'acme', 'order-1') // one aggregateOver HTTP — the same EventStoreRepo interface against an eventstore
service:
import { createEventStoreHttpClient } from '@baseworks/eventstore/http-client'
const store = createEventStoreHttpClient('http://events:3001')SQLITE_SCHEMA_SQL— the canonical DDL string; the single source of truth for theeventstable shape. Apply it on boot (client.executeMultiple(...)).- Drizzle tables:
@baseworks/eventstore/schema/sqlite(default) and@baseworks/eventstore/schema/pg(Postgres).
drizzle-orm + zod. No framework, no server — bring your own libsql/pg client.
pnpm test # vitest
pnpm build # tsup → dist