AgentSkillsCN

do-conventions

Lumenize 单体仓库中的 Cloudflare Durable Object 规范——存储机制、私有成员、计费模式、测试模式。在与 DO 实施相关时自动加载。

SKILL.md
--- frontmatter
name: do-conventions
description: Cloudflare Durable Object conventions for the Lumenize monorepo — storage, private members, billing models, testing patterns. Loaded automatically when relevant to DO implementation.
user-invocable: false

Durable Object Conventions

Reference guide for implementing Cloudflare Durable Objects in the Lumenize monorepo. These conventions apply to all DO classes across all packages.


1. Storage

Always use synchronous storage APIs. Never use the legacy async API (await ctx.storage.put, await ctx.storage.get).

  • this.svc.sql template literal — Preferred for everyday SQL queries in @lumenize/mesh DOs. Readable ${value} interpolation with automatic parameter binding. See SQL Tagged Template Literal.
  • ctx.storage.sql.* — Direct SQLite API. Use when you need cursors, streaming, or metadata (rowsRead, rowsWritten, raw()).
  • ctx.storage.kv.* — Simple key-value data: counters, flags, single-entity lookups, configuration state.

2. Private Members

Always use the JavaScript # prefix for private class members — fields, methods, and handler functions. Never use the TypeScript private keyword. The # prefix provides true runtime privacy, not just compile-time.


3. Environment Variables & Secrets

Multiple places can set environment variables, each with different tradeoffs. Consider the constraints before choosing.

Where to set variables:

LocationCommitted?ScopeBest for
wrangler.jsonc [vars]YesDev + productionNon-secret config safe to commit
.dev.varsNo (gitignored)Local dev onlySecrets and local-only overrides
Vitest config miniflare.bindingsYesTests onlyTest-mode flags (see below)
wrangler secret putN/AProduction onlyProduction secrets
Cloudflare dashboardN/AProduction onlyBoth secrets and non-secret config

There is no wrangler CLI equivalent for non-secret production environment variables — use one of the other alternatives.

Precedence: In local dev/test, values from .dev.vars, wrangler.jsonc, and vitest config are merged. .dev.vars overrides wrangler.jsonc; vitest miniflare bindings override both.

Secrets must never be committable: No secrets in wrangler.jsonc, source code, or any non-gitignored file. Use .dev.vars for local development and wrangler secret put or the dashboard for production.

Test-mode flags: Variables that enable test-only behavior (e.g., bypassing auth, disabling rate limits) are a security-sensitive surface. Set them in the vitest config via miniflare.bindings, so they cannot leak into production. LUMENIZE_AUTH_TEST_MODE is auth-internal only (for testing magic link delivery, invite flows, etc.) — mesh projects should use createTestRefreshFunction from @lumenize/mesh instead, which mints JWTs locally with no test-mode bindings needed. See website/docs/mesh/testing.mdx.

Local development setup:

  • Single root /lumenize/.dev.vars file (gitignored) holds all secrets and config for local dev
  • .dev.vars.example is the committed template — shows contributors which variables to set without exposing values
  • The root postinstall script symlinks .dev.vars into each package's test directories so they're available to miniflare or vitest-workers-pool

Env type generation: worker-configuration.d.ts (generated by npm run types) reads both wrangler.jsonc and .dev.vars to produce the global Env interface — no need to manually define one (see section 10).


3a. Cross-Platform cloudflare:workers Detection

Library code that needs env from cloudflare:workers but must also run in Node.js/Bun/browser should use top-level await import() in a try/catch:

typescript
let cfEnv: { DEBUG?: string } | null = null;
try {
  const mod = await import('cloudflare:workers');
  cfEnv = (mod as { env?: { DEBUG?: string } }).env ?? null;
} catch {
  // Not in Workers runtime — expected in Node.js, Bun, browser
}

This resolves in Workers and silently fails elsewhere. No build-time flags needed. The canonical example is @lumenize/debug — it auto-detects env.DEBUG this way, letting callers use debug('namespace') in all environments without calling debug.configure(env).

Do NOT use this pattern in code that only runs in Workers (DO classes, Worker entry points). Those can directly import { env } from 'cloudflare:workers' since the runtime is guaranteed.


4. Synchronous Methods

Only these entry points should be async:

  • fetch()
  • alarm()
  • webSocketMessage(), webSocketClose(), webSocketError()

All other methods — business logic, route handlers, helpers — should be synchronous. Never use setTimeout, setInterval, waitUntil, or await in business logic.

Exception: Methods that call APIs with no synchronous alternative (e.g., crypto.subtle.*) may be async. These complete in microseconds and don't open input gates long enough to cause practical interleaving, unlike network I/O or timers which can allow other requests to interleave and create race conditions.

typescript
async fetch(request: Request): Promise<Response> {
  const url = new URL(request.url);
  if (url.pathname === '/login') return this.#handleLogin(request);
  return new Response('Not found', { status: 404 });
}

#handleLogin(request: Request): Response {
  // Synchronous — no await, no setTimeout
  const token = this.#createToken(body.userId);
  return Response.json({ token });
}

// OK: async because crypto.subtle.verify is inherently async
async #verifyToken(token: string): Promise<Identity | null> {
  const payload = await verifyJwt(token, publicKey); // crypto.subtle.verify
  if (!payload) return null;
  return this.#lookupSubject(payload.sub); // sync SQL
}

Why: await and timers in business logic break Durable Object input/output gate concurrency model. If you need to call an external service, do it from a Worker using a two one-way calling pattern, or in rare cases, use Promise .then/.catch to make the concurrency model risk more deliberate.


5. No Mutable Instance State

Durable Objects can be hibernated or otherwise evicted from memory at any time. Instance variables that hold mutable state will be lost on eviction. Use ctx.storage.kv or ctx.storage.sql as the source of truth. DO storage reads are extremely cheap (~1/10,000th the cost of writes) and frequently read values are served from cache, so reading from storage on every access has no measurable performance penalty or cost versus instance variables and avoids race condition inconsistency risks.

typescript
// Wrong: mutable instance state — lost on eviction
#subscribers = new Set<string>();
subscribe(id: string) { this.#subscribers.add(id); }

// Right: Read from storage for every access. Write to storage on every change.
subscribe(id: string) {
  const subs = this.ctx.storage.kv.get('subscribers') ?? new Set();
  subs.add(id);
  this.ctx.storage.kv.put('subscribers', subs);
}

Safe uses for instance variables:

  • Statically initialized utilities (e.g., a pre-compiled regex, a bound shorthand like #sql = this.ctx.storage.sql.exec)
  • Ephemeral caches where loss is acceptable (e.g., rate-limiting counters where under-counting on eviction is fine)
  • Registry or service objects that themselves follow the always-read-from-storage pattern

6. IDs

  • Unordered unique IDs: crypto.randomUUID()
  • Ordered IDs: ulidFactory({ monotonic: true }) from the ulid-workers package

Never use Date.now() for generating IDs or timestamps. In Cloudflare Workers, the clock does not advance during a single execution — multiple calls to Date.now() within the same request return the same value, producing duplicate IDs.


7. Wall-Clock Billing

Durable Objects are billed for elapsed wall-clock time whenever any of these are active:

  • awaiting I/O (fetch, storage in legacy async API)
  • setTimeout / setInterval
  • Holding Workers RPC stubs open

Mitigation strategies:

  • Keep all business logic synchronous (see section 4).
  • Use the using keyword for Workers RPC stubs and create them in the narrowest scope possible so they're disposed promptly:
    typescript
    {
      using stub = env.MY_DO.get(id);
      const result = stub.someMethod();
      // stub is disposed at end of block — no lingering billing
    }
    
  • Avoid calling external APIs directly from a DO. Use the two-one-way-call pattern where a Worker makes the external call, and then "calls back" to the DO after it has the result — see Making Calls. For fetches that regularly take >5 seconds, @lumenize/fetch provides a fire-and-forget pattern with continuation — see Fetch Service.
  • Never use setTimeout or setInterval — use alarm() for scheduled work.

8. Route Pattern

Durable Objects handle HTTP routes in their fetch() method via URL path matching. Use private handler methods for each route's logic.

typescript
async fetch(request: Request): Promise<Response> {
  const url = new URL(request.url);
  const { pathname } = url;

  if (request.method === 'POST' && pathname === '/login') {
    return this.#handleLogin(request);
  }
  if (request.method === 'POST' && pathname === '/verify') {
    return this.#handleVerify(request);
  }
  // Other routes...
  return new Response('Not found', { status: 404 });
}

Use private handler methods (with # prefix) unless you need public methods for a Workers RPC API that mirrors the HTTP routes. Avoid an external dependency or building a router abstraction unless you have dozens of routes — direct if matching is efficient enough and has no dependency cost.

Prefix-based routing in Workers: A common pattern for Workers that dispatch to multiple DOs (or sub-handlers) is a routing helper that matches a URL prefix and returns undefined if it doesn't match. This composes naturally with || or ??:

typescript
// Worker fetch — dispatch by prefix
return (
  await routeDORequest(request, env, { prefix: '/auth' }) ??
  await routeDORequest(request, env, { prefix: '/docs' }) ??
  new Response('Not found', { status: 404 })
);

9. Testing

Primary approach: integration tests

Worker and DO code should be tested with integration tests that exercise the real Cloudflare runtime via miniflare/vitest. Unit tests are only for algorithmically complex pure functions and occassionally during development when trying to confirm behavior before building the next dependant part.

Coverage targets

  • Branch coverage: >80% (target close to 100%)
  • Statement coverage: >90%

Use vi.waitFor(), never setTimeout

When waiting for async state changes in tests, use vi.waitFor() which retries until the assertion passes. Never use setTimeout or arbitrary delays — they're flaky and slow.

Mesh testing pyramid

Two complementary patterns for testing mesh applications:

  • Integration tests (LumenizeClient + createTestRefreshFunction) — Full production path: Client → Worker fetch → auth hooks → Gateway → DO. The refresh callback mints JWTs locally; auth hooks verify them normally. No test-mode infrastructure needed.
  • Isolated DO tests (createTestingClient) — Direct DO RPC, bypasses Worker/Gateway/auth. Good for testing storage, alarms, business logic, and manipulating DO state (e.g., force-closing a WebSocket via ctx.getWebSockets()[0].close(code) to test client reconnection).

See website/docs/mesh/testing.mdx for full documentation and examples.

Test organization

A single vitest.config.js per package can define multiple projects when tests need different configurations. Common reasons to use multiple projects:

  • Separate wrangler.jsonc per for-docs/ mini-app — each mini-app has its own DO bindings and migrations. See packages/mesh/vitest.config.js where each for-docs/ directory gets its own project pointing to its own wrangler.jsonc.
  • Same tests across multiple runtimes — verify behavior in Node, Workers, and browser environments. See packages/structured-clone/vitest.config.js which runs the same test suite across all three.
  • E2E tests with different bindings — tests requiring service bindings or real external services get their own wrangler.jsonc (e.g., packages/auth/test/e2e-email/wrangler.jsonc adds AUTH_EMAIL_SENDER for real Resend email delivery). Use defineWorkersConfig with multiple projects to run these alongside unit tests.

10. Env Type

Use the global Env interface from worker-configuration.d.ts, which is auto-generated by running npm run types (calls wrangler types). Never manually define an Env interface or create custom env types like MyEnv or AuthEnv.


11. for-docs/ Tests Are Mini-Apps

Each test/for-docs/ directory is a self-contained application, not a toy example. These tests serve two equally important purposes:

  1. Bug-finding through realistic integration: Building a mini-app that wires up multiple DOs, a worker entry point, and real request flows exercises the code the way users will. Historically, for-docs/ tests have found more bugs than all other tests combined — precisely because they're full applications, not isolated tests.
  2. Documentation accuracy: Each test is linked from .mdx files via @check-example, guaranteeing that every code example in the docs actually works.

Small code snippets can satisfy purpose 2 alone. But purpose 1 requires building something substantial enough to expose integration bugs — wrong route paths, missing storage initialization, broken cross-node communication, subtle ordering issues. That's why for-docs/ tests are mini-apps.

Each directory has its own:

  • wrangler.jsonc with DO bindings and migrations
  • Worker entry point (index.ts)
  • Durable Object class files
  • A phased narrative test that exercises realistic multi-node scenarios end-to-end

Exemplar: packages/mesh/test/for-docs/getting-started/ — review its structure before creating new for-docs/ tests.

When to use for-docs/:

  • The scenario exercises cross-node interactions or multi-step workflows (primary bug-finding value)
  • The test validates a documentation scenario (will be linked from .mdx via @check-example)
  • The test tells a story that a user reading the docs would follow

When to use isolated tests in test/ instead:

  • Single-node logic, edge cases, error paths
  • Internal implementation details not shown in docs
  • Performance or stress tests

A for-docs/ test should read like a guided walkthrough: setup context, perform an action, verify the outcome, then build on that for the next step. Each phase corresponds to a section in the documentation.


12. SQL Naming Convention

When using ctx.storage.sql for DO storage:

  • Table names: PascalCase (Subjects, RefreshTokens, MagicLinks)
  • Column names: camelCase (emailVerified, adminApproved, tokenHash, createdAt)
  • Index names: idx_TableName_columnName (e.g., idx_Subjects_email, idx_RefreshTokens_subjectId)

This matches TypeScript/JavaScript conventions and allows SQL row objects to map directly to TypeScript interfaces with minimal conversion. SQLite column names are case-insensitive for queries but case-preserved in output.


13. Migrations: new_sqlite_classes vs new_classes

The synchronous storage API (ctx.storage.kv.*, ctx.storage.sql.*) requires SQLite-backed DOs. In wrangler.jsonc migrations, use new_sqlite_classes — not new_classes. Using new_classes creates a non-SQLite DO where only the legacy async API (await ctx.storage.put/get) is available.

jsonc
// Wrong: creates non-SQLite DO — ctx.storage.kv.* will throw
"migrations": [{ "tag": "v1", "new_classes": ["MyDO"] }]

// Right: creates SQLite-backed DO — ctx.storage.kv.* and ctx.storage.sql.* work
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["MyDO"] }]

This cannot be changed after a class is deployed to production.


14. Self-Referencing Service Bindings

A Worker can bind to its own WorkerEntrypoint classes via a self-referencing service binding. The "service" field matches the Worker's own "name" in wrangler.jsonc:

jsonc
{
  "name": "my-worker",
  "services": [
    {
      "binding": "AUTH_EMAIL_SENDER",
      "service": "my-worker",
      "entrypoint": "AuthEmailSender"
    }
  ]
}

The entrypoint class extends WorkerEntrypoint and is exported from the Worker's entry file. The DO communicates with it via RPC through the binding. This pattern works in both production and vitest-pool-workers tests.

Prior art: packages/mesh/test/for-docs/calls/test/wrangler.jsonc (SpellCheckWorker, AnalyticsWorker), packages/fetch/src/fetch-executor-entrypoint.ts (FetchExecutorEntrypoint), packages/auth/test/e2e-email/wrangler.jsonc (AuthEmailSender).


15. Hibernation WebSocket API

For DOs that push data to connected clients (e.g., the EmailTestDO in tooling/email-test/), use the Hibernation WebSocket API:

typescript
// Accept WebSocket in fetch()
async fetch(request: Request): Promise<Response> {
  if (request.headers.get('Upgrade') === 'websocket') {
    const pair = new WebSocketPair();
    const [client, server] = Object.values(pair);
    this.ctx.acceptWebSocket(server);
    return new Response(null, { status: 101, webSocket: client });
  }
  // ...
}

// Push to all connected clients
const message = JSON.stringify(data);
for (const ws of this.ctx.getWebSockets()) {
  ws.send(message);
}

// Handle close — code 1005 means "no status code present" and is invalid to echo back
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
  ws.close(code === 1005 ? 1000 : code, reason);
}

Testing: vitest-pool-workers tests can open new WebSocket() connections to external deployed Workers. This enables e2e patterns where the code under test runs in-process but interacts with deployed infrastructure via WebSocket.