> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pylonsync.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Sync relay (Durable Objects)

> Move live sync fan-out off your machine onto a Cloudflare Durable Object, so sockets survive deploys and idle machines can sleep.

The sync relay is an optional delivery tier for change events. Your Pylon machine keeps everything it handles today: the change log, the `seq` counter, mutations, pulls, and auth. Only the live WebSocket fan-out moves. A `PylonSync` Durable Object holds the client sockets (using Cloudflare's WebSocket hibernation), keeps a bounded ring of recent events, and filters every frame per subscriber with the same policy engine the machine uses.

This gives you:

* **Sockets survive deploys and restarts.** Clients stay connected to the DO while the machine restarts. When the machine comes back and pushes updates, clients see a catch-up instead of a disconnect.
* **Idle machines can sleep.** Live sockets no longer keep the machine awake. The DO serves reconnect catch-up from its ring without waking the origin.
* **Fan-out CPU leaves the machine.** Per-subscriber filtering and broadcast run at the edge.

The machine's own WS keeps working unchanged. The relay is additive (dual-write). Roll it out by attaching the sink first, comparing results, then pointing clients at the relay.

## Server setup

Deploy the relay worker with its own config, `crates/workers/wrangler.relay.toml`. That config declares only the `PylonSync` Durable Object plus the shared secret. It is not the full `wrangler.toml`, which also wires D1, KV, and R2 to run all of Pylon on Workers.

```bash theme={null}
cd crates/workers
wrangler deploy --config wrangler.relay.toml
printf '%s' "$(openssl rand -hex 32)" | \
  wrangler secret put PYLON_RELAY_SECRET --config wrangler.relay.toml
```

The worker's build pins `worker-build@0.1.2` (the release that matches the `worker` 0.5 crate the DO targets). The Durable Object uses SQLite-backed storage (`new_sqlite_classes`), which works on the Workers free plan and is Cloudflare's default for new namespaces.

Verify the deploy with the smoke script. It checks routing and auth-gating without needing the secret:

```bash theme={null}
crates/workers/scripts/relay-smoke.sh https://pylon-sync-relay.<subdomain>.workers.dev
```

Point the machine at it:

```bash theme={null}
PYLON_SYNC_RELAY_URL=https://your-worker.workers.dev
PYLON_SYNC_RELAY_SECRET=<same value as PYLON_RELAY_SECRET>
# optional:
PYLON_SYNC_RELAY_APP=<app id>            # default: PYLON_PROJECT_ID, then the manifest name
PYLON_SYNC_RELAY_PUBLIC_WS_URL=<wss url> # default: derived from the relay URL
PYLON_SYNC_RELAY_TOKEN_TTL_SECS=900      # relay auth blob lifetime
```

With both set, the machine does three things:

* **Pushes its manifest to the DO at boot.** The DO compiles its `PolicyEngine` from the manifest. Until the manifest arrives, the DO delivers nothing (it fails closed).
* **Pushes every committed change event.** Each push is batched, HMAC-signed, and fire-and-forget with one retry. The disk log stays the source of truth, so a dropped push degrades to a catch-up read, never data loss.
* **Serves `GET /api/sync/relay-token`.** It authenticates the caller, enriches org roles, and mints a signed auth blob that the DO's filter runs against.

## Client setup

```ts theme={null}
init({ baseUrl, relay: true });
```

That is the whole client change (TS and Swift both take `relay: true`). Before each socket attempt, the engine fetches a fresh token from `/api/sync/relay-token` and dials the relay with that token and its current cursor. The DO replays the ring tail past that cursor before sending live frames. Everything else (pull, push, mutations, auth) still talks to `baseUrl`.

## Security model

* The subscriber's identity travels as a machine-minted, HMAC-signed blob carrying the enriched auth context (user, roles, active org). The DO verifies the signature with the shared secret and never touches a database. Tampering with the blob, or its expiry, fails verification.
* Filtering matches the machine's WS hub decision for decision:
  * policy evaluated on the raw row
  * `serverOnly` and `syncOmit` fields stripped before the wire
  * visibility flips synthesized as delete tombstones
  * unscoped admin bypass
  * default-deny for entities with no policy
* Blobs expire (15 minutes by default). The DO closes expired sockets with code `4401`. The client then re-handshakes against the machine, which is how a revoked role takes effect.
* Machine-to-DO pushes are HMAC-signed with a timestamp (±5 min replay window).

## Limits

* **Change events only.** Rooms, CRDT row subscriptions, and reactive queries use the machine's own WS. Apps that use those features keep the direct connection.
* **`exists(...)` read policies deny at the relay.** The DO has no database to resolve them against, so it fails closed. Apps whose synced entities use `exists()` in read policies are not relay-eligible yet.
* **Deep history stays on the machine.** A reconnect cursor older than the DO's ring closes with code `4410`. The engine's normal `/api/sync/pull` catch-up (which runs on every reconnect anyway) covers the gap.
* Role changes made mid-connection take effect when the blob expires, not immediately.

## Design

The full design is in `docs/SYNC_DURABLE_OBJECTS_DESIGN.md` in the repo. It covers why the machine keeps `seq` and the change log, why the DO owns delivery but never truth, and the rejected alternatives.
