@doync/server
v0.4.0
Published
doync DoyncOrigin/DoyncMirror Durable Object base classes
Downloads
1,145
Maintainers
Readme
@doync/server
Durable Object base classes and Worker routing for the doync server side. Subclass DoyncOrigin (single-writer authoritative database) and DoyncMirror (read copy + client websocket terminator), point them at the same shared definition module from @doync/core, and mount routeClientUpgrade / routeAdmin in a Cloudflare Worker fetch handler.
The package ships four entries:
| Specifier | Contents |
| --- | --- |
| @doync/server | Origin / Mirror bases, their configs + sealing types, client-upgrade routing, consumer-visible error classes |
| @doync/server/auth | Auth resolve seam (JWT / session / admission) and the types that seal DoyncMirrorConfig.auth |
| @doync/server/admin | routeAdmin + Admin*/status/pool/report types that seal its responses |
| @doync/server/internal | Knobs, registration plumbing, WAE constants — may break in any release |
Schema / query / mutation authoring lives in @doync/core — this package assumes those definitions already exist.
Install
pnpm add @doync/server @doync/corePeer runtime: a Cloudflare Worker with Durable Objects (SQLite storage) and, for Mirror bootstrap / Snapshot dumps, an R2 bucket. wrangler is the usual dev/deploy tool.
End-to-end shape
Browser / RN client ──WebSocket──▶ Worker (routeClientUpgrade)
│
resolveAssignment │
▼
Origin ──push Changelog──▶ Mirror pool
│ │
run mutations resolve queries
dump Snapshots ◀── R2 ──▶ bootstrapOne Database name spans the vertical: the Origin key, the Mirror pool for that Origin, and the schema / queries / mutations / client Replica belonging to it. Use the same shared definition module on both DOs and every client.
1. Shared definitions
Author schema / queries / mutations once in a shared module — see @doync/core. The Worker imports that module the same way web and mobile clients do. The Origin/Mirror configs below import schema, queries, and mutations from that module.
2. Subclass Origin and Mirror
// worker/durable-objects.ts
import { defineAuth, DoyncMirror, DoyncOrigin } from '@doync/server'
import type { AuthConfig } from '@doync/server/auth'
import { ctxValidationSchema, mutations, queries, schema } from '../shared/data'
/** Sticky name for the single Origin that owns this database. */
export const ORIGIN_NAME = 'tasks'
export class TasksOrigin extends DoyncOrigin<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env, {
schema,
// Pool member stub by deterministic name.
mirrors: (name) => env.MIRROR.get(env.MIRROR.idFromName(name)),
// Forwarded client pushes resolve here — authorization runs once.
mutations,
// Snapshots every Mirror bootstraps from.
snapshots: env.SNAPSHOTS,
})
}
}
export class TasksMirror extends DoyncMirror<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env, {
schema,
origin: () => env.ORIGIN.get(env.ORIGIN.idFromName(ORIGIN_NAME)),
snapshots: env.SNAPSHOTS,
// Subscribed { name, args } resolve under the verified ctx.
queries,
auth: defineAuth({
jwt: { secret: env.AUTH_SECRET },
ctxValidationSchema,
toContext: (identity) => ({
userId: identity.userId,
role: String(identity.claims?.role ?? 'user'),
}),
// Per-operation admission — catches identities revoked after connect.
// validate: (op) => …,
}),
})
}
}DoyncOriginConfig (required + common)
| Field | Required | Notes |
| --- | --- | --- |
| schema | yes | Consumer DoyncSchema (from createDoync / createDoyncDrizzle). |
| mirrors | yes | (name) => MirrorEndpoint — typically env.MIRROR.get(env.MIRROR.idFromName(name)). |
| mutations | no* | MutationTree from defineMutations(…) — same object clients pass. Origin flattens + ports internally. Absent ⇒ every forwarded push is rejected. |
| snapshots | no* | R2 SnapshotBucket. Absent ⇒ feed still works; dumpToR2 / Mirror bootstrap throw. |
| maxActiveClientsPerMirror | no | Capacity preference before pool growth (default 200). |
| clientStateLifetimeMs | no | Assignment / capacity clock (default 7d). Same name and default as Mirror; set both to the same value — the engine does not RPC-sync them. |
| mutationTimeoutMs | no | Force-rollback bound for a hung async mutation body (default 30s). |
| snapshotEntriesThreshold | no | Changelog entries or Commits since the newest Snapshot before a refresh dump arms (default 1000). |
| analyticsEngine | no | Optional Workers Analytics Engine dataset (env.ANALYTICS). Absent ⇒ zero WAE writes, zero cost. Present ⇒ Origin heartbeats + event points. |
| analyticsHeartbeatMs | no | Heartbeat cadence when analyticsEngine is bound (default 60s). 0 disables heartbeats; event points still fire. Silent while the Origin is idle (no new wake source). |
*Required for a real app that accepts writes and boots Mirrors.
Workers Analytics Engine (optional)
Hand the Origin a WAE binding and it writes two kinds of points, both indexed on the Database name (originKeyOf — the same handle R2 snapshot keys under snapshots/<originKey>/… use):
- Heartbeat (
blobs[0] = 'heartbeat') — gauge snapshot riding the existing push tick and Origin alarm (no new wake source) atanalyticsHeartbeatMscadence. Doubles:headCommitV,gcFloor,changelogEntries,poolSize,maxWatermarkLag,mirrorsAhead(ack > head count — the PITR-rewind signal lag alone cannot express),activeClients.blobs[1]is the per-Mirror packname:ack:lag:ahead,…. Suppressed entirely while idle. - Event points (
blobs[0] = 'event',blobs[1]= kind) — written at occurrence fordeployment_skew,snapshot_reap_failure,below_gc_floor,dump_chain_restart.
The point schema (names, blobs, doubles, indexes) is a documented contract — see the operations runbook. Dashboards and alert rules are the consumer's side of the line (Cloudflare's OTel export remains the path for non-WAE stacks). Kind/blob constants live under @doync/server/internal for engine siblings and tests — application code should treat the runbook as the source of truth, not those imports.
// wrangler.jsonc
{
"analytics_engine_datasets": [
{ "binding": "ANALYTICS", "dataset": "doync" },
],
}
// durable-objects.ts
super(ctx, env, {
schema,
mirrors: (name) => env.MIRROR.get(env.MIRROR.idFromName(name)),
snapshots: env.SNAPSHOTS,
analyticsEngine: env.ANALYTICS, // omit entirely for zero cost
// analyticsHeartbeatMs: 60_000, // default
})Extend the class for domain RPCs that must run on the Origin (OAuth user upserts, bulk seed, operator triggers). Use this.runMutation((tx) => { tx.exec(…) }) so writes enter the Changelog and replicate. DO-local bookkeeping tables that must not sync are created with this.sql.exec(…) after super() and are never declared in schema.tables.
DoyncMirrorConfig (required + common)
| Field | Required | Notes |
| --- | --- | --- |
| schema | yes | Same DoyncSchema as the Origin. |
| origin | yes | () => OriginEndpoint thunk — catch-up / bootstrap only; no standing connection. |
| queries | no* | QueryTree from defineQueries(…) — same object clients pass. Absent ⇒ subscribe is a framed registration error. |
| snapshots | no* | Same R2 bucket the Origin dumps to. |
| auth | no | AuthConfig from @doync/server/auth (below). Absent ⇒ every connection is anonymous. |
| queryTtlMs | no | Connected-clock Subscription grace after unmount (default 30m). |
| maxQueryTtlMs | no | Connected-clock TTL ceiling (default 24h). |
| clientStateLifetimeMs | no | Disconnected CVR reap clock (default 7d). Same name and default as Origin; set both to the same value — the engine does not RPC-sync them. Connected clients are never reaped. |
Mirrors apply state and never enforce: constraints, triggers, and defaults run on the Origin; their effects arrive as delivered row images. A Mirror never executes client-supplied SQL.
3. Wrangler bindings sketch
// wrangler.jsonc
{
"name": "my-app",
"main": "./worker/index.ts",
"compatibility_date": "2025-08-03",
"compatibility_flags": ["nodejs_compat"],
"durable_objects": {
"bindings": [
{ "name": "ORIGIN", "class_name": "TasksOrigin" },
{ "name": "MIRROR", "class_name": "TasksMirror" },
],
},
"exports": {
"TasksOrigin": {
"type": "durable-object",
"storage": "sqlite",
"state": "created",
},
"TasksMirror": {
"type": "durable-object",
"storage": "sqlite",
"state": "created",
},
},
"r2_buckets": [
{
"binding": "SNAPSHOTS",
"bucket_name": "my-app-snapshots",
},
],
// Secrets (AUTH_SECRET, ADMIN_TOKEN, …) ride `.dev.vars` / `wrangler secret`.
// NEVER put the same name under `vars` — it clobbers the secret on deploy.
}Re-export the DO classes from the Worker entry so wrangler can bind them:
// worker/index.ts
export { TasksMirror, TasksOrigin } from './durable-objects'4. Worker fetch — route upgrades and admin
import { routeClientUpgrade } from '@doync/server'
import { routeAdmin } from '@doync/server/admin'
import { ORIGIN_NAME } from './durable-objects'
export { TasksMirror, TasksOrigin } from './durable-objects'
export default {
async fetch(request, env): Promise<Response> {
const url = new URL(request.url)
// Client sync WebSocket → assigned Mirror.
// URL must include ?clientId=<id>.
if (request.headers.get('Upgrade') === 'websocket') {
return routeClientUpgrade(request, {
origin: env.ORIGIN.get(env.ORIGIN.idFromName(ORIGIN_NAME)),
mirror: (name) => env.MIRROR.get(env.MIRROR.idFromName(name)),
})
}
// Operator admin surface. Mount on a path DISTINCT from the
// client connect route. Fail-closed when ADMIN_TOKEN is empty / unset.
// Dispatches by subpath: GET /admin/status, restore / undo-restore.
// Token rides Authorization: Bearer <token> (or the bare token).
if (url.pathname === '/admin' || url.pathname.startsWith('/admin/')) {
return routeAdmin(request, {
origin: env.ORIGIN.get(env.ORIGIN.idFromName(ORIGIN_NAME)),
token: env.ADMIN_TOKEN ?? '',
// basePath defaults to '/admin'
})
}
return new Response('Not Found', { status: 404 })
},
} satisfies ExportedHandler<Env>routeClientUpgrade(request, endpoints) — @doync/server
- Expects a WebSocket upgrade +
clientIdquery param. - One Origin RPC (
resolveAssignment) picks / creates the Client’s sticky Mirror Assignment, then forwards the upgrade to that Mirror. - Returns
426for a non-upgrade request,400whenclientIdis missing. - A lapsed Client’s rebootstrap signal rides the forwarded URL as
rebootstrap=1.
routeAdmin(request, options) — @doync/server/admin
- Token-gated admin router. Options:
origin,token, optionalbasePath(default/admin). - Auth is fixed
Authorizationbearer (or bare token on that header) — not configurable. - Dispatches by subpath under
basePath:GET <base>/status—AdminStatusJSON (pool members, watermark lag, active clients, Snapshot / GC signals, …).POST <base>/restore— body{ atMs }or{ bookmark }.POST <base>/undo-restore— empty body; uses the fence on adminStatus.
- Token compared with constant-time equality; empty configured token denies all requests. Unknown subpaths (and the mount root) are
404once authorized. - Never mount this on the ordinary client connect path.
- Types:
AdminRouteOptions,AdminStatusProvider,AdminStatus,AdminHealth,AdminOriginStatus,AdminPoolStatus,AdminMemberStatus,DeploymentSkew, plus the Origin/Mirror/Pool/GC report types (OriginStatus,MirrorStatus,MirrorPhase,BootstrapStarted,MirrorLiveReport,PoolStatus,PoolMember*,GcStatus,RegisteredSnapshot,AssignmentKind,AssignmentResolution).
5. Auth surface — @doync/server/auth
Auth resolves on the Mirror at connect / updateAuth. Order is token-first → session → anonymous. Identity is server-resolved from a verified token or session — never asserted on the wire. The raw token is verified and never retained — only the projected ctx is pinned to the socket.
import type {
AdmissionCheck,
AuthConfig,
AuthEnvelope,
AuthOperation,
Hs256JwtConfig,
JwksJwtConfig,
JwtConfig,
PublicKeyJwtConfig,
SessionResolver,
ToContext,
VerifiedIdentity,
} from '@doync/server/auth'
import {
ExpiredAuthError,
InvalidAuthContextError,
InvalidTokenError,
} from '@doync/server/auth'AuthConfig
| Field | Role |
| --- | --- |
| jwt? | JwtConfig — HS256 shared secret, JWKS (RS256 / ES256), or pinned public key. |
| session? | SessionResolver — cookie string (or null) → VerifiedIdentity \| null. |
| toContext? | (identity: VerifiedIdentity) => TAuthContext — app-facing projection queries and mutation bodies read. Invoked only for a non-null userId. Default projects the bare userId. Engine lifecycle fields (expiresAt, issuedAt) stay out of ctx. |
| validate? | AdmissionCheck — per subscribe / mutate gate on the Mirror. Cheap deny; authorization that mutates state still runs authoritatively at the Origin. |
JWT variants (JwtConfig)
Product fields only:
| Variant | Fields |
| ------------------------ | -------------------- |
| HS256 | { secret } |
| JWKS (RS256 / ES256) | { jwks: { url } } |
| Pinned public key | { publicKey, alg } |
// HS256 shared secret (symmetric).
const hs: Hs256JwtConfig = { secret: env.AUTH_SECRET }
// External IdP JWKS (Auth0 / Clerk / OIDC) — RS256 or ES256.
const jwks: JwksJwtConfig = {
jwks: { url: 'https://your-idp/.well-known/jwks.json' },
}
// Pinned public JWK.
const pinned: PublicKeyJwtConfig = {
publicKey: { kty: 'RSA' /* … */ },
alg: 'RS256',
}Verification uses WebCrypto only (no JWT library required on the server) and runs inside the Mirror — configure jwt / session and catch the errors: InvalidTokenError, InvalidAuthContextError, ExpiredAuthError. JWKS cache bounds and fetch/clock injects are engine defaults — lab doubles live on @doync/server/internal (JwksJwtConfigForTest), not the public type. A verified token without sub resolves anonymous — toContext is not invoked.
Session (cookie) path
const session: SessionResolver = async (cookie) => {
const identity = await lookupSession(cookie)
if (identity === null) return null
return {
userId: identity.userId,
claims: { role: identity.role },
expiresAt: identity.expiresAtMs, // optional
} satisfies VerifiedIdentity
}The session resolver's returned userId is authoritative for that path — identity is server-resolved, never client-asserted.
6. Related main-entry helpers and types — @doync/server
| Symbol | Use |
| --- | --- |
| Snapshot: SnapshotBucket, SnapshotManifest, SnapshotGoneError | Typing when wrapping the R2 bucket (seals config.snapshots) or reading dumpToR2() metadata. |
| Routing: routeClientUpgrade, AssignmentResolver, MirrorUpgradeTarget, PoolEndpoints | Client WebSocket upgrade path. |
| Endpoint / mutation path: MirrorEndpoint, OriginEndpoint, MutationResult, MutationOptions, PushOutcome | Config fields and authoritative run / push typing; a body's sql is core's MutationExec. |
| Errors: MutationTimeoutError, SchemaSkewError, SnapshotGoneError | Consumer-visible failure kinds (timeout on hung bodies; schema-mismatch on feed refuse; R2 gone). |
Definitions live in @doync/core
Do not re-author schema / queries / mutations inside the Worker. Import the shared module and pass the same trees into every surface:
import { DoyncMirror, DoyncOrigin } from '@doync/server'
import { mutations, queries, schema } from '@app/shared'Client packages (@doync/web, @doync/mobile, @doync/react) and the server DOs all take the trees (queries, mutations) directly.
Internal surface
The main entry (. / @doync/server), plus ./auth and ./admin, are the semver-governed public API this README documents.
Anything imported from @doync/server/internal may change in any release, including patches, with no notice. Sibling @doync/* packages use that subpath; application code should not.
