# Ephemera — Stateful coordination on Cloudflare (Durable Objects + CLI)

> Self-executing Markdown. The **Cloudflare binding** of the *stateful-coordination* intent — Durable Objects:
> single-threaded, strongly-consistent, addressable state (sessions, websocket rooms, counters, actors), with
> SQLite storage and a **migrations** lifecycle. The cloud is the source of truth; this file is intent + ledger
> + audit.

> **Provides / Requires**: **Provides** `durable-object(CLASS_NAME @ WORKER_NAME)` — other Workers reach it via
> a `durable_objects` binding + `idFromName`. **Requires** nothing mandatory; optionally a zone for routing.

---

## 🤖 Director prompt

You are the Director. Execute this plan:
- Observe-before-act; verify each step before advancing
- Stop at every 🔴 GATE and 💥 for human "go"
- Write realized values + verify results back into Live State
- **Migrations are append-only and applied at deploy** — never edit a shipped migration tag; add a new one
- Teardown deletes the Worker (and, to drop a class, a `deleted_classes` migration); nothing else
- Use only the commands in this plan

> **Candor:** authored, **not yet dogfooded** — confirm migration syntax + `wrangler` flags against `--help`
> live (the CLI is the source of truth; `new_sqlite_classes` vs `new_classes` depends on the storage backend).

```
Legend  🟢 create · 🟡 config · 🔴 GATE (human go) · 💥 destructive (human go) · ⏳ wait · ✔ verify
```

## Intent

A Durable Object is a named, single-instance piece of compute+storage: every request for the same id routes
to the **same** object, which holds state with **strong consistency** and no locks to manage. Use it for a
game/director session, a websocket room (live fan-out), an atomic counter, or a per-entity actor. State lives
in the object's **SQLite** (or legacy KV) storage; the class is versioned through **migrations** applied at
deploy. This is the gap a `STATE_STORE=durable-object` footnote can't fill: the object *is* the primary, and
its class lifecycle (create / rename / delete) is a first-class concern.

**Shared acceptance contract** (every stateful-coordination binding must pass):
1. state **persists across requests** to the same object id (write → later read returns it — strong consistency)
2. two **different ids are isolated** (independent state, no cross-talk)
3. **(`PATTERN=websocket-room`)** a message sent to a room reaches a **second** connected client (live fan-out)
4. a **v2 migration applies cleanly on redeploy** (rename/add a class) **without losing v1 object state**

## Provisioning Inputs

| # | Question | Options (closed enum) | Default | Sets | Gates |
|---|----------|-----------------------|---------|------|-------|
| 1 | Coordination pattern | `session` / `websocket-room` / `counter` | `session` | `PATTERN` | the class handler shape (§1) + acceptance 3 |
| 2 | Storage backend | `sqlite` / `kv-storage` | `sqlite` | `BACKING` | §2 migration verb (`new_sqlite_classes` vs `new_classes`) |
| 3 | Per-object scheduled work? | `none` / `alarms` | `none` | `ALARMS` | §1 `alarm()` handler + `setAlarm` |
| 4 | How is it exposed? | `workers-dev` / `route` / `custom-domain` | `workers-dev` | `ROUTING` | §3 (exposure) |
| 5 | Environment | `dev` / `stg` / `uat` / `prod` | `dev` | `ENV` | every resource name (`*-${ENV}`) |

**Why `sqlite` is the default:** SQLite-backed DOs are the current model — `this.ctx.storage.sql` gives a real
relational store per object, and **the free plan requires SQLite-backed classes** (`kv-storage` is paid). KV
storage (`this.ctx.storage.get/put`) is the legacy backend, still valid for simple key-value object state.
**The backend is fixed per class at its create migration** and changes the migration verb — pick once.
**`websocket-room`** uses hibernatable websockets (`ctx.acceptWebSocket`) so idle rooms cost nothing; `session`
is request/response state; `counter` is atomic increment coordination.

```yaml
# → written into Live State once resolved
resolved_inputs:
  pattern:  session            # session | websocket-room | counter
  backing:  sqlite             # sqlite | kv-storage
  alarms:   none               # none | alarms
  routing:  workers-dev        # workers-dev | route | custom-domain
  env:      dev
  resolved_by: <human who confirmed>
  resolved_at: <timestamp>
```

## Tags & provenance (binding asymmetry)

**Cloudflare has no resource-tag API** — a Durable Object class has no taggable resource at all (it lives
inside the Worker). Provenance is the Worker's naming (`${SVC}-${ENV}`) + `[vars]`
(`MANAGED_BY`/`SOURCE`/`PLAN_VERSION`/`ENVIRONMENT`) + `--message`.

## 0. Variables

```bash
export ENV="dev"
export PATTERN="session" BACKING="sqlite" ALARMS="none" ROUTING="workers-dev"
export SVC="director"
export WORKER_NAME="${SVC}-${ENV}"
export CLASS_NAME="DirectorSession"               # the DO class (PascalCase)
export CONFIG="$PWD/${SVC}.jsonc"
export MIG_VERB="new_sqlite_classes"; [ "$BACKING" = kv-storage ] && MIG_VERB="new_classes"
# NOTE: `command wrangler` bypasses the wrangler shell-fn so the ambient CLOUDFLARE_API_TOKEN is used.
```

## Dependency frontier

```
DO class code (§1) ─> wrangler.jsonc bindings + migration v1 (§2) ─> 🔴 deploy (§3, applies v1) ─> ✔ acceptance
                                                                          │
later schema/class change ─> append migration v2 (§4) ─> redeploy (applies v2, keeps v1 state)
```
Non-negotiable edges: **the class must exist in code before the v1 migration references it**; **migrations are
append-only** — a shipped tag is immutable, a change is a *new* tag; **deploy is where a migration runs**.
Teardown reverses (delete Worker; drop a class via a `deleted_classes` migration).

## 1. Durable Object class + Worker code  🟢

```bash
mkdir -p "$(dirname "$CONFIG")/src"
cat > "$(dirname "$CONFIG")/src/${SVC}.js" <<'JS'
import { DurableObject } from 'cloudflare:workers';

export class DirectorSession extends DurableObject {
  // SQLite-backed: a real table per object. (KV backend: use this.ctx.storage.get/put instead.)
  constructor(ctx, env) {
    super(ctx, env);
    ctx.storage.sql.exec('CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT)');
  }
  async fetch(request) {
    const url = new URL(request.url);
    // ---- websocket-room pattern (hibernatable) ----
    if (request.headers.get('Upgrade') === 'websocket') {
      const [client, server] = Object.values(new WebSocketPair());
      this.ctx.acceptWebSocket(server);                 // hibernation: idle rooms cost nothing
      return new Response(null, { status: 101, webSocket: client });
    }
    // ---- session/counter pattern (request/response state) ----
    if (request.method === 'POST') {
      const { k, v } = await request.json();
      this.ctx.storage.sql.exec('INSERT OR REPLACE INTO kv (k,v) VALUES (?,?)', k, v);
      return Response.json({ ok: true });
    }
    const k = url.searchParams.get('k');
    const row = this.ctx.storage.sql.exec('SELECT v FROM kv WHERE k=?', k).one();
    return Response.json({ k, v: row?.v ?? null });
  }
  // broadcast to every connected client in this room (websocket-room)
  webSocketMessage(ws, msg) { for (const s of this.ctx.getWebSockets()) s.send(msg); }
  // async alarm() { /* ALARMS=alarms: scheduled per-object work; reschedule with this.ctx.storage.setAlarm */ }
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname === '/healthz') return Response.json({ ok: true });
    const room = url.searchParams.get('room') || 'default';
    const id = env.SESSION.idFromName(room);            // same name → same object (strong consistency)
    return env.SESSION.get(id).fetch(request);
  }
};
JS
test -f "$(dirname "$CONFIG")/src/${SVC}.js" && echo "DO class written"   # ✔ verify
```

## 2. Bindings + migration v1 — `${SVC}.jsonc`  🟡

> The binding wires the class; the **migration** registers it. SQLite-backed classes use `new_sqlite_classes`.

```jsonc
{
  "name": "director-dev",
  "main": "src/director.js",
  "compatibility_date": "2025-04-01",
  "vars": { "MANAGED_BY": "ephemera", "SOURCE": "realtime.cloudflare.md", "PLAN_VERSION": "2026-06-27", "ENVIRONMENT": "dev" },
  "durable_objects": { "bindings": [ { "name": "SESSION", "class_name": "DirectorSession" } ] },
  "migrations": [ { "tag": "v1", "new_sqlite_classes": ["DirectorSession"] } ]
}
```
```bash
command wrangler deploy --config "$CONFIG" --dry-run 2>&1 | tail -5     # ✔ config + migration parse
```

## 3. Deploy  🔴 (outward-facing — applies migration v1)

> 🔴 Human go — deploy publishes the endpoint **and runs migration v1** (creates the class). Print the
> migration + URL, then deploy. Exposure via `routes`/`workers_dev` (see `service.cloudflare.md` §4).

```bash
command wrangler deploy --config "$CONFIG" --message "Source=realtime.cloudflare.md PlanVersion=$PLAN_VERSION v1"
# → Live State: URL; migration v1 applied; status: live
```

## 4. Migrations lifecycle  🟡  *(the first-class concern — append-only, applied on deploy)*

> A class change is a **new migration tag**, never an edit to a shipped one. The verbs:

```jsonc
// add a class:        { "tag": "v2", "new_sqlite_classes": ["LiveSession"] }
// rename a class:     { "tag": "v2", "renamed_classes": [ { "from": "DirectorSession", "to": "Director" } ] }
// delete a class:     { "tag": "v2", "deleted_classes": ["OldSession"] }   // 💥 DROPS all that class's objects
```
```bash
# append the new tag to "migrations" in $CONFIG, then redeploy — v1 object state is preserved across v2.
command wrangler deploy --config "$CONFIG" --message "migration v2"
```
> → Live State: record each applied migration tag. Acceptance 4 checks v1 state survives v2.

## Acceptance verify  ✔  *(the shared contract)*

```bash
URL="https://director-dev.<subdomain>.workers.dev"   # realized in §3
curl -sS -X POST "$URL?room=a" -H 'Content-Type: application/json' -d '{"k":"x","v":"1"}' >/dev/null
curl -sS "$URL?room=a&k=x" | grep -q '"v":"1"' && echo "1: persists OK"     # write then read, same id
curl -sS "$URL?room=b&k=x" | grep -q '"v":null' && echo "2: ids isolated OK" # different id → no state
# 3 (websocket-room): connect two clients to ?room=a, send on one, assert the other receives (use a ws client).
```
> → write Live State: status: live; fill verify rows. Test 2 is the **negative** — a second object id leaking
> the first's state would be a consistency/isolation bug, which is exactly what this plan exists to prevent.

## Live State

```yaml
status:        not-created      # published template - run it to realize state
last_action:   authored — Durable Objects coordination (pattern/backing/alarms knobs + migrations lifecycle)
last_verified: —
resolved_inputs: { pattern: session, backing: sqlite, alarms: none, routing: workers-dev, env: dev }
migrations_applied: []        # append each tag as it deploys (v1, v2, …)
```

| key         | value (filled on apply) |
|-------------|-------------------------|
| WORKER_NAME | `${SVC}-${ENV}` |
| CLASS_NAME  | `DirectorSession` |
| URL         | `—` |
| migrations  | `—` (v1, …) |

| ✔ check                     | expected                                   | observed | result |
|-----------------------------|--------------------------------------------|----------|--------|
| state persists (same id)    | write then read returns it                  | —        | — |
| ids isolated (negative)     | object B must **not** see object A's state  | —        | — |
| room fan-out (websocket)    | message reaches a second client             | —        | — |
| v2 migration keeps v1 state | redeploy applies v2; v1 objects intact      | —        | — |

## Update (idempotent reconcile)

- New handler code (no class change) → redeploy (no new migration).
- Schema/class change → **append a migration tag** (§4), redeploy. Never edit a shipped tag.
- Rename/delete a class → `renamed_classes`/`deleted_classes` migration (delete 💥 drops that class's objects).

## Teardown (observe-first, resumable)  💥

> 💥 Human go. Deleting the Worker removes the DO namespace **and all object state**. Observe first; this is
> irreversible for the stored data.

```bash
command wrangler delete --config "$CONFIG"           # removes the Worker + all Durable Objects of its classes
```
```bash
command wrangler deployments list --name "$WORKER_NAME" 2>&1 | grep -qi "not found\|no deployments" && echo "DO worker gone"  # ✔
```
> → Live State: status: gone; clear realized ids + migrations.

---

## Portability ledger — same intent, different bindings

| | AWS (`realtime.aws.md`, API GW WS + DynamoDB) | Cloudflare (`realtime.cloudflare.md`, Durable Objects) |
|---|---|---|
| Strong per-entity consistency | DynamoDB conditional writes + careful design | **native** — one single-threaded object per id |
| Websocket fan-out | API GW WebSocket + connection table + IAM | `ctx.acceptWebSocket` + `getWebSockets()` — hibernatable |
| Where state lives | external store (DynamoDB) | **inside the object** (`ctx.storage.sql` SQLite) |
| Scheduled per-entity work | EventBridge / DynamoDB TTL | DO **alarms** (`setAlarm`/`alarm()`) |
| Schema lifecycle | table migrations, separate | **migrations applied at deploy** (`new/renamed/deleted_*classes`) |
| Wiring | IAM + ARNs + connection mgmt | `idFromName` → `get` → `fetch` — by name |
| Tags | resource tags | **none** — naming + `[vars]` |

## Deliberately not included

- **DO as a mere status store** — that's the `STATE_STORE=durable-object` knob in `task-runner.cloudflare.md`;
  this plan is DO-as-primary (the object *is* the app component), incl. websockets + migrations.
- **Point-in-Time Recovery / storage export** — DO SQLite PITR is a backup concern, a follow-on.
- **Cross-object transactions** — DOs are isolated by design; coordinating many is an application pattern
  (a coordinator DO), not a binding.
- **RPC entrypoints (`WorkerEntrypoint`)** — class-based RPC between Workers; a composition concern.
