Getting started
This page gets you from zero to a working read + write path. For what each package does, see Packages; for the model behind it, see Core concepts.
Prerequisites
Section titled “Prerequisites”-
PostgreSQL 17+ (Supabase-compatible; the write path uses Supabase-style auth claims for RLS).
-
ElectricSQL ≥ 1.7, run with the subquery feature flag — this is mandatory:
Terminal window ELECTRIC_FEATURE_FLAGS=allow_subqueries,tagged_subqueriesWithout it, sync fails closed (no rows stream). See The Electric subquery requirement.
-
The
pgmqextension, but only if you use the event lane. Its generated migration runsCREATE EXTENSION IF NOT EXISTS pgmq, so the extension has to be present in the PostgreSQL image you deploy — Supabase’s images ship it; a stockpostgresimage does not. Registries with nostreamsneed nothing. -
A runtime that serves a web
fetchhandler for the pgxsinkit server — Bun, Deno, Supabase Edge Functions, Cloudflare Workers, etc. The server core is runtime-neutral and framework-free (web-standardRequest/Response, with the DB client injected); only the optionalserver.start()helper is Bun-specific. And Drizzle (drizzle-orm@1.0.0-rc.4+) for schema.
Install
Section titled “Install”bun add @pgxsinkit/client @pgxsinkit/server @pgxsinkit/contracts# React bindings (optional)bun add @pgxsinkit/reactpnpm add @pgxsinkit/client @pgxsinkit/server @pgxsinkit/contracts# React bindings (optional)pnpm add @pgxsinkit/reactnpm install @pgxsinkit/client @pgxsinkit/server @pgxsinkit/contracts# React bindings (optional)npm install @pgxsinkit/reactyarn add @pgxsinkit/client @pgxsinkit/server @pgxsinkit/contracts# React bindings (optional)yarn add @pgxsinkit/reactThe packages are published to public npm; install them with whichever package manager your app uses —
pgxsinkit mandates none. Peer dependencies include drizzle-orm, @electric-sql/pglite, and zod.
Stand up the write path
Section titled “Stand up the write path”-
Define your sync registry — the tables, their sync mode, and governance (managed fields like owner/timestamps). This is the single source of truth both paths read from.
sync-registry.ts import { clockMicrosecondsSql, defineSyncRegistry, defineSyncTable } from "@pgxsinkit/contracts";import { bigint, uuid, varchar } from "drizzle-orm/pg-core";export const registry = defineSyncRegistry({widgets: defineSyncTable({tableName: "widgets",mode: "readwrite",makeColumns: () => ({id: uuid("id").primaryKey(),label: varchar("label", { length: 120 }).notNull(),ownerId: uuid("owner_id"),// Server version: a strictly-monotonic per-row token convergence keys on.updatedAtUs: bigint("updated_at_us", { mode: "bigint" }).notNull().default(clockMicrosecondsSql),}),// Both are REQUIRED for a writable table — no silent default:conflictPolicy: "reject-if-stale", // or "last-write-wins"governance: {managedFields: [{ column: "updatedAtUs", applyOn: ["create", "update"], strategy: "nowMicroseconds" },// Stamp the owner from the verified `sub` claim. (`auth.uid()` is just claimPath: ["sub"];// use ["app_metadata","person_id"] etc. for an app-minted identity.){ column: "ownerId", applyOn: ["create"], strategy: "authClaim", claimPath: ["sub"] },],},// Add a shape.rowFilter for row-level read filtering (see the security note below).}),}); -
Provision the apply function. The write path applies batches through one in-database PL/pgSQL function,
pgxsinkit_apply_mutations. Generate a drizzle-kit migration that installs it from your registry with the publishedpgxsinkit-generateCLI (abinof@pgxsinkit/server), run from your own project. The CLI requires Bun on PATH — it imports your TypeScript registry and runsdrizzle-kit— but the bin itself launches under any runner;bunx, ornpx/pnpm exec/yarnonce Bun is installed (it prints a clear message if Bun is missing).Terminal window bunx pgxsinkit-generate \--registry ./sync-registry.ts \--export registry \--project-dir ./db \--config drizzle.config.ts \--name sync_artifactThis writes a standard drizzle-kit migration you commit and apply through your normal migration flow. The migration lands wherever your drizzle config’s
outpoints — pass--configand the generator readsoutfrom it (or set--outexplicitly); with neither it probesdrizzle/andinfra/drizzle/.The generated apply function and the audit/version column DEFAULTs (
clockMicrosecondsSqlabove) both call the canonicalpublic.pgxsinkit_clock_us()microsecond clock, so its utilities migration must be the first folder in the chain — generate it once with--utilities, before the drizzle-kit schema baseline, passing an early-sorting folder name:Terminal window bunx pgxsinkit-generate --utilities \--project-dir ./db \--config drizzle.config.ts \--name 20200101000000_pgxsinkit_utilitiesThe generated function carries a fingerprint of itself (a
COMMENT ON FUNCTION). It verifies itself on every call (ADR-0030): the server passes the fingerprint it expects for its registry + codegen, and the function compares that against its own stamped comment before touching any table — raisingPXS01and applying nothing on a mismatch. So a registry change or a@pgxsinkit/serverupgrade you forgot to regenerate + apply fails loud (a stale — or hand-installed, unfingerprinted — function is refused) instead of writing against the wrong applier. The check rides the existing call: no extra round trip, no startup query, no read-then-call race. To catch the same drift in CI before it ships, run the generator in--checkmode — read-only, writes nothing, non-zero exit on drift:Terminal window bunx pgxsinkit-generate --check \--registry ./sync-registry.ts --export registry \--project-dir ./db --config drizzle.config.ts -
Create the server and serve its
fetch. All writes go throughPOST /api/mutations; the ownership-enforcing shape proxy is served from the same app.import { createSyncServer } from "@pgxsinkit/server";import { registry } from "./sync-registry";const server = createSyncServer({registry,db, // your Drizzle databaseelectricUrl: process.env.ELECTRIC_URL!, // e.g. http://localhost:3000/v1/shaperesolveAuthClaims: async (_request) => {// verify the request's JWT and return its claims, or null to block all rowsreturn null;},});export default { fetch: server.fetch };// `server.fetch` is a web-standard handler — deploy it on Bun, Deno, Supabase Edge// Functions, or Cloudflare Workers. Only the optional `server.start()` helper needs Bun.Deploying onto a non-Bun runtime (Deno / Supabase Edge Functions) has a couple of concrete steps — a path rewrite and resolving claims from the platform’s JWT. See Deploying the server.
Stand up the read path
Section titled “Stand up the read path”The client writes locally into an overlay + a durable journal, flushes the journal to the server’s write route, and subscribes to Electric shapes that land in local PGlite. Reads are served from PGlite; the server’s shape proxy forwards shape requests to Electric and enforces ownership.
import { createSyncClient } from "@pgxsinkit/client";import { registry } from "./sync-registry";
const client = await createSyncClient({ registry, electricUrl: "/api/shape", // your shape-proxy path (createSyncServer's default route) batchWriteUrl: "/api/mutations", // the exact pgxsinkit batch endpoint getAuthToken: async () => currentJwt(),});// `createSyncClient` resolves at LOCAL-READ readiness (ADR-0041): cached rows are queryable immediately —// offline included — and writes transparently await the write runtime, so you can read and write straight// away. `await client.ready` below is optional: it waits for WHOLE-SYNC catch-up (every eager group), which// this demo does so the first read reflects the server. For a UI, paint at creation and drive per-view// loading with `hydrating`/`groupReady` instead of blocking on `ready`.await client.ready;
// Optimistic local write — staged in the overlay + journal, flushed on the next pass.await client.tables.widgets.create({ id: crypto.randomUUID(), label: "Hello" });
// Reads come from the local read model (the overlay unioned over synced rows).const widgets = await client.drizzle.select().from(client.views.widgets);These snippets compile against the published packages, so they stay in step with the shipped API. See The read path and The write path for the full flow, and Packages for the client entry points.
Naming the local store
Section titled “Naming the local store”Pass a storePath to name the local store — a plain name, never a storage URL:
const client = await createSyncClient({ registry, electricUrl: "/api/shape", batchWriteUrl: "/api/mutations", storePath: "my-app-store", // a plain name; omit it to use the built-in default});The storage backend is derived from where the code runs — IndexedDB in the browser, the filesystem on
Node/Bun — so you never write idb://… or file://… yourself. A scheme-bearing string is rejected at boot
with a clear error. A memory-backed store is deliberately not a production option; pgxsinkit’s durability
guarantees (persistent retention, the optimistic write journal) assume a persisted store.
Testing
Section titled “Testing”For unit tests that want a fast, throwaway in-memory store, import memoryStoreForTests from
@pgxsinkit/client/testing and spread it into the options — a named, deliberate opt-in whose durability
caveats are documented on the helper:
import { memoryStoreForTests } from "@pgxsinkit/client/testing";
const client = await createSyncClient({ registry, electricUrl: "/api/shape", batchWriteUrl: "/api/mutations", ...memoryStoreForTests("my-test"),});createSyncClient runs the engine on the calling thread — right for Node, tests, and as a fallback. In a
browser app you will usually attach through a SharedWorker instead: defineSyncWorker in a worker entry
and attachSyncClient in the tab return the same client shape off the main thread. Capability placement can
host the OPFS engine in that SharedWorker on Safari or behind it in an elected worker on Chromium/Firefox. See
Worker mode.
Project a writable table read-only per client
Section titled “Project a writable table read-only per client”When two clients consume the same table at different capabilities — a teacher writes a posting_restriction
a learner only reads, or a learner writes a report a teacher only moderates — mode is per-client, not
a property of the table. Define the table once in an authoritative registry at its writable capability,
generate the server from that, and give each client a projection of it.
mode is resolved at defineSyncTable time and drives the local write machinery (overlay + journal) and the
overlay-merged read-model view, so you cannot hand-spread { ...entry, mode: "readonly" } — the entry would
keep a view over overlay state the readonly client never creates. Use asReadonly, which re-derives a true
readonly entry: it drops the overlay/journal projection, the read-model view, and
conflictPolicy/governance/writeMode, and keeps the read contract (columns, primary key, synced table,
the shape/row filter) intact.
import { asReadonly, assertReadContractPreserved, defineSyncRegistry } from "@pgxsinkit/contracts";import { postingRestriction } from "./tables"; // defineSyncTable(…, { mode: "readwrite", conflictPolicy, governance })
// One authoritative registry — the server's apply function + shape proxy are generated from this.export const authoritativeRegistry = defineSyncRegistry({ posting_restriction: postingRestriction });
// Per-client projections: the teacher writes the table; the learner only reads it.export const teacherRegistry = defineSyncRegistry({ posting_restriction: postingRestriction });export const learnerRegistry = defineSyncRegistry({ posting_restriction: asReadonly(postingRestriction) });
// Fail closed if a projection ever diverges the data it syncs (columns / pk / row-filter shape):assertReadContractPreserved(authoritativeRegistry, teacherRegistry, { label: "teacher" });assertReadContractPreserved(authoritativeRegistry, learnerRegistry, { label: "learner" });Register an event stream (queue-shaped data)
Section titled “Register an event stream (queue-shaped data)”Not everything belongs on a synced table. High-volume, append-only client facts — “the user viewed this”,
interaction logs, review grades — are never edited, never conflict, and are never read back down, so putting
them on the sync rail makes every client re-download its own log. They go on the event lane instead,
registered on the same registry under a streams key whose record key is the stream name:
import { defineEventStream, defineSyncRegistry } from "@pgxsinkit/contracts";import { z } from "zod";
export const registry = defineSyncRegistry({ tables: { issue }, streams: { issue_viewed: defineEventStream({ // Strict is REQUIRED for an object payload, and validated at `appendEvent` as well as at ingest. payload: z.object({ issueId: z.uuid() }).strict(), // Stamped SERVER-side from the verified claims — the same claimPath addressing managed fields use. identity: { viewerId: { claimPath: ["sub"] } }, }), },});Then await client.appendEvent("issue_viewed", { issueId }) stages the event in a durable local Outbox and
resolves — delivery happens in the background, and an append made offline drains on reconnect. Three rules
worth knowing at authoring time:
- Never put a viewer/actor id in the payload. A payload is client-supplied and can lie;
identityis read from verified claims, and the client’s envelope carries no identity at all. - Stream names are validated when the module evaluates — lowercase
[a-z][a-z0-9_]*, at most 30 characters — so a name that could not be provisioned fails at definition rather than at deployment. - An object payload must be strict —
.strict()orz.strictObject(), on the root and on every object in a union — and the registry throws if it is not, so an unknown key can never be stripped in silence. - A payload schema may only evolve backward-compatibly. Events written offline under the old schema are
still in flight; an incompatible change needs a new stream name. Bump
revisionwhenever you change acceptance logic the lock’s JSON-Schema hash cannot see (a.refine, a transform), or the review gate never fires.
Registering a stream touches no synced table and no apply function, so it never rebuilds a client’s read cache — but it does add a deploy step (per-stream queues) and a consumer process. Full model: The event lane; deployment: Deploying the server.
Try the demo
Section titled “Try the demo”The repository’s apps/board (a Linear-style board + chat) drives all of the above end-to-end
against a partial Supabase + Electric stack:
mise install && bun installmkcert -install # one-time: trust the local CA so the browser accepts the gateway's TLS certcp .env.example .envbun run infra:up # the full board stack (Supabase + Electric) via Podman composebun run seed:board # GoTrue identities + fixturesbun run dev:boardFor the minimal @pgxsinkit/server reference instead, use bun run infra:harness:up (PostgreSQL +
Electric) + bun run dev:api. See Demo & harness for what each is for.