Skip to content

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 helper

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.

  1. Strip the function-name prefix before server.fetch. Edge Functions route by the first path segment, so a POST to /functions/v1/write/api/mutations arrives at your worker as /write/api/mutations. Strip only the /write prefix so the server receives its canonical /api/mutations path:

    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: proxyElectricShapeRequest keys off the query string, not the path, so a shape-proxy function can hand it the request as-is.

  2. Resolve claims from the platform JWT in resolveAuthClaims. verify_jwt is a gateway concept; the portable move is to verify the token yourself and return its claims. A GoTrue access token is already JwtClaims-shaped — sub, a top-level role (the Postgres role), and app_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 rejects
    return await verifyHs256(token, Deno.env.get("JWT_SECRET")!); // your HS256 verify → claims | null
    }

    The applier reads role to switch the RLS actor and app_metadata.roles for any admin predicate; the read proxy reads sub + app_metadata.roles for the row filter. Both paths share this one adapter, so read and write authorization can never drift.

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:

  • writecreateSyncServer({ registry, db, resolveAuthClaims }) without electricUrl registers 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 force cache-control: no-store on 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:

Terminal window
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 --check command, and createSyncServer({ applyFunctionGrantExecuteTo: ["app_writer"] }). Otherwise every write fails with PXS01 (stale artifact).
  • Everyone regenerates once on upgrade — the ACL moved the fingerprint. Re-run the --utilities migration too: it hardens public.pgxsinkit_clock_us() the same way, keeping its grants to anon/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 revokes EXECUTE from every one that is neither the function’s owner nor on your --grant-execute-to list. This matters if your own cluster carries ALTER DEFAULT PRIVILEGES … GRANT ALL ON FUNCTIONS TO <role>: like Supabase’s, it re-grants at the CREATE inside 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:

Terminal window
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` command
createSyncServer({ 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.

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:

Terminal window
bun run pgxsinkit-generate --events --registry ./sync-registry.ts --export registry \
--project-dir ./db --config drizzle.config.ts --name event_lane_artifact

It 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 budgetMs under 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: false means “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 drainOnce beside a live start(), beside another pass, or after stop(), 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.