Back to Claude Mem

Phase 5: Two-Lane Multi-Device Sync (per-user Durable Object log)

plans/2026-07-17-phase5-two-lane-sync.md

13.12.223.9 KB
Original Source

Phase 5: Two-Lane Multi-Device Sync (per-user Durable Object log)

Date: 2026-07-17 Status: Planned — Phase 0 discovery complete (three subagents, live docs + repo extraction) Decision record: Four-architecture adversarial competition (HTTP-only / two-lane hybrid / socket-native / off-the-shelf engines) → verdict: two-lane hybrid — HTTP carries everything durable, WebSocket is a downstream-only advisory speed layer. All four advocates independently converged on the same storage core (per-user ordered log, monotonic seq, cursor resume, idempotent apply, rev for mutations, epoch guard), so the log is settled and only transport was ever in dispute. Off-the-shelf engines were rejected by their own advocate (ElectricSQL is read-path-only, Zero rejects offline writes, cr-sqlite dead 30 months). Hosting: Cloudflare Workers Paid + one SQLite-backed DO per user — validated $5/mo (100 users), ~$15 (1k), ~$230 (10k); dormant users cost ~$0.

Prime directives inherited from the decision record:

  1. The mid-dev batch lane is NOT a floor. What survives is what four adversarial advocates independently kept: DB-as-queue outbox, fail-closed device identity, size clamps, debounced nudge. What dies: the Vercel per-kind endpoints, toCloud mappers' overwrite-upsert races, stampGuardSql machinery, the live|backfill lane heuristic.
  2. Nothing durable ever rides the socket. HTTP cursor is the sole source of truth; the WS may be wrong, dropped, or disabled with zero data loss.
  3. Session start always pulls immediately (the moment memory is injected is the latency budget's one hard spend).
  4. Never long-poll a DO (a held request defeats hibernation ≈ $4/device/mo — same trap, different hat).
  5. Cost guardrails are structural: watchdog → poll mode, never "stop working."

Phase 0: Documentation Discovery — CONSOLIDATED FINDINGS

Three subagents completed 2026-07-17 (live Cloudflare docs via raw <url>index.md; repo extraction on branch release/v13.11.0; Bun/vitest tooling). Executors: do NOT re-research; copy from the anchors below. Full agent reports are in the session transcript; every claim below carries its source.

0.1 Allowed Cloudflare APIs (live-fetched; do not invent others)

APIExact formSource
DO classimport { DurableObject } from "cloudflare:workers"; class SyncHub extends DurableObject<Env>; constructor(ctx, env) { super(ctx, env) }durable-objects/api/base/
Routingenv.SYNC_HUB.getByName(userId) (one-step; replaces idFromName+get). RPC for data endpoints; DO fetch() ONLY for the WS upgrade (return stub.fetch(request))best-practices/create-durable-object-stubs-and-send-requests/
SQLthis.ctx.storage.sql.exec(query, ...bindings) → cursor with .toArray(), .one(), .raw(), rowsRead/rowsWritten. Consume cursors synchronously — never across await. No BEGIN via exec; use ctx.storage.transactionSync(cb) (sync callback). Schema init in constructor via ctx.blockConcurrencyWhile()api/sqlite-storage-api/
Hibernating WSthis.ctx.acceptWebSocket(server, tags?); class methods webSocketMessage(ws, msg), webSocketClose(ws, code, reason, wasClean), webSocketError(ws, err); this.ctx.getWebSockets(tag?); ws.serializeAttachment(obj) (16 KB max — store keys, not state) / deserializeAttachment(); upgrade: new WebSocketPair()acceptWebSocket(server)new Response(null, { status: 101, webSocket: client })best-practices/websockets/ + examples/websocket-hibernation-server/
Keepalivethis.ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong")) (≤2,048 chars each; answered without waking the DO). Protocol-level pings are auto-ponged by the runtime, also without wakingapi/state/ + best-practices/websockets/
Alarmsawait this.ctx.storage.setAlarm(msEpoch) (one per DO; each setAlarm = 1 billed row write); handler alarm(info?: {retryCount, isRetry}) — at-least-once, must be idempotent, must reschedule itself; always getAlarm()-check before setting in the constructorapi/alarms/
WranglerMinimal keys name/main/compatibility_date. DO binding + either migrations: [{tag:"v1", new_sqlite_classes:["SyncHub"]}] (example-page flow) or the newer exports: {"SyncHub": {type:"durable-object", storage:"sqlite"}} — pick ONE flow. Scaffold: npm create cloudflare@latest -- durable-object-starterreference/durable-objects-migrations/ + workers/wrangler/configuration/

Hard limits that shape the design (platform/limits pages): 10 GB SQLite per DO; 2 MB max row; 100 KB max statement; 100 bound params per query (chunk multi-row INSERTs at ≤ ~12 rows × 8 cols); 32 MiB max incoming WS message; 128 MB isolate memory (⇒ replay must paginate, never buffer 50 MB); ~1,000 req/s soft limit per DO; incoming WS messages billed 20:1, outgoing free.

0.2 Repo anchors (exact file:line, verified on this branch)

NeedAnchor
Migration chain append pointsrc/services/sqlite/SessionStore.ts:111 (last call in constructor chain; claim v41 — v40 is highest)
Column-add migration templateensureSyncedAtColumns SessionStore.ts:447-474 (PRAGMA check → ALTER → partial index → version row as bookkeeping)
Version-gated one-shot templaterequeuePromptCloudSyncAfterMapperFix SessionStore.ts:488-500
Mutation site (a): worktree remapWorktreeAdoption.ts:210-215 (merged_into_project on observations + session_summaries, inside tx 224-225). Opens its own DB connection (line 185) — cannot reach notify(); rev-bump must be pure SQL here
Mutation site (b): cwd remapProcessManager.ts:312-314 (project on sdk_sessions/observations/session_summaries, tx 317-326). Also its own connection (line 276)
Mutation site (c): custom titleSessionStore.ts:1922-1927 (only writer of custom_title, inside createSDKSession)
Mutation site (d): prompt→session repairrequeuePromptSync SessionStore.ts:1492-1497; callers at 1481 (updateMemorySessionId) and 1526 (ensureMemorySessionIdRegistered)
Apply machinerystoreObservations SessionStore.ts:2060-2176 (tx, content-hash ON CONFLICT ... DO NOTHING RETURNING id, overrideTimestampEpoch param exists — use it to preserve remote created_at_epoch); storeSummary 2014-2058; saveUserPrompt 1940-1954
FTSTrigger-based — direct INSERTs auto-index. observations/summaries FTS + triggers: SessionSearch.ts:76-152; user_prompts FTS: SessionStore.ts:867-895. Gracefully absent without FTS5
Chroma ingestionsyncObservation ChromaSync.ts:375-427, syncSummary 429-474, syncUserPrompt 492-530; fire-and-forget .then().catch() pattern at ResponseProcessor.ts:296-318
Route class templateCloudSyncRoutes src/services/worker/http/routes/CloudSyncRoutes.ts:15-32; base is src/services/worker/http/BaseRouteHandler.ts (NOT under routes/); late registration at worker-service.ts:534
Settings keysInterface SettingsDefaultsManager.ts:67-73 + DEFAULTS 158-163; extend CloudSyncSettingKeys Pick at CloudSync.ts:184-190
Timer idiomnotify() CloudSync.ts:297-313 (.unref?.() at 305); scheduleRetry 493-503; single-flight flush 320-349; stopped re-checks 399/413/418/434/442
Service-class shapeTranscriptWatcher watcher.ts:86-111 (constructor/start/stop); startup kick site worker-service.ts:612
Session-start pull hookSearchRoutes.handleContextInject SearchRoutes.ts:278 — slot the pull between ~317 (dynamic import) and 332 (generateContextWithStats). Session-init: SessionRoutes.ts:385-562, existing nudge at 493
Test harnesstests/worker/sync/cloud-sync.test.ts — makeFetchMock 24-40, makeCloudSync 59-73 (injected fetchImpl, fast debounce/backoff), stampGuard regression test 370-409. Runner: bun test (package.json:98)
Build caveatbuild-hooks.js is entrypoint-based (new src/ code reached from worker-service.ts bundles automatically). sync-marketplace.cjs:78 rsyncs the repo root — a new top-level workers/ dir will be copied into the installed plugin unless added to the exclude list
WS precedentNone in src/ (zero hits) — the client is greenfield; SSE + TranscriptWatcher are the nearest analogues

0.3 Tooling facts (live-fetched)

  • Bun WS client supports auth headers natively (Bun-specific extension): new WebSocket(url, { headers: { Authorization, "X-Device-Id" } }); also ping(), pong(), terminate(), bufferedAmount, tls, proxy options. Source: bun.com/docs/runtime/http/websockets + bun.com/reference/bun/WebSocketOptions. No ws npm dependency needed.
  • Verified keepalive pair: Bun client sends protocol ws.ping() every 30–45 s → CF runtime auto-pongs without waking the DO (documented). Additionally set setWebSocketAutoResponse("ping","pong") for future browser clients (browsers can't send protocol pings). GAP flagged by discovery: Bun's auto-pong to server pings is unverified in docs — irrelevant here because the client initiates.
  • Testing (2026 shape — training-data snippets are stale): @cloudflare/vitest-pool-workers ≥0.18 uses a Vite plugin: plugins: [cloudflareTest({ wrangler: { configPath: "./wrangler.jsonc" } })] with Vitest ≥4.1 (defineWorkersConfig was removed in v0.13). Test APIs from "cloudflare:test": runInDurableObject(stub, (instance, state) => ...), runDurableObjectAlarm(stub), evictDurableObject(stub, { webSockets: "hibernate" }). Known issue: WS + DO tests require --max-workers=1 --no-isolate — keep WS tests in a separate vitest invocation. wrangler dev serves ws://localhost:8787 locally (SQLite DOs do NOT work with --remote).
  • Lint guards: ESLint flat config no-restricted-globals (setTimeout/setInterval) + no-restricted-syntax (selector CallExpression[callee.property.name='accept'], bare fetch() scoped to the DO source glob — PLUS a dumb grep -rn CI step (catches globalThis.setTimeout evasion).

0.4 Anti-pattern list (verified against live docs — these are the $4.11/user/mo + $34k traps)

  1. server.accept() + addEventListener (legacy WS API) — defeats hibernation. Only ctx.acceptWebSocket() + class handler methods.
  2. setTimeout/setInterval anywhere in the DO — pins it awake. Alarms only, and only when there's work.
  3. No outbound I/O from the DO — at all. June 19 2026 change: outbound connect()/WebSocket pins the DO up to 15 min per connection; awaited outbound fetch() blocks idling. Token verification, upstream calls, everything happens in the stateless Worker in front.
  4. setAlarm() in the constructor without a getAlarm() check — the documented footgun behind the $34k runaway.
  5. In-memory maps as socket state — lost on hibernation. Attachments (≤16 KB) hold keys; SQLite holds state; constructor rebuilds from getWebSockets().
  6. Buffering a full replay in memory (128 MB isolate) — paginate /changes, chunk WS pushes.
  7. Long-poll / held HTTP requests on the DO — same as #2 economically.

Phase 1: The Sync Hub (Worker + per-user DO, HTTP lanes only)

Goal: greenfield workers/sync-hub/ wrangler project — per-user ordered log + two HTTP endpoints. No sockets in this phase.

Tasks:

  1. Scaffold with npm create cloudflare@latest -- durable-object-starter into workers/sync-hub/ (own package.json; NOT reached by build-hooks.js — that's correct, it deploys via wrangler, not the plugin bundle). Add workers/ to the sync-marketplace.cjs exclude list (sync-marketplace.cjs:78) so the installed plugin doesn't ship it.
  2. wrangler.jsonc: copy the hibernation-example config shape (Phase 0.1 wrangler row) — binding SYNC_HUB → class SyncHub, migrations: [{tag:"v1", new_sqlite_classes:["SyncHub"]}], compatibility_date current.
  3. SyncHub extends DurableObject<Env>: schema init in constructor via blockConcurrencyWhile (copy shape from sqlite-storage-api "SQL API" section):
    sql
    CREATE TABLE IF NOT EXISTS ops (
      seq           INTEGER PRIMARY KEY AUTOINCREMENT,
      kind          TEXT NOT NULL,            -- 'observation'|'summary'|'prompt'|'mutation'
      origin_device TEXT NOT NULL,
      origin_id     TEXT NOT NULL,            -- device-local rowid; op UUID for mutations
      rev           INTEGER NOT NULL DEFAULT 1,
      body          TEXT NOT NULL,            -- canonical row JSON (opaque-able later for E2E)
      server_ts     INTEGER NOT NULL
    );
    CREATE UNIQUE INDEX IF NOT EXISTS ops_entity ON ops(origin_device, kind, origin_id, rev);
    CREATE TABLE IF NOT EXISTS devices (device_id TEXT PRIMARY KEY, name TEXT, last_ack_seq INTEGER DEFAULT 0, last_seen INTEGER);
    CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT);  -- epoch, counters
    
  4. RPC methods (not fetch — Phase 0.1 routing row): pushOps(deviceId, ops[]) → {acked:[{origin_id, rev, seq}], head_seq} (idempotent via the unique index — duplicate push returns the existing seq; INSERTs chunked ≤100 bound params; per-row body ≤2 MB enforced with the client-side clamp as backstop) and getChanges(sinceSeq, limit≤500) → {epoch, ops[], head_seq, more} (cursor .toArray() consumed synchronously — Phase 0.1 SQL row).
  5. Stateless Worker in front: parses Authorization: Bearer + X-User-Id + X-Device-Id (same headers as today — CloudSync.ts:448-466), verifies the token, routes env.SYNC_HUB.getByName(userId). Token verification lives here, never in the DO (anti-pattern #3): call cmem.ai's verify endpoint and cache the verdict in Workers KV with a short TTL. Routes: POST /v1/sync/ops, GET /v1/sync/changes, GET /v1/sync/status.
  6. Mutation ops (Phase 3 writes them; hub must accept them now): kind='mutation', body {op: 'set_title'|'set_prompt_session'|'remap_project', target|where, fields}. Predicate sanity cap: refuse remap_project ops from a device if the hub can't parse them — validation only; application happens client-side.
  7. Compaction alarm (daily): delete ops superseded by a higher rev of the same entity below MIN(devices.last_ack_seq). Copy the alarm scheduler shape from api/alarms/ "Example"; getAlarm()-check first (anti-pattern #4); handler idempotent and self-rescheduling.

Verification:

  • Vitest per Phase 0.3: runInDurableObject tests — push idempotency (same op twice → same seq), cursor pagination (600 ops → 2 pages), rev supersession, chunked-insert correctness at 100-param boundary, alarm via runDurableObjectAlarm.
  • wrangler dev + curl localhost:8787/v1/sync/ops / /changes round-trip.
  • Greps: grep -rn "setTimeout\|setInterval\|\.accept()\|fetch(" workers/sync-hub/src/do/ → only allowed hits (none).

Anti-pattern guards: items 2/3/4/6/7 of Phase 0.4; ESLint flat-config block from Phase 0.3 committed with the scaffold.


Phase 2: Client schema + apply path (migration v41)

Goal: local SQLite learns origins, revisions, and a cursor; incoming remote ops apply through the same hooks native writes use.

Tasks:

  1. Migration v41 — copy ensureSyncedAtColumns (SessionStore.ts:447-474) shape; append call after line 111. Adds to observations/session_summaries/user_prompts: origin_device_id TEXT, origin_local_id TEXT, sync_rev INTEGER DEFAULT 1; unique index (origin_device_id, kind-implicit-per-table, origin_local_id) where origin is non-null; new table sync_state (k TEXT PRIMARY KEY, v TEXT) holding cursor and epoch. Native rows: origin columns NULL (self).
  2. SyncApply module: applyOps(ops[]) in one transaction — remote row ops upsert via INSERT keyed on origin identity, pre-stamped synced_at = now (echo guard: the push drain's WHERE synced_at IS NULL structurally cannot re-push them), overrideTimestampEpoch preserved from body.createdAtEpoch; mutation ops apply as UPDATEs matched on origin identity (or the remap predicate), guarded incoming.rev >= local.sync_rev; cursor advanced in the same transaction (crash-safe exactly-once). Skip ops where origin_device == self. FTS updates via existing triggers automatically (Phase 0.2 FTS row); Chroma via the fire-and-forget pattern (ResponseProcessor.ts:296-318 shape) after commit.
  3. Epoch guard: getChanges epoch ≠ stored epoch → reset cursor to 0, re-pull (idempotent by design).

Verification: bun test — new tests copying the cloud-sync.test.ts harness (in-temp-dir SessionStore): apply idempotency (same batch twice → identical DB), cursor-with-rows atomicity (throw mid-batch → cursor unmoved), echo guard (applied rows never selected by the drain query), rev guard (stale mutation ignored), FTS row present after apply (when FTS5 available).

Anti-pattern guards: no Date.now() as row identity; never apply with synced_at NULL; no schema change without the PRAGMA-check pattern.


Phase 3: Push retarget + pull loop + rev bumps (ships the complete highway at poll latency)

Tasks:

  1. Retarget the existing drain: keep notify()/debounce/single-flight/backoff/clamps/device-identity (CloudSync.ts:276-313, 320-349, 493-511, 529-594 survive), replace the three per-kind pushBatch targets with one POST /v1/sync/ops carrying {kind, origin_id: String(localId), rev: sync_rev, body} per row. Delete: KINDS[*].toCloud per-kind endpoints, stampGuardSql/stampGuard (SessionStore's requeuePromptSync now emits a set_prompt_session mutation op instead — ordering makes the guard unnecessary), the lane heuristic (392-393). Stamp synced_at on ack.
  2. Rev bumps at the four mutation sites (Phase 0.2 anchors): custom title (SessionStore.ts:1922-1927) and prompt repair (1492-1497) run inside the worker — bump sync_rev, null synced_at, enqueue the mutation op, notify(). The two remap sites (WorktreeAdoption.ts:210-215, ProcessManager.ts:312-314) open their own DB connections — pure SQL there: bump + null in the same statements; the worker's next startup drain (worker-service.ts:612) or next notify() picks them up.
  3. Pull loop in a SyncClient service (TranscriptWatcher shape, watcher.ts:86-111): poll GET /v1/sync/changes — 30 s while a session is active, 5 min idle, suspend after 1 h of no sessions; every push response piggybacks head_seq (free poll for the active device). .unref?.() on every timer (CloudSync.ts:305 idiom). Start beside the cloud-sync kick at worker-service.ts:612.
  4. Immediate session-start pull: in SearchRoutes.handleContextInject (SearchRoutes.ts:278), before generateContextWithStats (~line 332), await syncClient.pullOnce({ timeoutMs: 1500 }) — bounded so a dead network can't stall context injection.
  5. Pre-launch supersession note (2026-07-20): the historical hub-cutover requeue remains in place during Phase 0. The canonical pre-launch Postgres-to-Turbopuffer plan removes requeueAllForHubCutover and legacy state adoption in its Phase 2, after the v2 path is ready; this old task is not a Phase 0 cleanup requirement.

Verification: two SessionStores + one real wrangler dev hub (or the vitest SELF binding): write on A → flush → pull on B → row present with preserved epoch, FTS row exists, no echo (A's drain empty after B applies); title/remap/repair mutations converge on B; kill hub mid-flush → rows stay queued → recover on restart (backoff test shape from cloud-sync.test.ts:370-409). Full suite: bun test green; npm run build-and-sync boots the worker.

Anti-pattern guards: directive #4 (no long-poll — plain short polls only); pull failures must never block writes (same swallow-everything contract as notify(), CloudSync.ts:307-312).


Phase 4: Advisory WebSocket (the speed layer)

Tasks:

  1. Hub: GET /v1/sync/ws upgrade through the Worker → DO fetch() (the one non-RPC path). Copy the hibernation example verbatim (Phase 0.1 WS row): WebSocketPairctx.acceptWebSocket(server) → 101; serializeAttachment({device_id}) only; setWebSocketAutoResponse("ping","pong"). On committed push: fan out {type:'op', seq, ...} frames to all sockets except the origin device (batch 50–100 ms per best-practices), or a bare {type:'advance', head_seq} when a push exceeds one page.
  2. Client: Bun new WebSocket(url, { headers }) (Phase 0.3) inside SyncClient. Socket is ADVISORY: on op/advance frames where seq == cursor+1 apply directly, else — or on any parse anomaly — close and run one HTTP pullOnce() (the lane-2 self-heal). Protocol ws.ping() every 40 s; reconnect with full-jitter backoff (1 s base, 60 s cap); connected ⇒ poll interval stretches to the idle tier.
  3. Debounce drops 1500 ms → 250 ms when the socket is live (fan-out is now push; debounce dominates latency).

Verification: vitest WS suite (separate invocation, --max-workers=1 --no-isolate — Phase 0.3 known issue): upgrade, fan-out excludes origin, evictDurableObject({webSockets:"hibernate"}) then message → attachment-restored delivery; e2e on wrangler dev: two Bun clients, write on A → B applies < 2 s; kill B's socket mid-stream → B converges via HTTP on reconnect.

Anti-pattern guards: the full Phase 0.4 list is now live — ESLint block + CI grep land in the same PR as the first socket code. The socket handler contains no storage-independent state, no timers, no outbound I/O, and nothing whose failure loses data (delete the socket path entirely and Phase 3 still passes — that's the acceptance test of "advisory").


Phase 5: Guardrails + monitoring (first-class, not afterthought)

Tasks:

  1. Watchdog: scheduled Worker (cron, hourly) querying the GraphQL Analytics API for the DO namespace — duration GB-s (expected ≈ 0; the hibernation-defeat detector), rows read/written (the runaway detector), request count (~messages/20). Thresholds from the validated workload model; alert via the existing Discord webhook rails (scripts/discord-release-notify.js pattern, creds in ~/Scripts/claude-mem/.env).
  2. Kill switch: KV flag checked by the front Worker — tripped ⇒ refuse WS upgrades + X-Sync-Mode: poll on HTTP responses; clients fall back to Phase 3 polling (product stays complete, ~$0.03/user/mo indefinitely).
  3. Canary: one synthetic user, two fake devices, trickle writes 24/7; its DO's duration metric is a known constant — a hibernation regression shows within hours, not on the invoice.
  4. CI: the ESLint block + grep -rn "setTimeout\|setInterval\|\.accept()\|fetch(\|connect(" workers/sync-hub/src/do/ as a required check; weekly invoice-glance scheduled agent (Discord ping only on delta).

Verification: trip each threshold artificially (canary flood; forced non-hibernating build on a test account) and confirm alert + kill-switch + client fallback end-to-end.


Phase 6: Final verification + horizon

  1. Full-matrix e2e: fresh device bootstrap (since=0), week-offline catch-up, concurrent two-device writes, all four mutation types, epoch reset, kill-switch degradation — all on wrangler dev + two real worker daemons.
  2. Anti-pattern sweep: the Phase 0.4 greps across workers/; bun test full suite; npm run build-and-sync; confirm the marketplace rsync excludes workers/ (inspect ~/.claude/plugins/marketplaces/thedotmack/ after sync).
  3. Documentation: update docs/public/ cloud-sync pages (Mintlify auto-deploys from main).
  4. Horizon (explicitly out of scope, recorded so nobody re-litigates): dashboard as a /changes client; E2E encryption (bodies become opaque blobs — the hub never parses body except mutation envelopes); team corpora (corpus-keyed DO, per-member last_ack_seq); Electric read-path adoption ONLY if Postgres ever lands server-side.

Open decisions for the maintainer

  1. Dead-device history import (Phase 3.5): accept loss vs. one-time cmem.ai-side import script. Recommendation: import.
  2. Old lane during rollout: hard cutover via CLAUDE_MEM_CLOUD_SYNC_HUB_URL (recommended — the re-push migration rebuilds the log) vs. temporary dual-push. Dual-push doubles write cost for no correctness gain.
  3. Wrangler config flow: migrations (matches all current example pages) vs. exports (the declared successor). Recommendation: exports on the fresh scaffold; it's the forward path and this project has no legacy namespaces.