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

  • ElectricSQL ≥ 1.7, run with the subquery feature flag — this is mandatory:

    Terminal window
    ELECTRIC_FEATURE_FLAGS=allow_subqueries,tagged_subqueries

    Without it, sync fails closed (no rows stream). See The Electric subquery requirement.

  • 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

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

  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; 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 database
    electricUrl: process.env.ELECTRIC_URL!, // e.g. http://localhost:3000/v1/shape
    resolveAuthClaims: async (_request) => {
    // verify the request's JWT and return its claims, or null to block all rows
    return 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.

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.

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.

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; 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 partial Supabase + Electric stack:

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 (Supabase + Electric) 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 + Electric) + bun run dev:api. See Demo & harness for what each is for.