plans/2026-07-17-phase5-two-lane-sync.md
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:
toCloud mappers' overwrite-upsert races, stampGuardSql machinery, the live|backfill lane heuristic.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.
| API | Exact form | Source |
|---|---|---|
| DO class | import { DurableObject } from "cloudflare:workers"; class SyncHub extends DurableObject<Env>; constructor(ctx, env) { super(ctx, env) } | durable-objects/api/base/ |
| Routing | env.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/ |
| SQL | this.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 WS | this.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/ |
| Keepalive | this.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 waking | api/state/ + best-practices/websockets/ |
| Alarms | await 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 constructor | api/alarms/ |
| Wrangler | Minimal 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-starter | reference/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.
| Need | Anchor |
|---|---|
| Migration chain append point | src/services/sqlite/SessionStore.ts:111 (last call in constructor chain; claim v41 — v40 is highest) |
| Column-add migration template | ensureSyncedAtColumns SessionStore.ts:447-474 (PRAGMA check → ALTER → partial index → version row as bookkeeping) |
| Version-gated one-shot template | requeuePromptCloudSyncAfterMapperFix SessionStore.ts:488-500 |
| Mutation site (a): worktree remap | WorktreeAdoption.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 remap | ProcessManager.ts:312-314 (project on sdk_sessions/observations/session_summaries, tx 317-326). Also its own connection (line 276) |
| Mutation site (c): custom title | SessionStore.ts:1922-1927 (only writer of custom_title, inside createSDKSession) |
| Mutation site (d): prompt→session repair | requeuePromptSync SessionStore.ts:1492-1497; callers at 1481 (updateMemorySessionId) and 1526 (ensureMemorySessionIdRegistered) |
| Apply machinery | storeObservations 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 |
| FTS | Trigger-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 ingestion | syncObservation ChromaSync.ts:375-427, syncSummary 429-474, syncUserPrompt 492-530; fire-and-forget .then().catch() pattern at ResponseProcessor.ts:296-318 |
| Route class template | CloudSyncRoutes 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 keys | Interface SettingsDefaultsManager.ts:67-73 + DEFAULTS 158-163; extend CloudSyncSettingKeys Pick at CloudSync.ts:184-190 |
| Timer idiom | notify() 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 shape | TranscriptWatcher watcher.ts:86-111 (constructor/start/stop); startup kick site worker-service.ts:612 |
| Session-start pull hook | SearchRoutes.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 harness | tests/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 caveat | build-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 precedent | None in src/ (zero hits) — the client is greenfield; SSE + TranscriptWatcher are the nearest analogues |
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.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.@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).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).server.accept() + addEventListener (legacy WS API) — defeats hibernation. Only ctx.acceptWebSocket() + class handler methods.setTimeout/setInterval anywhere in the DO — pins it awake. Alarms only, and only when there's work.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.setAlarm() in the constructor without a getAlarm() check — the documented footgun behind the $34k runaway.getWebSockets()./changes, chunk WS pushes.Goal: greenfield workers/sync-hub/ wrangler project — per-user ordered log + two HTTP endpoints. No sockets in this phase.
Tasks:
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.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.SyncHub extends DurableObject<Env>: schema init in constructor via blockConcurrencyWhile (copy shape from sqlite-storage-api "SQL API" section):
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
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).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.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.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:
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.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.
Goal: local SQLite learns origins, revisions, and a cursor; incoming remote ops apply through the same hooks native writes use.
Tasks:
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).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.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.
Tasks:
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.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.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.SearchRoutes.handleContextInject (SearchRoutes.ts:278), before generateContextWithStats (~line 332), await syncClient.pullOnce({ timeoutMs: 1500 }) — bounded so a dead network can't stall context injection.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).
Tasks:
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): WebSocketPair → ctx.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.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.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").
Tasks:
scripts/discord-release-notify.js pattern, creds in ~/Scripts/claude-mem/.env).X-Sync-Mode: poll on HTTP responses; clients fall back to Phase 3 polling (product stays complete, ~$0.03/user/mo indefinitely).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.
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.workers/; bun test full suite; npm run build-and-sync; confirm the marketplace rsync excludes workers/ (inspect ~/.claude/plugins/marketplaces/thedotmack/ after sync).docs/public/ cloud-sync pages (Mintlify auto-deploys from main)./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.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.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.