| name | adding-a-new-durable-object-mixin |
| description | Add or modify a Durable Object mixin in packages/shared/src/durable-object-utils with the repo's required API design, type documentation, tests, and Cloudflare runtime checks. Use when creating a new withX mixin, changing mixin composition types, or extending the durable-object-utils test harness. |
| publish | false |
Adding A New Durable Object Mixin
Use this before adding or changing a mixin under packages/shared/src/durable-object-utils.
The default is do not code immediately. First spec the API with the user. If the shape is unclear, use the grill-me skill and ask direct design questions until the runtime behavior, type surface, and tests are obvious.
Ground Rules
- Keep
DurableObject as the root. Do not add a separate root class, pipe, flow, or builder unless explicitly specced.
- Prefer nested composition:
withB(withA(withDurableObjectCore(DurableObject))) when the stack needs local storage, alarms, or lifecycle hooks.
- Preserve Cloudflare-style ergonomics:
const Base = withX(...)(withDurableObjectCore(DurableObject)); class Room extends Base<Env> {}.
- Keep env requirements and capability requirements separate.
- Use the smallest public API that solves the problem. Do not add optional knobs for hypothetical users.
- If a mixin can own a generic loop, configure the mixin with the concrete
pieces instead of pushing the loop into every subclass. A subclass should not
contain domain-specific copy/paste just because the mixin only exposed
low-level protected helpers.
- Avoid inventing extra nouns. If the user has settled on "runner", don't
introduce "adapter", "mount", "runtime", "slot", or similar names unless
there is a separately explainable concept.
- Document every non-obvious type expression with the call-site benefit it protects.
- Add examples to
README.md; AGENTS.md in this folder is a symlink to that README, so agents and humans share the same guidance.
Reference Implementations
mixins/with-durable-object-core.ts: root adapter for Cloudflare's protected ctx APIs. It exposes protected local SQLite, synchronous KV, and platform alarm capabilities so feature mixins do not reach into ctx directly.
mixins/with-lifecycle-hooks.ts: protected subclass surface, named initialization, first-initialize/start hooks, static/generic preservation through DurableObjectClass, and optional lifecycle-owned D1 object catalog projection.
mixins/with-kv-inspector.ts: fetch wrapper that preserves generic Base<Env>.
mixins/with-outerbase.ts: fetch wrapper around SQLite debug routes.
mixins/with-lifecycle-hooks.type.test.ts: expect-type examples that prove lifecycle, env lower-bound, and protected-surface type incantations still work.
test-harness/initialize-fronting-worker.ts: shared Worker entrypoint for worker-pool unit tests and deployed E2E.
README.md: human/agent docs for composition, type shapes, runtime behavior, and test commands.
Design Checklist
Before editing, write down:
- What route/RPC/method/state does the mixin add?
- Does it wrap
fetch, override/extend an existing method, or only add new methods?
- If it overrides
alarm, fetch, or another runtime hook, does it call the inherited implementation and document what subclasses above it must do?
- Is the mixin a low-level capability provider, or should it be configured with
concrete callbacks and own a complete generic operation? Prefer the latter
when otherwise every subclass would write the same loop.
- If the mixin is configured, which configured values are created once per
Durable Object wake and cached? Document why cached closure state is safe and
what durable state still belongs in storage.
- Does the mixin need to support multiple configured items at once, or should it
be stackable? Don't assume either shape. Compare the call sites:
withX({ items: { a, b } })(Base) vs withX({ item: a })(withX({ item: b })(Base)).
- Does it require env bindings? If yes, what is the minimum env shape?
- Does it require members from earlier mixins? If yes, what exact capability interface should the base satisfy?
- Does it need local SQLite, synchronous KV, or platform alarms? If yes, depend on
DurableObjectCoreProtected from withDurableObjectCore() instead of reaching into ctx directly.
- Is any work intentionally allowed to continue after the triggering method returns?
If yes, pass a narrow
waitUntil(promise) capability or use ctx.waitUntil
at the Durable Object boundary, and document what observable event records the
eventual result.
- What should happen if a caller uses the mixin in production by accident?
If any answer is fuzzy, stop and grill the user. Do not encode uncertainty as optional config.
Type Pattern
Name each mixin implementation file after the mixin function in kebab-case:
with-thing.ts for withThing().
For a simple member-adding mixin with no new env requirement, use the Cloudflare withVoice style:
type WithThingResult<TBase extends DurableObjectConstructor> = TBase & Constructor<ThingMembers>;
TBase preserves the original class value, including statics and the generic Base<Env> shape. Constructor<ThingMembers> adds the instance methods.
For a mixin that must preserve accumulated env requirements and members through multiple wrappers, use the explicit generic Durable Object constructor pattern:
type DurableObjectClass<ReqEnv = unknown, Members = object> = abstract new <Env extends ReqEnv>(
ctx: DurableObjectState,
env: Env,
) => DurableObject<Env> & Members;
Use comments to explain:
ReqEnv is the minimum env shape required by mixins applied so far.
Members is the instance surface accumulated so far.
- The purpose is to keep
class Room extends Base<Env> valid after composition.
Feature mixins should generally consume protected capabilities from lower layers
instead of touching Cloudflare ctx directly. For example, withOuterbase()
and withKvInspector() require Constructor<DurableObjectCoreProtected> and use
this.useDurableObjectSql(...) / this.useDurableObjectKv(...), while
withDurableObjectCore() is the only reusable layer that adapts
ctx.storage.sql, ctx.storage.kv, and platform alarms.
When the mixin itself owns the operation, put the important call-site API at the
top of the file. Keep the lower-level protected helpers below it or private. For
example, withLifecycleHooks(...) is configured with a nameSchema and a
d1ObjectCatalog setting (with getDatabase(env) and indexes), then owns the
whole startup gate and catalog projection: it exposes initialize(),
ensureStarted(), assertInitialized(), and getD1ObjectCatalogRecord(). The
app Durable Object should not also contain the initialization/catalog loop.
Configured mixins often need cached values. Store them under explicit private
fields with names scoped to the mixin, the way withLifecycleHooks() keeps
#structuredName, #instanceWakePromise, and #firstInitializeHooks.
This avoids accidental collisions and makes future stackability easier to reason
about. If a mixin may be applied more than once, use unique method names or a
single configured-map shape; don't rely on vague super chaining without a
documented call order.
When a mixin needs one storage operation, prefer the scoped callback helpers:
return this.useDurableObjectKv((kv) => Response.json(readKvEntries(kv)));
Only use the raw protected storage handles when the mixin owns durable state and
needs several related operations in one method. This keeps debug/fetch helpers
from passing raw Cloudflare storage handles through unrelated rendering code.
For env bindings, prefer a lower-bound type selected by the call site:
type NeedsCatalog = {
DO_CATALOG: D1Database;
};
const Base = withLifecycleHooks<RoomInit, undefined, NeedsCatalog>({
d1ObjectCatalog: {
className: "Room",
getDatabase(env) {
return env.DO_CATALOG;
},
},
nameSchema: RoomInit,
})(withDurableObjectCore(DurableObject));
class Room extends Base<NeedsCatalog & { OTHER: string }> {}
Avoid app-wide Env in the library if a smaller binding fragment is enough.
Documentation Requirements
Every mixin needs:
- A top-level JSDoc saying what it adds and what routes/methods it owns.
- A clear warning if it exposes storage, SQL, debug state, or unsafe behavior.
- Comments beside type aliases when preserving
TBase, ReqEnv, Members, protected members, or env lower-bounds.
- README examples showing the call-site benefit, not just implementation details.
- A note explaining any detached promises, synchronous KV, SQLite, or D1 behavior that is not obvious.
Do not write “magic” comments. Prefer:
over:
Tests
Add or update all relevant levels:
- Type tests:
mixins/*.type.test.ts using Vitest expectTypeOf.
- Unit tests: worker-pool Vitest tests using
cloudflare:test.
- E2E tests: fronting Worker tests when runtime behavior depends on real Worker/DO/D1 behavior.
Type tests should read as examples:
- composed base can still be extended as
Base<Env>;
- members from earlier mixins survive later mixins;
- env lower-bounds reject missing bindings;
- protected members are visible in subclasses and hidden from callers;
- helper options are required/optional exactly when intended.
- configured callbacks infer the intended init params, env lower-bound, and
returned state shape;
- if a mixin requires lifecycle/core capabilities, applying it directly to
DurableObject should be a type error.
Run before handing off:
pnpm --dir packages/shared typecheck
pnpm --dir packages/shared test:durable-object-utils
pnpm exec oxlint packages/shared/src/durable-object-utils --threads 1 --deny-warnings
For production-runtime coverage:
cd packages/shared
doppler run --config dev_jonas -- pnpm test:durable-object-utils:e2e:deploy
PR Hygiene
- Keep
packages/shared/src/durable-object-utils/AGENTS.md symlinked to README.md.
- Do not add barrel files unless explicitly requested.
- Keep relative imports explicit with
.ts.
- Avoid
as any; if a cast is unavoidable, keep it local and explain the exact runtime fact that makes it safe.
- If a review comment says the type surface is unclear, add a focused example instead of adding more abstractions.