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, resolveAuthClaims, readPath: { engine, key } });export default { fetch: server.fetch }; // Bun.serve picks this up// or: await server.start(); // the only Bun-specific helperreadPath is what mounts the read path’s control plane (/sync/v1/subscribe, /sync/v1/refresh,
/sync/v1/barrier) beside the mutation route; omit it and the deployment is write-only. It takes the
CircuitsEngineClient for the engine’s control API, the stream-token signing key, and — for a registry
that declares a shared-tier shape — the entitlements set backing it.
Deno / 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));});Both read functions need the same strip. The control plane routes on
/sync/v1/*and the edge on/v1/stream/*, so each needs its own function-name prefix removed before the path is recognized — there is no read surface that keys off the query string instead. -
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 write route rejects, the control plane answers 401return 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 control plane readssub+app_metadata.rolesto compile the row filter, expand a shared shape to the subject’s scopes, and bind the minted stream token to that subject. Both paths share this one adapter, so read and write authorization can never drift.
Three functions: write, control plane, edge
Section titled “Three functions: write, control plane, edge”Splitting the deployment into three functions is a deployment choice for the first two and a design requirement for the third:
- write —
createSyncServer({ registry, db, resolveAuthClaims })withoutreadPathregisters only the mutation route. Wrap with the path rewrite from step 1. - control plane (
sync) —createSyncServer({ registry, resolveAuthClaims, readPath: { engine, key } })serves/sync/v1/subscribe,/sync/v1/refreshand/sync/v1/barrier. It answers per-subject questions and mints stream tokens, so it is never cacheable: forcecache-control: no-store. Its upstream is the Circuits engine’s control API, which must not be client-reachable. - edge (
stream) —createStreamGate({ key, registry, durableStreamsUrl })mounted at/v1/stream. It needs no claims resolver and no database: the stream token is the authorization, which is what lets this half scale and cache independently of the one that answers per-subject questions. It does need the sameregistrythe control plane compiles shapes from, so it can tell which shapes declare aserverProjection.rowTransformand must be rewritten per read (those are answeredprivate, no-store; every other shape is proxied untouched). Set its idle/wall-clock timeout above the durable-streams long-poll hold so a live subscription is not recycled mid-cycle.
Put the edge on its own origin. The cache key is the URL, so the surface a CDN may share between subscribers has to be addressable apart from the one that answers per-subject questions. Mounting them on one origin forecloses ever putting a CDN in front of the shared tier without also caching the private one.
The control plane and the edge share the stream-token signing key, and nothing else. Both read
functions import the same registry as the write function and share the same resolveAuthClaims, which
is what keeps the 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 onPoll: (report) => (lastPollAt = report.at), // fires after EVERY read — your liveness heartbeat});consumer.start();process.on("SIGTERM", () => void consumer.stop());Answer your liveness probe from onPoll, not from the callback. The callback fires only when a batch is
delivered, so on an idle queue it says nothing at all and a probe wired to it reports a perfectly healthy
runner as dead. onPoll fires after every read the runner performs — productive or empty, in this mode
and in the bounded one below — with { stream, delivered, empty, fault, at }. Record at and let the probe
fail once it is older than a few idle ceilings. fault: true means the read itself failed (a queue or
database fault, or a message body that could not be parsed); the runner backs off and keeps going, so read
that as queue health rather than liveness. The hook fires on the read, before the batch is delivered, so a
slow callback never delays the beat — and a hook that throws is caught and warn-logged once per runner, never
allowed to take a loop down.
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 stream
Section titled “The gateway must speak HTTP/2 — one long-poll connection per stream”@pgxsinkit/client’s reader holds one live long-poll connection open per synced stream, and one
stream is one shape — so a subject holding K scopes of a shared-tier shape keeps K connections
continuously busy, not one. 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.
This is mandatory, not an optimisation: durable-streams serves one stream per request and the Rust
server is HTTP/1.1-only with no TLS, so a gateway speaking HTTP/2 (or HTTP/3) to browsers is the only
thing that keeps the per-origin cap from binding. Any production-grade ingress already does it — Cloud
Supabase, 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 Envoy gateway with a small
TLS-terminating Caddy sidecar (h2 + h3) for exactly this reason.