Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Tenancy

The kernel is single-tenant on purpose. One kernel = one event log, one set of plugins, one database, one judge state. Multi-tenancy is a host concern: resolve the tenant from the request, give each tenant its own kernel on its own store. Nothing is shared between tenants except code, so an agent plugin that one tenant approves can never touch another tenant’s data or schema.

request ──resolve──▶ tenantId ──▶ kernel[tenantId] ──▶ http.handle(request)
                                     │
                                     └── store: sqlite file / postgres schema / durable object

On Node / Fastify: createTenantPool

import { createTenantPool, tenantFromHeader } from '@sms/baseline'
import { openPostgresStore } from '@sms/store-postgres'

const tenants = createTenantPool({
  resolve: tenantFromHeader('x-tenant'),                // or tenantFromSubdomain(), or (req) => jwt(req).org
  tenant: async (id) => ({                               // BaselineOptions for this tenant
    store: await openPostgresStore({ connectionString: DATABASE_URL, schema: `tenant_${id}` }),
    executor, model, plugins: [invoicesPlugin],
    adminToken: tokens[id],
  }),
  idleMs: 30 * 60_000,                                   // close kernels idle for 30 min; they re-boot on demand
})
await createFastifyHost({ app, prefix: '/sms' }).listen(tenants.handle)
  • Tenant ids are validated (^[a-z0-9][a-z0-9_-]{0,62}$) because they become file / schema names.
  • Booting is memoised per tenant; a failed boot is retried on the next request.
  • pool.get(id) returns the tenant’s Baseline (for jobs, migrations, tests); tenants() lists the booted ones; evict(id) closes one; close() closes all.
  • A request whose tenant does not resolve gets onMissing(request) (both createTenantPool and createCloudflareApp take it) or a 400 no tenant by default.
  • Postgres: one Pool per tenant with search_path pinned to the schema; the store creates the schema. SQLite: one file per tenant (openSqliteStore(data/${id}.db)).

Cost model: a warm kernel is a few objects plus the executor instances of its agent plugins (QuickJS: one small WASM runtime each). Thousands of idle tenants are fine with idleMs; keep hot ones warm.

On Cloudflare: one Durable Object per tenant

const app = createCloudflareApp<Env>({
  resolve: (req, env) => tenantFromSubdomain({ root: env.ROOT_DOMAIN, fallback: 'dev' })(req),
  binding: 'TENANT',
  tenants: (env) => (env.CRON_TENANTS ?? '').split(',').filter(Boolean), // who the cron trigger ticks
  tenant: ({ tenantId, env, store }) =>
    createBaseline({ name: tenantId, store, executor: new QuickJSExecutor({ variant: import('@jitl/quickjs-singlefile-mjs-release-sync') }), model, plugins }),
})
export const TenantKernel = app.DurableObject
export default { fetch: app.fetch, scheduled: app.scheduled }

tenantFromSubdomain({ root, fallback, reserved }) maps acme.example.comacme, the bare root / localhost / IPs → fallback, acme.localhostacme (local dev without DNS) and refuses reserved labels (www, api, …). resolve receives env, so root domains can come from wrangler vars.

Scheduled jobs (@sms/plugin-scheduler): a Durable Object cannot enumerate itself, so the Worker’s scheduled() ticks the tenants tenants(env) returns (a wrangler var, a KV list, a registry DO you keep). The tick travels as an internal request the public fetch can never forge. On Node, setInterval(() => pool.tick(), 60_000) does the same for every booted tenant.

The Worker’s fetch resolves the tenant and forwards to TENANT.get(idFromName(tenantId)); the DO opens @sms/store-do on its own SQLite (or whatever the store option returns, e.g. openD1Store(env.DB)), boots the kernel once and keeps it in memory until the platform evicts it (the log + blobs survive in DO storage; restore() re-mounts plugins on the next boot). Single-threaded per DO, transactional storage, global addressing — the Durable Object is the tenant pool.

Sharing across tenants

Share code (system plugins, drivers, the model) — pass the same objects to every tenant. Do not share a Store, an Executor instance’s plugin instances, or admin tokens. If you need cross-tenant features (a marketplace of approved agent plugins, say), implement them as a system plugin that talks to other kernels through the pool — never by pointing two kernels at one log.