Deploying the server
The server is a web-standard fetch handler, so it runs unmodified anywhere that speaks
Request → Response. “Unmodified” is true at the API level — the few concrete steps below are
about the runtime around it, not the toolkit code.
The trivial case. Either export the handler, or use the bundled start() helper:
const server = createSyncServer({ registry, db, electricUrl, resolveAuthClaims });export default { fetch: server.fetch }; // Bun.serve picks this up// or: await server.start(); // the only Bun-specific helperDeno / Supabase Edge Functions
Section titled “Deno / Supabase Edge Functions”Deno runs TypeScript natively and resolves npm: specifiers, so the common case needs no build
step: import the published npm:@pgxsinkit/server, import your own registry as local TypeScript,
and deploy.
What you do hit on Deno and the Edge platform are two runtime concerns — a path prefix and claims resolution. Neither is a toolkit limitation; they are how the platform routes and authenticates, and they apply however you deploy. Here they are with their fixes.
-
Strip the function-name prefix before
server.fetch. Edge Functions route by the first path segment, so a POST to/functions/v1/write/api/mutationsarrives at your worker as/write/api/mutations. Strip only the/writeprefix so the server receives its canonical/api/mutationspath:const server = createSyncServer({ registry, db, resolveAuthClaims });Deno.serve((request) => {const url = new URL(request.url);url.pathname = url.pathname.replace(/^\/write(?=\/|$)/, "") || "/";return server.fetch(new Request(url, request));});The read path needs no rewrite:
proxyElectricShapeRequestkeys off the query string, not the path, so a shape-proxy function can hand it the request as-is. -
Resolve claims from the platform JWT in
resolveAuthClaims.verify_jwtis a gateway concept; the portable move is to verify the token yourself and return its claims. A GoTrue access token is alreadyJwtClaims-shaped —sub, a top-levelrole(the Postgres role), andapp_metadata— so once verified you return it directly:async function resolveAuthClaims(request: Request): Promise<JwtClaims | null> {const token = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "");if (!token) return null; // fail closed: the proxy blocks all rows, the write route rejectsreturn await verifyHs256(token, Deno.env.get("JWT_SECRET")!); // your HS256 verify → claims | null}The applier reads
roleto switch the RLS actor andapp_metadata.rolesfor any admin predicate; the read proxy readssub+app_metadata.rolesfor the row filter. Both paths share this one adapter, so read and write authorization can never drift.
Read vs write as two functions
Section titled “Read vs write as two functions”Splitting the write route and the shape proxy into two deployments (e.g. a write function and a
sync function) is a deployment choice, not a toolkit one:
- write —
createSyncServer({ registry, db, resolveAuthClaims })withoutelectricUrlregisters only the mutation route. Wrap with the path rewrite from step 1. - sync — call
proxyElectricShapeRequest(request, claims, { registry, electricUrl })directly. No rewrite needed. Set the function’s idle/wall-clock timeout above Electric’s bounded long-poll (~25s) so live updates are not cut off mid-cycle. If it is a same-origin proxy with no CDN, also forcecache-control: no-storeon the response so the browser never serves a rotated shape handle stale (the 409-loop fix in Operating in production).
Both import the same registry and share the same resolveAuthClaims, which is what keeps the two
ingress points honest.
Auth is bearer-token; the built-in CORS is credentialless
Section titled “Auth is bearer-token; the built-in CORS is credentialless”resolveAuthClaims reads the Authorization: Bearer … token — pgxsinkit does not authenticate from
cookies. To match that, the server’s built-in CORS reflects your configured allowedOrigins and the
request methods/headers but does not send Access-Control-Allow-Credentials, so browsers will not
attach cookies on cross-origin calls. That is the right default for a token API. If you intend to drive
the server from a Supabase cookie session (rather than a bearer token), that is a deliberate
extension: terminate credentialled CORS at your own gateway and turn the cookie into a bearer token (or
into JwtClaims) inside resolveAuthClaims.
The apply function is deny-by-default — name your server’s database role
Section titled “The apply function is deny-by-default — name your server’s database role”The generated pgxsinkit_apply_mutations takes the request’s claims as an argument and trusts them: it
copies them into request.jwt.claims and switches role before running your RLS-governed DML. That is
correct for your server, which passes claims it verified, and catastrophic for anyone else — a caller who
can invoke the function directly picks its own claims. So the generated migration is deny-by-default: right
after CREATE, it revokes EXECUTE from PUBLIC and (guarded on role existence) from anon,
authenticated and service_role, then grants only the roles you name:
bun run pgxsinkit-generate --registry ./sync-registry.ts --export registry \ --project-dir ./db --config drizzle.config.ts --name sync_artifact \ --grant-execute-to app_writer # repeatable, or comma-separated- The default is owner-only, and that is usually right: if your server connects as the role that applies the migrations (the function’s owner) or as a superuser, you need no grant at all.
- Name only server roles. A granted role can pass any claims it likes — by design — so the grant list is the write path’s entire trust boundary. Never grant a client-facing role.
- The grant list is part of the artifact fingerprint, so the same roles must appear in three places:
the generate command, the CI
--checkcommand, andcreateSyncServer({ applyFunctionGrantExecuteTo: ["app_writer"] }). Otherwise every write fails withPXS01(stale artifact). - Everyone regenerates once on upgrade — the ACL moved the fingerprint. Re-run the
--utilitiesmigration too: it hardenspublic.pgxsinkit_clock_us()the same way, keeping its grants toanon/authenticated/service_role(a column DEFAULT calling the clock runs as the writing role). - Roles you never named are revoked too. After those named revokes, the migration enumerates the
installed function’s actual grantees (
aclexplode(pg_proc.proacl)) and revokesEXECUTEfrom every one that is neither the function’s owner nor on your--grant-execute-tolist. This matters if your own cluster carriesALTER DEFAULT PRIVILEGES … GRANT ALL ON FUNCTIONS TO <role>: like Supabase’s, it re-grants at theCREATEinside every install, and only an enumeration can close a name the toolkit cannot know. The end state is exactly owner + the roles you named, on every install.
The failure mode when the grant is missing is loud and total, by design. A server connecting as a role that
is neither the function’s owner nor named in --grant-execute-to fails every write with SQLSTATE
42501, permission denied for function pgxsinkit_apply_mutations — not some writes, not some rows, and no
degraded mode. The fix is never a GRANT typed into a psql session — the next regenerate’s DROP FUNCTION
resets it. Regenerate the migration with --grant-execute-to naming the role your server connects as,
and add that role to the CI --check command and to applyFunctionGrantExecuteTo in the same change.
pgxsinkit itself needs no PostgREST, and leaving it out is good practice — it removes the
/rest/v1/rpc/* surface that would otherwise expose every public function over HTTP. Treat that as
defence in depth, though: the ACL is the control, and it holds whatever your topology looks like.
Installing into a schema of your own: --function-schema
Section titled “Installing into a schema of your own: --function-schema”By default the apply function is generated unqualified and resolved through the connection’s
search_path. pgxsinkit-generate --function-schema app_fns installs it into a schema you choose
instead — and, exactly like the grant list, the schema is part of the artifact’s fingerprint, because
the function names itself in its own self-check. So it too must appear in three places, or writes fail:
bun run pgxsinkit-generate --registry ./sync-registry.ts --export registry \ --project-dir ./db --config drizzle.config.ts --name sync_artifact \ --function-schema app_fns # …and the same flag on the CI `--check` commandcreateSyncServer({ registry, db, resolveAuthClaims, applyFunctionSchema: "app_fns" });applyFunctionSchema does both halves of the job: it makes the server compute the fingerprint the
schema-qualified artifact was stamped with, and it qualifies the call the server actually makes.
Generate with --function-schema but leave the server option unset and the call goes out unqualified —
it either finds nothing (42883, function pgxsinkit_apply_mutations(…) does not exist) or finds a
same-named function elsewhere on the search_path and fails PXS01 against a fingerprint that install
does not carry. Set both to the same schema, or neither.
Deploying the event lane
Section titled “Deploying the event lane”If your registry declares streams, you get a second lane to deploy alongside the two above (the whole
model is in The event lane). Three concrete obligations:
1. The ingestion route mounts itself. createSyncServer registers POST /api/events only when the
registry registers at least one event stream — with no streams the path stays a 404. Nothing is probed at
startup, so the zero-startup-query posture is unchanged, and because the route is a sibling of
/api/mutations under /api/, the same function-name path rewrite (step 1 above) serves it. Two options are
yours:
createSyncServer({ registry, db, resolveAuthClaims, // Consent / entitlement refusal, keyed by stream name. ABSENT = every well-formed event is allowed. // Called per event, after its payload validated and its identity resolved, before anything is enqueued; // a refusal is a per-event `refused` verdict. A gate that THROWS fails the whole batch retryably — // a gate that cannot decide is never read as "allow". eventGate: ({ stream, identity }) => allowed(stream, identity["viewerId"]), // Defaults to the shipped pgmq backend over this server's own `db`, which is what lets an enqueue join // the endpoint's transaction. Override for another backend, or a fake in tests. eventQueue: createPgmqEventQueue({ db }),});2. pgmq is a cluster prerequisite, and the queues are deploy-time DDL. The generated migration begins
with CREATE EXTENSION IF NOT EXISTS pgmq, so the extension must be available in the PostgreSQL image you
deploy (Supabase’s images ship it; a stock postgres image does not). Generate the migration and apply it
through your normal flow — never at runtime, because the endpoint may enqueue long before any consumer runner
first starts:
bun run pgxsinkit-generate --events --registry ./sync-registry.ts --export registry \ --project-dir ./db --config drizzle.config.ts --name event_lane_artifactIt lands in its own migration folder and carries a fingerprint of the registry’s stream set. Add
--events --check to CI beside the apply-function check: adding or removing a stream without regenerating
then fails before deploy, instead of at the first enqueue onto a queue that does not exist.
3. The consumer runner is a long-lived process, not a function. defineEventConsumer returns a
start()/stop() handle your app runs in its own Bun process — deliberately apart from the serverless
posture the routes above run under, because it polls. There is no CLI entrypoint and no signal handling
inside it; wire stop() from wherever you already handle shutdown.
const consumer = defineEventConsumer({ registry, queue: createPgmqEventQueue({ db }), // required — the runner is backend-agnostic by construction streams: ["issue_viewed"], // optional: the knob that splits streams across processes (unknown name → throws) callback: async ({ stream, events }) => archive(stream, events), onDeadLetter: (report) => alert(report), // the runner ALSO warn-logs every one, unconditionally});consumer.start();process.on("SIGTERM", () => void consumer.stop());Its defaults are sized for a small deployment, and each is a knob:
batchSize(default 10) — messages one read delivers, each message being one single-stream sub-batch.maxAttempts(default 5) — deliveries a sub-batch gets before a further callback failure dead-letters it into the queue backend’s own archive (requeueing from there is always a deliberate act).- Polling is adaptive and internal: a non-empty read is followed immediately by another, and consecutive
empty reads grow the wait from ~250 ms toward a ~5 s idle ceiling. The floor/ceiling/factor are tuning
(
poll), never contract — the mechanism itself may change. concurrency(default 1, strictly sequential) — the max callbacks in flight per stream. Values above 1 are safe precisely because delivery is at-least-once with no promised order across sub-batches, which is what your idempotent callback already assumes; reads stay serial, only one read’s callbacks run in parallel.visibilityTimeoutSeconds(default 60) is not a processing budget. While the runner is working through a read it renews the lease on every unsettled message of that read — the one in flight and the ones queued behind it — at half the window, so a long callback cannot let its own siblings resurface. What the timeout actually sets is the redelivery delay of a sub-batch whose callback threw (a throw drops it from renewal at once, and that lapse is the retry pacing) and the crash-recovery bound — how long a dead runner’s messages stay stuck. So size it above one callback’s worst case, never the whole batch’s; too short redelivers a callback that is merely slower than one renewal interval.
A graceful stop() starts no new read and no new callback, but the callbacks already in flight are awaited
and keep their leases renewed until they settle — otherwise every ordinary rollout would hand a second
runner the very message this one is finishing. The rest of that read (messages no callback had started) is
released immediately, so those redeliver promptly rather than waiting out the window.
No process to host it? Drain on a schedule instead
Section titled “No process to host it? Drain on a schedule instead”Some platforms give you no long-lived compute at all — a managed backend whose only server-side unit is a per-request function. There the runner has nowhere to live, and the queue would simply never drain. The same handle answers a second, bounded mode for exactly that:
// Inside your scheduled (or nudged) function. Construction is query-free, so building the consumer// per invocation costs nothing — and the handle is one-way, so each invocation builds its own.const consumer = defineEventConsumer({ registry, queue: createPgmqEventQueue({ db }), callback });const { delivered, deadLettered, empty } = await consumer.drainOnce({ budgetMs: 20_000 });One pass reads → delivers → acks across every configured stream until they all read empty or the wall-clock budget runs out. Everything else is identical to the loop mode — same delivery path, same lease renewal, same retry, same dead-lettering — because pacing was always internal.
- Set
budgetMsunder your platform’s invocation cap, with head-room for one callback. The budget is checked between sub-batches and never inside one: a callback already running is awaited and acked as normal, so a pass can overrun by that much. empty: falsemeans “there is more” — the budget cut the pass short, or a read faulted. A scheduler reading that can invoke again immediately instead of waiting out its period.- Overlapping invocations are safe. Two functions draining the same queue are arbitrated by the
visibility timeout, exactly as two long-lived runners would be. (Two passes on one handle are not: a
drainOncebeside a livestart(), beside another pass, or afterstop(), throws.) - A sub-batch whose callback throws near the budget edge is not lost. It is left unacked, its lease lapses, and the next pass redelivers it — at-least-once, working as designed.
Pair the schedule with the ingest-side nudge so an interactive append does not wait for the next tick:
createSyncServer({ registry, db, // Fired after a request ENQUEUED something, with its deduplicated stream names. Fire-and-forget: the // library owns no transport, the throw is caught and warn-logged, and the response is never affected. onEventsEnqueued: ({ streams }) => void fetch(drainUrl, { method: "POST", headers: drainAuth(streams) }),});The gateway must speak HTTP/2 — one long-poll connection per shape
Section titled “The gateway must speak HTTP/2 — one long-poll connection per shape”Electric’s client holds one live long-poll connection open per synced shape. A client that subscribes to six shapes therefore keeps six connections continuously busy. Browsers cap HTTP/1.1 at ~6 connections per origin, so on a plain-HTTP gateway those long-polls consume every slot and the write request (which shares the origin) gets Stalled in the browser’s connection queue — it is not even dispatched until a long-poll cycle frees a slot, adding seconds of latency that look like a slow server but are entirely client-side queuing.
The fix is to serve the gateway over HTTP/2 (or HTTP/3), which multiplexes every request over a
single connection, so the per-origin cap never binds regardless of shape count. Any production-grade
ingress already does this — Cloud Supabase, Electric Cloud, an istio/Envoy gateway, or any TLS
reverse proxy. The one place it bites is a local self-hosted stack served over plain http://:
browsers only negotiate HTTP/2 over TLS, so a plain-HTTP gateway is stuck on HTTP/1.1. The board demo
fronts its kong gateway with a small TLS-terminating Caddy sidecar (h2 + h3) for exactly this reason.