The tenant object
What the framework runs for you, and the small part you write.
Each tenant's data lives in its own Durable Object. That object has a lot to do — storage, subscriptions, hibernation recovery, WebSocket frames, retention — and almost none of it varies between applications, so you don't write it:
import { createTenantObject, tenantStub } from "@highx/cloudflare";
import { permissions, revalidate } from "./auth.config.js";
import { databaseSchema } from "./functions.js";
import { retentionPolicy } from "./retention.js";
import { serverFunctions, subscribableQueries } from "../.highx/server-api.js";
export const TenantObject = createTenantObject<Env>({
schema: databaseSchema,
functions: serverFunctions,
subscribableQueries,
revalidate, // a socket outlives its request, so credentials are re-checked
permissions,
retention: retentionPolicy,
});serverFunctions and subscribableQueries are generated by highx generate
from your exported descriptors, so adding a function needs no wiring here.
What it handles
| Storage | SQLite database, idempotency store, saga store, connection store |
| Tenant binding | Refuses any identity that is not the tenant this object serves |
| Subscriptions | Registry, invalidation, patch computation, per-subscription sequences |
| Hibernation | Rebuilds live subscriptions from storage when the object restarts |
| WebSockets | Frame decoding, sub/unsub/resume/mutate/ping, snapshot fallback |
| Retention | An alarm that sweeps expired records and archives them |
| Data rights | exportTenant and deleteTenant |
| Tracing | Spans for every call, run, and step, flushed before it hibernates |
Every one of those is easy to get subtly wrong, and none of it is your application.
What you write
The object itself, and the handful of decisions that are actually yours:
export const TenantObject = createTenantObject<Env>({
schema: databaseSchema,
functions: serverFunctions,
subscribableQueries,
revalidate,
permissions,
retention: retentionPolicy,
});
export default createWorker<Env>({
auth: authConfig, // how a request becomes an identity
tenant: (env, tenant) => tenantStub(env.TENANT_OBJECT, tenant),
sessions,
publicRoutes: passwordAuthRoutes({ sessions, accounts }),
routes: { "GET /me": (context) => Response.json({ userId: context.identity.userId }) },
});The socket upgrade, function dispatch, CSRF, the operational routes the CLI drives, and error shaping are identical in every application, so they are the framework's. What is left is what only this application can answer: who its callers are, where its tenants live, and its own routes.
The identity is verified by authConfig (see Authentication),
and everything downstream trusts it. highx init scaffolds the KV-backed
password provider; replace it with providerAuth({ adapter }) before deploying
anything real if you use an external issuer.
Every option
createTenantObject<Env>({
schema: databaseSchema,
functions: serverFunctions,
subscribableQueries,
permissions, // the role → permission table
revalidate, // re-verify a live connection's credential
migrations, // [{ version, name, apply }, …]
// The D1 read model, for actions that cross storage authorities.
globalStore: (env) => new D1GlobalStore(env.GLOBAL_DB),
// Runs inside blockConcurrencyWhile on every start, before any request is
// served. Where to create global tables.
onStart: async (env) => { await new D1GlobalStore(env.GLOBAL_DB).ensureTable("published_notes"); },
// Without an archive, expired records are deleted rather than kept.
archive: (env, tenant) => new R2ArchiveSink({ bucket: env.ARCHIVE_BUCKET, tenantId: tenant }),
retention: { subscriptionHistoryLimit: 32, sagaRetentionDays: 30, idempotencyRetentionDays: 7 },
// Presence: one row per connected person, written on arrival and removed on
// departure, so "who is here" is a fact rather than an inference from timestamps.
onPresenceChange: ({ kind, identity, db, now }) => { /* … */ },
// Spans for every call, run, and step. Flushed when a unit of work ends.
telemetry: { tracer: recorder, flush: () => recorder.flush() },
});Retention is opt-in: omit it and no sweep is ever scheduled — fine for a prototype, not for anything that runs.
Addressing an object
const tenant = tenantStub(env.TENANT_OBJECT, identity.tenantId);tenantStub applies the one tenant-to-object mapping, and the object checks the
same mapping against its own name. Two mechanisms agree, so a routing bug fails
closed rather than serving another tenant's data.