feat(sync): complete SyncHub-only client cutover

Retire legacy client adoption and per-kind sync state, establish the one-time launch boundary, and expose authenticated Hub health. Add bounded device metadata and a loopback-only two-client protocol-v2 matrix E2E.
This commit is contained in:
Alex Newman
2026-07-20 09:30:22 -07:00
parent 1e3b2853ff
commit b3eafc82b9
41 changed files with 3296 additions and 2328 deletions
+35 -36
View File
@@ -44,14 +44,16 @@ that content must stay on your machine.
**The database is the queue.** Every synced table carries a `synced_at` column
(`NULL` = not in the log yet). After each write, the worker nudges a debounced
flusher that drains `WHERE synced_at IS NULL` and stamps rows on success. That
one mechanism **is** live sync, backfill, offline catch-up, and retry — a
never-synced install simply has everything `NULL`, and anything that fails to
upload stays `NULL` until a later flush picks it up.
one mechanism handles live sync, offline catch-up, and retry. Rows written
after the SyncHub launch boundary start as `NULL`; anything that fails to
upload stays `NULL` until a later flush picks it up. Pre-launch local rows are
not treated as a cloud migration corpus.
- **Debounced:** write bursts coalesce; the flusher runs ~1.5 s after the
last write (250 ms while the speed layer is connected), and only one
flush runs at a time.
- **Batched:** ops drain in requests of up to 500 ops / 2 MB.
- **Batched:** ops drain in requests of up to 500 ops / 4,000,000 encoded
bytes. Each individual canonical body is capped at 256,000 encoded bytes.
- **Timeboxed:** every request has a 30 s timeout — a dead network can
never hang the worker.
- **Retrying:** a failed upload leaves rows `NULL` and retries on the next
@@ -109,10 +111,8 @@ turn sync off.
Run the `/cloud-sync` skill in Claude Code to check status and walk through
setup. It writes the settings into `~/.claude-mem/settings.json` (mode
`0600`) without ever echoing the token, restarts the worker, and polls status
until the pending counts fall. If you previously used the legacy standalone
sync client, the skill retires it and carries your device identity over — see
[Migrating from the standalone client](#migrating-from-the-standalone-client).
`0600`) without ever echoing the token, restarts the worker, and verifies the
installed client can reach SyncHub.
## Settings
@@ -122,9 +122,13 @@ sync client, the skill retires it and carries your device identity over — see
| `CLAUDE_MEM_CLOUD_SYNC_USER_ID` | `''` | Your cmem.ai user id (from **cmem.ai → Connect**). All your devices share it — it names your hub log. |
| `CLAUDE_MEM_CLOUD_SYNC_HUB_URL` | `''` | Sync hub base URL. **Empty = sync off.** |
| `CLAUDE_MEM_CLOUD_SYNC_WS` | `'true'` | Advisory WebSocket speed layer. `'false'` = HTTP polling only; sync stays fully correct at poll latency. |
| `CLAUDE_MEM_CLOUD_SYNC_DEVICE_ID` | `''` | Stable identity of this machine. Resolved at first start — adopted from the legacy client's state file if present, otherwise a fresh UUID — then persisted back here. Don't edit it: every row is attributed to its origin device, and a changed id forks every previously synced row into a duplicate. |
| `CLAUDE_MEM_CLOUD_SYNC_DEVICE_ID` | `''` | Stable identity of this machine. A fresh UUID is minted and persisted on first start. Don't edit it: every row is attributed to its origin device. |
| `CLAUDE_MEM_CLOUD_SYNC_DEVICE_NAME` | machine hostname | Human-readable label for this device. |
| `CLAUDE_MEM_CLOUD_SYNC_URL` | (legacy) | The pre-hub per-kind endpoint base. Unused since the hub cutover; kept only so existing settings files round-trip. |
A Hub accepts at most 64 distinct device ids per account. Existing devices
continue to sync normally at the limit; a new device receives
`409 device_limit_exceeded`. Status/metadata reads and renaming an unknown
device do not create phantom devices.
## Status endpoint
@@ -143,44 +147,39 @@ Configured:
{
"configured": true,
"deviceId": "2f6b1c9e-7d41-4c1a-9b0e-3d5f8a2c6e10",
"pending": { "observations": 0, "summaries": 0, "prompts": 2, "mutations": 0 },
"pending": { "observations": 0, "summaries": 0, "prompts": 2, "mutations": 0, "tombstones": 0 },
"quarantine": { "count": 0, "latestReason": null },
"lastFlushAt": 1783981042731,
"lastError": null
"lastError": null,
"hub": {
"checkedAt": 1783981042800,
"reachable": true,
"epoch": "1783981042000",
"headSeq": "42",
"projectedSeq": "42",
"error": null
}
}
```
Every configured status request makes an authenticated, read-only
`GET /v1/sync/status` directly to SyncHub, including when all pending counts
are zero. The probe does not append an operation or advance a pull cursor.
`hub.reachable: false` and `hub.error` therefore expose a bad token, wrong Hub
URL, malformed response, timeout, or network failure
that an empty queue would otherwise hide.
- `pending` — rows (and queued mutation ops) still waiting to upload. Counts
near 0 mean the hub's log has everything this device wrote.
- `lastFlushAt` — epoch ms of the last successful flush, `null` before the first.
- `lastError` — message from the most recent failed flush, `null` when healthy.
It never contains the token.
- `hub` — the most recent authenticated SyncHub probe. Treat
`hub.reachable: true` as the connectivity check; `lastError: null` alone is
not enough. Sequence and epoch fields remain decimal strings.
Not configured:
```json
{ "configured": false }
```
## Migrating from the standalone client
Before sync moved into the worker, cloud sync ran as a separate standalone
client (`cloud-sync.mjs` plus a `.cloud-sync.env` credentials file and a
`cloud-sync.pid` file). The worker supersedes it entirely, so the
`/cloud-sync` skill retires it during setup:
- **Stopped:** if `cloud-sync.pid` points at a live daemon, it is killed.
- **Retired:** `cloud-sync.mjs`, `.cloud-sync.env`, and `cloud-sync.pid` are
renamed with a `.retired` suffix (archived, not deleted). Your token is copied
from `.cloud-sync.env` into `settings.json` first, so setup needs no re-pasting.
- **Preserved:** `~/.claude-mem/cloud-sync-state.json` is deliberately **left
in place** — the worker adopts its `deviceId` into
`CLAUDE_MEM_CLOUD_SYNC_DEVICE_ID` so this machine keeps one identity across
the migration. Renaming or deleting that file before the first worker start
would mint a new device identity and fork every previously synced row into a
duplicate.
<Note>
On first contact with the hub, every device re-pushes its own corpus once
(the hub's dedupe makes this safe) — that is how the shared log is built, and
it doubles as the initial backfill of your existing database.
</Note>
+1
View File
@@ -103,6 +103,7 @@
"test:infra": "bun test tests/infrastructure/",
"test:server": "bun test tests/server/",
"e2e:server:docker": "bash scripts/e2e-server-docker.sh",
"e2e:sync-matrix": "bun scripts/sync-matrix-e2e.ts",
"check:postinstall-allowlist": "node scripts/check-postinstall-allowlist.js",
"smoke:clean-room": "node scripts/smoke-clean-room.cjs",
"prepublishOnly": "npm run build && node scripts/check-postinstall-allowlist.js",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+57 -113
View File
@@ -9,155 +9,99 @@ allowed-tools:
# Cloud Sync (cmem.ai Pro)
The worker syncs memories itself: every write nudges a background flusher that
drains unsynced rows to cmem.ai. There is no daemon to install or babysit. This
skill is a thin front-end — check status, and on first run collect credentials,
retire the old standalone client, and restart the worker so it picks them up.
The installed worker syncs through SyncHub. There is one client, one durable
operation log, and no separate sync daemon. This skill checks status or writes
the three connection values issued by **cmem.ai → Connect**.
**Security rule for this entire skill:** NEVER print the sync token, never put
it in a command-line argument (argv is visible to other processes), and never
log it. It travels only inside heredoc-fed stdin scripts or files that already
hold it. When confirming, report its length — not its value.
**Security rule:** never print the sync token, put it in argv, or log it.
Confirm only its length. Preserve every unrelated setting and keep
`~/.claude-mem/settings.json` mode `0600`.
## 1. Check status
Resolve the worker port (env → `~/.claude-mem/settings.json` → per-UID default
`37700 + (uid % 100)`, matching how the worker picks its own port; re-run this
in any fresh shell before the curls in step 5):
Resolve the worker port and query the always-registered status route:
```bash
PORT="${CLAUDE_MEM_WORKER_PORT:-$(node -e "const fs=require('fs'),p=require('path'),os=require('os');const uid=(typeof process.getuid==='function'?process.getuid():77);const fallback=String(37700+(uid%100));try{const s=JSON.parse(fs.readFileSync(p.join(os.homedir(),'.claude-mem','settings.json'),'utf-8'));process.stdout.write(String(s.CLAUDE_MEM_WORKER_PORT||fallback));}catch{process.stdout.write(fallback);}" 2>/dev/null)}"
curl -s "http://127.0.0.1:${PORT}/api/sync/status"
```
Responses:
- `configured: true` and `hub.reachable: true` → the worker completed an
authenticated `GET /v1/sync/status` against SyncHub. Report `deviceId`,
pending counts, `lastFlushAt`, `lastError`, and the Hub head/checkpoint;
stop unless the user asked to replace the connection.
- `configured: true` and `hub.reachable: false` → report `hub.error` and say
the SyncHub connection is not verified. A zero pending count or
`lastError: null` is not success because an empty queue performs no push.
- `configured: false` → continue.
- Connection refused, 404, or 503 immediately after restart → retry every
three seconds for about 30 seconds before diagnosing the worker.
- `{"configured": true, "deviceId": ..., "pending": {"observations": N, "summaries": N, "prompts": N}, "lastFlushAt": ..., "lastError": ...}` → go to step 2.
- `{"configured": false}` → go to step 3.
- **404 / 503 / connection refused** → the route registers late during worker
startup, so a request right after a restart can miss it. Retry every 3s for
~15s before concluding anything. If 404 persists, the running worker predates
cloud sync — restart it with the command in step 5, wait, and retry.
## 2. Obtain the connection
## 2. Already configured → report and stop
Ask for all three values shown by **cmem.ai → Connect**:
Report the three pending counts, `lastFlushAt`, and `lastError` (null means
healthy). Pending counts near 0 mean the cloud copy is current. Done — do not
run the setup steps below.
1. sync token;
2. user id;
3. SyncHub URL.
## 3. Not configured → obtain credentials
The Hub URL must be an absolute `https://` URL. Do not substitute the cmem.ai
application API URL; the installed client talks only to SyncHub.
Priority order:
## 3. Write installed-client settings
**(a) Legacy standalone client present.** If `~/.claude-mem/.cloud-sync.env`
exists, the user already set up the old standalone sync client. Tell them:
"Found your existing standalone cloud-sync setup — migrating it into the
worker. Your token and device identity carry over; nothing re-uploads." Do NOT
`cat` the file or print its values. Migrate it with this script (it reads the
file itself, so the token never enters the conversation):
```bash
node - <<'EOF'
const fs = require('fs'), os = require('os'), path = require('path');
const dir = path.join(os.homedir(), '.claude-mem');
const env = fs.readFileSync(path.join(dir, '.cloud-sync.env'), 'utf8');
const get = (k) => (env.match(new RegExp('^' + k + '=(.*)$', 'm')) || [])[1]?.trim().replace(/^["']|["']$/g, '') || '';
const token = get('CMEM_SYNC_TOKEN'), userId = get('CMEM_USER_ID');
if (!token || !userId) { console.error('legacy env file is missing CMEM_SYNC_TOKEN or CMEM_USER_ID'); process.exit(1); }
const file = path.join(dir, 'settings.json');
const settings = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {};
settings.CLAUDE_MEM_CLOUD_SYNC_TOKEN = token;
settings.CLAUDE_MEM_CLOUD_SYNC_USER_ID = userId;
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
fs.chmodSync(file, 0o600);
console.log(`migrated: token length ${token.length}, user id length ${userId.length}`);
EOF
```
**(b) No legacy file.** Use AskUserQuestion to ask the user to paste two
values from **cmem.ai → Connect**: their sync token and their user id. Then
write them with the same merge script, embedding the two values as string
literals inside the quoted heredoc (heredoc body is stdin, not argv — the
token stays off the command line; do not echo it back afterward):
Substitute the collected values inside this quoted stdin script. Do not echo
them before or after running it:
```bash
node - <<'EOF'
const fs = require('fs'), os = require('os'), path = require('path');
const token = 'PASTE_TOKEN_HERE';
const userId = 'PASTE_USER_ID_HERE';
const file = path.join(os.homedir(), '.claude-mem', 'settings.json');
const hubUrl = 'PASTE_HUB_URL_HERE';
if (!token || !userId || !/^https:\/\/[^\s]+$/.test(hubUrl)) {
console.error('token, user id, and an https SyncHub URL are required');
process.exit(1);
}
const dir = path.join(os.homedir(), '.claude-mem');
const file = path.join(dir, 'settings.json');
fs.mkdirSync(dir, { recursive: true });
const settings = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {};
settings.CLAUDE_MEM_CLOUD_SYNC_TOKEN = token;
settings.CLAUDE_MEM_CLOUD_SYNC_USER_ID = userId;
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
const target = settings.env && typeof settings.env === 'object' ? settings.env : settings;
target.CLAUDE_MEM_CLOUD_SYNC_TOKEN = token;
target.CLAUDE_MEM_CLOUD_SYNC_USER_ID = userId;
target.CLAUDE_MEM_CLOUD_SYNC_HUB_URL = hubUrl.replace(/\/+$/, '');
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
fs.chmodSync(file, 0o600);
console.log(`saved: token length ${token.length}, user id length ${userId.length}`);
console.log(`saved cloud connection: token length ${token.length}, user id length ${userId.length}`);
EOF
```
Merge rules (both paths, non-negotiable): parse the existing JSON and merge
the two keys in — never clobber other settings, never rewrite the file from a
template, and restore file mode 0600 after writing (both scripts above do
this).
These are the only required connection keys. The worker mints and persists a
device id on first start and defaults the device name to the hostname.
## 4. Retire the legacy daemon
The standalone client is superseded by worker-native sync. If it is still
running it would double-upload, so shut it down and archive its artifacts:
```bash
D="$HOME/.claude-mem"
if [ -f "$D/cloud-sync.pid" ]; then
LEGACY_PID=$(cat "$D/cloud-sync.pid")
if [ -n "$LEGACY_PID" ] && ps -p "$LEGACY_PID" -o command= 2>/dev/null | grep -q cloud-sync; then
kill "$LEGACY_PID"
fi
fi
for f in cloud-sync.mjs .cloud-sync.env cloud-sync.pid; do
[ -f "$D/$f" ] && mv "$D/$f" "$D/$f.retired"
done
ls "$D" | grep retired
```
**Leave `~/.claude-mem/cloud-sync-state.json` exactly where it is.** The
worker's migration stamps already-synced rows from its cursors and adopts its
device id — renaming or deleting it forks every cloud row into a duplicate.
## 5. Restart the worker and watch it drain
The worker reads credentials at startup, so restart it:
## 4. Restart and verify
```bash
curl -s -X POST "http://127.0.0.1:${PORT}/api/admin/restart"
```
The old worker spawns its own successor once its port closes — do not spawn or
kill anything yourself; the POST is the entire restart. Then poll:
Poll the status route every five seconds for up to 30 seconds while the
successor starts. Success means `configured: true`, `hub.reachable: true`, and
`lastError: null`. The local route always makes an authenticated, read-only
SyncHub status probe, even when every pending count is zero; it never uses a
legacy cmem.ai Pro status route and never appends or advances sync state.
Pending counts describe only writes made after the SyncHub launch baseline;
setup does not migrate a pre-launch local corpus.
```bash
curl -s "http://127.0.0.1:${PORT}/api/sync/status"
```
If `hub.reachable` is false, report `hub.error`. If `lastError` is non-null,
report it too. Ask the user to verify the three values in **cmem.ai →
Connect**. Never include the token.
every ~5s. Tolerate connection-refused/404 for the first ~30s (successor
booting, route registering late). Expect `configured: true` with the pending
counts falling as the flusher drains. Stop polling when either:
## 5. Report
- all three pending counts reach 0, or
- the counts stop changing across 3 consecutive polls with `lastError` null —
a large first backfill flushes in batches and can take minutes; report the
current counts and note the worker keeps draining in the background.
If `lastError` is non-null and pending is not moving, report the error text
verbatim (it never contains the token) and suggest re-checking the token and
user id against cmem.ai → Connect.
## 6. Report
- **Status check (already configured):** pending counts, last flush time,
last error.
- **First-time setup:** device id from the status response, what the counts
drained to, whether a legacy client was migrated/retired, and this one-line
privacy note:
Report device id, pending counts, last successful flush, Hub reachability and
checkpoint, and any Hub/flush error. End with this privacy note:
> Cloud sync uploads your observation narratives and full prompt text to your
> cmem.ai account.
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,9 +1,9 @@
var __IMPORT_META_URL__ = require("node:url").pathToFileURL(__filename).href;
"use strict";var h=Object.defineProperty;var b=Object.getOwnPropertyDescriptor;var w=Object.getOwnPropertyNames;var F=Object.prototype.hasOwnProperty;var P=(o,r)=>{for(var e in r)h(o,e,{get:r[e],enumerable:!0})},N=(o,r,e,t)=>{if(r&&typeof r=="object"||typeof r=="function")for(let n of w(r))!F.call(o,n)&&n!==e&&h(o,n,{get:()=>r[n],enumerable:!(t=b(r,n))||t.enumerable});return o};var U=o=>N(h({},"__esModule",{value:!0}),o);var K={};P(K,{parseFileList:()=>z});module.exports=U(K);var l=require("fs"),k=require("path");var i=require("path"),v=require("os"),f=require("fs"),_=require("url");var M=null;function B(o){return(M??process.stderr.write.bind(process.stderr))(o)}function u(o){B(o)}var rr=process.platform==="win32";function G(o){return o.replace(/^\uFEFF/,"")}function g(o){return JSON.parse(G(o))}function j(){return typeof __dirname<"u"?__dirname:(0,i.dirname)((0,_.fileURLToPath)(__IMPORT_META_URL__))}var ir=j();function W(){if(process.env.CLAUDE_MEM_DATA_DIR)return process.env.CLAUDE_MEM_DATA_DIR;let o=(0,i.join)((0,v.homedir)(),".claude-mem"),r=(0,i.join)(o,"settings.json");try{if((0,f.existsSync)(r)){let e=g((0,f.readFileSync)(r,"utf-8")),t=e.env??e;if(t.CLAUDE_MEM_DATA_DIR)return t.CLAUDE_MEM_DATA_DIR}}catch{}return o}var c=W(),H=process.env.CLAUDE_CONFIG_DIR||(0,i.join)((0,v.homedir)(),".claude"),or=(0,i.join)(H,"plugins","marketplaces","thedotmack"),J=(0,i.join)(c,"logs"),sr=(0,i.join)(c,"settings.json"),cr=(0,i.join)(c,"claude-mem.db"),Y=(0,i.join)(c,"observer-sessions"),lr=(0,i.basename)(Y);var D={dataDir:()=>c,workerPid:()=>(0,i.join)(c,"worker.pid"),serverPid:()=>(0,i.join)(c,".server-beta.pid"),serverPort:()=>(0,i.join)(c,".server-beta.port"),serverRuntime:()=>(0,i.join)(c,".server-beta.runtime.json"),settings:()=>(0,i.join)(c,"settings.json"),database:()=>(0,i.join)(c,"claude-mem.db"),chroma:()=>(0,i.join)(c,"chroma"),combinedCerts:()=>(0,i.join)(c,"combined_certs.pem"),transcriptsConfig:()=>(0,i.join)(c,"transcript-watch.json"),transcriptsState:()=>(0,i.join)(c,"transcript-watch-state.json"),cloudSyncState:()=>(0,i.join)(c,"cloud-sync-state.json"),corpora:()=>(0,i.join)(c,"corpora"),supervisorRegistry:()=>(0,i.join)(c,"supervisor.json"),envFile:()=>(0,i.join)(c,".env"),logsDir:()=>J};var O=(s=>(s[s.DEBUG=0]="DEBUG",s[s.INFO=1]="INFO",s[s.WARN=2]="WARN",s[s.ERROR=3]="ERROR",s[s.SILENT=4]="SILENT",s))(O||{}),$=null,C=class{level=null;useColor;logFilePath=null;logFileInitialized=!1;constructor(){this.useColor=process.stdout.isTTY??!1}ensureLogFileInitialized(){if(!this.logFileInitialized){this.logFileInitialized=!0;try{let r=D.logsDir();(0,l.existsSync)(r)||(0,l.mkdirSync)(r,{recursive:!0});let e=new Date().toISOString().split("T")[0];this.logFilePath=(0,k.join)(r,`claude-mem-${e}.log`)}catch(r){console.error("[LOGGER] Failed to initialize log file:",r instanceof Error?r.message:String(r)),this.logFilePath=null}}}getLevel(){if(this.level===null)try{let r=D.settings();if((0,l.existsSync)(r)){let e=(0,l.readFileSync)(r,"utf-8"),n=(g(e).CLAUDE_MEM_LOG_LEVEL||"INFO").toUpperCase();this.level=O[n]??1}else this.level=1}catch(r){console.error("[LOGGER] Failed to load log level from settings:",r instanceof Error?r.message:String(r)),this.level=1}return this.level}formatData(r){if(r==null)return"";if(typeof r=="string")return r;if(typeof r=="number"||typeof r=="boolean")return r.toString();if(typeof r=="object"){if(r instanceof Error)return this.getLevel()===0?`${r.message}
${r.stack}`:r.message;if(Array.isArray(r))return`[${r.length} items]`;let e=Object.keys(r);return e.length===0?"{}":e.length<=3?JSON.stringify(r):`{${e.length} keys: ${e.slice(0,3).join(", ")}...}`}return String(r)}formatTool(r,e){if(!e)return r;let t=e;if(typeof e=="string")try{t=JSON.parse(e)}catch{t=e}if(r==="Bash"&&t.command)return`${r}(${t.command})`;if(t.file_path)return`${r}(${t.file_path})`;if(t.notebook_path)return`${r}(${t.notebook_path})`;if(r==="Glob"&&t.pattern)return`${r}(${t.pattern})`;if(r==="Grep"&&t.pattern)return`${r}(${t.pattern})`;if(t.url)return`${r}(${t.url})`;if(t.query)return`${r}(${t.query})`;if(r==="Task"){if(t.subagent_type)return`${r}(${t.subagent_type})`;if(t.description)return`${r}(${t.description})`}return r==="Skill"&&t.skill?`${r}(${t.skill})`:r==="LSP"&&t.operation?`${r}(${t.operation})`:r}formatTimestamp(r){let e=r.getFullYear(),t=String(r.getMonth()+1).padStart(2,"0"),n=String(r.getDate()).padStart(2,"0"),s=String(r.getHours()).padStart(2,"0"),d=String(r.getMinutes()).padStart(2,"0"),S=String(r.getSeconds()).padStart(2,"0"),m=String(r.getMilliseconds()).padStart(3,"0");return`${e}-${t}-${n} ${s}:${d}:${S}.${m}`}log(r,e,t,n,s){if(r<this.getLevel())return;this.ensureLogFileInitialized();let d=this.formatTimestamp(new Date),S=O[r].padEnd(5),m=e.padEnd(6),y="";n?.correlationId?y=`[${n.correlationId}] `:n?.sessionId&&(y=`[session-${n.sessionId}] `);let a="";if(s!=null)if(s instanceof Error)a=this.getLevel()===0?`
"use strict";var h=Object.defineProperty;var b=Object.getOwnPropertyDescriptor;var w=Object.getOwnPropertyNames;var F=Object.prototype.hasOwnProperty;var P=(o,r)=>{for(var e in r)h(o,e,{get:r[e],enumerable:!0})},N=(o,r,e,t)=>{if(r&&typeof r=="object"||typeof r=="function")for(let n of w(r))!F.call(o,n)&&n!==e&&h(o,n,{get:()=>r[n],enumerable:!(t=b(r,n))||t.enumerable});return o};var U=o=>N(h({},"__esModule",{value:!0}),o);var K={};P(K,{parseFileList:()=>z});module.exports=U(K);var l=require("fs"),k=require("path");var i=require("path"),v=require("os"),f=require("fs"),_=require("url");var M=null;function B(o){return(M??process.stderr.write.bind(process.stderr))(o)}function u(o){B(o)}var rr=process.platform==="win32";function G(o){return o.replace(/^\uFEFF/,"")}function g(o){return JSON.parse(G(o))}function W(){return typeof __dirname<"u"?__dirname:(0,i.dirname)((0,_.fileURLToPath)(__IMPORT_META_URL__))}var ir=W();function j(){if(process.env.CLAUDE_MEM_DATA_DIR)return process.env.CLAUDE_MEM_DATA_DIR;let o=(0,i.join)((0,v.homedir)(),".claude-mem"),r=(0,i.join)(o,"settings.json");try{if((0,f.existsSync)(r)){let e=g((0,f.readFileSync)(r,"utf-8")),t=e.env??e;if(t.CLAUDE_MEM_DATA_DIR)return t.CLAUDE_MEM_DATA_DIR}}catch{}return o}var c=j(),H=process.env.CLAUDE_CONFIG_DIR||(0,i.join)((0,v.homedir)(),".claude"),or=(0,i.join)(H,"plugins","marketplaces","thedotmack"),J=(0,i.join)(c,"logs"),sr=(0,i.join)(c,"settings.json"),cr=(0,i.join)(c,"claude-mem.db"),Y=(0,i.join)(c,"observer-sessions"),lr=(0,i.basename)(Y);var D={dataDir:()=>c,workerPid:()=>(0,i.join)(c,"worker.pid"),serverPid:()=>(0,i.join)(c,".server-beta.pid"),serverPort:()=>(0,i.join)(c,".server-beta.port"),serverRuntime:()=>(0,i.join)(c,".server-beta.runtime.json"),settings:()=>(0,i.join)(c,"settings.json"),database:()=>(0,i.join)(c,"claude-mem.db"),chroma:()=>(0,i.join)(c,"chroma"),combinedCerts:()=>(0,i.join)(c,"combined_certs.pem"),transcriptsConfig:()=>(0,i.join)(c,"transcript-watch.json"),transcriptsState:()=>(0,i.join)(c,"transcript-watch-state.json"),corpora:()=>(0,i.join)(c,"corpora"),supervisorRegistry:()=>(0,i.join)(c,"supervisor.json"),envFile:()=>(0,i.join)(c,".env"),logsDir:()=>J};var O=(s=>(s[s.DEBUG=0]="DEBUG",s[s.INFO=1]="INFO",s[s.WARN=2]="WARN",s[s.ERROR=3]="ERROR",s[s.SILENT=4]="SILENT",s))(O||{}),$=null,C=class{level=null;useColor;logFilePath=null;logFileInitialized=!1;constructor(){this.useColor=process.stdout.isTTY??!1}ensureLogFileInitialized(){if(!this.logFileInitialized){this.logFileInitialized=!0;try{let r=D.logsDir();(0,l.existsSync)(r)||(0,l.mkdirSync)(r,{recursive:!0});let e=new Date().toISOString().split("T")[0];this.logFilePath=(0,k.join)(r,`claude-mem-${e}.log`)}catch(r){console.error("[LOGGER] Failed to initialize log file:",r instanceof Error?r.message:String(r)),this.logFilePath=null}}}getLevel(){if(this.level===null)try{let r=D.settings();if((0,l.existsSync)(r)){let e=(0,l.readFileSync)(r,"utf-8"),n=(g(e).CLAUDE_MEM_LOG_LEVEL||"INFO").toUpperCase();this.level=O[n]??1}else this.level=1}catch(r){console.error("[LOGGER] Failed to load log level from settings:",r instanceof Error?r.message:String(r)),this.level=1}return this.level}formatData(r){if(r==null)return"";if(typeof r=="string")return r;if(typeof r=="number"||typeof r=="boolean")return r.toString();if(typeof r=="object"){if(r instanceof Error)return this.getLevel()===0?`${r.message}
${r.stack}`:r.message;if(Array.isArray(r))return`[${r.length} items]`;let e=Object.keys(r);return e.length===0?"{}":e.length<=3?JSON.stringify(r):`{${e.length} keys: ${e.slice(0,3).join(", ")}...}`}return String(r)}formatTool(r,e){if(!e)return r;let t=e;if(typeof e=="string")try{t=JSON.parse(e)}catch{t=e}if(r==="Bash"&&t.command)return`${r}(${t.command})`;if(t.file_path)return`${r}(${t.file_path})`;if(t.notebook_path)return`${r}(${t.notebook_path})`;if(r==="Glob"&&t.pattern)return`${r}(${t.pattern})`;if(r==="Grep"&&t.pattern)return`${r}(${t.pattern})`;if(t.url)return`${r}(${t.url})`;if(t.query)return`${r}(${t.query})`;if(r==="Task"){if(t.subagent_type)return`${r}(${t.subagent_type})`;if(t.description)return`${r}(${t.description})`}return r==="Skill"&&t.skill?`${r}(${t.skill})`:r==="LSP"&&t.operation?`${r}(${t.operation})`:r}formatTimestamp(r){let e=r.getFullYear(),t=String(r.getMonth()+1).padStart(2,"0"),n=String(r.getDate()).padStart(2,"0"),s=String(r.getHours()).padStart(2,"0"),d=String(r.getMinutes()).padStart(2,"0"),m=String(r.getSeconds()).padStart(2,"0"),S=String(r.getMilliseconds()).padStart(3,"0");return`${e}-${t}-${n} ${s}:${d}:${m}.${S}`}log(r,e,t,n,s){if(r<this.getLevel())return;this.ensureLogFileInitialized();let d=this.formatTimestamp(new Date),m=O[r].padEnd(5),S=e.padEnd(6),y="";n?.correlationId?y=`[${n.correlationId}] `:n?.sessionId&&(y=`[session-${n.sessionId}] `);let a="";if(s!=null)if(s instanceof Error)a=this.getLevel()===0?`
${s.message}
${s.stack}`:` ${s.message}`;else if(this.getLevel()===0&&typeof s=="object")try{a=`
`+JSON.stringify(s,null,2)}catch{a=" "+this.formatData(s)}else a=" "+this.formatData(s);let R="";if(n){let{sessionId:p,memorySessionId:E,correlationId:V,...L}=n;Object.keys(L).length>0&&(R=` {${Object.entries(L).map(([T,A])=>`${T}=${A}`).join(", ")}}`)}let x=`[${d}] [${S}] [${m}] ${y}${t}${R}${a}`;if(this.logFilePath)try{(0,l.appendFileSync)(this.logFilePath,x+`
`+JSON.stringify(s,null,2)}catch{a=" "+this.formatData(s)}else a=" "+this.formatData(s);let R="";if(n){let{sessionId:p,memorySessionId:E,correlationId:V,...L}=n;Object.keys(L).length>0&&(R=` {${Object.entries(L).map(([T,A])=>`${T}=${A}`).join(", ")}}`)}let x=`[${d}] [${m}] [${S}] ${y}${t}${R}${a}`;if(this.logFilePath)try{(0,l.appendFileSync)(this.logFilePath,x+`
`,"utf8")}catch(p){let E=p instanceof Error?p:new Error(String(p));u(`[LOGGER] Failed to write to log file: ${E.message}
${E.stack??""}
`)}else u(x+`
+3 -3
View File
@@ -103,8 +103,8 @@ async function main(): Promise<void> {
const tempDir = mkdtempSync(join(tmpdir(), 'claude-mem-kill-e2e-'));
const dbA = new Database(':memory:');
const dbB = new Database(':memory:');
new SessionStore(dbA, { cloudSyncStatePath: join(tempDir, 'no-legacy-a.json') });
new SessionStore(dbB, { cloudSyncStatePath: join(tempDir, 'no-legacy-b.json') });
new SessionStore(dbA);
new SessionStore(dbB);
seedSession(dbA);
// Device A: the push drain.
@@ -116,7 +116,6 @@ async function main(): Promise<void> {
CLAUDE_MEM_CLOUD_SYNC_DEVICE_NAME: 'e2e-a',
}, {
settingsPath: join(tempDir, 'settings-a.json'),
legacyStatePath: join(tempDir, 'no-legacy-a.json'),
debounceMs: 100,
});
@@ -127,6 +126,7 @@ async function main(): Promise<void> {
token: TOKEN,
userId: USER,
deviceId: DEV_B,
deviceName: 'e2e-b',
activePollMs: 1_000,
idlePollMs: 1_000, // fast re-probe while the socket is live
minPullGapMs: 0,
+692 -416
View File
File diff suppressed because it is too large Load Diff
+94 -127
View File
@@ -1,7 +1,6 @@
import { Database, type SQLQueryBindings } from 'bun:sqlite';
import { existsSync, readFileSync } from 'fs';
import { randomUUID } from 'crypto';
import { DATA_DIR, DB_PATH, ensureDir, OBSERVER_SESSIONS_PROJECT, paths } from '../../shared/paths.js';
import { DATA_DIR, DB_PATH, ensureDir, OBSERVER_SESSIONS_PROJECT } from '../../shared/paths.js';
import { logger } from '../../utils/logger.js';
import {
TableColumnInfo,
@@ -75,7 +74,7 @@ interface SdkSessionDetailRow {
export class SessionStore {
public db: Database;
constructor(dbPathOrDb: string | Database = DB_PATH, options: { cloudSyncStatePath?: string; cloudSyncHubUrl?: string } = {}) {
constructor(dbPathOrDb: string | Database = DB_PATH) {
if (dbPathOrDb instanceof Database) {
this.db = dbPathOrDb;
} else {
@@ -114,13 +113,12 @@ export class SessionStore {
this.ensureSDKSessionsPlatformContentIdentity();
this.ensureUserPromptsSessionDbId();
this.ensurePendingMessagesSessionToolUniqueIndex();
this.ensureSyncedAtColumns(options.cloudSyncStatePath ?? paths.cloudSyncState());
this.requeuePromptCloudSyncAfterMapperFix();
this.ensureSyncedAtColumns();
this.ensureSyncOriginColumns();
this.ensureSyncOutbox();
this.ensureSyncEntityLedger();
this.ensureSyncRevisionTextAffinity();
this.requeueAllForHubCutover(options.cloudSyncHubUrl);
this.initializeSyncHubLaunchBaseline();
}
private getIndexColumns(indexName: string): string[] {
@@ -456,13 +454,11 @@ export class SessionStore {
}
}
private ensureSyncedAtColumns(cloudSyncStatePath: string): void {
private ensureSyncedAtColumns(): void {
// Not gated on a schema_versions row: the community-edge line already
// consumed versions 36-38 without adding synced_at, so affected DBs have
// those version rows but not the columns. The PRAGMA checks are the real
// guard; version 39 is recorded for bookkeeping only.
let columnsAdded = false;
for (const table of ['observations', 'session_summaries', 'user_prompts']) {
const tableInfo = this.db.query(`PRAGMA table_info(${table})`).all() as TableColumnInfo[];
const hasSyncedAt = tableInfo.some(col => col.name === 'synced_at');
@@ -470,47 +466,14 @@ export class SessionStore {
if (!hasSyncedAt) {
this.db.run(`ALTER TABLE ${table} ADD COLUMN synced_at INTEGER`);
logger.debug('DB', `Added synced_at column to ${table} table`);
columnsAdded = true;
}
this.db.run(`CREATE INDEX IF NOT EXISTS idx_${table}_unsynced ON ${table}(id) WHERE synced_at IS NULL`);
}
// Legacy cursor adoption is once-only: it runs only in the call that
// created the columns.
if (columnsAdded) {
this.stampRowsSyncedByLegacyClient(cloudSyncStatePath);
}
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(39, new Date().toISOString());
}
/**
* One-time cloud repair (version 40): every prompt synced before the
* CloudSync mapper fix went to the cloud with memory_session_id =
* content_session_id and project = 'unknown', so the cloud viewer could
* never attach a prompt to its session. Re-nulling synced_at makes the
* next flush re-push the full prompt history through the fixed mapper
* (sdk_sessions join); the server upserts on (user_id, device_id,
* local_id) with a change guard, so corrected rows overwrite in place and
* still-identical rows (no local mapping) cost nothing. Runs after
* ensureSyncedAtColumns the column must exist. Harmless when cloud sync
* is unconfigured: rows simply sit unsynced, which is their natural state.
*/
private requeuePromptCloudSyncAfterMapperFix(): void {
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(40) as SchemaVersion | undefined;
if (applied) return;
const res = this.db.prepare(`
UPDATE user_prompts SET synced_at = NULL WHERE synced_at IS NOT NULL
`).run();
logger.info('DB', 'Requeued prompt cloud sync after mapper fix (v40)', {
requeued: res.changes
});
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(40, new Date().toISOString());
}
/**
* Two-lane sync origins (version 41): every synced table learns where a row
* came from. Native rows keep the origin columns NULL (NULL = this device);
@@ -768,97 +731,101 @@ export class SessionStore {
.run(45, new Date().toISOString());
}
/**
* Hub cutover one-shot (plan Phase 3 task 5): when settings point at a
* sync hub (CLAUDE_MEM_CLOUD_SYNC_HUB_URL non-empty) that this DB has not
* cut over to yet, re-null `synced_at` on every NATIVE row once so this
* device re-pushes its whole corpus into that hub's log; the hub's
* (origin_device, kind, origin_id, rev) unique index dedupes replays.
* Replica rows (origin_device_id NOT NULL) are excluded they are another
* device's corpus and must never be pushed under this device's identity.
*
* GATING keyed on HUB IDENTITY, not a version number: the hub URL the
* cutover last ran against is stored in sync_state ('cutover_hub_url'),
* written in the SAME transaction as the requeue. The one-shot fires
* whenever a non-empty hub URL differs from the stored value so it is
* exactly-once per (DB, hub URL): the first configuration fires it, every
* later boot with the same URL is a no-op, and pointing at a DIFFERENT hub
* later fires it again (a burned version row would leave the corpus
* permanently un-pushed into the new hub's empty log silent fleet-wide
* data loss). A schema_versions row 43 is still recorded as a legacy
* bookkeeping marker but is NOT consulted, so pre-fix DBs that burned v43
* (and have no stored cutover_hub_url) self-heal with one extra re-push
* hub dedupe makes over-firing safe. Callers that don't know the settings
* (tests, CLI utilities) pass no URL and stay inert the worker, which
* owns the push drain, passes the URL via DatabaseManager.
*
* The other leg of hub-identity change the SAME URL whose DO log was
* lost/rebuilt (new epoch) is handled by SyncApply.handleEpoch, which
* re-nulls native rows on an epoch MISMATCH.
* One-time launch boundary (v47) plus its durable revision exclusions
* (v48). This product line has no released cloud corpus to migrate, so the
* exact native revisions present at launch are a local-only baseline. The
* exclusion ledger survives Hub epoch changes; if one of those rows is
* edited later, its higher revision is eligible for ordinary sync/rebuild.
* Fresh databases run this while empty.
*/
private requeueAllForHubCutover(hubUrl: string | undefined): void {
// Normalize like CloudSync does, so "https://hub" and "https://hub/"
// are one hub identity, not a spurious re-fire.
const normalized = (hubUrl ?? '').trim().replace(/\/+$/, '');
if (normalized === '') return;
private initializeSyncHubLaunchBaseline(): void {
const tables = [
{ table: 'observations', kind: 'observation' },
{ table: 'session_summaries', kind: 'summary' },
{ table: 'user_prompts', kind: 'prompt' },
] as const;
const exclusionTableExisted = this.db.prepare(`
SELECT 1 AS present FROM sqlite_master
WHERE type = 'table' AND name = 'sync_launch_exclusions'
`).get() !== undefined;
this.db.run(`
CREATE TABLE IF NOT EXISTS sync_launch_exclusions (
kind TEXT NOT NULL CHECK (kind IN ('observation', 'summary', 'prompt')),
origin_local_id TEXT NOT NULL,
through_rev TEXT NOT NULL,
PRIMARY KEY (kind, origin_local_id)
)
`);
const stored = this.db.prepare(`SELECT v FROM sync_state WHERE k = 'cutover_hub_url'`).get() as { v: string } | undefined;
if (stored?.v === normalized) return;
const applied = this.db.prepare(
'SELECT version, applied_at FROM schema_versions WHERE version = ?'
).get(47) as { version: number; applied_at: string } | undefined;
let requeued = 0;
const tx = this.db.transaction(() => {
for (const table of ['observations', 'session_summaries', 'user_prompts']) {
const res = this.db.prepare(`
UPDATE ${table} SET synced_at = NULL
WHERE synced_at IS NOT NULL AND origin_device_id IS NULL
`).run();
requeued += res.changes;
if (!applied) {
const now = Date.now();
const tx = this.db.transaction(() => {
// Recompute if a migration fixture deliberately removes v47. In a
// real pre-v47 database this table is newly created and already empty.
this.db.run('DELETE FROM sync_launch_exclusions');
for (const { table, kind } of tables) {
this.db.prepare(`
INSERT INTO sync_launch_exclusions (kind, origin_local_id, through_rev)
SELECT ?, CAST(id AS TEXT), CAST(sync_rev AS TEXT)
FROM ${table}
WHERE origin_device_id IS NULL
`).run(kind);
this.db.prepare(`
UPDATE ${table} SET synced_at = ?
WHERE synced_at IS NULL AND origin_device_id IS NULL
`).run(now);
}
this.db.run('DELETE FROM sync_outbox');
this.db.run('DELETE FROM sync_content_outbox');
this.db.run('DELETE FROM sync_dead_letter');
// Adopt the launch Hub as a genuinely first epoch. Retaining a cursor
// or epoch from a pre-launch test Hub would make SyncApply interpret
// the first connection as a rebuild. Parked mutations belong to that
// discarded test log, so pre-launch sync_state is stale control-plane
// state; the exclusion ledger above is the only boundary state kept.
this.db.run('DELETE FROM sync_state');
const appliedAt = new Date(now).toISOString();
this.db.prepare('INSERT INTO schema_versions (version, applied_at) VALUES (?, ?)').run(47, appliedAt);
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(48, appliedAt);
});
tx();
return;
}
// Repair databases that ran the earlier v47 implementation before the
// explicit exclusion ledger existed. v47 stamped the launch baseline at
// its applied_at millisecond. Rows still stamped at/before that boundary
// are the excluded launch revisions; NULL or later stamps are post-launch
// writes/acks and must remain eligible for an epoch rebuild.
const exclusionsApplied = this.db.prepare(
'SELECT version FROM schema_versions WHERE version = ?'
).get(48) as SchemaVersion | undefined;
if (exclusionsApplied && exclusionTableExisted) return;
const boundaryMs = Date.parse(applied.applied_at);
if (!Number.isSafeInteger(boundaryMs) || boundaryMs < 0) {
throw new Error(`schema v48: invalid v47 applied_at ${applied.applied_at}`);
}
const repair = this.db.transaction(() => {
for (const { table, kind } of tables) {
this.db.prepare(`
INSERT OR IGNORE INTO sync_launch_exclusions (kind, origin_local_id, through_rev)
SELECT ?, CAST(id AS TEXT), CAST(sync_rev AS TEXT)
FROM ${table}
WHERE origin_device_id IS NULL
AND synced_at > 0
AND synced_at <= ?
`).run(kind, boundaryMs);
}
this.db.prepare(`
INSERT INTO sync_state (k, v) VALUES ('cutover_hub_url', ?)
ON CONFLICT(k) DO UPDATE SET v = excluded.v
`).run(normalized);
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(43, new Date().toISOString());
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)')
.run(48, new Date().toISOString());
});
tx();
logger.info('DB', 'Requeued full corpus for sync hub cutover', {
hubUrl: normalized,
previousHubUrl: stored?.v ?? null,
requeued,
});
}
// Rows the standalone cloud-sync client already uploaded (its cursors live in
// cloud-sync-state.json) are stamped so they are not re-uploaded. The state
// file is left in place — device-id adoption still reads it.
private stampRowsSyncedByLegacyClient(statePath: string): void {
if (!existsSync(statePath)) return;
let state: { lastId?: number; lastSummaryId?: number; lastPromptId?: number };
try {
state = JSON.parse(readFileSync(statePath, 'utf-8'));
} catch (error) {
logger.warn('DB', 'Failed to read legacy cloud-sync state, skipping synced_at adoption', { statePath }, error instanceof Error ? error : new Error(String(error)));
return;
}
if (state === null || typeof state !== 'object') {
logger.warn('DB', 'Legacy cloud-sync state is not an object, skipping synced_at adoption', { statePath });
return;
}
const now = Date.now();
const cursors: Array<[table: string, lastSyncedId: unknown]> = [
['observations', state.lastId],
['session_summaries', state.lastSummaryId],
['user_prompts', state.lastPromptId],
];
for (const [table, lastSyncedId] of cursors) {
if (!(typeof lastSyncedId === 'number' && lastSyncedId > 0)) continue;
this.db.prepare(`UPDATE ${table} SET synced_at = ? WHERE id <= ? AND synced_at IS NULL`).run(now, lastSyncedId);
logger.debug('DB', `Stamped synced_at on ${table} rows already uploaded by the legacy cloud-sync client`, { lastSyncedId });
}
repair();
}
private dropDeadPendingMessagesColumns(): void {
+135 -57
View File
@@ -5,8 +5,9 @@
// trailing debounce coalesces bursts into one `flush()`, which drains
// `WHERE synced_at IS NULL AND origin_device_id IS NULL` in batches, POSTs to
// the per-user sync hub (workers/sync-hub), and stamps rows on ack. That
// single mechanism IS live sync, backfill, offline catch-up, and retry — no
// second process, no cursor files. Mutation ops (custom title, prompt→session
// single mechanism handles post-launch live sync, offline catch-up, and retry
// — no historical/pre-launch backfill, second process, or cursor files.
// Mutation ops (custom title, prompt→session
// repair, project remaps) ride the same flush from the `sync_outbox` table
// (migration v42). Already-frozen content (especially tombstones) drains
// first; mutations drain before newly materialized row snapshots so title
@@ -30,7 +31,7 @@
// Acked mutation ops are DELETEd from sync_outbox (queue entries, not data).
//
// SIZE CONTRACT: each canonical body is at most 256,000 UTF-8 bytes and each
// request is packed below 4,000,000 bytes / 500 ops. Invalid historical rows
// request is packed below 4,000,000 bytes / 500 ops. Invalid queued rows
// are moved to a durable dead-letter with their exact rejection reason so a
// poison row cannot wedge later work. Mutation semantics are never clamped or
// rewritten: all bounded fields are validated in UTF-8 bytes before append.
@@ -46,7 +47,7 @@ import { randomUUID } from 'crypto';
import { logger } from '../../utils/logger.js';
import { parseJsonWithBom, writeJsonFileAtomic } from '../../shared/atomic-json.js';
import { SettingsDefaultsManager, type SettingsDefaults } from '../../shared/SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH, paths } from '../../shared/paths.js';
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
import {
assertCanonicalDecimal,
buildContentOperation,
@@ -65,6 +66,15 @@ const BATCH = 200;
const MAX_BODY_BYTES = 4_000_000;
// Hub cap: ≤500 ops per POST /v1/sync/ops request.
const MAX_OPS_PER_PUSH = 500;
const EMPTY_PUSH_REQUEST_BYTES = Buffer.byteLength(
JSON.stringify({ protocol_version: 2, ops: [] }),
'utf8',
);
/** Exact encoded request bytes from the sum of serialized op wrapper bytes. */
function pushRequestBytes(opBytes: number, opCount: number): number {
return EMPTY_PUSH_REQUEST_BYTES + opBytes + Math.max(0, opCount - 1);
}
type LocalRow = Record<string, unknown> & { id: string; sync_rev: string };
type OpBody = Record<string, unknown>;
@@ -306,8 +316,6 @@ export interface CloudSyncOptions {
fetchImpl?: typeof fetch;
/** settings.json path where a newly resolved device id is persisted. */
settingsPath?: string;
/** Legacy standalone-client state file (~/.claude-mem/cloud-sync-state.json). */
legacyStatePath?: string;
/** Trailing debounce for notify() bursts. */
debounceMs?: number;
/**
@@ -331,6 +339,14 @@ export interface CloudSyncStatus {
quarantine: { count: number; latestReason: string | null };
lastFlushAt: number | null;
lastError: string | null;
hub: {
checkedAt: number | null;
reachable: boolean | null;
epoch: string | null;
headSeq: string | null;
projectedSeq: string | null;
error: string | null;
};
}
export class CloudSync {
@@ -341,7 +357,6 @@ export class CloudSync {
private readonly deviceName: string;
private readonly fetchImpl: typeof fetch;
private readonly settingsPath: string;
private readonly legacyStatePath: string;
private readonly debounceMs: number;
private readonly fastDebounceMs: number;
private readonly backoffInitialMs: number;
@@ -358,6 +373,14 @@ export class CloudSync {
private stopped = false;
private lastFlushAt: number | null = null;
private lastError: string | null = null;
private hubStatus: CloudSyncStatus['hub'] = {
checkedAt: null,
reachable: null,
epoch: null,
headSeq: null,
projectedSeq: null,
error: null,
};
/** True while SyncClient's advisory socket is live (setFastDebounce). */
private fastDebounce = false;
/**
@@ -380,15 +403,13 @@ export class CloudSync {
this.db = db;
this.token = settings.CLAUDE_MEM_CLOUD_SYNC_TOKEN ?? '';
this.userId = settings.CLAUDE_MEM_CLOUD_SYNC_USER_ID ?? '';
// Hard cutover (plan Phase 3 task 5, open decision 2): the hub URL has NO
// default. Empty ⇒ sync is OFF entirely — there is no legacy per-kind
// fallback lane.
// Launch contract: the Hub URL has no default. Empty means sync is off;
// there is no application-API or per-kind fallback lane.
this.hubUrl = (settings.CLAUDE_MEM_CLOUD_SYNC_HUB_URL ?? '').trim().replace(/\/+$/, '');
// Human-readable device label for the dashboard's Devices panel.
this.deviceName = (settings.CLAUDE_MEM_CLOUD_SYNC_DEVICE_NAME || hostname() || '').slice(0, 80);
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
this.settingsPath = options.settingsPath ?? USER_SETTINGS_PATH;
this.legacyStatePath = options.legacyStatePath ?? paths.cloudSyncState();
this.debounceMs = options.debounceMs ?? 1_500;
this.fastDebounceMs = options.fastDebounceMs ?? 250;
this.backoffInitialMs = options.backoffInitialMs ?? 30_000;
@@ -439,8 +460,9 @@ export class CloudSync {
}
/**
* Kick one flush (non-blocking). This IS backfill: a never-synced install
* simply has everything `synced_at IS NULL`.
* Kick one non-blocking catch-up flush for eligible post-launch writes.
* The v47 launch baseline is deliberately stamped/excluded and is not a
* historical corpus to upload.
*/
start(): void {
if (!this.isActive()) {
@@ -450,7 +472,7 @@ export class CloudSync {
});
return;
}
logger.info('CLOUD_SYNC', 'Cloud sync active — kicking startup drain', {
logger.info('CLOUD_SYNC', 'Cloud sync active — kicking post-launch catch-up drain', {
hubUrl: this.hubUrl,
deviceId: this.deviceId,
deviceName: this.deviceName,
@@ -536,9 +558,92 @@ export class CloudSync {
quarantine: this.quarantineStatus(),
lastFlushAt: this.lastFlushAt,
lastError: this.lastError,
hub: { ...this.hubStatus },
};
}
/**
* Status for the local `/api/sync/status` route. Even with an empty queue,
* authenticate directly against SyncHub so "nothing to upload" cannot be
* mistaken for a working connection. This is a read-only Hub status GET:
* it never appends an operation or advances a client cursor.
*/
async statusWithHubProbe(): Promise<CloudSyncStatus> {
if (!this.stopped && this.isActive()) {
await this.probeHubStatus();
}
return this.status();
}
private async probeHubStatus(): Promise<void> {
let checkedAt = Date.now();
try {
const response = await this.fetchImpl(`${this.hubUrl}/v1/sync/status`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.token}`,
'X-User-Id': this.userId,
'X-Device-Id': this.deviceId,
...(this.deviceName ? { 'X-Device-Name': this.deviceName } : {}),
},
signal: AbortSignal.timeout(this.requestTimeoutMs),
});
checkedAt = Date.now();
const syncMode = response.headers.get('X-Sync-Mode');
if (syncMode !== null || response.ok) this.emitSyncMode(syncMode);
if (!response.ok) {
const body = (await response.text().catch(() => '')).slice(0, 200);
throw new Error(`sync hub status ${response.status}: ${body}`);
}
let parsed: unknown;
try {
parsed = await response.json();
} catch {
throw new Error('sync hub status: response is not JSON');
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('sync hub status: response must be an object');
}
const record = parsed as Record<string, unknown>;
if (record.protocol_version !== 2) {
throw new Error('sync hub status: response requires protocol_version 2');
}
if (
typeof record.epoch !== 'string'
|| typeof record.head_seq !== 'string'
|| typeof record.projected_seq !== 'string'
) {
throw new Error('sync hub status: response requires decimal-string epoch/head_seq/projected_seq');
}
const epoch = assertCanonicalDecimal(record.epoch, { positive: true });
const headSeq = assertCanonicalDecimal(record.head_seq);
const projectedSeq = assertCanonicalDecimal(record.projected_seq);
if (compareCanonicalDecimals(projectedSeq, headSeq) > 0) {
throw new Error('sync hub status: projected_seq exceeds head_seq');
}
this.hubStatus = {
checkedAt,
reachable: true,
epoch,
headSeq,
projectedSeq,
error: null,
};
} catch (error) {
const raw = error instanceof Error ? error.message : String(error);
const safe = this.token === '' ? raw : raw.split(this.token).join('[REDACTED]');
this.hubStatus = {
checkedAt,
reachable: false,
epoch: null,
headSeq: null,
projectedSeq: null,
error: safe,
};
}
}
/**
* Queue and apply a local content deletion atomically. The tombstone stays
* durable in sync_content_outbox until Hub projection acknowledges it.
@@ -630,7 +735,10 @@ export class CloudSync {
let bytes = 0;
for (const row of rows) {
const size = Buffer.byteLength(JSON.stringify(row), 'utf8');
if (batch.length > 0 && (batch.length >= MAX_OPS_PER_PUSH || bytes + size > MAX_BODY_BYTES)) {
if (batch.length > 0 && (
batch.length >= MAX_OPS_PER_PUSH
|| pushRequestBytes(bytes + size, batch.length + 1) > MAX_BODY_BYTES
)) {
await this.sendOps(batch);
if (this.stopped) return;
batch = [];
@@ -708,7 +816,10 @@ export class CloudSync {
`).run(op.body, op.operation_sha256, row.id);
}
const size = Buffer.byteLength(JSON.stringify(op), 'utf8');
if (buf.length > 0 && (bufBytes + size > MAX_BODY_BYTES || buf.length >= MAX_OPS_PER_PUSH)) {
if (buf.length > 0 && (
pushRequestBytes(bufBytes + size, buf.length + 1) > MAX_BODY_BYTES
|| buf.length >= MAX_OPS_PER_PUSH
)) {
await send();
if (this.stopped) return;
}
@@ -825,6 +936,10 @@ export class CloudSync {
}
private async pushOps(ops: WireOp[]): Promise<PushResponse> {
const requestBody = JSON.stringify({ protocol_version: 2, ops });
if (Buffer.byteLength(requestBody, 'utf8') > MAX_BODY_BYTES) {
throw new Error(`sync hub push invariant: request exceeds ${MAX_BODY_BYTES} encoded bytes`);
}
const res = await this.fetchImpl(`${this.hubUrl}/v1/sync/ops`, {
method: 'POST',
headers: {
@@ -834,7 +949,7 @@ export class CloudSync {
'X-Device-Id': this.deviceId,
...(this.deviceName ? { 'X-Device-Name': this.deviceName } : {}),
},
body: JSON.stringify({ protocol_version: 2, ops }),
body: requestBody,
signal: AbortSignal.timeout(this.requestTimeoutMs),
});
// Mode hint BEFORE the ok-check: the kill-switch header rides error
@@ -1301,50 +1416,13 @@ export class CloudSync {
// -------------------------------------------------------------------------
/**
* Resolve this install's stable device id, in priority order:
* 1. CLAUDE_MEM_CLOUD_SYNC_DEVICE_ID from settings (already resolved once);
* 2. the legacy standalone client's cloud-sync-state.json deviceId;
* 3. a freshly minted randomUUID().
*
* CRITICAL: never mint a new id while a legacy state file exists the hub
* keys ops on (origin_device, kind, origin_id, rev), so a new id forks
* every previously pushed row into a duplicate entity. If the legacy file
* is unreadable, fail closed (sync disabled) rather than guess.
* Resolve this launch client's stable device id from settings, or mint and
* immediately persist one. There is no standalone-client state to adopt.
*/
private resolveDeviceId(configuredId: string): string {
if (configuredId) return configuredId;
if (existsSync(this.legacyStatePath)) {
try {
const parsed = parseJsonWithBom<{ deviceId?: unknown }>(readFileSync(this.legacyStatePath, 'utf-8'));
const legacyId = parsed && typeof parsed === 'object' ? parsed.deviceId : undefined;
if (typeof legacyId !== 'string' || legacyId === '') {
throw new Error('legacy cloud-sync state has no valid deviceId');
}
try {
this.persistDeviceId(legacyId);
} catch (persistError) {
// Adoption survives a failed persist: the legacy file still holds
// the id, so the next start re-adopts the SAME id — no fork risk.
logger.warn('CLOUD_SYNC', 'Adopted legacy device id but failed to persist it to settings; will re-adopt on next start', {
settingsPath: this.settingsPath,
}, persistError instanceof Error ? persistError : new Error(String(persistError)));
}
logger.info('CLOUD_SYNC', 'Adopted device id from legacy cloud-sync state', {
deviceId: legacyId,
statePath: this.legacyStatePath,
});
return legacyId;
} catch (error) {
this.lastError = 'legacy cloud-sync state unreadable — sync disabled to avoid forking device identity';
logger.error('CLOUD_SYNC', 'Legacy cloud-sync state exists but is unusable; refusing to mint a new device id (fix or delete the file)', {
statePath: this.legacyStatePath,
}, error instanceof Error ? error : new Error(String(error)));
return '';
}
}
// First run on a fresh install: mint and persist immediately, so a later
// First run: mint and persist immediately, so a later
// transient failure can't mint a different one and fork device identity.
const minted = randomUUID();
try {
+28 -13
View File
@@ -358,15 +358,12 @@ export class SyncApply {
* and the cursor was reset to 0 the caller must discard the current page
* and re-pull from 0. First-ever epoch is adopted without a reset.
*
* An epoch MISMATCH means the hub's log was lost/rebuilt: everything this
* device previously pushed is gone from the new log, so in the same
* transaction every NATIVE row's synced_at is re-nulled the push drain
* re-uploads the corpus into the rebuilt log (hub dedupe makes over-firing
* safe). Replica rows are untouched: they are another device's corpus and
* must never be pushed under this identity; THEIR origin devices re-push
* them the same way. Without this, the pull side would self-heal while
* this device's history silently never re-entered the log every counter
* healthy, other devices converging on empty history.
* An epoch MISMATCH means the hub's log was lost/rebuilt: eligible native
* revisions this device previously pushed are re-nulled so the push drain
* can repopulate the rebuilt log. The one-time v47 launch baseline is
* deliberately excluded through the exact revisions recorded in
* sync_launch_exclusions; a later edit has a higher revision and is
* eligible. Replica rows and quarantined (-1) rows are never requeued.
*/
handleEpoch(epoch: string): boolean {
const stored = this.getEpoch();
@@ -376,17 +373,35 @@ export class SyncApply {
this.setState('epoch', epoch);
if (stored !== null) {
this.setState('cursor', '0');
for (const table of ['observations', 'session_summaries', 'user_prompts']) {
for (const { table, kind } of [
{ table: 'observations', kind: 'observation' },
{ table: 'session_summaries', kind: 'summary' },
{ table: 'user_prompts', kind: 'prompt' },
]) {
requeued += this.db.prepare(`
UPDATE ${table} SET synced_at = NULL
WHERE synced_at IS NOT NULL AND origin_device_id IS NULL
`).run().changes;
WHERE synced_at > 0
AND origin_device_id IS NULL
AND NOT EXISTS (
SELECT 1
FROM sync_launch_exclusions AS launch
WHERE launch.kind = ?
AND launch.origin_local_id = CAST(${table}.id AS TEXT)
AND (
LENGTH(launch.through_rev) > LENGTH(CAST(${table}.sync_rev AS TEXT))
OR (
LENGTH(launch.through_rev) = LENGTH(CAST(${table}.sync_rev AS TEXT))
AND launch.through_rev >= CAST(${table}.sync_rev AS TEXT)
)
)
)
`).run(kind).changes;
}
}
});
tx();
if (stored !== null) {
logger.warn('SYNC_APPLY', 'Sync hub epoch changed — cursor reset, full re-pull required, native corpus requeued for re-push', {
logger.warn('SYNC_APPLY', 'Sync hub epoch changed — cursor reset, full re-pull required, eligible native revisions requeued', {
oldEpoch: stored,
newEpoch: epoch,
requeued,
+6
View File
@@ -138,6 +138,8 @@ export interface SyncClientOptions {
userId: string;
/** MUST be the CloudSync-resolved device id (single identity source). */
deviceId: string;
/** Human-readable Hub device label (CLAUDE_MEM_CLOUD_SYNC_DEVICE_NAME). */
deviceName?: string;
/** Injectable for tests; defaults to globalThis.fetch. */
fetchImpl?: typeof fetch;
/** Poll interval while a session is active. */
@@ -203,6 +205,7 @@ export class SyncClient {
private readonly token: string;
private readonly userId: string;
private readonly deviceId: string;
private readonly deviceName: string;
private readonly fetchImpl: typeof fetch;
private readonly activePollMs: number;
private readonly idlePollMs: number;
@@ -262,6 +265,7 @@ export class SyncClient {
this.token = options.token ?? '';
this.userId = options.userId ?? '';
this.deviceId = options.deviceId;
this.deviceName = (options.deviceName ?? '').trim().slice(0, 80);
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
this.activePollMs = options.activePollMs ?? 30_000;
this.idlePollMs = options.idlePollMs ?? 300_000;
@@ -523,6 +527,7 @@ export class SyncClient {
'Authorization': `Bearer ${this.token}`,
'X-User-Id': this.userId,
'X-Device-Id': this.deviceId,
...(this.deviceName ? { 'X-Device-Name': this.deviceName } : {}),
},
// Plain short request — never a held connection (directive #4).
signal: AbortSignal.timeout(Math.max(1, Math.min(this.requestTimeoutMs, remaining))),
@@ -608,6 +613,7 @@ export class SyncClient {
'Authorization': `Bearer ${this.token}`,
'X-User-Id': this.userId,
'X-Device-Id': this.deviceId,
...(this.deviceName ? { 'X-Device-Name': this.deviceName } : {}),
},
});
this.socket = ws;
+5 -3
View File
@@ -525,6 +525,7 @@ export class WorkerService implements WorkerRef {
token: settings.CLAUDE_MEM_CLOUD_SYNC_TOKEN,
userId: settings.CLAUDE_MEM_CLOUD_SYNC_USER_ID,
deviceId: pullDeviceId,
deviceName: settings.CLAUDE_MEM_CLOUD_SYNC_DEVICE_NAME,
isSessionActive: () => this.sessionManager.getActiveSessionCount() > 0,
// Advisory WebSocket (plan Phase 4): enabled by default alongside
// the hub URL; CLAUDE_MEM_CLOUD_SYNC_WS='false' pins HTTP-only.
@@ -646,9 +647,10 @@ export class WorkerService implements WorkerRef {
}
// Cloud sync startup drain (non-blocking). The database is the queue:
// everything unsynced is simply `synced_at IS NULL`, so this one kick
// IS backfill, offline catch-up, and retry. Null when no token/user
// id/hub URL is configured (DatabaseManager gates construction).
// eligible post-launch writes remain `synced_at IS NULL`, so this one
// kick handles catch-up and retry without migrating the pre-launch
// baseline. Null when no token/user id/hub URL is configured
// (DatabaseManager gates construction).
this.dbManager.getCloudSync()?.start();
// Pull loop start (plan Phase 3 task 3): immediate catch-up pull, then
// 30 s active / 5 min idle / suspended after 1 h without sessions.
+7 -11
View File
@@ -22,13 +22,10 @@ export class DatabaseManager {
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
// cloudSyncHubUrl gates the one-shot v43 hub-cutover requeue inside the
// migration chain (SessionStore.requeueAllForHubCutover) — it must run
// BEFORE the first drain so the whole corpus is queued when the hub URL
// first appears.
this.sessionStore = new SessionStore(this.db, {
cloudSyncHubUrl: settings.CLAUDE_MEM_CLOUD_SYNC_HUB_URL,
});
// The launch schema is SyncHub-native. SessionStore marks any pre-launch
// local corpus as a nonqueued baseline once; only subsequent writes enter
// the canonical v2 outbox.
this.sessionStore = new SessionStore(this.db);
this.sessionSearch = new SessionSearch(this.db);
const chromaEnabled = settings.CLAUDE_MEM_CHROMA_ENABLED !== 'false';
@@ -38,10 +35,9 @@ export class DatabaseManager {
logger.info('DB', 'Chroma disabled via CLAUDE_MEM_CHROMA_ENABLED=false, using SQLite-only search');
}
// Cloud sync is active token AND user id AND hub URL are all non-empty
// (no separate enabled flag; empty hub URL = sync OFF — the hard cutover,
// plan Phase 3 task 5). Inactive installs get null so the write-site
// `getCloudSync()?.notify()` nudges are free no-ops.
// Cloud sync is active iff token, user id, and Hub URL are all non-empty.
// Inactive installs get null so the write-site `getCloudSync()?.notify()`
// nudges are free no-ops.
if (
settings.CLAUDE_MEM_CLOUD_SYNC_TOKEN !== '' &&
settings.CLAUDE_MEM_CLOUD_SYNC_USER_ID !== '' &&
@@ -21,12 +21,15 @@ export class CloudSyncRoutes extends BaseRouteHandler {
app.get('/api/sync/status', this.handleGetStatus.bind(this));
}
private handleGetStatus = this.wrapHandler((_req: Request, res: Response): void => {
// CloudSync.status() carries counts and metadata only — never the token.
const status = this.dbManager.getCloudSync()?.status();
if (!status) {
private handleGetStatus = this.wrapHandler(async (_req: Request, res: Response): Promise<void> => {
const cloudSync = this.dbManager.getCloudSync();
if (!cloudSync) {
logger.debug('CLOUD_SYNC', 'Status requested but cloud sync is not configured');
res.json({ configured: false });
return;
}
res.json(status ?? { configured: false });
// Always performs an authenticated, read-only SyncHub status GET. An
// empty local queue alone is not evidence that the connection works.
res.json(await cloudSync.statusWithHubProbe());
});
}
+2 -4
View File
@@ -70,7 +70,6 @@ export interface SettingsDefaults {
// entirely (the old per-kind cmem.ai lane was deleted in the hub cutover).
CLAUDE_MEM_CLOUD_SYNC_TOKEN: string;
CLAUDE_MEM_CLOUD_SYNC_USER_ID: string;
CLAUDE_MEM_CLOUD_SYNC_URL: string; // legacy per-kind endpoint base — unused since the hub cutover, kept so existing settings files round-trip
CLAUDE_MEM_CLOUD_SYNC_HUB_URL: string;
CLAUDE_MEM_CLOUD_SYNC_DEVICE_ID: string;
CLAUDE_MEM_CLOUD_SYNC_DEVICE_NAME: string;
@@ -162,9 +161,8 @@ export class SettingsDefaultsManager {
// Worker-native cloud sync: credentials come from cmem.ai → Connect.
CLAUDE_MEM_CLOUD_SYNC_TOKEN: '',
CLAUDE_MEM_CLOUD_SYNC_USER_ID: '',
CLAUDE_MEM_CLOUD_SYNC_URL: 'https://cmem.ai/api/pro/sync', // legacy, unused since the hub cutover
CLAUDE_MEM_CLOUD_SYNC_HUB_URL: '', // sync-hub base URL (e.g. https://sync.cmem.ai). Empty = sync OFF (hard cutover, plan Phase 3 task 5)
CLAUDE_MEM_CLOUD_SYNC_DEVICE_ID: '', // Resolved at first CloudSync start (legacy state file → adopt; else randomUUID), then persisted back here
CLAUDE_MEM_CLOUD_SYNC_HUB_URL: '', // sync-hub base URL (e.g. https://sync.cmem.ai). Empty = sync OFF
CLAUDE_MEM_CLOUD_SYNC_DEVICE_ID: '', // Minted at first CloudSync start, then persisted back here
CLAUDE_MEM_CLOUD_SYNC_DEVICE_NAME: hostname(), // Human-readable label for the cmem.ai Devices panel
CLAUDE_MEM_CLOUD_SYNC_WS: 'true', // Advisory WebSocket speed layer (plan Phase 4). 'false' = HTTP polling only — sync stays fully correct, just poll-latency (prime directive #2)
CLAUDE_MEM_TELEGRAM_ENABLED: 'true',
-1
View File
@@ -72,7 +72,6 @@ export const paths = {
combinedCerts: () => join(DATA_DIR, 'combined_certs.pem'),
transcriptsConfig: () => join(DATA_DIR, 'transcript-watch.json'),
transcriptsState: () => join(DATA_DIR, 'transcript-watch-state.json'),
cloudSyncState: () => join(DATA_DIR, 'cloud-sync-state.json'),
corpora: () => join(DATA_DIR, 'corpora'),
supervisorRegistry: () => join(DATA_DIR, 'supervisor.json'),
envFile: () => join(DATA_DIR, '.env'),
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'bun:test';
import { readFileSync } from 'fs';
import { join } from 'path';
const root = join(import.meta.dir, '../..');
const script = readFileSync(join(root, 'scripts/sync-matrix-e2e.ts'), 'utf8');
describe('sync matrix E2E safety contract', () => {
it('uses only the local Miniflare runner and explicit loopback guards', () => {
expect(script).toContain('test/run-miniflare-pro-e2e.mjs');
expect(script).toContain("const HUB_DIR = resolve(import.meta.dir, '../workers/sync-hub')");
expect(script).toContain("'--worker-root', HUB_DIR");
expect(script).toContain("hostname: '127.0.0.1'");
expect(script).toContain('refused non-loopback URL');
expect(script).not.toContain('wrangler');
expect(script).not.toContain('cmem.ai');
expect(script).not.toContain('https://');
});
it('spawns the Hub with an allowlisted environment instead of inherited secrets or code selectors', () => {
expect(script).toContain("const CHILD_ENV_ALLOWLIST = ['PATH', 'TMPDIR', 'TMP', 'TEMP', 'LANG', 'LC_ALL']");
expect(script).toContain('env: childEnvironment({');
expect(script).not.toContain('...process.env');
expect(script).not.toContain('CMEM_HUB_WORKER_ROOT: process.env');
});
it('defines exactly two real client identities and canonical protocol-v2 pushes', () => {
expect(script).toContain("const DEVICE_IDS = { a: 'matrix-device-a', b: 'matrix-device-b' }");
expect(script).not.toContain('matrix-device-c');
expect(script).not.toContain('rawPush');
expect(script).toContain('SessionStore');
expect(script).toContain('CloudSync');
expect(script).toContain('SyncApply');
expect(script).toContain('SyncClient');
expect(script).toContain('protocol v2');
});
it('is exposed as the package E2E command', () => {
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { scripts?: Record<string, string> };
expect(pkg.scripts?.['e2e:sync-matrix']).toBe('bun scripts/sync-matrix-e2e.ts');
});
});
@@ -504,8 +504,7 @@ describe('SessionStore migrations', () => {
// anyone reverts the predicate.
const db = new Database(':memory:');
try {
const missingStatePath = '/nonexistent/claude-mem-cloud-sync-state.json';
new SessionStore(db, { cloudSyncStatePath: missingStatePath });
new SessionStore(db);
db.prepare(`
INSERT INTO sdk_sessions (content_session_id, memory_session_id, project, started_at, started_at_epoch, status)
@@ -520,7 +519,7 @@ describe('SessionStore migrations', () => {
// A second construction over the fully migrated DB must be a no-op for
// session_summaries.
new SessionStore(db, { cloudSyncStatePath: missingStatePath });
new SessionStore(db);
const row = db.prepare(`
SELECT synced_at, origin_device_id, origin_local_id, sync_rev
+329 -393
View File
@@ -1,9 +1,8 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { Database } from 'bun:sqlite';
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { SessionStore } from '../../src/services/sqlite/SessionStore.js';
import { CloudSync } from '../../src/services/sync/CloudSync.js';
import { SyncApply } from '../../src/services/sync/SyncApply.js';
const SYNCED_TABLES = ['observations', 'session_summaries', 'user_prompts'] as const;
@@ -20,295 +19,44 @@ function stampedCount(db: Database, table: string): number {
return (db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE synced_at IS NOT NULL`).get() as { n: number }).n;
}
function contentSnapshot(db: Database): Record<string, Array<Record<string, unknown>>> {
return Object.fromEntries(SYNCED_TABLES.map(table => [
table,
(db.prepare(`SELECT * FROM ${table} ORDER BY id`).all() as Array<Record<string, unknown>>)
.map(({ synced_at: _syncedAt, ...content }) => content),
]));
}
function rowCount(db: Database, table: string): number {
return (db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n;
}
function seedRows(db: Database): void {
const now = new Date().toISOString();
const epoch = Date.now();
db.prepare(`
INSERT INTO sdk_sessions (content_session_id, memory_session_id, project, started_at, started_at_epoch, status)
VALUES (?, ?, ?, ?, ?, 'active')
`).run('content-sync', 'memory-sync', 'sync-project', now, epoch);
const insertObs = db.prepare(`
INSERT INTO observations (memory_session_id, project, type, content_hash, created_at, created_at_epoch)
VALUES ('memory-sync', 'sync-project', 'discovery', ?, ?, ?)
`);
for (let i = 0; i < 5; i++) insertObs.run(`hash-${i}`, now, epoch + i);
const insertSummary = db.prepare(`
INSERT INTO session_summaries (memory_session_id, project, request, created_at, created_at_epoch)
VALUES ('memory-sync', 'sync-project', 'request', ?, ?)
`);
for (let i = 0; i < 3; i++) insertSummary.run(now, epoch + i);
const insertPrompt = db.prepare(`
INSERT INTO user_prompts (content_session_id, prompt_number, prompt_text, created_at, created_at_epoch)
VALUES ('content-sync', ?, 'prompt', ?, ?)
`);
for (let i = 0; i < 4; i++) insertPrompt.run(i + 1, now, epoch + i);
}
/**
* A modern (v35-era) schema WITHOUT synced_at columns, seeded by hand so the
* migration's column adoption and legacy stamping can be exercised against
* pre-existing rows. `throughVersion: 38` reproduces the community-edge
* collision: schema_versions rows 36-38 exist while synced_at does not.
*/
function seedPreSyncedAtDb(db: Database, throughVersion: number): void {
const now = new Date().toISOString();
const epoch = Date.now();
db.run(`
CREATE TABLE schema_versions (
id INTEGER PRIMARY KEY,
version INTEGER UNIQUE NOT NULL,
applied_at TEXT NOT NULL
)
`);
db.run(`
CREATE TABLE sdk_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content_session_id TEXT NOT NULL,
memory_session_id TEXT UNIQUE,
project TEXT NOT NULL,
platform_source TEXT NOT NULL DEFAULT 'claude',
user_prompt TEXT,
started_at TEXT NOT NULL,
started_at_epoch INTEGER NOT NULL,
completed_at TEXT,
completed_at_epoch INTEGER,
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'completed', 'failed')),
worker_port INTEGER,
prompt_counter INTEGER DEFAULT 0,
custom_title TEXT
)
`);
db.run('CREATE UNIQUE INDEX ux_sdk_sessions_platform_content ON sdk_sessions(platform_source, content_session_id)');
db.run(`
CREATE TABLE observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
memory_session_id TEXT NOT NULL,
project TEXT NOT NULL,
text TEXT,
type TEXT NOT NULL,
title TEXT,
subtitle TEXT,
facts TEXT,
narrative TEXT,
concepts TEXT,
files_read TEXT,
files_modified TEXT,
prompt_number INTEGER,
discovery_tokens INTEGER DEFAULT 0,
content_hash TEXT,
agent_type TEXT,
agent_id TEXT,
merged_into_project TEXT,
generated_by_model TEXT,
metadata TEXT,
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE
)
`);
db.run('CREATE UNIQUE INDEX ux_observations_session_hash ON observations(memory_session_id, content_hash)');
db.run(`
CREATE TABLE session_summaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
memory_session_id TEXT NOT NULL,
project TEXT NOT NULL,
request TEXT,
investigated TEXT,
learned TEXT,
completed TEXT,
next_steps TEXT,
files_read TEXT,
files_edited TEXT,
notes TEXT,
prompt_number INTEGER,
discovery_tokens INTEGER DEFAULT 0,
merged_into_project TEXT,
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE
)
`);
db.run(`
CREATE TABLE user_prompts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_db_id INTEGER,
content_session_id TEXT NOT NULL,
prompt_number INTEGER NOT NULL,
prompt_text TEXT NOT NULL,
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
FOREIGN KEY(session_db_id) REFERENCES sdk_sessions(id) ON DELETE CASCADE
)
`);
db.run(`
CREATE TABLE pending_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_db_id INTEGER NOT NULL,
content_session_id TEXT NOT NULL,
tool_use_id TEXT,
message_type TEXT NOT NULL CHECK(message_type IN ('observation', 'summarize')),
tool_name TEXT,
tool_input TEXT,
tool_response TEXT,
cwd TEXT,
last_user_message TEXT,
last_assistant_message TEXT,
prompt_number INTEGER,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'processing')),
created_at_epoch INTEGER NOT NULL,
agent_type TEXT,
agent_id TEXT,
FOREIGN KEY (session_db_id) REFERENCES sdk_sessions(id) ON DELETE CASCADE
)
`);
db.run(`
CREATE UNIQUE INDEX ux_pending_session_tool
ON pending_messages(session_db_id, tool_use_id)
WHERE tool_use_id IS NOT NULL
`);
const insertVersion = db.prepare('INSERT INTO schema_versions (version, applied_at) VALUES (?, ?)');
for (let version = 4; version <= throughVersion; version++) insertVersion.run(version, now);
db.prepare(`
INSERT INTO sdk_sessions (id, content_session_id, memory_session_id, project, started_at, started_at_epoch)
VALUES (1, 'content-sync', 'memory-sync', 'sync-project', ?, ?)
`).run(now, epoch);
const insertObs = db.prepare(`
INSERT INTO observations (memory_session_id, project, type, content_hash, created_at, created_at_epoch)
VALUES ('memory-sync', 'sync-project', 'discovery', ?, ?, ?)
`);
for (let i = 0; i < 5; i++) insertObs.run(`hash-${i}`, now, epoch + i);
const insertSummary = db.prepare(`
VALUES ('memory-sync', 'sync-project', 'discovery', 'hash-1', ?, ?)
`).run(now, epoch);
db.prepare(`
INSERT INTO session_summaries (memory_session_id, project, request, created_at, created_at_epoch)
VALUES ('memory-sync', 'sync-project', 'request', ?, ?)
`);
for (let i = 0; i < 3; i++) insertSummary.run(now, epoch + i);
const insertPrompt = db.prepare(`
INSERT INTO user_prompts (session_db_id, content_session_id, prompt_number, prompt_text, created_at, created_at_epoch)
VALUES (1, 'content-sync', ?, 'prompt', ?, ?)
`);
for (let i = 0; i < 4; i++) insertPrompt.run(i + 1, now, epoch + i);
`).run(now, epoch);
db.prepare(`
INSERT INTO user_prompts (content_session_id, prompt_number, prompt_text, created_at, created_at_epoch)
VALUES ('content-sync', 1, 'prompt', ?, ?)
`).run(now, epoch);
}
function expectStampedThroughCursors(db: Database, before: number): void {
const observations = syncedAtById(db, 'observations');
expect(observations.get(1)).toBeGreaterThanOrEqual(before);
expect(observations.get(2)).toBeGreaterThanOrEqual(before);
expect(observations.get(3)).toBeGreaterThanOrEqual(before);
expect(observations.get(4)).toBeNull();
expect(observations.get(5)).toBeNull();
const summaries = syncedAtById(db, 'session_summaries');
expect(summaries.get(1)).toBeGreaterThanOrEqual(before);
expect(summaries.get(2)).toBeGreaterThanOrEqual(before);
expect(summaries.get(3)).toBeNull();
// Prompts end up NULL regardless of the legacy cursor: the v40 repair
// migration re-nulls every prompt's synced_at right after v39 stamps them,
// because the legacy client uploaded prompts with the broken
// memory_session_id/project mapping and they must re-push through the
// fixed mapper.
const prompts = syncedAtById(db, 'user_prompts');
expect(prompts.get(1)).toBeNull();
expect(prompts.get(2)).toBeNull();
expect(prompts.get(3)).toBeNull();
expect(prompts.get(4)).toBeNull();
}
describe('SessionStore synced_at migration (v39)', () => {
let tempDir: string;
let missingStatePath: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'claude-mem-synced-at-'));
missingStatePath = join(tempDir, 'does-not-exist.json');
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it('adds synced_at columns and partial unsynced indexes to all three tables', () => {
describe('SessionStore SyncHub launch baseline', () => {
it('creates synced_at columns, unsynced indexes, and the durable launch boundary', () => {
const db = new Database(':memory:');
try {
new SessionStore(db, { cloudSyncStatePath: missingStatePath });
for (const table of SYNCED_TABLES) {
expect(columnNames(db, table).has('synced_at')).toBe(true);
const index = db.prepare(`
SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?
`).get(`idx_${table}_unsynced`) as { sql: string } | undefined;
expect(index?.sql).toContain('synced_at IS NULL');
}
const version = db.prepare('SELECT version FROM schema_versions WHERE version = 39').get() as { version: number } | undefined;
expect(version?.version).toBe(39);
const plan = db.prepare('EXPLAIN QUERY PLAN SELECT id FROM observations WHERE synced_at IS NULL').all() as Array<{ detail: string }>;
expect(plan.some(row => row.detail.includes('idx_observations_unsynced'))).toBe(true);
} finally {
db.close();
}
});
it('is idempotent: repeat construction, even without the version-39 row, does not throw or duplicate columns', () => {
const db = new Database(':memory:');
try {
new SessionStore(db, { cloudSyncStatePath: missingStatePath });
expect(() => new SessionStore(db, { cloudSyncStatePath: missingStatePath })).not.toThrow();
// The version row is bookkeeping only — losing it must not break re-runs.
db.run('DELETE FROM schema_versions WHERE version = 39');
expect(() => new SessionStore(db, { cloudSyncStatePath: missingStatePath })).not.toThrow();
for (const table of SYNCED_TABLES) {
const syncedAtColumns = (db.query(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>)
.filter(col => col.name === 'synced_at');
expect(syncedAtColumns.length).toBe(1);
}
} finally {
db.close();
}
});
it('adds columns, indexes, and stamps legacy rows even when community-edge version rows 36-38 already exist', () => {
const db = new Database(':memory:');
try {
seedPreSyncedAtDb(db, 38);
// Collision preconditions: version rows 36-38 present, synced_at absent.
const collidingVersions = db.prepare('SELECT COUNT(*) AS n FROM schema_versions WHERE version IN (36, 37, 38)').get() as { n: number };
expect(collidingVersions.n).toBe(3);
for (const table of SYNCED_TABLES) {
expect(columnNames(db, table).has('synced_at')).toBe(false);
}
const statePath = join(tempDir, 'cloud-sync-state.json');
writeFileSync(statePath, JSON.stringify({
deviceId: 'ee1b7637-test',
lastId: 3,
lastSummaryId: 2,
lastPromptId: 2,
}));
const before = Date.now();
new SessionStore(db, { cloudSyncStatePath: statePath });
new SessionStore(db);
for (const table of SYNCED_TABLES) {
expect(columnNames(db, table).has('synced_at')).toBe(true);
const index = db.prepare(`
@@ -316,162 +64,355 @@ describe('SessionStore synced_at migration (v39)', () => {
`).get(`idx_${table}_unsynced`) as { sql: string } | undefined;
expect(index?.sql).toContain('synced_at IS NULL');
}
expectStampedThroughCursors(db, before);
const version = db.prepare('SELECT version FROM schema_versions WHERE version = 39').get() as { version: number } | undefined;
expect(version?.version).toBe(39);
// The state file is left in place — later phases still read it.
expect(existsSync(statePath)).toBe(true);
expect(db.prepare('SELECT version FROM schema_versions WHERE version = 47').get()).not.toBeNull();
expect(db.prepare('SELECT version FROM schema_versions WHERE version = 48').get()).not.toBeNull();
expect(db.prepare(`
SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'sync_launch_exclusions'
`).get()).not.toBeNull();
} finally {
db.close();
}
});
it('stamps rows at or below the legacy cursors on a v35-era DB when cloud-sync-state.json exists', () => {
it('preserves all content while marking only the native pre-launch corpus and clearing stale sync state', () => {
const db = new Database(':memory:');
try {
seedPreSyncedAtDb(db, 35);
new SessionStore(db);
seedRows(db);
db.prepare(`
INSERT INTO observations
(memory_session_id, project, type, title, content_hash, created_at, created_at_epoch, synced_at)
VALUES ('memory-sync', 'sync-project', 'discovery', 'already stamped', 'hash-2',
'2026-07-20T00:00:00.000Z', 1752969600000, 777)
`).run();
db.prepare(`
INSERT INTO observations
(memory_session_id, project, type, title, content_hash, created_at, created_at_epoch,
synced_at, origin_device_id, origin_local_id)
VALUES ('memory-sync', 'sync-project', 'discovery', 'replica', 'hash-3',
'2026-07-20T00:00:01.000Z', 1752969601000, NULL, 'device-other', '9')
`).run();
db.prepare(`
INSERT INTO sync_outbox (op_uuid, rev, body, created_at_epoch)
VALUES ('old-mutation', '1', '{"op":"set_title"}', 1)
`).run();
db.prepare(`
INSERT INTO sync_content_outbox
(entity_id, kind, origin_local_id, entity_rev, body, operation_sha256, deleted, created_at_epoch)
VALUES ('old-doc', 'observation', '1', '1', '{}', 'hash', 1, 1)
`).run();
db.prepare(`
INSERT INTO sync_dead_letter
(lane, queue_key, kind, origin_local_id, entity_rev, reason, raw_body, created_at_epoch)
VALUES ('content', 'old-doc', 'observation', '1', '1', 'pre-launch fixture', '{}', 1)
`).run();
db.prepare(`
INSERT INTO sync_entity_heads
(entity_id, kind, origin_device_id, origin_local_id, entity_rev,
operation_sha256, deleted, updated_at_epoch)
VALUES ('preserved-head', 'observation', 'device-self', '1', '4', 'head-hash', 0, 1)
`).run();
const insertState = db.prepare('INSERT INTO sync_state (k, v) VALUES (?, ?)');
insertState.run('cursor', '42');
insertState.run('epoch', 'pre-launch-epoch');
insertState.run('cutover_hub_url', 'https://pre-launch-hub.test');
insertState.run('parked_title:mem:old', 'stale title');
const statePath = join(tempDir, 'cloud-sync-state.json');
writeFileSync(statePath, JSON.stringify({
deviceId: 'ee1b7637-test',
lastId: 3,
lastSummaryId: 2,
lastPromptId: 2,
}));
const contentBefore = contentSnapshot(db);
const before = Date.now();
new SessionStore(db, { cloudSyncStatePath: statePath });
// Reproduce a database created before the v47 launch boundary landed.
db.run('DELETE FROM schema_versions WHERE version = 47');
new SessionStore(db);
expectStampedThroughCursors(db, before);
} finally {
db.close();
}
});
it('stamps nothing when no state file exists', () => {
const db = new Database(':memory:');
try {
seedPreSyncedAtDb(db, 35);
new SessionStore(db, { cloudSyncStatePath: missingStatePath });
for (const table of SYNCED_TABLES) {
expect(columnNames(db, table).has('synced_at')).toBe(true);
expect(stampedCount(db, table)).toBe(0);
expect(contentSnapshot(db)).toEqual(contentBefore);
for (const table of ['session_summaries', 'user_prompts']) {
expect(stampedCount(db, table)).toBe(1);
}
expect(db.prepare(`
SELECT title, synced_at, origin_device_id FROM observations ORDER BY id
`).all()).toEqual([
{ title: null, synced_at: expect.any(Number), origin_device_id: null },
{ title: 'already stamped', synced_at: 777, origin_device_id: null },
{ title: 'replica', synced_at: null, origin_device_id: 'device-other' },
]);
expect(rowCount(db, 'sync_outbox')).toBe(0);
expect(rowCount(db, 'sync_content_outbox')).toBe(0);
expect(rowCount(db, 'sync_dead_letter')).toBe(0);
expect(rowCount(db, 'sync_state')).toBe(0);
expect(rowCount(db, 'sync_entity_heads')).toBe(1);
expect(db.prepare(`
SELECT kind, origin_local_id, through_rev
FROM sync_launch_exclusions
ORDER BY kind, origin_local_id
`).all()).toEqual([
{ kind: 'observation', origin_local_id: '1', through_rev: '1' },
{ kind: 'observation', origin_local_id: '2', through_rev: '1' },
{ kind: 'prompt', origin_local_id: '1', through_rev: '1' },
{ kind: 'summary', origin_local_id: '1', through_rev: '1' },
]);
expect(db.prepare('SELECT entity_rev, operation_sha256 FROM sync_entity_heads').get())
.toEqual({ entity_rev: '4', operation_sha256: 'head-hash' });
} finally {
db.close();
}
});
it('does not re-run stamping once the columns exist, even if a state file appears later', () => {
it('preserves excluded pre-launch revisions across epoch changes while requeueing post-launch native rows', () => {
const db = new Database(':memory:');
let sync: CloudSync | null = null;
try {
new SessionStore(db, { cloudSyncStatePath: missingStatePath });
new SessionStore(db);
seedRows(db);
const statePath = join(tempDir, 'cloud-sync-state.json');
writeFileSync(statePath, JSON.stringify({ deviceId: 'late', lastId: 5, lastSummaryId: 3, lastPromptId: 4 }));
// Reproduce a database whose content existed when the one-time launch
// boundary was applied.
db.run('DELETE FROM schema_versions WHERE version IN (47, 48)');
db.run('DELETE FROM sync_launch_exclusions');
new SessionStore(db);
new SessionStore(db, { cloudSyncStatePath: statePath });
const baseline = Object.fromEntries(SYNCED_TABLES.map(table => [
table,
db.prepare(`SELECT id, synced_at FROM ${table} ORDER BY id`).all(),
]));
expect(rowCount(db, 'sync_launch_exclusions')).toBe(3);
const now = new Date().toISOString();
const epoch = Date.now() + 10_000;
db.prepare(`
INSERT INTO observations
(memory_session_id, project, type, title, content_hash, created_at, created_at_epoch, synced_at)
VALUES ('memory-sync', 'sync-project', 'discovery', 'post-launch observation',
'post-launch-hash', ?, ?, ?)
`).run(now, epoch, epoch);
db.prepare(`
INSERT INTO session_summaries
(memory_session_id, project, request, created_at, created_at_epoch, synced_at)
VALUES ('memory-sync', 'sync-project', 'post-launch summary', ?, ?, ?)
`).run(now, epoch + 1, epoch + 1);
db.prepare(`
INSERT INTO user_prompts
(content_session_id, prompt_number, prompt_text, created_at, created_at_epoch, synced_at)
VALUES ('content-sync', 2, 'post-launch prompt', ?, ?, ?)
`).run(now, epoch + 2, epoch + 2);
const apply = new SyncApply(db, { deviceId: 'epoch-boundary-device' });
expect(apply.handleEpoch('epoch-one')).toBe(false);
expect(apply.handleEpoch('epoch-two')).toBe(true);
for (const table of SYNCED_TABLES) {
expect(stampedCount(db, table)).toBe(0);
const rows = db.prepare(`SELECT id, synced_at FROM ${table} ORDER BY id`).all() as Array<{
id: number;
synced_at: number | null;
}>;
expect(rows[0]).toEqual((baseline[table] as Array<{ id: number; synced_at: number }>)[0]);
expect(rows.at(-1)?.synced_at).toBeNull();
expect(db.prepare(`
SELECT id FROM ${table}
WHERE synced_at IS NULL AND origin_device_id IS NULL
`).all()).toEqual([{ id: rows.at(-1)?.id }]);
}
sync = new CloudSync(db, {
CLAUDE_MEM_CLOUD_SYNC_TOKEN: 'test-token',
CLAUDE_MEM_CLOUD_SYNC_USER_ID: 'test-user',
CLAUDE_MEM_CLOUD_SYNC_HUB_URL: 'https://hub.test',
CLAUDE_MEM_CLOUD_SYNC_DEVICE_ID: 'epoch-boundary-device',
});
expect(sync.status().pending).toEqual({
observations: 1,
summaries: 1,
prompts: 1,
mutations: 0,
tombstones: 0,
});
// The exclusion is revision-bounded, not a permanent row-id ban. A
// post-launch edit of a baseline row has a higher native revision and
// must re-enter a later rebuilt Hub log.
db.prepare(`
UPDATE observations SET sync_rev = '2', synced_at = ? WHERE id = 1
`).run(epoch + 3);
expect(apply.handleEpoch('epoch-three')).toBe(true);
expect(db.prepare('SELECT synced_at FROM observations WHERE id = 1').get())
.toEqual({ synced_at: null });
} finally {
sync?.stop();
db.close();
}
});
it('stamps nothing when the state file contains the JSON literal null', () => {
it('repairs an earlier v47 database without excluding later writes', () => {
const db = new Database(':memory:');
try {
seedPreSyncedAtDb(db, 38);
new SessionStore(db);
const appliedAt = (db.prepare(`
SELECT applied_at FROM schema_versions WHERE version = 47
`).get() as { applied_at: string }).applied_at;
const boundaryMs = Date.parse(appliedAt);
const statePath = join(tempDir, 'cloud-sync-state.json');
writeFileSync(statePath, 'null');
expect(() => new SessionStore(db, { cloudSyncStatePath: statePath })).not.toThrow();
// The migration must complete: version recorded, columns added, no rows stamped.
const version = db.prepare('SELECT version FROM schema_versions WHERE version = 39').get() as { version: number } | undefined;
expect(version?.version).toBe(39);
for (const table of SYNCED_TABLES) {
expect(columnNames(db, table).has('synced_at')).toBe(true);
expect(stampedCount(db, table)).toBe(0);
}
} finally {
db.close();
}
});
it('stamps nothing when the state file is unreadable JSON', () => {
const db = new Database(':memory:');
try {
seedPreSyncedAtDb(db, 35);
const statePath = join(tempDir, 'cloud-sync-state.json');
writeFileSync(statePath, 'not json{');
expect(() => new SessionStore(db, { cloudSyncStatePath: statePath })).not.toThrow();
expect(stampedCount(db, 'observations')).toBe(0);
} finally {
db.close();
}
});
});
describe('SessionStore v40 prompt requeue (one-time cloud repair)', () => {
let tempDir: string;
let missingStatePath: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'claude-mem-v40-requeue-'));
missingStatePath = join(tempDir, 'does-not-exist.json');
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it('records version 40 and never re-nulls prompts stamped after the repair ran', () => {
const db = new Database(':memory:');
try {
new SessionStore(db, { cloudSyncStatePath: missingStatePath });
const version = db.prepare('SELECT version FROM schema_versions WHERE version = 40').get() as { version: number } | undefined;
expect(version?.version).toBe(40);
// Prompts synced through the FIXED mapper after the repair must keep
// their stamps across restarts — a repeat requeue would re-push the
// whole history on every worker boot.
seedRows(db);
db.run('UPDATE user_prompts SET synced_at = 1751234567890');
new SessionStore(db, { cloudSyncStatePath: missingStatePath });
for (const table of SYNCED_TABLES) db.run(`UPDATE ${table} SET synced_at = ${boundaryMs}`);
db.prepare(`
INSERT INTO observations
(memory_session_id, project, type, title, content_hash, created_at, created_at_epoch, synced_at)
VALUES ('memory-sync', 'sync-project', 'discovery', 'later write', 'later-write-hash',
?, ?, ?)
`).run(new Date(boundaryMs + 1).toISOString(), boundaryMs + 1, boundaryMs + 1);
expect(stampedCount(db, 'user_prompts')).toBe(4);
// Earlier v47 builds had only the applied_at boundary and stamps, not
// the explicit revision ledger introduced by v48.
db.run('DROP TABLE sync_launch_exclusions');
db.run('DELETE FROM schema_versions WHERE version = 48');
new SessionStore(db);
expect(rowCount(db, 'sync_launch_exclusions')).toBe(3);
expect(db.prepare(`
SELECT origin_local_id FROM sync_launch_exclusions
WHERE kind = 'observation' ORDER BY origin_local_id
`).all()).toEqual([{ origin_local_id: '1' }]);
const apply = new SyncApply(db, { deviceId: 'v48-repair-device' });
expect(apply.handleEpoch('repair-one')).toBe(false);
expect(apply.handleEpoch('repair-two')).toBe(true);
expect(db.prepare(`
SELECT id, synced_at FROM observations ORDER BY id
`).all()).toEqual([
{ id: 1, synced_at: boundaryMs },
{ id: 2, synced_at: null },
]);
} finally {
db.close();
}
});
it('runs once and cannot clear post-boundary queues after restarts or lower-version migration repair', () => {
const db = new Database(':memory:');
try {
const store = new SessionStore(db);
seedRows(db);
for (const table of SYNCED_TABLES) expect(stampedCount(db, table)).toBe(0);
store.createSDKSession('content-edit', 'sync-project', 'prompt', 'Post-launch title', 'claude');
db.prepare(`
INSERT INTO sync_content_outbox
(entity_id, kind, origin_local_id, entity_rev, body, operation_sha256, deleted, created_at_epoch)
VALUES ('post-launch-doc', 'observation', '1', '1', '{}', 'post-launch-hash', 0, 2)
`).run();
db.prepare(`
INSERT INTO sync_dead_letter
(lane, queue_key, kind, origin_local_id, entity_rev, reason, raw_body, created_at_epoch)
VALUES ('content', 'post-launch-bad', 'observation', '2', '1', 'post-launch fixture', '{}', 2)
`).run();
// Simulate an older build repairing the lower v44-v46 bookkeeping rows.
// Unknown v47 remains in schema_versions, as SQLite migrations must.
db.run('DELETE FROM schema_versions WHERE version IN (44, 45, 46)');
new SessionStore(db);
for (const table of SYNCED_TABLES) expect(stampedCount(db, table)).toBe(0);
expect(rowCount(db, 'sync_outbox')).toBe(1);
expect(rowCount(db, 'sync_content_outbox')).toBe(1);
expect(rowCount(db, 'sync_dead_letter')).toBe(1);
expect((db.prepare('SELECT COUNT(*) AS n FROM schema_versions WHERE version = 47').get() as { n: number }).n).toBe(1);
new SessionStore(db);
expect(rowCount(db, 'sync_outbox')).toBe(1);
expect(rowCount(db, 'sync_content_outbox')).toBe(1);
expect(rowCount(db, 'sync_dead_letter')).toBe(1);
} finally {
db.close();
}
});
it('keeps later edits, deletes, and revives in the canonical queues across restart', async () => {
const db = new Database(':memory:');
let sync: CloudSync | null = null;
try {
const store = new SessionStore(db);
seedRows(db);
store.createSDKSession('content-edit', 'sync-project', 'prompt', 'Post-launch title', 'claude');
expect(rowCount(db, 'sync_outbox')).toBe(1);
new SessionStore(db);
expect(rowCount(db, 'sync_outbox')).toBe(1);
let seq = 0;
let failNext = false;
const fetchImpl = (async (_input: RequestInfo | URL, init?: RequestInit) => {
if (failNext) {
failNext = false;
return new Response('offline', { status: 503 });
}
const request = JSON.parse(String(init?.body)) as {
ops: Array<{ body: string; operation_sha256: string }>;
};
const acked = request.ops.map(op => {
const body = JSON.parse(op.body) as {
id: string;
kind: string;
origin_local_id: string | null;
entity_rev: string;
};
seq += 1;
return {
id: body.id,
kind: body.kind,
origin_local_id: body.origin_local_id,
entity_rev: body.entity_rev,
operation_sha256: op.operation_sha256,
seq: String(seq),
};
});
return Response.json({ acked, head_seq: String(seq), projected_seq: String(seq) });
}) as typeof fetch;
sync = new CloudSync(db, {
CLAUDE_MEM_CLOUD_SYNC_TOKEN: 'token',
CLAUDE_MEM_CLOUD_SYNC_USER_ID: 'user',
CLAUDE_MEM_CLOUD_SYNC_HUB_URL: 'https://hub.test',
CLAUDE_MEM_CLOUD_SYNC_DEVICE_ID: 'device-launch-boundary',
CLAUDE_MEM_CLOUD_SYNC_DEVICE_NAME: 'launch-boundary-test',
}, {
fetchImpl,
});
expect(sync.queueDelete('observation', '1')).toBe('2');
expect(db.prepare(`
SELECT entity_rev, deleted FROM sync_content_outbox WHERE entity_id LIKE 'observation:%'
`).get()).toEqual({ entity_rev: '2', deleted: 1 });
new SessionStore(db);
expect(rowCount(db, 'sync_content_outbox')).toBe(1);
await sync.flush();
expect(rowCount(db, 'sync_content_outbox')).toBe(0);
db.prepare(`
INSERT INTO observations
(id, memory_session_id, project, type, title, content_hash, created_at, created_at_epoch)
VALUES (1, 'memory-sync', 'sync-project', 'discovery', 'revived after launch', 'hash-revived',
'2026-07-20T00:00:02.000Z', 1752969602000)
`).run();
failNext = true;
await sync.flush();
expect(db.prepare(`
SELECT entity_rev, deleted FROM sync_content_outbox
WHERE entity_id LIKE 'observation:%' ORDER BY id DESC LIMIT 1
`).get()).toEqual({ entity_rev: '3', deleted: 0 });
const queuedBeforeRestart = rowCount(db, 'sync_content_outbox');
new SessionStore(db);
expect(rowCount(db, 'sync_content_outbox')).toBe(queuedBeforeRestart);
} finally {
sync?.stop();
db.close();
}
});
});
describe('SessionStore prompt re-push hooks (memory id lands after first sync)', () => {
let tempDir: string;
let missingStatePath: string;
let db: Database;
let store: SessionStore;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'claude-mem-prompt-requeue-'));
missingStatePath = join(tempDir, 'does-not-exist.json');
db = new Database(':memory:');
store = new SessionStore(db, { cloudSyncStatePath: missingStatePath });
store = new SessionStore(db);
const now = new Date().toISOString();
const epoch = Date.now();
@@ -482,7 +423,6 @@ describe('SessionStore prompt re-push hooks (memory id lands after first sync)',
insertSession.run('sess-1', 'mem-a', now, epoch);
insertSession.run('sess-2', 'mem-b', now, epoch);
// All prompts start out synced (as if the pre-registration push happened).
const insertPrompt = db.prepare(`
INSERT INTO user_prompts (session_db_id, content_session_id, prompt_number, prompt_text, created_at, created_at_epoch, synced_at)
VALUES (?, ?, ?, 'prompt', ?, ?, 1751234567890)
@@ -494,22 +434,18 @@ describe('SessionStore prompt re-push hooks (memory id lands after first sync)',
afterEach(() => {
db.close();
rmSync(tempDir, { recursive: true, force: true });
});
it('updateMemorySessionId requeues only that session\'s prompts', () => {
store.updateMemorySessionId(1, 'mem-a2');
const prompts = syncedAtById(db, 'user_prompts');
expect(prompts.get(1)).toBeNull();
expect(prompts.get(2)).toBeNull();
expect(prompts.get(3)).toBe(1751234567890); // other session untouched
expect(prompts.get(3)).toBe(1751234567890);
});
it('updateMemorySessionId(null) clears the mapping without requeueing', () => {
store.updateMemorySessionId(1, null);
// Re-pushing now would only re-send the fallback shape — nothing to repair.
expect(stampedCount(db, 'user_prompts')).toBe(3);
});
+23
View File
@@ -45,4 +45,27 @@ describe('skill docs placement (#1651)', () => {
expect(content).not.toContain('tree-sitter');
expect(content).not.toContain('Bundled Languages');
});
it('cloud-sync/SKILL.md requires the authenticated Hub probe for success', () => {
const path = join(SKILLS_DIR, 'cloud-sync/SKILL.md');
expect(existsSync(path)).toBe(true);
const content = readFileSync(path, 'utf-8');
expect(content).toContain('hub.reachable: true');
expect(content).toContain('hub.reachable: false');
expect(content).toContain('authenticated, read-only');
expect(content).toContain('GET /v1/sync/status');
expect(content).toContain('never appends or advances');
expect(content).not.toContain('/api/pro/sync/status');
});
it('cloud sync copy keeps the launch boundary and 4,000,000-byte request contract accurate', () => {
const docs = readFileSync(join(import.meta.dir, '../../docs/public/cloud-sync.mdx'), 'utf-8');
const source = readFileSync(join(import.meta.dir, '../../src/services/sync/CloudSync.ts'), 'utf-8');
expect(docs).toContain('up to 500 ops / 4,000,000 encoded');
expect(docs).not.toContain('500 ops / 2&nbsp;MB');
expect(source).toContain('no historical/pre-launch backfill');
expect(source).not.toContain('This IS backfill');
});
});
@@ -33,6 +33,18 @@ function buildHandler(routes: CloudSyncRoutes): (req: Request, res: Response) =>
return handler!;
}
async function invoke(
handler: (req: Request, res: Response) => void,
req: Partial<Request>,
res: Partial<Response>,
jsonSpy: ReturnType<typeof mock>,
): Promise<void> {
handler(req as Request, res as Response);
for (let index = 0; index < 100 && jsonSpy.mock.calls.length === 0; index++) {
await new Promise(resolve => setTimeout(resolve, 1));
}
}
describe('CloudSyncRoutes — GET /api/sync/status', () => {
beforeEach(() => {
loggerSpies = [
@@ -49,60 +61,80 @@ describe('CloudSyncRoutes — GET /api/sync/status', () => {
mock.restore();
});
it('returns the service status with pending counts when cloud sync is configured', () => {
it('performs the authenticated Hub probe even when pending counts are zero', async () => {
const status: CloudSyncStatus = {
configured: true,
deviceId: 'device-fixture',
pending: { observations: 3, summaries: 2, prompts: 1, mutations: 0, tombstones: 0 },
pending: { observations: 0, summaries: 0, prompts: 0, mutations: 0, tombstones: 0 },
quarantine: { count: 0, latestReason: null },
lastFlushAt: 1751990400000,
lastError: null,
hub: {
checkedAt: 1751990400100,
reachable: true,
epoch: '10',
headSeq: '20',
projectedSeq: '20',
error: null,
},
};
const probe = mock(async () => status);
const mockDbManager = {
getCloudSync: () => ({ status: () => status }),
getCloudSync: () => ({ statusWithHubProbe: probe }),
};
const handler = buildHandler(new CloudSyncRoutes(mockDbManager as any));
const { req, res, jsonSpy, statusSpy } = createMockReqRes();
handler(req as Request, res as Response);
await invoke(handler, req, res, jsonSpy);
expect(probe).toHaveBeenCalledTimes(1);
expect(jsonSpy).toHaveBeenCalledTimes(1);
expect(jsonSpy).toHaveBeenCalledWith(status);
expect(statusSpy).not.toHaveBeenCalled(); // implicit 200
});
it('returns {configured: false} with 200 (not 500) when no service exists', () => {
it('returns {configured: false} with 200 (not 500) when no service exists', async () => {
const mockDbManager = {
getCloudSync: () => null,
};
const handler = buildHandler(new CloudSyncRoutes(mockDbManager as any));
const { req, res, jsonSpy, statusSpy } = createMockReqRes();
handler(req as Request, res as Response);
await invoke(handler, req, res, jsonSpy);
expect(jsonSpy).toHaveBeenCalledTimes(1);
expect(jsonSpy).toHaveBeenCalledWith({ configured: false });
expect(statusSpy).not.toHaveBeenCalled(); // no error status set
});
it('never leaks the sync token in the response payload', () => {
it('surfaces Hub probe failure and never leaks the sync token', async () => {
const mockDbManager = {
getCloudSync: () => ({
status: () => ({
statusWithHubProbe: async () => ({
configured: true,
deviceId: 'device-fixture',
pending: { observations: 0, summaries: 0, prompts: 0 },
pending: { observations: 0, summaries: 0, prompts: 0, mutations: 0, tombstones: 0 },
quarantine: { count: 0, latestReason: null },
lastFlushAt: null,
lastError: null,
hub: {
checkedAt: 1751990400100,
reachable: false,
epoch: null,
headSeq: null,
projectedSeq: null,
error: 'sync hub status 401: denied',
},
}),
}),
};
const handler = buildHandler(new CloudSyncRoutes(mockDbManager as any));
const { req, res, jsonSpy } = createMockReqRes();
handler(req as Request, res as Response);
await invoke(handler, req, res, jsonSpy);
const payload = (jsonSpy.mock.calls[0] as unknown[])[0] as Record<string, unknown>;
expect(payload.hub).toMatchObject({ reachable: false, error: 'sync hub status 401: denied' });
const keys = Object.keys(payload).map(k => k.toLowerCase());
expect(keys.some(k => k.includes('token'))).toBe(false);
expect(JSON.stringify(payload).toLowerCase()).not.toContain('token');
@@ -18,7 +18,7 @@ describe('DataRoutes synchronized delete APIs', () => {
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'cmem-delete-routes-'));
db = new Database(':memory:');
store = new SessionStore(db, { cloudSyncStatePath: join(tempDir, 'missing-state.json') });
store = new SessionStore(db);
sync = new CloudSync(db, {
CLAUDE_MEM_CLOUD_SYNC_TOKEN: 'test-token',
CLAUDE_MEM_CLOUD_SYNC_USER_ID: 'test-user',
@@ -27,7 +27,6 @@ describe('DataRoutes synchronized delete APIs', () => {
CLAUDE_MEM_CLOUD_SYNC_DEVICE_NAME: 'test',
}, {
settingsPath: join(tempDir, 'settings.json'),
legacyStatePath: join(tempDir, 'missing-state.json'),
fetchImpl: mock(async () => new Response('{}', { status: 500 })) as typeof fetch,
});
+84 -54
View File
@@ -8,7 +8,7 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { Database } from 'bun:sqlite';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { SessionStore } from '../../../src/services/sqlite/SessionStore.js';
@@ -127,7 +127,6 @@ describe('CloudSync', () => {
let db: Database;
let store: SessionStore;
let settingsPath: string;
let missingLegacyPath: string;
function makeSettings(overrides: Partial<CloudSyncSettingKeys> = {}): CloudSyncSettingKeys {
return {
@@ -148,7 +147,6 @@ describe('CloudSync', () => {
return new CloudSync(db, makeSettings(settingsOverrides), {
fetchImpl,
settingsPath,
legacyStatePath: missingLegacyPath,
debounceMs: 25,
backoffInitialMs: 20,
backoffMaxMs: 200,
@@ -305,9 +303,8 @@ describe('CloudSync', () => {
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'claude-mem-cloud-sync-'));
settingsPath = join(tempDir, 'settings.json');
missingLegacyPath = join(tempDir, 'no-such-cloud-sync-state.json');
db = new Database(':memory:');
store = new SessionStore(db, { cloudSyncStatePath: missingLegacyPath });
store = new SessionStore(db);
db.prepare(`
INSERT INTO sdk_sessions (content_session_id, memory_session_id, project, started_at, started_at_epoch, status)
VALUES ('sess-abc', 'mem-1', 'proj-x', ?, 1751234567000, 'active')
@@ -530,6 +527,85 @@ describe('CloudSync', () => {
expect(status.lastError).toBeNull();
});
it('authenticates a read-only Hub status probe even when the local queue is empty', async () => {
const calls: Array<{ url: string; method: string; headers: Headers; body: unknown; hasSignal: boolean }> = [];
const impl = (async (input: RequestInfo | URL, init?: RequestInit) => {
calls.push({
url: String(input),
method: init?.method ?? 'GET',
headers: new Headers(init?.headers),
body: init?.body,
hasSignal: init?.signal != null,
});
return Response.json({
protocol_version: 2,
epoch: '18446744073709551615',
head_seq: '9007199254740993',
projected_seq: '9007199254740993',
op_count: 7,
device_count: 2,
});
}) as typeof fetch;
const sync = makeCloudSync(impl);
// An empty drain performs no write request and therefore proves nothing
// about connectivity. The status route's probe must still hit the Hub.
await sync.flush();
expect(calls).toHaveLength(0);
const status = await sync.statusWithHubProbe();
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe('https://hub.test/v1/sync/status');
expect(calls[0].method).toBe('GET');
expect(calls[0].body).toBeUndefined();
expect(calls[0].hasSignal).toBe(true);
expect(calls[0].headers.get('Authorization')).toBe('Bearer test-token-1234');
expect(calls[0].headers.get('X-User-Id')).toBe('user-42');
expect(calls[0].headers.get('X-Device-Id')).toBe('device-fixture');
expect(calls[0].headers.get('X-Device-Name')).toBe('test-host');
expect(status.pending).toEqual({ observations: 0, summaries: 0, prompts: 0, mutations: 0, tombstones: 0 });
expect(status.hub).toMatchObject({
reachable: true,
epoch: '18446744073709551615',
headSeq: '9007199254740993',
projectedSeq: '9007199254740993',
error: null,
});
expect(status.hub.checkedAt).toBeNumber();
});
it('surfaces Hub authentication, network, and malformed-status failures without leaking the token', async () => {
const scenarios: Array<{ response: Response | Error; error: RegExp }> = [
{
response: new Response('denied test-token-1234', { status: 401 }),
error: /sync hub status 401: denied \[REDACTED\]/,
},
{ response: new Error('connect ECONNREFUSED'), error: /ECONNREFUSED/ },
{
response: Response.json({ protocol_version: 2, epoch: '1', head_seq: '2', projected_seq: '3' }),
error: /projected_seq exceeds head_seq/,
},
];
for (const scenario of scenarios) {
const impl = (async () => {
if (scenario.response instanceof Error) throw scenario.response;
return scenario.response.clone();
}) as typeof fetch;
const sync = makeCloudSync(impl);
const status = await sync.statusWithHubProbe();
expect(status.hub).toMatchObject({
reachable: false,
epoch: null,
headSeq: null,
projectedSeq: null,
});
expect(status.hub.error).toMatch(scenario.error);
expect(JSON.stringify(status)).not.toContain('test-token-1234');
expect(status.lastError).toBeNull();
sync.stop();
}
});
it('packs oversized pages into multiple bodies, each under the request cap', async () => {
// Each operation stays under the 256KB canonical-body cap while the
// combined page crosses the 4MB request packing budget.
@@ -1323,40 +1399,14 @@ describe('CloudSync', () => {
});
describe('device id resolution', () => {
it('adopts the legacy cloud-sync-state.json deviceId and never mints a new one', () => {
const legacyPath = join(tempDir, 'cloud-sync-state.json');
writeFileSync(legacyPath, JSON.stringify({
deviceId: 'legacy-dev-123',
lastId: 10,
lastSummaryId: 2,
lastPromptId: 3,
}));
it('uses the settings-configured device id without rewriting settings', () => {
const { impl } = makeFetchMock();
const sync = makeCloudSync(impl, { CLAUDE_MEM_CLOUD_SYNC_DEVICE_ID: '' }, { legacyStatePath: legacyPath });
expect(sync.status().deviceId).toBe('legacy-dev-123');
// Persisted back to settings so future starts skip legacy resolution.
const persisted = JSON.parse(readFileSync(settingsPath, 'utf-8'));
expect(persisted.CLAUDE_MEM_CLOUD_SYNC_DEVICE_ID).toBe('legacy-dev-123');
// A second instance resolving from scratch adopts the SAME id.
const again = makeCloudSync(impl, { CLAUDE_MEM_CLOUD_SYNC_DEVICE_ID: '' }, { legacyStatePath: legacyPath });
expect(again.status().deviceId).toBe('legacy-dev-123');
});
it('prefers the settings-configured device id over the legacy file', () => {
const legacyPath = join(tempDir, 'cloud-sync-state.json');
writeFileSync(legacyPath, JSON.stringify({ deviceId: 'legacy-dev-123' }));
const { impl } = makeFetchMock();
const sync = makeCloudSync(impl, { CLAUDE_MEM_CLOUD_SYNC_DEVICE_ID: 'settings-dev-9' }, { legacyStatePath: legacyPath });
const sync = makeCloudSync(impl, { CLAUDE_MEM_CLOUD_SYNC_DEVICE_ID: 'settings-dev-9' });
expect(sync.status().deviceId).toBe('settings-dev-9');
// No resolution ran, so nothing was persisted.
expect(existsSync(settingsPath)).toBe(false);
});
it('mints a UUID and persists it when neither settings nor legacy state exist', () => {
it('mints a UUID and persists it when settings have no device id', () => {
const { impl } = makeFetchMock();
const sync = makeCloudSync(impl, { CLAUDE_MEM_CLOUD_SYNC_DEVICE_ID: '' });
@@ -1366,26 +1416,6 @@ describe('CloudSync', () => {
const persisted = JSON.parse(readFileSync(settingsPath, 'utf-8'));
expect(persisted.CLAUDE_MEM_CLOUD_SYNC_DEVICE_ID).toBe(deviceId);
});
it('fails closed (no uploads, no minting) when the legacy state file is corrupt', async () => {
seedObservation();
const legacyPath = join(tempDir, 'cloud-sync-state.json');
writeFileSync(legacyPath, 'not json{');
const { impl, calls } = makeFetchMock();
const sync = makeCloudSync(impl, { CLAUDE_MEM_CLOUD_SYNC_DEVICE_ID: '' }, { legacyStatePath: legacyPath });
sync.start();
sync.notify();
await sleep(120);
expect(calls.length).toBe(0);
expect(sync.status().deviceId).toBe('');
expect(sync.status().lastError).toContain('legacy cloud-sync state unreadable');
expect(pendingCount('observations')).toBe(1);
// Nothing persisted — a new id here would fork every cloud row.
expect(existsSync(settingsPath)).toBe(false);
});
});
describe('sync-mode piggyback (kill switch, plan Phase 5 task 2)', () => {
-145
View File
@@ -1,145 +0,0 @@
// Phase 3 verification (plan 2026-07-17, task 5): the settings-conditional
// hub-cutover one-shot, keyed on HUB IDENTITY (sync_state 'cutover_hub_url'),
// not a burnable version number. It must fire exactly once per (DB, hub URL):
// first configuration fires it, same-URL reboots are no-ops, and a DIFFERENT
// hub URL later fires it again — otherwise a device pointed at a new hub
// would never push its corpus into the new (empty) log while every counter
// read healthy. The requeue and the identity write commit in one
// transaction; settings-less constructors stay inert.
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { Database } from 'bun:sqlite';
import { SessionStore } from '../../../src/services/sqlite/SessionStore.js';
const ISO = '2026-07-09T00:00:00.000Z';
describe('hub cutover one-shot (hub-identity keyed)', () => {
let tempDir: string;
let dbPath: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'claude-mem-hub-cutover-'));
dbPath = join(tempDir, 'claude-mem.db');
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
function open(hubUrl?: string): SessionStore {
return new SessionStore(dbPath, {
cloudSyncStatePath: join(tempDir, 'none.json'),
...(hubUrl !== undefined ? { cloudSyncHubUrl: hubUrl } : {}),
});
}
function seed(store: SessionStore): void {
store.db.prepare(`
INSERT INTO sdk_sessions (content_session_id, memory_session_id, project, started_at, started_at_epoch, status)
VALUES ('sess-1', 'mem-1', 'proj-x', ?, 1751234567000, 'active')
`).run(ISO);
// A native row already pushed through some earlier lane…
store.db.prepare(`
INSERT INTO observations (memory_session_id, project, type, title, created_at, created_at_epoch, synced_at)
VALUES ('mem-1', 'proj-x', 'discovery', 'native', ?, 1751234567890, 111)
`).run(ISO);
// …and a replica applied from the hub (another device's corpus).
store.db.prepare(`
INSERT INTO observations (memory_session_id, project, type, title, created_at, created_at_epoch, synced_at, origin_device_id, origin_local_id)
VALUES ('mem-1', 'proj-x', 'discovery', 'replica', ?, 1751234567891, 222, 'device-other', '9')
`).run(ISO);
}
function syncedAt(db: Database, title: string): number | null {
return (db.prepare('SELECT synced_at FROM observations WHERE title = ?').get(title) as { synced_at: number | null }).synced_at;
}
function cutoverHubUrl(db: Database): string | null {
const row = db.prepare(`SELECT v FROM sync_state WHERE k = 'cutover_hub_url'`).get() as { v: string } | undefined;
return row?.v ?? null;
}
it('stays inert while the hub URL is absent (settings-less constructors included)', () => {
const store = open();
seed(store);
expect(cutoverHubUrl(store.db)).toBeNull();
store.db.close();
const again = open(); // e.g. a CLI-path constructor that knows no settings
expect(cutoverHubUrl(again.db)).toBeNull();
expect(syncedAt(again.db, 'native')).toBe(111);
again.db.close();
});
it('fires when the hub URL first appears: native rows requeued, replicas untouched, identity stored', () => {
const before = open();
seed(before);
before.db.close();
const cutover = open('https://hub-one.test');
expect(cutoverHubUrl(cutover.db)).toBe('https://hub-one.test');
expect(syncedAt(cutover.db, 'native')).toBeNull(); // re-pushes its own corpus
expect(syncedAt(cutover.db, 'replica')).toBe(222); // another device's corpus — never ours to push
// Legacy bookkeeping marker still recorded (not consulted for gating).
expect(cutover.db.prepare('SELECT version FROM schema_versions WHERE version = 43').get()).not.toBeNull();
cutover.db.close();
});
it('is a no-op on later boots with the same hub URL (trailing-slash variants included)', () => {
const first = open('https://hub-one.test');
seed(first);
// Simulate the drain re-stamping after the cutover push.
first.db.prepare(`UPDATE observations SET synced_at = 333 WHERE title = 'native'`).run();
first.db.close();
const same = open('https://hub-one.test');
expect(syncedAt(same.db, 'native')).toBe(333);
same.db.close();
const slashed = open('https://hub-one.test///');
expect(syncedAt(slashed.db, 'native')).toBe(333); // normalized — same identity
slashed.db.close();
});
it('re-fires exactly once when the hub URL CHANGES, and clearing the URL is inert', () => {
const first = open('https://hub-one.test');
seed(first);
first.db.prepare(`UPDATE observations SET synced_at = 333 WHERE title = 'native'`).run();
first.db.close();
// (a) URL cleared: sync is OFF; nothing fires, identity is retained.
const cleared = open('');
expect(syncedAt(cleared.db, 'native')).toBe(333);
expect(cutoverHubUrl(cleared.db)).toBe('https://hub-one.test');
cleared.db.close();
// A DIFFERENT hub appears: its log has none of our corpus — re-fire.
const second = open('https://hub-two.test');
expect(cutoverHubUrl(second.db)).toBe('https://hub-two.test');
expect(syncedAt(second.db, 'native')).toBeNull();
expect(syncedAt(second.db, 'replica')).toBe(222);
second.db.prepare(`UPDATE observations SET synced_at = 444 WHERE title = 'native'`).run();
second.db.close();
// Same second URL again: exactly-once per (DB, hub URL).
const again = open('https://hub-two.test');
expect(syncedAt(again.db, 'native')).toBe(444);
again.db.close();
});
it('self-heals pre-fix DBs that burned v43 without a stored hub identity', () => {
const legacy = open();
seed(legacy);
// Simulate the pre-fix state: v43 burned, no cutover_hub_url row.
legacy.db.prepare(`INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (43, ?)`).run(ISO);
legacy.db.close();
const healed = open('https://hub-one.test');
expect(syncedAt(healed.db, 'native')).toBeNull(); // one extra re-push; hub dedupe makes it safe
expect(cutoverHubUrl(healed.db)).toBe('https://hub-one.test');
healed.db.close();
});
});
+5 -5
View File
@@ -66,7 +66,7 @@ describe('mutation sites', () => {
beforeEach(() => {
db = new Database(':memory:');
store = new SessionStore(db, { cloudSyncStatePath: join(tempDir, 'none.json') });
store = new SessionStore(db);
});
afterEach(() => db.close());
@@ -119,7 +119,7 @@ describe('mutation sites', () => {
beforeEach(() => {
db = new Database(':memory:');
store = new SessionStore(db, { cloudSyncStatePath: join(tempDir, 'none.json') });
store = new SessionStore(db);
db.prepare(`
INSERT INTO sdk_sessions (content_session_id, memory_session_id, project, platform_source, started_at, started_at_epoch, status)
VALUES ('sess-1', NULL, 'proj-x', 'claude', ?, 1751234567000, 'active')
@@ -211,7 +211,7 @@ describe('mutation sites', () => {
beforeEach(() => {
dbPath = join(tempDir, 'claude-mem.db');
const store = new SessionStore(dbPath, { cloudSyncStatePath: join(tempDir, 'none.json') });
const store = new SessionStore(dbPath);
store.db.prepare(`
INSERT INTO sdk_sessions (content_session_id, memory_session_id, project, started_at, started_at_epoch, status)
VALUES ('sess-1', 'mem-1', 'parent/wt', ?, 1751234567000, 'active')
@@ -332,7 +332,7 @@ describe('mutation sites', () => {
const dataDir = join(tempDir, 'data');
mkdirSync(dataDir);
const dbPath = join(dataDir, 'claude-mem.db');
const store = new SessionStore(dbPath, { cloudSyncStatePath: join(tempDir, 'none.json') });
const store = new SessionStore(dbPath);
store.db.prepare(`
INSERT INTO sdk_sessions (content_session_id, memory_session_id, project, started_at, started_at_epoch, status)
VALUES ('sess-1', 'mem-1', 'mainrepo/wt', ?, 1751234567000, 'active')
@@ -383,7 +383,7 @@ describe('mutation sites', () => {
const dataDir = join(tempDir, 'data');
mkdirSync(dataDir);
const dbPath = join(dataDir, 'claude-mem.db');
const store = new SessionStore(dbPath, { cloudSyncStatePath: join(tempDir, 'none.json') });
const store = new SessionStore(dbPath);
store.db.prepare(`
INSERT INTO sdk_sessions (content_session_id, memory_session_id, project, started_at, started_at_epoch, status)
VALUES ('sess-1', 'mem-1', 'stale-name', ?, 1751234567000, 'active')
+2 -5
View File
@@ -109,7 +109,6 @@ describe('SyncApply', () => {
let tempDir: string;
let db: Database;
let settingsPath: string;
let missingLegacyPath: string;
function makeApply(options: { deviceId?: string; chromaSync?: ChromaSyncLike | null } = {}): SyncApply {
return new SyncApply(db, {
@@ -159,9 +158,8 @@ describe('SyncApply', () => {
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'claude-mem-sync-apply-'));
settingsPath = join(tempDir, 'settings.json');
missingLegacyPath = join(tempDir, 'no-such-cloud-sync-state.json');
db = new Database(':memory:');
new SessionStore(db, { cloudSyncStatePath: missingLegacyPath });
new SessionStore(db);
db.prepare(`
INSERT INTO sdk_sessions (content_session_id, memory_session_id, project, started_at, started_at_epoch, status)
VALUES ('sess-abc', 'mem-1', 'proj-x', ?, 1751234567000, 'active')
@@ -202,7 +200,7 @@ describe('SyncApply', () => {
});
it('is idempotent — re-running the constructor changes nothing', () => {
new SessionStore(db, { cloudSyncStatePath: missingLegacyPath });
new SessionStore(db);
const cols = db.query('PRAGMA table_info(observations)').all() as Array<{ name: string }>;
expect(cols.filter(c => c.name === 'origin_device_id').length).toBe(1);
});
@@ -497,7 +495,6 @@ describe('SyncApply', () => {
const sync = new CloudSync(db, makeSettings(), {
fetchImpl,
settingsPath,
legacyStatePath: missingLegacyPath,
debounceMs: 25,
backoffInitialMs: 20,
backoffMaxMs: 200,
+4 -2
View File
@@ -147,6 +147,7 @@ describe('SyncClient advisory WebSocket', () => {
token: 'test-token-1234',
userId: 'user-42',
deviceId: SELF,
deviceName: 'test laptop',
fetchImpl,
webSocketImpl: ws,
// Slow poll tiers by default: WS behavior must not hide behind polls.
@@ -176,7 +177,7 @@ describe('SyncClient advisory WebSocket', () => {
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'claude-mem-sync-ws-'));
db = new Database(':memory:');
new SessionStore(db, { cloudSyncStatePath: join(tempDir, 'no-legacy.json') });
new SessionStore(db);
apply = new SyncApply(db, { deviceId: SELF });
clients = [];
});
@@ -187,7 +188,7 @@ describe('SyncClient advisory WebSocket', () => {
rmSync(tempDir, { recursive: true, force: true });
});
it('connects to the ws URL with the exact auth header trio', async () => {
it('connects to the ws URL with auth and device metadata headers', async () => {
const { impl } = makeHub({ epoch: '1' });
const { ctor, sockets } = makeWsFactory();
makeClient(impl, ctor).start();
@@ -199,6 +200,7 @@ describe('SyncClient advisory WebSocket', () => {
'Authorization': 'Bearer test-token-1234',
'X-User-Id': 'user-42',
'X-Device-Id': SELF,
'X-Device-Name': 'test laptop',
});
});
+3 -1
View File
@@ -79,6 +79,7 @@ describe('SyncClient', () => {
token: 'test-token-1234',
userId: 'user-42',
deviceId: SELF,
deviceName: 'test laptop',
fetchImpl,
// This suite covers the HTTP lanes exactly as they behave with the
// advisory socket absent (prime directive #2: deleting the socket path
@@ -108,7 +109,7 @@ describe('SyncClient', () => {
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'claude-mem-sync-client-'));
db = new Database(':memory:');
new SessionStore(db, { cloudSyncStatePath: join(tempDir, 'no-legacy.json') });
new SessionStore(db);
apply = new SyncApply(db, { deviceId: SELF });
clients = [];
});
@@ -133,6 +134,7 @@ describe('SyncClient', () => {
expect(state.requests[0].headers['Authorization']).toBe('Bearer test-token-1234');
expect(state.requests[0].headers['X-User-Id']).toBe('user-42');
expect(state.requests[0].headers['X-Device-Id']).toBe(SELF);
expect(state.requests[0].headers['X-Device-Name']).toBe('test laptop');
});
it('loops while more=true, presenting the advanced cursor each page', async () => {
+30 -3
View File
@@ -2,8 +2,9 @@
Production deployment steps for the two-lane sync hub (plan
`plans/2026-07-17-phase5-two-lane-sync.md`). Everything below is a
**production-only** action: local dev, vitest, and the e2e scripts need none
of it (they run against `wrangler dev` with `.dev.vars`).
**production-only** action: local Vitest and the canonical matrix E2E need none
of it. The matrix starts an ephemeral local Miniflare Worker/SQLite Durable
Object plus loopback-only verifier/projector sidecars.
Prime directive for every knob in this file: **cost guardrails are
structural — watchdog trips → poll mode, never "stop working."** A tripped
@@ -115,7 +116,33 @@ or endpoint that can enable deletion. Keep the full ordered log until a
snapshot/reset bootstrap protocol exists, so a newly first-seen device at
cursor `0` can always replay contiguous history.
### 1.4 Local Pro/Hub Miniflare E2E hook
### 1.4 Device admission ceiling
The Durable Object admits at most 64 distinct device ids per user, atomically,
across push, pull, and WebSocket touch paths. A 65th id on an admitting path
returns `409 {"error":"device_limit_exceeded"}`; already-registered devices
continue normally. Public status and internal metadata are read-only for an
unknown id, so connectivity probes cannot consume or exhaust device slots;
status may refresh last-seen/name only for an already-registered device.
Unknown-device renames also create nothing. Treat the stable 409 from an
admitting path as an account/device-management condition, not a retriable
transport failure.
### 1.5 Local Pro/Hub Miniflare E2E hook
The canonical client matrix is the safe default from the repository root:
```sh
npm run e2e:sync-matrix
```
It drives exactly two file-backed client stacks against the actual bundled
Worker and SQLite Durable Object. The Hub, token verifier, and projector all
bind ephemeral `127.0.0.1` ports; runtime guards reject any non-loopback URL.
It covers WebSocket hints plus HTTP authority, concurrent writes, offline
retry, restart, delete/revive, all three mutation types, decimal cursors, and
the projection checkpoint reaching the head. The runner owns clean shutdown
and leaves no persistent Hub or client state.
`test/miniflare-pro-e2e.ts` exports
`createSyncHubMiniflareE2EOptions(...)`. A sibling Pro Vitest config passes its
+107
View File
@@ -0,0 +1,107 @@
# SyncHub internal metadata contract
SyncHub owns device identity, last-seen state, sync cursors, and the
authoritative Turbopuffer projection checkpoint. Pro reads this payload-free
control-plane state instead of querying content tables or `pro_sync_state`.
Both routes require:
```http
Authorization: Bearer <CMEM_INTERNAL_PROJECTOR_SECRET>
Content-Type: application/json
```
Missing or incorrect credentials return `401`. Bodies are exact versioned
objects: unknown fields return `400`, and non-`POST` methods return `405`.
## Read metadata
`POST /internal/v1/sync/metadata`
```json
{ "protocol_version": 1, "user_id": "canonical-user-id" }
```
```json
{
"protocol_version": 1,
"user_id": "canonical-user-id",
"epoch": "1784531270123",
"head_seq": "42",
"projected_seq": "40",
"projection_lag_ops": "2",
"sync_health": "projector_lagging",
"devices": [
{
"device_id": "a-stable-device-id",
"name": "Alex's Laptop",
"last_seen_at": "2026-07-20T12:00:00.000Z",
"last_seen_epoch_ms": "1784548800000",
"last_ack_seq": "39",
"cursor_lag_ops": "3",
"connection_state": "disconnected"
}
]
}
```
`epoch`, every sequence/cursor, and every lag are canonical unsigned decimal
strings. They must never pass through a JavaScript `number`.
`sync_health` is `healthy` exactly when `projected_seq === head_seq`, and is
`projector_lagging` otherwise. An offline device's cursor lag is informational
and does not make projection unhealthy. Devices sort by most-recent last seen,
then by device id. This response intentionally contains no content, content
counts, local outbox depth, or migration/backfill telemetry.
Clients send `X-Device-Name` (trimmed, at most 80 characters) with their Hub
requests. The first nonempty client name is retained; a dashboard rename is
not overwritten by a later hostname header. `connection_state` reflects an
accepted advisory WebSocket at read time. Correctness never depends on it.
## Device admission bound
Each user's Hub stores at most 64 distinct device ids. Device-admitting paths
enforce the same bound: push, pull, and WebSocket upgrade (including their
optional `X-Device-Name` header). At the limit, an existing device still
updates last-seen/cursor state and continues to sync; a previously unseen id
on one of those paths receives HTTP `409` with the stable body:
```json
{ "error": "device_limit_exceeded" }
```
Admission is one atomic SQLite statement inside the per-user Durable Object,
so concurrent first-seen requests cannot overshoot 64. Metadata returns at
most 64 devices. Metadata reads and every public status request are
non-admitting: a known status device may refresh last-seen/name, while an
unknown `X-Device-Id` is ignored for persistence. This keeps repeated
authenticated connectivity probes from exhausting the cap. Renaming an
unknown device also creates nothing and remains `404`.
## Rename a device
`POST /internal/v1/sync/device-name`
```json
{
"protocol_version": 1,
"user_id": "canonical-user-id",
"device_id": "a-stable-device-id",
"name": "Desk Mac"
}
```
The name is trimmed and must contain 180 characters. The device id is trimmed
and must contain 1128 characters. A registered device returns:
```json
{
"protocol_version": 1,
"user_id": "canonical-user-id",
"device_id": "a-stable-device-id",
"name": "Desk Mac"
}
```
An unknown device returns `404`; rename never creates a phantom device.
+195 -26
View File
@@ -25,6 +25,8 @@ const DAY_MS = 24 * 60 * 60 * 1000;
const MAX_PAGE = 500;
const ADVANCE_MAX_OPS = 100;
const ADVANCE_MAX_FRAME_BYTES = 262_144;
export const MAX_DEVICES_PER_USER = 64;
export const DEVICE_LIMIT_ERROR = "device_limit_exceeded";
/** 45s Hub abort < 60s Pro platform ceiling < 90s fencing lease. */
export const PROJECTION_LEASE_MS = 90_000;
const encoder = new TextEncoder();
@@ -45,12 +47,12 @@ export interface PushResult {
head_seq: string;
}
export interface PushRefusal {
export interface HubRefusal {
refused: true;
error: string;
}
export type PushOutcome = PushResult | PushRefusal;
export type PushOutcome = PushResult | HubRefusal;
export interface ChangeOp {
seq: string;
@@ -67,6 +69,8 @@ export interface ChangesResult {
more: boolean;
}
export type ChangesOutcome = ChangesResult | HubRefusal;
export interface StatusResult {
protocol_version: 2;
epoch: string;
@@ -76,6 +80,29 @@ export interface StatusResult {
device_count: number;
}
export type StatusOutcome = StatusResult | HubRefusal;
export interface DeviceMetadata {
device_id: string;
name: string | null;
last_seen_at: string | null;
last_seen_epoch_ms: string | null;
last_ack_seq: string;
cursor_lag_ops: string;
connection_state: "connected" | "disconnected";
}
export interface HubMetadata {
protocol_version: 1;
user_id: string;
epoch: string;
head_seq: string;
projected_seq: string;
projection_lag_ops: string;
sync_health: "healthy" | "projector_lagging";
devices: DeviceMetadata[];
}
export interface ProjectionLease {
acquired: boolean;
lease_token?: string;
@@ -112,6 +139,14 @@ function projectionError(message: string): Error {
return new Error(`${PROJECTION_ERROR_PREFIX} ${message}`);
}
function deviceLimitError(): Error {
return new Error(DEVICE_LIMIT_ERROR);
}
function isDeviceLimitError(error: unknown): boolean {
return error instanceof Error && error.message === DEVICE_LIMIT_ERROR;
}
interface ValidatedOp {
body: CanonicalContentBody;
serialized: string;
@@ -204,6 +239,18 @@ export class SyncHub extends DurableObject<Env> {
}
const deviceId = (request.headers.get("X-Device-Id") ?? "").trim();
if (deviceId.length === 0) return new Response("missing X-Device-Id header", { status: 400 });
const deviceName = normalizeDeviceName(request.headers.get("X-Device-Name"));
try {
this.touchDevice(deviceId, deviceName);
} catch (error) {
if (isDeviceLimitError(error)) {
return new Response(JSON.stringify({ error: DEVICE_LIMIT_ERROR }), {
status: 409,
headers: { "Content-Type": "application/json" },
});
}
throw error;
}
const [client, server] = Object.values(new WebSocketPair());
this.ctx.acceptWebSocket(server);
server.serializeAttachment({ device_id: deviceId });
@@ -274,7 +321,7 @@ export class SyncHub extends DurableObject<Env> {
// Canonical append path and client cursor reads.
// ---------------------------------------------------------------------
async pushOps(deviceId: string, ops: PushOp[]): Promise<PushOutcome> {
async pushOps(deviceId: string, ops: PushOp[], deviceName: string | null = null): Promise<PushOutcome> {
let rows: ValidatedOp[];
try {
if (typeof deviceId !== "string" || deviceId.length === 0) throw invalid("deviceId must be non-empty");
@@ -294,6 +341,7 @@ export class SyncHub extends DurableObject<Env> {
if (error instanceof Error && error.message.startsWith(INVALID_OPS_PREFIX)) {
return { refused: true, error: error.message };
}
if (isDeviceLimitError(error)) return { refused: true, error: DEVICE_LIMIT_ERROR };
throw error;
}
@@ -304,12 +352,7 @@ export class SyncHub extends DurableObject<Env> {
const acked: AckedOp[] = [];
try {
this.ctx.storage.transactionSync(() => {
sql.exec(
`INSERT INTO devices (device_id, last_seen) VALUES (?, ?)
ON CONFLICT(device_id) DO UPDATE SET last_seen = excluded.last_seen`,
deviceId,
now,
);
this.touchDevice(deviceId, normalizeDeviceName(deviceName), now);
for (const row of rows) {
const body = row.body;
const head = sql.exec<HeadRow>(
@@ -387,6 +430,7 @@ export class SyncHub extends DurableObject<Env> {
if (error instanceof Error && error.message.startsWith(INVALID_OPS_PREFIX)) {
return { refused: true, error: error.message };
}
if (isDeviceLimitError(error)) return { refused: true, error: DEVICE_LIMIT_ERROR };
throw error;
}
@@ -394,28 +438,38 @@ export class SyncHub extends DurableObject<Env> {
return { acked, head_seq: this.headSeq() };
}
getChanges(deviceId: string, sinceSeq: string, limit = MAX_PAGE): ChangesResult {
getChanges(
deviceId: string,
sinceSeq: string,
limit = MAX_PAGE,
deviceName: string | null = null,
): ChangesOutcome {
if (typeof deviceId !== "string" || deviceId.length === 0) throw invalid("deviceId must be non-empty");
const since = assertCanonicalDecimal(sinceSeq);
const lim = Number.isFinite(limit) ? Math.min(MAX_PAGE, Math.max(1, Math.floor(limit))) : MAX_PAGE;
const head = this.headSeq();
const acknowledged = decimalMin(since, head);
const sql = this.ctx.storage.sql;
const existing = sql.exec<{ last_ack_seq: string }>(
"SELECT last_ack_seq FROM devices WHERE device_id = ?",
deviceId,
).toArray()[0];
const nextAck = existing && compareCanonicalDecimals(existing.last_ack_seq, acknowledged) > 0
? existing.last_ack_seq
: acknowledged;
sql.exec(
`INSERT INTO devices (device_id, last_seen, last_ack_seq) VALUES (?, ?, ?)
ON CONFLICT(device_id) DO UPDATE SET last_seen=excluded.last_seen,
last_ack_seq=excluded.last_ack_seq`,
deviceId,
Date.now(),
nextAck,
);
try {
this.ctx.storage.transactionSync(() => {
this.touchDevice(deviceId, normalizeDeviceName(deviceName));
const existing = sql.exec<{ last_ack_seq: string }>(
"SELECT last_ack_seq FROM devices WHERE device_id = ?",
deviceId.trim(),
).one();
const nextAck = compareCanonicalDecimals(existing.last_ack_seq, acknowledged) > 0
? existing.last_ack_seq
: acknowledged;
sql.exec(
"UPDATE devices SET last_ack_seq = ? WHERE device_id = ?",
nextAck,
deviceId.trim(),
);
});
} catch (error) {
if (isDeviceLimitError(error)) return { refused: true, error: DEVICE_LIMIT_ERROR };
throw error;
}
const rows = sql.exec<{
seq: string;
body: string;
@@ -453,7 +507,11 @@ export class SyncHub extends DurableObject<Env> {
};
}
getStatus(): StatusResult {
getStatus(deviceId: string | null = null, deviceName: string | null = null): StatusOutcome {
// Status is an authenticated read/probe, not device admission. A known
// device may refresh its display metadata, but an arbitrary X-Device-Id
// must not consume one of the account's 64 durable device slots.
if (deviceId !== null) this.touchExistingDevice(deviceId, normalizeDeviceName(deviceName));
const sql = this.ctx.storage.sql;
return {
protocol_version: 2,
@@ -465,6 +523,63 @@ export class SyncHub extends DurableObject<Env> {
};
}
getMetadata(userId: string): HubMetadata {
if (typeof userId !== "string" || userId.length === 0) throw invalid("user_id must be non-empty");
const head = this.headSeq();
const projected = this.projectedSeq();
const connected = new Set<string>();
for (const socket of this.ctx.getWebSockets()) {
try {
const attachment = socket.deserializeAttachment() as { device_id?: unknown } | null;
if (typeof attachment?.device_id === "string") connected.add(attachment.device_id);
} catch {}
}
const devices = this.ctx.storage.sql.exec<{
device_id: string;
name: string | null;
last_ack_seq: string;
last_seen: number | null;
}>(
`SELECT device_id, name, last_ack_seq, last_seen
FROM devices
ORDER BY last_seen IS NULL, last_seen DESC, device_id
LIMIT ${MAX_DEVICES_PER_USER}`,
).toArray().map((row) => {
const lastSeen = row.last_seen === null ? null : String(row.last_seen);
return {
device_id: row.device_id,
name: row.name,
last_seen_at: row.last_seen === null ? null : new Date(row.last_seen).toISOString(),
last_seen_epoch_ms: lastSeen,
last_ack_seq: row.last_ack_seq,
cursor_lag_ops: decimalLag(head, row.last_ack_seq),
connection_state: connected.has(row.device_id) ? "connected" as const : "disconnected" as const,
};
});
return {
protocol_version: 1,
user_id: userId,
epoch: this.meta("epoch"),
head_seq: head,
projected_seq: projected,
projection_lag_ops: decimalLag(head, projected),
sync_health: head === projected ? "healthy" : "projector_lagging",
devices,
};
}
renameDevice(deviceId: string, name: string): boolean {
const normalizedId = deviceId.trim();
const normalizedName = normalizeDeviceName(name);
if (normalizedId.length === 0 || normalizedId.length > 128) throw invalid("device_id must be 1-128 characters");
if (normalizedName === null) throw invalid("name must be 1-80 characters");
return this.ctx.storage.sql.exec(
"UPDATE devices SET name = ? WHERE device_id = ?",
normalizedName,
normalizedId,
).rowsWritten > 0;
}
// ---------------------------------------------------------------------
// Authoritative projection checkpoint and short per-user lease.
// ---------------------------------------------------------------------
@@ -656,6 +771,45 @@ export class SyncHub extends DurableObject<Env> {
private headSeq(): string { return this.meta("head_seq"); }
private projectedSeq(): string { return this.meta("projected_seq"); }
private touchDevice(deviceId: string, name: string | null, now = Date.now()): void {
const normalizedId = this.normalizeDeviceId(deviceId);
const result = this.ctx.storage.sql.exec(
`INSERT INTO devices (device_id, name, last_seen)
SELECT ?, ?, ?
WHERE EXISTS (SELECT 1 FROM devices WHERE device_id = ?)
OR (SELECT COUNT(*) FROM devices) < ?
ON CONFLICT(device_id) DO UPDATE SET
name=COALESCE(devices.name, excluded.name),
last_seen=excluded.last_seen`,
normalizedId,
name,
now,
normalizedId,
MAX_DEVICES_PER_USER,
);
if (result.rowsWritten === 0) throw deviceLimitError();
}
private touchExistingDevice(deviceId: string, name: string | null, now = Date.now()): void {
const normalizedId = this.normalizeDeviceId(deviceId);
this.ctx.storage.sql.exec(
`UPDATE devices
SET name = COALESCE(name, ?), last_seen = ?
WHERE device_id = ?`,
name,
now,
normalizedId,
);
}
private normalizeDeviceId(deviceId: string): string {
const normalizedId = deviceId.trim();
if (normalizedId.length === 0 || normalizedId.length > 128) {
throw invalid("deviceId must be 1-128 characters");
}
return normalizedId;
}
private meta(key: string): string {
const row = this.ctx.storage.sql.exec<{ v: string }>("SELECT v FROM meta WHERE k = ?", key).toArray()[0];
if (!row) throw new Error(`sync-hub invariant: missing meta ${key}`);
@@ -678,3 +832,18 @@ export class SyncHub extends DurableObject<Env> {
this.ctx.storage.sql.exec("DELETE FROM meta WHERE k = ?", key);
}
}
function normalizeDeviceName(value: string | null): string | null {
if (value === null) return null;
const normalized = value.trim();
if (normalized.length === 0) return null;
if (normalized.length > 80) throw invalid("device name must be at most 80 characters");
return normalized;
}
function decimalLag(head: string, cursor: string): string {
const canonicalHead = assertCanonicalDecimal(head);
const canonicalCursor = assertCanonicalDecimal(cursor);
if (compareCanonicalDecimals(canonicalCursor, canonicalHead) >= 0) return "0";
return (BigInt(canonicalHead) - BigInt(canonicalCursor)).toString(10);
}
+108 -10
View File
@@ -22,10 +22,13 @@
* authentication (canonical-userId binding) as
* every other route; the socket itself carries
* nothing durable it is a downstream hint lane.
* POST /internal/v1/sync/metadata payload-free Hub state for Pro.
* POST /internal/v1/sync/device-name rename an existing Hub device.
*/
import type { PushOp } from "./do/SyncHub";
import {
DEVICE_LIMIT_ERROR,
INVALID_OPS_PREFIX,
PROJECTION_LEASE_MS,
PROJECTION_ERROR_PREFIX,
@@ -99,6 +102,7 @@ interface AuthOk {
ok: true;
userId: string;
deviceId: string | null;
deviceName: string | null;
}
interface AuthFail {
@@ -209,6 +213,14 @@ export async function authenticateRequest(
// missing ids). Canonical operation bodies themselves are never rewritten.
const deviceIdTrimmed = (request.headers.get("X-Device-Id") ?? "").trim();
const deviceId = deviceIdTrimmed.length > 0 ? deviceIdTrimmed : null;
const deviceNameTrimmed = (request.headers.get("X-Device-Name") ?? "").trim();
if (deviceId !== null && deviceId.length > 128) {
return { ok: false, response: errorResponse(400, "X-Device-Id must be at most 128 characters") };
}
if (deviceNameTrimmed.length > 80) {
return { ok: false, response: errorResponse(400, "X-Device-Name must be at most 80 characters") };
}
const deviceName = deviceNameTrimmed.length > 0 ? deviceNameTrimmed : null;
if (!authHeader.startsWith("Bearer ")) {
return { ok: false, response: errorResponse(401, "missing bearer token") };
@@ -231,7 +243,7 @@ export async function authenticateRequest(
dependencies.logCacheFailure("get", error);
}
if (cached === "1") {
return { ok: true, userId, deviceId };
return { ok: true, userId, deviceId, deviceName };
}
let verifyRes: Response;
@@ -275,7 +287,7 @@ export async function authenticateRequest(
// could not be populated. The next request will verify upstream again.
dependencies.logCacheFailure("put", error);
}
return { ok: true, userId, deviceId };
return { ok: true, userId, deviceId, deviceName };
}
if (verifyRes.status === 401 || verifyRes.status === 403) {
return { ok: false, response: errorResponse(401, "invalid token") };
@@ -291,6 +303,7 @@ async function handlePushOps(
env: Env,
userId: string,
deviceId: string,
deviceName: string | null,
): Promise<Response> {
const raw = await request.text();
// Deliberate 413s (see the cap constants above): an oversize batch must
@@ -324,9 +337,9 @@ async function handlePushOps(
const stub = env.SYNC_HUB.getByName(userId);
try {
const result = await stub.pushOps(deviceId, ops as PushOp[]);
const result = await stub.pushOps(deviceId, ops as PushOp[], deviceName);
if ("refused" in result) {
return errorResponse(400, result.error);
return errorResponse(result.error === DEVICE_LIMIT_ERROR ? 409 : 400, result.error);
}
const projection = await drainProjection(env, userId, result.head_seq);
if (!projection.ok) {
@@ -349,6 +362,7 @@ async function handleGetChanges(
env: Env,
userId: string,
deviceId: string,
deviceName: string | null,
): Promise<Response> {
const sinceRaw = url.searchParams.get("since") ?? "0";
const limitRaw = url.searchParams.get("limit");
@@ -366,24 +380,100 @@ async function handleGetChanges(
const stub = env.SYNC_HUB.getByName(userId);
try {
const result = await stub.getChanges(deviceId, sinceRaw, limit);
const result = await stub.getChanges(deviceId, sinceRaw, limit, deviceName);
if ("refused" in result) return errorResponse(409, result.error);
return json(200, result);
} catch (e) {
return mapHubError(e);
}
}
async function handleGetStatus(env: Env, userId: string): Promise<Response> {
async function handleGetStatus(
env: Env,
userId: string,
deviceId: string | null,
deviceName: string | null,
): Promise<Response> {
const stub = env.SYNC_HUB.getByName(userId);
try {
const result = await stub.getStatus();
const result = await stub.getStatus(deviceId, deviceName);
if ("refused" in result) return errorResponse(409, result.error);
return json(200, result);
} catch (e) {
return mapHubError(e);
}
}
function hasInternalCredential(request: Request, env: Env): boolean {
const secret = env.CMEM_INTERNAL_PROJECTOR_SECRET ?? "";
return secret.length > 0 && request.headers.get("Authorization") === `Bearer ${secret}`;
}
function exactKeys(record: Record<string, unknown>, expected: string[]): boolean {
const keys = Object.keys(record).sort();
return keys.length === expected.length && keys.every((key, index) => key === expected[index]);
}
async function readInternalBody(request: Request): Promise<Record<string, unknown> | null> {
let value: unknown;
try { value = await request.json(); } catch { return null; }
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
async function handleMetadataRead(request: Request, env: Env): Promise<Response> {
if (!hasInternalCredential(request, env)) return errorResponse(401, "invalid internal credential");
const body = await readInternalBody(request);
if (
body === null
|| !exactKeys(body, ["protocol_version", "user_id"])
|| body.protocol_version !== 1
|| typeof body.user_id !== "string"
|| body.user_id.trim().length === 0
) {
return errorResponse(400, "expected exactly {protocol_version:1,user_id}");
}
const userId = body.user_id.trim();
try {
return json(200, await env.SYNC_HUB.getByName(userId).getMetadata(userId));
} catch (error) {
return mapHubError(error);
}
}
async function handleDeviceRename(request: Request, env: Env): Promise<Response> {
if (!hasInternalCredential(request, env)) return errorResponse(401, "invalid internal credential");
const body = await readInternalBody(request);
if (
body === null
|| !exactKeys(body, ["device_id", "name", "protocol_version", "user_id"])
|| body.protocol_version !== 1
|| typeof body.user_id !== "string"
|| typeof body.device_id !== "string"
|| typeof body.name !== "string"
) {
return errorResponse(400, "expected exactly {protocol_version:1,user_id,device_id,name}");
}
const userId = body.user_id.trim();
const deviceId = body.device_id.trim();
const name = body.name.trim();
if (userId.length === 0) return errorResponse(400, "user_id must be non-empty");
if (deviceId.length === 0 || deviceId.length > 128) return errorResponse(400, "device_id must be 1-128 characters");
if (name.length === 0 || name.length > 80) return errorResponse(400, "name must be 1-80 characters");
try {
const renamed = await env.SYNC_HUB.getByName(userId).renameDevice(deviceId, name);
if (!renamed) return errorResponse(404, "device not found");
return json(200, { protocol_version: 1, user_id: userId, device_id: deviceId, name });
} catch (error) {
return mapHubError(error);
}
}
function mapHubError(e: unknown): Response {
if (e instanceof Error && e.message.includes(DEVICE_LIMIT_ERROR)) {
return errorResponse(409, DEVICE_LIMIT_ERROR);
}
if (e instanceof Error && e.message.includes(INVALID_OPS_PREFIX)) {
return errorResponse(400, e.message);
}
@@ -738,6 +828,14 @@ export default {
if (request.method !== "POST") return errorResponse(405, "use POST");
return handleRepairDrain(request, env);
}
if (pathname === "/internal/v1/sync/metadata") {
if (request.method !== "POST") return errorResponse(405, "use POST");
return handleMetadataRead(request, env);
}
if (pathname === "/internal/v1/sync/device-name") {
if (request.method !== "POST") return errorResponse(405, "use POST");
return handleDeviceRename(request, env);
}
if (
pathname !== "/v1/sync/ops" &&
@@ -801,18 +899,18 @@ export default {
if (pathname === "/v1/sync/ops") {
if (request.method !== "POST") return errorResponse(405, "use POST");
if (!auth.deviceId) return errorResponse(400, "missing X-Device-Id header");
return handlePushOps(request, env, auth.userId, auth.deviceId);
return handlePushOps(request, env, auth.userId, auth.deviceId, auth.deviceName);
}
if (pathname === "/v1/sync/changes") {
if (request.method !== "GET") return errorResponse(405, "use GET");
if (!auth.deviceId) return errorResponse(400, "missing X-Device-Id header");
return handleGetChanges(url, env, auth.userId, auth.deviceId);
return handleGetChanges(url, env, auth.userId, auth.deviceId, auth.deviceName);
}
// /v1/sync/status
if (request.method !== "GET") return errorResponse(405, "use GET");
return handleGetStatus(env, auth.userId);
return handleGetStatus(env, auth.userId, auth.deviceId, auth.deviceName);
})();
if (killSwitch.tripped) {
response.headers.set(SYNC_MODE_HEADER, SYNC_MODE_POLL);
+1 -1
View File
@@ -8,7 +8,7 @@
* watchdog treats absence as "unconfigured" and skips instead of crashing.
*/
interface Env {
/** Shared Hub/Pro internal projector credential. */
/** Shared Hub/Pro internal projector and payload-free metadata credential. */
CMEM_INTERNAL_PROJECTOR_SECRET?: string;
/**
* Cloudflare API token for the GraphQL Analytics API.
+3 -2
View File
@@ -151,7 +151,7 @@ describe("token-verdict cache behavior", () => {
};
const result = await authenticateRequest(request, authEnv, dependencies);
expect(result).toEqual({ ok: true, userId, deviceId: "dev-auth" });
expect(result).toEqual({ ok: true, userId, deviceId: "dev-auth", deviceName: null });
expect(verifyCalls).toBe(1);
expect(putCalls).toBe(1);
expect(logged).toEqual(["get"]);
@@ -177,7 +177,7 @@ describe("token-verdict cache behavior", () => {
};
const result = await authenticateRequest(request, authEnv, dependencies);
expect(result).toEqual({ ok: true, userId, deviceId: "dev-auth" });
expect(result).toEqual({ ok: true, userId, deviceId: "dev-auth", deviceName: null });
expect(verifyCalls).toBe(1);
expect(logged).toEqual(["put"]);
});
@@ -209,6 +209,7 @@ describe("token-verdict cache behavior", () => {
ok: true,
userId,
deviceId: "dev-auth",
deviceName: null,
});
expect(ttlWrites).toEqual([60]);
revoked = true;
+306 -6
View File
@@ -15,9 +15,15 @@ import {
type CanonicalContentBody,
} from "../src/canonical-content";
import {
DEVICE_LIMIT_ERROR,
MAX_DEVICES_PER_USER,
PROJECTION_LEASE_MS,
type ChangesOutcome,
type ChangesResult,
type PushOutcome,
type PushResult,
type StatusOutcome,
type StatusResult,
type SyncHub,
} from "../src/do/SyncHub";
import {
@@ -50,6 +56,16 @@ function ok(outcome: PushOutcome): PushResult {
return outcome;
}
function changes(outcome: ChangesOutcome): ChangesResult {
if ("refused" in outcome) throw new Error(`unexpected refusal: ${outcome.error}`);
return outcome;
}
function status(outcome: StatusOutcome): StatusResult {
if ("refused" in outcome) throw new Error(`unexpected refusal: ${outcome.error}`);
return outcome;
}
function refused(outcome: PushOutcome, pattern: RegExp): void {
expect(outcome).toHaveProperty("refused", true);
expect((outcome as { error: string }).error).toMatch(pattern);
@@ -214,7 +230,7 @@ describe("shared canonical content-v2 contract", () => {
expect(appended.acked[0].seq, `${vector.name}: ${path}`).toBe("1");
expect(appended.acked[0].operation_sha256).toBe(paddedWrapper.operation_sha256);
const stored = await stub.getChanges("first-reader", "0", 500);
const stored = changes(await stub.getChanges("first-reader", "0", 500));
expect(stored.ops).toHaveLength(1);
expect(stored.ops[0].seq).toBe("1");
expect(stored.ops[0].body).toBe(paddedWrapper.body);
@@ -241,7 +257,7 @@ describe("canonical reducer and entity-head ledger", () => {
refused(await stub.pushOps("dev-a", [conflicting]), /revision_hash_conflict/);
const second = ok(await stub.pushOps("dev-a", [await observationOp("1", "2")]));
refused(await stub.pushOps("dev-a", [firstOp]), /stale_revision/);
expect((await stub.getStatus()).head_seq).toBe(second.head_seq);
expect(status(await stub.getStatus()).head_seq).toBe(second.head_seq);
});
it("supports tombstone-before-create and a higher-revision revive", async () => {
@@ -289,7 +305,7 @@ describe("canonical reducer and entity-head ledger", () => {
entity_rev: "18446744073709551615",
seq: "9223372036854775809",
});
const page = await stub.getChanges("dev-reader", "9223372036854775808", 500);
const page = changes(await stub.getChanges("dev-reader", "9223372036854775808", 500));
expect(page.ops.map((op) => op.seq)).toEqual(["9223372036854775809"]);
expect(page.head_seq).toBe("9223372036854775809");
});
@@ -350,7 +366,7 @@ describe("projection checkpoint, lease fencing, and launch log retention", () =>
]);
});
const firstSeen = await stub.getChanges("brand-new-device", "0", 500);
const firstSeen = changes(await stub.getChanges("brand-new-device", "0", 500));
expect(firstSeen.ops.map((op) => op.seq)).toEqual(["1", "2"]);
expect(firstSeen.ops.every((op, index) => BigInt(op.seq) === BigInt(index + 1))).toBe(true);
expect(firstSeen.head_seq).toBe("2");
@@ -421,7 +437,7 @@ describe("projection checkpoint, lease fencing, and launch log retention", () =>
expect(page.ops.length).toBeLessThan(ops.length);
const next = ok(await stub.pushOps("dev-a", []));
expect(next.head_seq).toBe(pushed.head_seq);
const allChanges = await stub.getChanges("boundary-reader", "0", 500);
const allChanges = changes(await stub.getChanges("boundary-reader", "0", 500));
const nextOp = allChanges.ops[page.ops.length];
expect(projectionRequestBytes({
userId,
@@ -707,7 +723,7 @@ describe("large cursor pagination", () => {
let cursor = "0";
let count = 0;
for (;;) {
const page = await stub.getChanges("dev-reader", cursor, 500);
const page = changes(await stub.getChanges("dev-reader", cursor, 500));
for (const op of page.ops) {
expect(BigInt(op.seq)).toBe(BigInt(cursor) + 1n);
cursor = op.seq;
@@ -817,3 +833,287 @@ describe("front Worker durability and repair", () => {
expect(invalid.status).toBe(400);
});
});
describe("internal payload-free Hub metadata", () => {
const base = "https://sync-hub.test";
const internalHeaders = {
Authorization: "Bearer test-projector-secret",
"Content-Type": "application/json",
};
it("reports device names, last seen, decimal cursors, projection lag, and health", async () => {
const userId = "88888888-8888-4888-8888-888888888888";
const stub = hub(userId);
changes(await stub.getChanges("dev-dashboard", "0", 500));
const clientStatus = await SELF.fetch(`${base}/v1/sync/status`, {
headers: {
Authorization: `Bearer valid-for:${userId}`,
"X-User-Id": userId,
"X-Device-Id": "dev-dashboard",
"X-Device-Name": " Alex's Laptop ",
},
});
expect(clientStatus.status).toBe(200);
ok(await stub.pushOps("dev-writer", [
await observationOp("1", "1", "dev-writer"),
await observationOp("2", "1", "dev-writer"),
], "Writer"));
await stub.getChanges("dev-reader", "1", 500, "Reader");
const response = await SELF.fetch(`${base}/internal/v1/sync/metadata`, {
method: "POST",
headers: internalHeaders,
body: JSON.stringify({ protocol_version: 1, user_id: userId }),
});
expect(response.status).toBe(200);
const body = await response.json() as {
protocol_version: number;
user_id: string;
epoch: string;
head_seq: string;
projected_seq: string;
projection_lag_ops: string;
sync_health: string;
devices: Array<Record<string, unknown>>;
};
expect(body).toMatchObject({
protocol_version: 1,
user_id: userId,
head_seq: "2",
projected_seq: "0",
projection_lag_ops: "2",
sync_health: "projector_lagging",
});
expect(body.epoch).toMatch(/^(?:0|[1-9][0-9]*)$/);
expect(body).not.toHaveProperty("op_count");
expect(body.devices).toHaveLength(3);
const byId = new Map(body.devices.map((device) => [device.device_id, device]));
expect(byId.get("dev-dashboard")).toMatchObject({
name: "Alex's Laptop",
last_ack_seq: "0",
cursor_lag_ops: "2",
connection_state: "disconnected",
});
expect(byId.get("dev-reader")).toMatchObject({
name: "Reader",
last_ack_seq: "1",
cursor_lag_ops: "1",
});
for (const device of body.devices) {
expect(device.last_seen_epoch_ms).toMatch(/^[1-9][0-9]*$/);
expect(device.last_seen_at).toMatch(/Z$/);
}
});
it("renames only registered devices and preserves dashboard names over client headers", async () => {
const userId = "99999999-9999-4999-8999-999999999999";
const stub = hub(userId);
changes(await stub.getChanges("dev-a", "0", 500, "Initial hostname"));
const rename = await SELF.fetch(`${base}/internal/v1/sync/device-name`, {
method: "POST",
headers: internalHeaders,
body: JSON.stringify({ protocol_version: 1, user_id: userId, device_id: "dev-a", name: "Desk Mac" }),
});
expect(rename.status).toBe(200);
expect(await rename.json()).toEqual({
protocol_version: 1,
user_id: userId,
device_id: "dev-a",
name: "Desk Mac",
});
await stub.getStatus("dev-a", "Changed hostname");
const metadata = await SELF.fetch(`${base}/internal/v1/sync/metadata`, {
method: "POST",
headers: internalHeaders,
body: JSON.stringify({ protocol_version: 1, user_id: userId }),
});
const body = await metadata.json() as { sync_health: string; projection_lag_ops: string; devices: Array<{ name: string }> };
expect(body.sync_health).toBe("healthy");
expect(body.projection_lag_ops).toBe("0");
expect(body.devices[0].name).toBe("Desk Mac");
const missing = await SELF.fetch(`${base}/internal/v1/sync/device-name`, {
method: "POST",
headers: internalHeaders,
body: JSON.stringify({ protocol_version: 1, user_id: userId, device_id: "missing", name: "Nope" }),
});
expect(missing.status).toBe(404);
});
it("fails closed on internal auth and rejects contract extensions", async () => {
const denied = await SELF.fetch(`${base}/internal/v1/sync/metadata`, {
method: "POST",
headers: { Authorization: "Bearer wrong", "Content-Type": "application/json" },
body: JSON.stringify({ protocol_version: 1, user_id: "user" }),
});
expect(denied.status).toBe(401);
const extended = await SELF.fetch(`${base}/internal/v1/sync/metadata`, {
method: "POST",
headers: internalHeaders,
body: JSON.stringify({ protocol_version: 1, user_id: "user", include_content_counts: true }),
});
expect(extended.status).toBe(400);
});
});
describe("per-user device admission bound", () => {
const base = "https://sync-hub.test";
const internalHeaders = {
Authorization: "Bearer test-projector-secret",
"Content-Type": "application/json",
};
function clientHeaders(userId: string, deviceId?: string, deviceName?: string): Record<string, string> {
return {
Authorization: `Bearer valid-for:${userId}`,
"X-User-Id": userId,
...(deviceId === undefined ? {} : { "X-Device-Id": deviceId }),
...(deviceName === undefined ? {} : { "X-Device-Name": deviceName }),
};
}
async function metadata(userId: string): Promise<{
devices: Array<{ device_id: string; name: string | null }>;
head_seq: string;
}> {
const response = await SELF.fetch(`${base}/internal/v1/sync/metadata`, {
method: "POST",
headers: internalHeaders,
body: JSON.stringify({ protocol_version: 1, user_id: userId }),
});
expect(response.status).toBe(200);
return response.json();
}
it("direct status refreshes known devices but never admits an unknown probe", async () => {
const userId = "device-status-direct-read-only";
const stub = hub(userId);
expect(status(await stub.getStatus("probe-only", "Must Not Persist")).device_count).toBe(0);
expect((await metadata(userId)).devices).toEqual([]);
changes(await stub.getChanges("known-device", "0", 500));
expect(status(await stub.getStatus("known-device", "Known Name")).device_count).toBe(1);
expect(await metadata(userId)).toMatchObject({
devices: [{ device_id: "known-device", name: "Known Name" }],
});
expect(status(await stub.getStatus("another-probe", "Still Not Persisted")).device_count).toBe(1);
expect((await metadata(userId)).devices.map((device) => device.device_id)).toEqual(["known-device"]);
});
it("concurrent status probes create zero devices and cannot exhaust admission", async () => {
const userId = "device-cap-concurrent";
const probes = await Promise.all(Array.from({ length: 80 }, (_, index) => {
const device = `device-${String(index).padStart(2, "0")}`;
return SELF.fetch(`${base}/v1/sync/status`, {
headers: clientHeaders(userId, device, `Test ${index}`),
});
}));
expect(probes.every((response) => response.status === 200)).toBe(true);
expect((await metadata(userId)).devices).toEqual([]);
const admissions = await Promise.all(Array.from({ length: 80 }, (_, index) => {
const device = `admitted-${String(index).padStart(2, "0")}`;
return SELF.fetch(`${base}/v1/sync/changes?since=0`, {
headers: clientHeaders(userId, device, `Admitted ${index}`),
});
}));
const accepted = admissions.filter((response) => response.status === 200);
const rejected = admissions.filter((response) => response.status === 409);
expect(accepted).toHaveLength(MAX_DEVICES_PER_USER);
expect(rejected).toHaveLength(80 - MAX_DEVICES_PER_USER);
for (const response of rejected) expect(await response.json()).toEqual({ error: DEVICE_LIMIT_ERROR });
const state = await metadata(userId);
expect(state.devices).toHaveLength(MAX_DEVICES_PER_USER);
expect(new Set(state.devices.map((device) => device.device_id)).size).toBe(MAX_DEVICES_PER_USER);
expect(state.devices.every((device) => device.name?.startsWith("Admitted "))).toBe(true);
const afterCapProbes = await Promise.all(Array.from({ length: 80 }, (_, index) =>
SELF.fetch(`${base}/v1/sync/status`, {
headers: clientHeaders(userId, `post-cap-probe-${index}`),
})
));
expect(afterCapProbes.every((response) => response.status === 200)).toBe(true);
expect((await metadata(userId)).devices).toHaveLength(MAX_DEVICES_PER_USER);
});
it("keeps existing devices writable/readable while new admitting paths are rejected at the cap", async () => {
const userId = "device-cap-http-paths";
for (let index = 0; index < MAX_DEVICES_PER_USER; index++) {
const response = await SELF.fetch(`${base}/v1/sync/changes?since=0`, {
headers: clientHeaders(userId, `device-${index}`, `Named ${index}`),
});
expect(response.status).toBe(200);
}
const existingStatus = await SELF.fetch(`${base}/v1/sync/status`, {
headers: clientHeaders(userId, "device-0", "Changed by client"),
});
expect(existingStatus.status).toBe(200);
const readOnlyNewStatus = await SELF.fetch(`${base}/v1/sync/status`, {
headers: clientHeaders(userId, "device-new"),
});
expect(readOnlyNewStatus.status).toBe(200);
const rejectedPull = await SELF.fetch(`${base}/v1/sync/changes?since=0`, {
headers: clientHeaders(userId, "device-pull-new"),
});
expect(rejectedPull.status).toBe(409);
expect(await rejectedPull.json()).toEqual({ error: DEVICE_LIMIT_ERROR });
const rejectedPush = await SELF.fetch(`${base}/v1/sync/ops`, {
method: "POST",
headers: { ...clientHeaders(userId, "device-push-new"), "Content-Type": "application/json" },
body: JSON.stringify({
protocol_version: 2,
ops: [await observationOp("1", "1", "device-push-new")],
}),
});
expect(rejectedPush.status).toBe(409);
expect(await rejectedPush.json()).toEqual({ error: DEVICE_LIMIT_ERROR });
const acceptedPush = await SELF.fetch(`${base}/v1/sync/ops`, {
method: "POST",
headers: { ...clientHeaders(userId, "device-0"), "Content-Type": "application/json" },
body: JSON.stringify({
protocol_version: 2,
ops: [await observationOp("1", "1", "device-0")],
}),
});
expect(acceptedPush.status).toBe(200);
const acceptedPull = await SELF.fetch(`${base}/v1/sync/changes?since=0`, {
headers: clientHeaders(userId, "device-1"),
});
expect(acceptedPull.status).toBe(200);
expect((await acceptedPull.json() as { ops: unknown[] }).ops).toHaveLength(1);
const readOnlyStatus = await SELF.fetch(`${base}/v1/sync/status`, {
headers: clientHeaders(userId),
});
expect(readOnlyStatus.status).toBe(200);
const unknownRename = await SELF.fetch(`${base}/internal/v1/sync/device-name`, {
method: "POST",
headers: internalHeaders,
body: JSON.stringify({
protocol_version: 1,
user_id: userId,
device_id: "unknown-rename",
name: "Must Not Exist",
}),
});
expect(unknownRename.status).toBe(404);
const state = await metadata(userId);
expect(state.head_seq).toBe("1");
expect(state.devices).toHaveLength(MAX_DEVICES_PER_USER);
expect(state.devices.find((device) => device.device_id === "device-0")?.name).toBe("Named 0");
expect(state.devices.some((device) => device.device_id === "unknown-rename")).toBe(false);
});
});
+32 -2
View File
@@ -1,6 +1,13 @@
import { env, evictDurableObject, SELF } from "cloudflare:test";
import { describe, expect, it } from "vitest";
import type { PushOutcome, PushResult } from "../src/do/SyncHub";
import {
DEVICE_LIMIT_ERROR,
MAX_DEVICES_PER_USER,
type ChangesOutcome,
type ChangesResult,
type PushOutcome,
type PushResult,
} from "../src/do/SyncHub";
import { KILL_SWITCH_KEY } from "../src/kill-switch";
import { observationOp } from "./content-v2-helpers";
@@ -36,6 +43,11 @@ function ok(outcome: PushOutcome): PushResult {
return outcome;
}
function changes(outcome: ChangesOutcome): ChangesResult {
if ("refused" in outcome) throw new Error(`unexpected refusal: ${outcome.error}`);
return outcome;
}
interface Connected {
ws: WebSocket;
messages: string[];
@@ -80,6 +92,24 @@ describe("upgrade and auth", () => {
const notUpgrade = await SELF.fetch(`${base}/v1/sync/ws`, { headers: headers("user-ws-426", "dev-a") });
expect(notUpgrade.status).toBe(426);
});
it("rejects a 65th WebSocket device but still upgrades an existing device", async () => {
const user = "user-ws-device-cap";
for (let index = 0; index < MAX_DEVICES_PER_USER; index++) {
const response = await SELF.fetch(`${base}/v1/sync/changes?since=0`, {
headers: headers(user, `dev-${index}`),
});
expect(response.status).toBe(200);
}
const rejected = await SELF.fetch(`${base}/v1/sync/ws`, {
headers: { ...headers(user, "dev-new"), Upgrade: "websocket" },
});
expect(rejected.status).toBe(409);
expect(await rejected.json()).toEqual({ error: DEVICE_LIMIT_ERROR });
const existing = await connect(user, "dev-0");
existing.ws.close();
});
});
describe("canonical advisory fan-out", () => {
@@ -154,7 +184,7 @@ describe("canonical advisory fan-out", () => {
replica.ws.close();
const result = ok(await stub.pushOps("dev-a", [await observationOp("2")]));
expect(result.acked).toHaveLength(1);
expect((await stub.getChanges("dev-c", "0", 500)).ops).toHaveLength(2);
expect(changes(await stub.getChanges("dev-c", "0", 500)).ops).toHaveLength(2);
});
});