Skip to content

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.

  • PostgreSQL 17+ (Supabase-compatible; the write path uses Supabase-style auth claims for RLS), running with wal_level = logical — the Circuits engine ingests logical replication and creates its own replication slot. Supabase’s images already ship it; verify with postgres -C wal_level.

  • The read path’s two services: the Circuits engine (ghcr.io/pgxsinkit/circuits/engine) and a durable-streams server (ghcr.io/pgxsinkit/circuits/durable-streams). Both are built in pgxsinkit/circuits and released together under one tag, so pin the pair with the same one. The engine is configured entirely through four environment variables:

    Terminal window
    CIRCUITS_PG_URL=postgres://… # the database it replicates from
    CIRCUITS_DS_URL=http://ds:8791 # the durable-streams server it publishes into
    CIRCUITS_PG_TABLES=issue,team,… # an EXPLICIT list of bare table names — never `*`
    CIRCUITS_BIND=0.0.0.0:7010 # its control API, which must not be client-reachable

    * introspects every public table with a primary key, which sweeps in tables you never meant to publish. The engine exits if a listed table is absent, so run it under a restart policy while migrations land.

  • A supported browser for the client’s local database: Safari and iOS 18.4, Chrome 137, Firefox 131 or later (see Supported browsers). Older browsers cannot run it.

  • The pgmq extension, but only if you use the event lane. Its generated migration runs CREATE EXTENSION IF NOT EXISTS pgmq, so the extension has to be present in the PostgreSQL image you deploy — Supabase’s images ship it; a stock postgres image does not. Registries with no streams need nothing.

  • A runtime that serves a web fetch handler for the pgxsinkit server — Bun, Deno, Supabase Edge Functions, Cloudflare Workers, etc. The server core is runtime-neutral and framework-free (web-standard Request/Response, with the DB client injected); only the optional server.start() helper is Bun-specific. And Drizzle (drizzle-orm@1.0.0-rc.4+) for schema.

Terminal window
bun add @pgxsinkit/client @pgxsinkit/server @pgxsinkit/contracts
# React bindings (optional)
bun add @pgxsinkit/react
# An app that builds with Vite (see below)
bun add @pgxsinkit/pgwasm @pgxsinkit/pgwasm-c @pgxsinkit/pgwasm-pg-dump

The packages are published to public npm; install them with whichever package manager your app uses — pgxsinkit mandates none. Peer dependencies are drizzle-orm and zod. The client brings its local Postgres, pgwasm, and the C Postgres build as ordinary dependencies; there is nothing to override.

If your app builds with Vite, keep pgwasm and the two packages that ship WebAssembly files next to their code out of dependency pre-bundling, and list all three in your app’s own dependencies:

vite.config.ts
export default defineConfig({
optimizeDeps: {
exclude: ["@pgxsinkit/pgwasm", "@pgxsinkit/pgwasm-c", "@pgxsinkit/pgwasm-pg-dump"],
},
});

Pre-bundling would move the two packages’ code away from their WebAssembly files. Both import pgwasm, so a pre-bundled pgwasm would load a second time on the dev server, and an instanceof check against one of its error classes would fail. Vite resolves an excluded package from your app, not from the client that imports it, so a package manager that links only an app’s direct dependencies (pnpm, Bun workspaces) finds the three only when the app lists them.

  1. 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).
    }),
    });
  2. 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 published pgxsinkit-generate CLI (a bin of @pgxsinkit/server), run from your own project. The CLI requires Bun on PATH — it imports your TypeScript registry and runs drizzle-kit — but the bin itself launches under any runner; bunx, or npx / pnpm exec / yarn once 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_artifact

    This writes a standard drizzle-kit migration you commit and apply through your normal migration flow. The migration lands wherever your drizzle config’s out points — pass --config and the generator reads out from it (or set --out explicitly); with neither it probes drizzle/ and infra/drizzle/.

    The generated apply function and the audit/version column DEFAULTs (clockMicrosecondsSql above) both call the canonical public.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_utilities

    The 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 — raising PXS01 and applying nothing on a mismatch. So a registry change or a @pgxsinkit/server upgrade 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 --check mode — 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
  3. Create the server and serve its fetch. All writes go through POST /api/mutations; adding readPath also mounts the read path’s control plane — /sync/v1/subscribe, /sync/v1/refresh, /sync/v1/barrier — beside it, in the same app.

    import { createCircuitsEngineClient, createSyncServer, importStreamTokenKey } from "@pgxsinkit/server";
    import { registry } from "./sync-registry";
    const server = createSyncServer({
    registry,
    db, // your Drizzle database
    resolveAuthClaims: async (_request) => {
    // verify the request's JWT and return its claims, or null — never a claims object with no `sub`
    return null;
    },
    readPath: {
    // The engine's control API. Only shape LIFECYCLE goes through it; reads never do.
    engine: createCircuitsEngineClient({ baseUrl: process.env.CIRCUITS_ENGINE_URL! }),
    // The stream-token signing key, shared with the edge and with nothing else.
    key: await importStreamTokenKey(process.env.STREAM_TOKEN_SECRET!),
    },
    });
    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.

    readPath requires resolveAuthClaims — createSyncServer refuses the combination without it, because the control plane cannot mint a stream token for a subject it cannot resolve. The edge that serves the stream bytes is a separate mount on its own origin; see Deploying the server, which also covers the two concrete steps a non-Bun runtime (Deno / Supabase Edge Functions) needs — a path rewrite and resolving claims from the platform’s JWT.

The client writes locally into an overlay + a durable journal, flushes the journal to the server’s write route, and subscribes to shape streams that land in the local store (pgwasm). Reads are served from that store. The client does not construct stream URLs: it asks the control plane what it may read and is told, then reads those streams through the edge, which checks the token it was granted.

import { createSyncClient } from "@pgxsinkit/client";
import { registry } from "./sync-registry";
const client = await createSyncClient({
registry,
controlPlaneUrl: "/", // where /sync/v1/subscribe|refresh|barrier are mounted
streamBaseUrl: "https://stream.example.com/v1/stream", // the edge, on its own origin
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.

Pass a storePath to name the local store — a plain name, never a storage URL:

const client = await createSyncClient({
registry,
controlPlaneUrl: "/",
streamBaseUrl: "https://stream.example.com/v1/stream",
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.

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,
controlPlaneUrl: "/",
streamBaseUrl: "https://stream.example.com/v1/stream",
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 + read-path shapes 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; identity is 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() or z.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 revision whenever 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.

The repository’s apps/board (a Linear-style board + chat) drives all of the above end-to-end against a trimmed Supabase + Circuits stack — Postgres, GoTrue, an Envoy gateway, durable-streams, the Circuits engine, and three edge functions (board-write, board-sync for the control plane, and board-stream for the edge):

Terminal window
mise install && bun install
mkcert -install # one-time: trust the local CA so the browser accepts the gateway's TLS cert
cp .env.example .env
bun run infra:up # the full board stack via Podman compose
bun run seed:board # GoTrue identities + fixtures
bun run dev:board

For the minimal @pgxsinkit/server reference instead, use bun run infra:harness:up (PostgreSQL + durable-streams + the Circuits engine) + bun run dev:api. See Demo & harness for what each is for.