HighX
Platform

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:

highx/index.ts
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

StorageSQLite database, idempotency store, saga store, connection store
Tenant bindingRefuses any identity that is not the tenant this object serves
SubscriptionsRegistry, invalidation, patch computation, per-subscription sequences
HibernationRebuilds live subscriptions from storage when the object restarts
WebSocketsFrame decoding, sub/unsub/resume/mutate/ping, snapshot fallback
RetentionAn alarm that sweeps expired records and archives them
Data rightsexportTenant and deleteTenant
TracingSpans 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:

highx/index.ts
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.

On this page