@playfast/reform-db
Type-safe, PGlite-backed reactive SQL state for Reform.
Define tables once, read them reactively as Reform Sources, and write through
ordinary Procedures — all with column-level type-safety inferred through
Kysely. Built for on-device / local-first apps
(Electron, Electrobun, browser) where the database lives next to the UI and writes
are instant.
Install
npm install @playfast/reform-db kysely
# PGlite is an optional peer — only needed for the real driver (Db.pglite/Db.memory)
npm install @electric-sql/pgliteeffect, kysely, and @playfast/reform are peer dependencies.
Quick Start
import { Db, DbColumn, DbQuery, DbSchema, DbTable, Migration } from '@playfast/reform-db'
import { Procedure } from '@playfast/reform'
import { Layer } from 'effect'
// 1. Define tables — each column carries its SQL type + a runtime Schema.
const Environments = DbTable.make('environments', {
columns: {
id: DbColumn.text({ primaryKey: true }),
name: DbColumn.text(),
status: DbColumn.literal(['idle', 'running', 'done']),
createdAt: DbColumn.timestamp(),
},
primaryKey: 'id',
})
// 2. One schema → one Kysely Database type, shared by reads AND writes.
const AppDb = DbSchema.make('app', { tables: [Environments] })
// 3. A reactive read — `yield* ActiveEnvironments` yields AsyncData<Row[]>.
const ActiveEnvironments = DbQuery.make('activeEnvironments', { output: Environments.Row })
const ActiveEnvironmentsLive = DbQuery.live(ActiveEnvironments, {
schema: AppDb,
inputs: [],
build: (_inputs, db) =>
db.selectFrom('environments').selectAll().where('status', '=', 'running').orderBy('createdAt'),
})
// 4. A write — a Procedure using Db.exec, typed against the SAME schema.
const CreateEnvironmentLive = Procedure.live(CreateEnvironment, {
run: ({ name }) =>
Db.exec(
AppDb.kysely
.insertInto('environments')
.values({ id: ulid(), name, status: 'idle', createdAt: Date.now() }),
),
})
// 5. Wire the driver at the root (renderer-side, instant writes).
const DataLayer = Layer.mergeAll(ActiveEnvironmentsLive, CreateEnvironmentLive).pipe(
Layer.provideMerge(Db.pglite({ dataDir: 'idb://app', migrations: Migration.fromSchema(AppDb) })),
)Use Db.memory() (ephemeral PGlite) in tests and @playfast/proof — the driver is
an interface, so nothing else changes.
Key Concepts
| Piece | Role |
|---|---|
DbColumn |
A column carrying both its SQL type and a runtime Schema (text, integer, real, timestamp, boolean, literal, json). |
DbTable |
A table definition (make — columns + primary key); exposes a .Row schema. |
DbSchema |
A set of tables → one Kysely Database type and a .kysely builder, shared by reads and writes. |
DbQuery |
A reactive, SQL-backed Source (make + live) yielding AsyncData<Rows>. |
Db |
The driver service: Db.pglite / Db.memory layers + the Db.exec write helper + dump() for snapshotting. |
Migration |
CREATE TABLE DDL derived from a schema via Migration.fromSchema. |
There is no DbMutation primitive: a write is a side effect inside an ordinary
Reform Procedure. Direct DB writes are a local-app concern, so writes reuse the
existing mutation path rather than adding a parallel concept.
How it fits Reform
Reform's React binding is useSyncExternalStore-shaped: a Store<A> exposes
getSnapshot / subscribe / getVersion. A PGlite live query exposes the same
shape. The core seam, SyncedStore (in @playfast/reform), bridges any
external reactive source into a Reform Store. reform-db builds DbQuery on top
of it, so a SQL query renders exactly like an AsyncCalc — its value is an
AsyncData<Rows>.
PGlite live query ──► Db driver ──► SyncedStore ──► Reform render host
Docs
- API reference: playbook/api.doc.md
- Pairs with
@playfast/reform— the core render host andSyncedStore.
License
MIT