Skip to content

Runtime Adapters

A runtime adapter is the few lines that adapt one platform’s serve API to a handler core builds for you. Core knows nothing about Workers, Bun or Lambda: it takes a standard Request and one invocation object, and everything a second runtime has to supply is on this page.

Three interfaces make up the contract.

RuntimeAdapter is what your package exports and what a site names in its runtime config slot. It answers two questions: what handler does this app get, and what does the generated entry module look like.

PlumixHandler is that handler. It is one object with a fetch, and optionally a scheduled, a run and a dispose. You never write one: createRuntimeHandler(app) builds it from what your adapter declares, already carrying app-context assembly, binding validation, request-scoped database wiring and the scheduled-task loop.

Invocation is what you hand fetch on every call: the runtime’s configuration bag, an optional waitUntil, and an optional trusted client address. It is the seam that keeps the platform’s shape out of core — a Worker’s positional (env, ctx), a Bun.serve handler’s server, a Lambda event all collapse into it.

The generated entry module is where those meet. plumix build asks your adapter for its source, the plumix Vite plugin bundles it, and the result is what the platform runs.

The smallest adapter that runs a Plumix site: a RuntimeAdapter declaring what its platform adds to the default handler, plus the entry its platform’s serve API wants.

  1. Declare the handler. Everything runtime-neutral is already in the handler core builds. Supply an assets resolver so the admin SPA is served; nothing else is required.

    import type {
    AssetsBinding,
    EntrySourceOptions,
    RuntimeAdapter,
    } from "plumix/runtime";
    declare function serveStagedFile(request: Request): Promise<Response>;
    // Step 2 writes this one.
    declare function generateEntry(options: EntrySourceOptions): string;
    const assets: AssetsBinding = { fetch: serveStagedFile };
    export function deno(): RuntimeAdapter {
    return { name: "deno", handler: { assets: () => assets }, generateEntry };
    }
  2. Emit the entry. The entry adapts the serve API. It builds the app once, has core build the handler from the config’s own runtime, and turns each call into an Invocation.

    import type { EntrySourceOptions } from "plumix";
    export function generateEntry({ configModule }: EntrySourceOptions): string {
    return [
    'import { buildApp, createRuntimeHandler } from "plumix/runtime";',
    'import assetManifest from "virtual:plumix/asset-manifest";',
    'import pluginCatalogs from "virtual:plumix/plugin-catalogs";',
    `import config from ${JSON.stringify(configModule)};`,
    "",
    "const app = await buildApp(config, { assetManifest, pluginCatalogs });",
    "const handler = createRuntimeHandler(app);",
    "",
    "Deno.serve((request, info) =>",
    " handler.fetch(request, {",
    " env: Deno.env.toObject(),",
    " clientAddress: info.remoteAddr.hostname,",
    " }),",
    ");",
    "",
    "Deno.addSignalListener('SIGTERM', async () => {",
    " await handler.dispose?.();",
    " Deno.exit(0);",
    "});",
    ].join("\n");
    }
  3. Name it in the config. An adapter is a slot like any other, so a site swaps runtimes by swapping one call.

    import { deno } from "@example/runtime-deno";
    import { defineConfig } from "plumix";
    export default defineConfig({
    runtime: deno(),
    // …
    });
  4. Prove the slots. Run the conformance suites against whatever kv, storage, cdn, assets and database implementations you ship beside the adapter. Proving a slot below has the shape.

Three members are required; the rest exist for platforms and deployments that need them.

A short identifier for the runtime. plumix doctor prints it, and plumix --help groups the runtime’s own commands under it, so it reads best as the platform’s name rather than the package’s.

import type { RuntimeAdapter } from "plumix";
declare const rest: Omit<RuntimeAdapter, "name">;
export const adapter: RuntimeAdapter = { name: "deno", ...rest };

What your platform adds to the handler core builds, as a RuntimeHandlerSpec. The entry calls createRuntimeHandler(app) once, at module scope or on the first request, and core composes the handler from this; the adapter never holds the built app. Every member is optional:

  • assets(env) resolves the static-asset fetcher for an invocation. Without it the admin SPA answers admin-not-available.
  • disposeTimeoutMs bounds how long dispose() waits for deferred work; five seconds by default.
  • clientAddress(request) reads the client address off the request for platforms whose edge reports it there. It replaces whatever the invocation carried, absent included — the Cloudflare adapter reads cf-connecting-ip this way.
  • prepare(handlers) runs once per handler, before its first request: a platform check that should fail fast.
  • wrap(handler, config) puts the platform’s own routing in front of the built handler, as the demo runtime does for its session routes.
import type { RuntimeHandlerSpec } from "plumix/runtime";
export const handler: RuntimeHandlerSpec = {
disposeTimeoutMs: 10_000,
clientAddress: (request) => request.headers.get("fly-client-ip") ?? undefined,
};

Returns the source of the entry module, as a string. The plumix Vite plugin pre-emits it at the start of every plumix build and every plumix dev, and again in dev whenever the config file changes. Because the plugin emits it, the entry may import the virtual:plumix/* modules the plugin resolves — virtual:plumix/asset-manifest for the hashed stylesheet links, virtual:plumix/worker-exports for anything workerExports contributed.

Interpolate configModule through JSON.stringify: a project path can carry spaces or quotes.

Build the source from literals. Unlike commandsModule, this is a live function on the adapter the config constructs, so it ships inside the serving bundle along with everything it imports — a node:* import at module scope in this file is a load-time failure on a runtime with no Node built-ins.

import type { EntrySourceOptions } from "plumix";
export function generateEntry({ configModule }: EntrySourceOptions): string {
return `import config from ${JSON.stringify(configModule)};\n`;
}

Module specifiers whose named exports the generated entry must re-export. Cloudflare needs a Durable Object class to be a named export of the entry module, and that entry is generated — so a DO-backed feature contributes its class module here and the Vite plugin surfaces it through virtual:plumix/worker-exports.

Omit it. Most runtimes have no concept of a platform-level export beyond the default one.

import type { RuntimeAdapter } from "plumix";
declare const base: RuntimeAdapter;
export const adapter: RuntimeAdapter = {
...base,
workerExports: ["@example/plugin-rooms/durable-object"],
};

A specifier the CLI imports to load the commands this runtime contributes to plumix. dev, build, deploy and types come from the runtime rather than from core, which is why swapping the runtime swaps them.

A specifier rather than a live function, deliberately: it keeps build and deploy tooling out of the serving bundle. The module it names exports its CommandRegistry as commands, and may export migrations, a RuntimeMigrations that tells plumix migrate how to open the runtime’s database for drizzle’s migrator: the site’s, or a scratch one in memory. Its optional targetOptions declare the flags that name a deployed database, each a RuntimeTargetOption with a type ("boolean" or "string") and a one-line description. The CLI merges them into its strict parser and plumix --help, rejects any other flag with unknown_option naming the runtime, and hands the parsed values to open as targetValues. The MigrationDatabase that open returns names itself in target: whether it is remote, the summary line the CLI prints first, the notes a plan adds, and the database’s name, which plumix migrate fresh asks a person to type before it wipes a remote one. A write to a remote database prints a plan and is confirmed first, with --yes and --dry-run. Its wipe() drops every table but the engine’s own with foreign-key checks suspended, and its optional restorePoint() returns the one line plumix migrate fresh prints before a remote wipe, naming where the database can be restored to and the command that does it. open receives the site’s config rather than a built app, because plumix dev applies every pending migration through it before the runtime’s own dev runs. It also names the legacyTable an earlier release tracked migrations in. The MigrationDatabase that open returns carries drizzle, the drizzle instance the adapter builds over its driver, with no schema registered, which is the db each data migration’s up receives. The CLI owns the rest: which owners run, in what order, adopting an older database, and what it prints. Its openRun takes the same options bar location and returns a RunEnvironment for plumix meta and plumix mail test: the env a PlumixHandler.run reaches that same database through, its target, and a close. On Node and Bun the slot opens the database from the process env, so env is process.env. On Cloudflare env is the process env with the D1 binding, and any R2 bucket and KV namespace the site’s slots name, from wrangler’s getPlatformProxy, local or remote as the target options name.

Two helpers in plumix/cli carry the same rules to where a deploy happens. A deploy command that ships to a remote database calls migrateBeforeDeploy(ctx, targetValues) first, with the target values that name that database: it fails when the site’s own tables are ahead of its history, then applies every owner and then every data migration to the remote database, so a failure stops the deploy before it ships. A self-hosted runtime’s server calls applyBuiltMigrations({ serverDir, cwd, config, migrations }) before it listens, applying the histories copyOwnerHistories laid out beside the bundle as migrations/<owner>/, then core’s data migrations and those in migrations/data-migrations.js, which it imports by a URL computed at start so the server bundle never contains them. runBuildCommand from plumix/vite calls copyOwnerHistories for you, then builds migrations/data-migrations.js with the server environment’s resolve options, with an empty list when the site declares none.

It may also export diagnose, a RuntimeDiagnose that plumix doctor calls with the project root. It returns DiagnosticFindings, each { level, message, fix? } with level either "ok" or "warn", and doctor prints them under deploy:. Use it to check the platform’s deploy config for something a deploy would quietly go without, like Cloudflare’s check for Workers Logs.

import type { RuntimeAdapter } from "plumix";
declare const base: RuntimeAdapter;
export const adapter: RuntimeAdapter = {
...base,
commandsModule: "@example/runtime-deno/commands",
};

It may also export sync, a RuntimeSync that plumix sync moves data through, typed in plumix/cli. It may declare targetOptions as migrations does. open gets the same cwd, config, --binding and targetValues as migrations, plus the end it opens ("local" or "remote"), --url, apiToken (the token for --url, resolved on its first call) and the migration owners, and returns a SyncEnd. An end reports its origin and each owner’s head (readSyncHeads reads them), exports the rows its scopes select into a DumpWriter, and imports a DumpReader: it checks the dump’s heads first (assertSameHeads throws sync_migrations_differ), deletes what the scopes select, then inserts the dump’s rows parents first, matching users by email when users is not among the scopes. With dryRun it writes nothing and returns the same report a real import would. It also snapshots itself before a push, says how to restore that snapshot, and settles by running core’s settleSync inside the site, which for a deployed site means calling POST /_plumix/api/v1/system/sync/settle; settleDeployedSite(url, event, apiToken) from plumix/cli makes that call with the token apiToken resolves, so a runtime passes the apiToken its open got. Two builders in plumix/cli cover a SQLite runtime: databaseSyncEnd is an end over a database the process opens itself, and remoteSyncEnd is one over the deployed site’s sync routes. The routes reach the database through the database: slot’s openSync(env), a connection of its own with transactions and a snapshot; a slot without it answers 501. A runtime with no sync export fails plumix sync with sync_runtime_unsupported. Moving data between environments covers the rules, and describeSyncContract below pins them.

The admin areas this deployment refuses whoever is signed in, as AdminAreas: apiTokens, deviceAuthorization, passkeys, oauthLinking and emailDelivery. The plumix Vite plugin carries them into the admin, which hides every card, page and action of a refused area instead of offering one that can only fail. A role or capability can’t say this: it is the same for every visitor.

Declaring an area hides its surfaces and nothing more. Refuse the requests behind them in your own routing too, usually in handler.wrap.

The demo runtime from @plumix/runtime-cloudflare/demo is the example. Every visitor works against their own sandbox database, so its gate refuses only what reaches past that sandbox, a credential usable outside the visitor’s tab or a real email, and it declares all five areas so the demo admin hides them.

Omit it. A deployment that refuses nothing shows every surface.

import type { RuntimeAdapter } from "plumix";
declare const base: RuntimeAdapter;
export const adapter: RuntimeAdapter = {
...base,
refusedAdminAreas: ["apiTokens", "passkeys"],
};

How-to-fix hints the dev error page shows for your platform’s own errors, as DevErrorHintRegistrations: an id, a matches(error) test, and the hint’s title, body and docs. They show after core’s hints and before any plugin’s. An id another hint already holds fails boot.

Gate the value on process.env.PLUMIX_DEV inline, so the hints stay out of a production build. The Cloudflare adapter points a missing-binding error at wrangler.jsonc this way.

Omit it. A platform whose errors core’s hints already cover adds none.

import type { RuntimeAdapter } from "plumix";
declare const base: RuntimeAdapter;
export const adapter: RuntimeAdapter = {
...base,
errorHints: process.env.PLUMIX_DEV
? [
{
id: "deno-permission",
matches: (error) =>
error instanceof Error && error.name === "PermissionDenied",
title: "Grant the permission",
body: "Run `plumix dev` with the `--allow-*` flag the error names.",
},
]
: undefined,
};

Its members are properties rather than methods, so an adapter cannot narrow the invocation it accepts and still conform.

Takes a standard Request and one Invocation, and answers with a Response. This is the whole request path: routing, rendering, the admin, the RPC surface and every plugin route are behind it.

It does not throw. A failure inside becomes a response, so an entry that calls it needs no try/catch of its own.

import type { PlumixEnv, PlumixHandler } from "plumix";
declare const handler: PlumixHandler;
declare const env: PlumixEnv;
export async function serve(request: Request): Promise<Response> {
return handler.fetch(request, { env });
}

Runs the site’s scheduled tasks for one fired schedule. The ScheduledEvent it takes is { scheduledTime, cron } — the shape Workers cron produces, and the two fields an OS-cron bridge has to supply itself, as the example below does.

cron decides which tasks run: a task whose declared cron differs is skipped, and a task that declared none runs on every invocation. Core registers tasks of its own and plugins register more, some on a schedule the site configures. Read app.scheduledTasks on the built app for the schedules a deploy needs, rather than hard-coding a list.

import type { PlumixEnv, PlumixHandler } from "plumix";
declare const handler: PlumixHandler;
declare const env: PlumixEnv;
export async function runCron(cron: string): Promise<void> {
await handler.scheduled?.({ scheduledTime: Date.now(), cron }, { env });
}

Runs one piece of core work against the site from outside a request, and resolves with what the work returned. The work gets the context a request would: the site’s database, its plugins and its bound slots. It ends the way a request does, committing the scoped write and flushing the CDN purges the work enqueued. plumix meta settle runs through it, which is how a command line reaches the same database, plugins and purges the site uses.

It throws when the database can’t be reached through the env it is given: a binding that only exists inside the platform, like D1 from a Node process given only process.env. The CLI hands it the env the runtime’s openRun returns. Call dispose() after it, as after scheduled, so the purges it enqueued are delivered.

import type { PlumixEnv, PlumixHandler } from "plumix";
declare const handler: PlumixHandler;
declare const env: PlumixEnv;
export async function countPlugins(): Promise<number | undefined> {
const count = await handler.run?.(
(ctx) => Promise.resolve(ctx.plugins.entryTypes.size),
{ env },
);
await handler.dispose?.();
return count;
}

Drains the deferred work no waitUntil took, then resolves. Telemetry delivery and CDN purges go through ctx.defer, and on a runtime whose invocations carry no waitUntil the default handler tracks those promises in a pending set instead. dispose() is what empties it.

The drain follows work that defers more work, against an absolute deadline — disposeTimeoutMs on the handler options, five seconds by default — so a chain of quick tasks cannot hold a shutdown open indefinitely. Abandoning is final: a supervisor escalating SIGTERM to SIGINT does not buy a stuck task a second timeout.

A long-lived process calls it on shutdown.

import type { PlumixHandler } from "plumix";
declare const handler: PlumixHandler;
process.on("SIGTERM", () => {
void handler.dispose?.().then(() => {
process.exit(0);
});
});

Build it per call, from whatever your platform’s serve API hands you.

The runtime’s configuration bag: bindings, secrets and plain variables. Every slot’s connect(env) reads it, requiredBindings is validated against it, and an (env) => value resolver is called with it.

On Workers it is the Worker env. On a process runtime it is process.env plus any clients you bound alongside. Its type is PlumixEnv, the interface a runtime augments through declare module "plumix", so a site’s slots read your keys type-checked.

Core assumes env is stable for the handler’s life: object storage, kv, cdn and image delivery are connected once, on the first invocation, and reused. Two things are not bound once: the database, through connectRequest, and the assets resolver, which is called per request because a binding read is cheap.

connectRequest has no release seam — unlike connect, whose ConnectedDb.close the handler calls on every response path including the error one. An adapter that answers through connectRequest must own its own pooling and hand out a borrowed handle rather than mint a connection per request; today’s two adapters (D1 Sessions, the demo Durable Object proxy) both do.

An adapter whose db may read a replica also returns credentialDb from connectRequest: a handle that sees every committed write. Core validates sessions and API tokens through it, and through db when it is absent.

declare module "plumix" {
interface PlumixEnv {
readonly DATABASE_URL: string;
readonly S3_SECRET_ACCESS_KEY?: string;
}
}
export {};

Keeps the runtime alive until a promise settles. When you supply it, ctx.defer routes through it; when you do not, the default handler tracks the promise for dispose() instead. Rejections reach the configured logger either way.

Supply it on a runtime that would otherwise tear the execution context down at the response — a Worker isolate, a Lambda invocation. Omit it in a long-lived process and drain on shutdown instead.

import type { PlumixEnv, PlumixHandler } from "plumix";
declare const handler: PlumixHandler;
declare const env: PlumixEnv;
declare const ctx: { waitUntil(promise: Promise<unknown>): void };
export async function serve(request: Request): Promise<Response> {
return handler.fetch(request, {
env,
waitUntil: (promise) => {
ctx.waitUntil(promise);
},
});
}

The client address, as whatever your platform trusts reports it. It lands on the app context as ctx.clientAddress, where session metadata, visitor-meta hashing and any plugin rate limiter read it.

Core never parses x-forwarded-for, cf-connecting-ip or any other forwarding header — a header a visitor can set is not a fact, and only the adapter knows which proxy in front of it is authoritative. Supply the value from your platform’s own API where there is one, and from a header only where the terminating proxy is yours and overwrites it.

A request with no resolvable address is still handled; visitor-meta falls back to a shared bucket and session rows record nothing.

import type { PlumixEnv, PlumixHandler } from "plumix";
declare const handler: PlumixHandler;
declare const env: PlumixEnv;
declare const server: {
requestIP(request: Request): { address: string } | null;
};
export async function serve(request: Request): Promise<Response> {
return handler.fetch(request, {
env,
clientAddress: server.requestIP(request)?.address,
});
}

Seven obligations, none of them enforced by a type. Each is a fact core derives from something only the runtime can supply, so breaking one gives you a live site behaving subtly wrong rather than a build failure.

Build the Request with its public URL. Scheme and host included, as the visitor sees them — not the address the origin process is listening on. Core derives the session cookie’s Secure flag from request.url’s protocol, so a site behind a TLS-terminating proxy that hands core http://127.0.0.1:8787 sends its session cookies in the clear.

The host has a second reader, and how much rests on it depends on the site. Absolute URLs — canonical tags, magic links, OAuth callbacks — are built from ctx.origin, which is the operator’s configured origin where there is one and the request’s origin where there is not. A site that configures origin risks only the cookie flag; one that leaves it unset mints links to whatever host you passed, and those reach a reader’s inbox.

The host matters in development too. The dev-only surfaces — the debug bar, the request history, the dev error page — are gated on two factors: the PLUMIX_DEV bundle define, and request.url’s hostname being loopback. A dev server whose adapter passes through a container or tunnel host fails the second and loses them; PLUMIX_DEV_ALLOW_REMOTE is the deliberate opt-out. In production the first factor is absent anyway, so a wrong host there costs the cookie flag and the canonical URL rather than the dev surfaces.

Supply the client address from something you trust. See Invocation.clientAddress. The adapter is the only layer that knows which proxy is in front of it.

Supply waitUntil, or call dispose() on shutdown. Without waitUntil the default handler still starts the work and tracks it, so in a long-lived process it finishes like any floating promise. What a runtime that does neither loses is whatever is still in flight when the process ends: a telemetry batch that never reaches its endpoint, a CDN purge that never fires and leaves a published entry serving a stale page. It is a silent loss — nothing rejects, the promise dies with the process.

Supply env as the augmented PlumixEnv. See Invocation.env. Core binds every slot against the first env it sees, so building a fresh one per request silently pins the site to one request’s values.

Fire app.scheduledTasks without overlapping runs. The task’s declared cron is the contract — core matches on it and skips the rest. Core’s own tasks survive a double run by construction: publish-scheduled re-asserts status = 'scheduled' in its write, so a second run flips nothing and emits no second event. A plugin’s task carries no such guarantee, and even where one does, an overlapping run costs a duplicate pass over the same rows and a duplicate telemetry span.

A platform with its own scheduler should use it. On one without, createScheduledRunGuard(...) is the piece to build on: it takes two rows in the site’s own database — a claim recording the last minute each schedule fired, and a lease held for the duration of a run — and between them they give at most one run per schedule per minute and no two runs overlapping, across every replica rather than only within one process. The database is the right home for it because a lock in memory or in a file beside the process is silently wrong the moment a deploy runs more than one replica against one database, which plumix/db/libsql makes possible today. The lease frees itself by expiry rather than by a heartbeat: a task holding a synchronous driver blocks its own timers, so an expiry sized in minutes is what actually holds, at the price of pausing the schedule for that long after a replica is killed. @plumix/runtime-node’s scheduler is the worked example.

Declare schedules in the portable subset. Runtimes disagree about cron. Cloudflare reads the day-of-week field Quartz-style — 1-7, where 1 is Sunday — and also accepts L, W and #; Unix cron reads 0-6, where 0 is Sunday. So 0 0 * * 1 means Sunday on one and Monday on the other, and a site that changes runtime would find its weekly job had quietly moved a day. Plumix therefore accepts only what means the same thing everywhere: *, lists, ranges and steps in every field, numeric months, weekdays and months by name (MON, JAN), and no Quartz extensions — no numeric weekday, no L/W/#, no @daily shorthand, and no seconds column. buildApp parses every declared cron at boot on every runtime and refuses one it cannot fire, naming the task — so a mistyped schedule is a failed deploy rather than a task that silently never runs. Matching is always UTC.

Serve the staged admin SPA through an AssetsBinding. plumix build stages the admin under the public directory; an adapter that supplies no assets resolver answers /_plumix/admin/* with admin-not-available. The binding is one method — fetch(request): Promise<Response> — over whatever your platform serves static files with. Two answers matter: a file it holds comes back as itself, and the mount prefix /_plumix/admin/ comes back as the shell HTML with a 200 rather than a redirect, because that is the URL core fetches to resolve an admin deep link. The assets conformance suite asserts both.

Build with the plumix Vite plugin. The plugin substitutes the dev defines (process.env.PLUMIX_DEV above all, which is what lets every dev-only surface tree-shake out of a production build), applies the island transform, discovers island entries and pre-emits your generateEntry output. In an SSR build for a process runtime, externalise native server dependencies rather than bundling them: @libsql/client loads a native addon and does not survive a bundler.

Core is built on Web APIs, and five of them have to be present for a Plumix site to run at all. Node is where they arrived at widely different times, so it gets the column.

API What core does with it In Node since
Global URLPattern Matches every public route a plugin or theme registers. 24.0.0
crypto.randomUUID on the global crypto Mints the request id on every app context. 19.0.0
AsyncLocalStorage from node:async_hooks Carries the request context. The one node: import on the request path. 12.17.0
Streaming Response bodies Asset and object-storage reads, which are never buffered. 18.0.0
Headers keeping every Set-Cookie apart Lets a database adapter’s commit append its own cookie beside the session’s. 19.7.0

Node 23.8 has URLPattern but exports it from node:url only, and core uses the global — which is what puts Node’s floor at 24. On Deno, Bun and Workers the other four all predate whichever API pins the row below.

Engine Floor What pins it
Node 24.0.0 Global URLPattern, per the table above.
Deno 2.0 Conservative, not derived: URLPattern has been stable since 1.15 and node:async_hooks arrived well before 2.0, but no Plumix site has been run on an earlier Deno.
Bun 1.3.4 URLPattern, which Bun added in that release.
Cloudflare Workers nodejs_compat node:async_hooks. Everything else is in the base runtime, so the flag is the whole requirement.

Plumix’s own repository pins Node to ^24.21.0, above that floor. A runtime adapter can ask more of its engine than core does: @plumix/runtime-bun needs Bun 1.4, whose server waits for in-flight requests on stop(), and the repository pins Bun to 1.4.2 in .bun-version. Bun covers what that adapter adds.

An adapter usually ships slot implementations beside it — a Redis kv, an S3 bucket, a cdn: provider fronting a third-party CDN. Each of those has a contract core relies on and no compiler checks: that list honours limit as an upper bound, that a cursor resumes without repeating a key, that a TTL expires, that delete is idempotent.

plumix/test/conformance exports one parameterised suite per slot port. Each takes a factory that returns a connected slot and runs the contract against it. Core’s own in-memory slots and the Cloudflare slots are the first callers, so the suites are the same assertions the shipped implementations are held to.

import type { ConnectedKv } from "plumix";
import { describeKvContract } from "plumix/test/conformance";
declare function connectRedisKv(): Promise<ConnectedKv>;
describeKvContract({
connect: connectRedisKv,
// Workers KV rejects a TTL under a minute; a store with no floor omits this.
minTtlSeconds: 60,
});

describeObjectStorageContract, describeCdnContract and describeAssetsContract take the same shape, each with the options its port needs — whether the storage can presign, how the asset layer answers a path it does not hold. Pass a clock-advancing function where your rig has one: without it the TTL cases skip rather than sleep, and expiry is the one guarantee that then goes unproven.

describeDatabaseContract takes a factory returning the database: adapter and the env it connects against, bound to an empty database. It applies core’s schema itself and holds the adapter to what core’s queries assume of every driver: snake_case columns, returning, joins and relational queries, a rowsAffected that leaves out rows a trigger wrote, a raw sql Date bound as epoch milliseconds and a boolean as 1/0, foreign-key cascades, and core’s shipped migration history keeping the change-feed triggers. libsql, nodeSqlite and D1 are its first callers.

describeSyncContract holds a runtime’s sync export to the transport contract. Its open(handlers) returns { sync, local, remote }: the transport, and both ends as the suite seeds and reads them, each with an execute(sql, args) over its database and its object storage, migrated and empty, with every settle running the sync.after handlers on handlers. A remote end that calls its site also returns remoteUrl and apiToken, which the suite passes as url and apiToken; the site may hold that token’s user, under an id above 100 and an email no case seeds. It covers the round trip, a BLOB included, the dry run, the head check, users matched by email, dependents counted, auth tables kept out of every dump, the media replace, and a failed settle step reported rather than thrown. memorySync({ local, remote }) from plumix/test is the reference transport that passes it: each site is a libsql client, with optional object storage and an optional ctx its settle runs in.

describeRunContract holds a runtime’s migrations.openRun to what plumix meta and plumix mail test do with it. Its open() returns { migrations, cwd, database }: the runtime’s RuntimeMigrations, the project root, and the database: slot naming a database there with nothing in it. Each case migrates that database through migrations.open, seeds sixty posts whose toggle holds 1 through a PlumixHandler.run in the env openRun returns, then reports and settles them the same way. A report counts every value and writes nothing. A settle converts all of them, more rows than one sweep call reaches, and a run after it finds none left. A mail test run hands core’s mailTest to the site’s mailer once. Node, Bun and Cloudflare (against a local D1) are its first callers.

An absent port member is a supported provider, not a gap the suite reports — but declare it: a vendor that cannot invalidate by tag omits purgeTags and leaves the suite’s option of the same name off; one that cannot purge everything omits purgeAll likewise. The suite asserts the two agree in both directions, so a member you drop while still declaring it fails rather than passing quietly. Publish such a provider and recommend a short ttl beside it — CDN Caching says why freshness is then the site’s only control.

Cloudflare Workers is the shipped adapter, documented from the operator’s side. Bindings and Environment covers what lands on env and how a slot names the key it reads. Secrets covers the (env) => value resolvers, which are how a credential reaches a slot from env instead of being written into the config file.

Configuration lists every slot a site config carries, runtime among them. Overview puts the build, the entry and the staged assets in the order a deploy runs them.

Decide what your platform can answer. An adapter that supplies env, a public-URL Request and an assets resolver serves a site; the client address, waitUntil and the scheduler are what take it from serving to correct. Cloudflare Workers shows what each of those looks like when the platform can answer all of them.