首页
/ claude-mem SyncHub 内部元数据契约:面向 Pro 控制面的无负载设备与同步状态读取协议

claude-mem SyncHub 内部元数据契约:面向 Pro 控制面的无负载设备与同步状态读取协议

2026-09-06 18:53:29作者:姚月梅Lane

导读

SyncHub 是 claude-mem 双通道同步方案中负责承接设备身份、last-seen 状态、同步游标与权威 Turbopuffer 投影检查点的多用户 Durable Object 层。本文以 workers/sync-hub/METADATA-CONTRACT.md 为骨架,完整解析这套面向服务端内部控制面(Pro)的轻量契约:包括 POST /internal/v1/sync/metadata 无负载元数据读取、POST /internal/v1/sync/device-name 设备重命名两条内部路由的报文格式与校验规则,以及单用户 64 台设备的原子准入上限与"非准入路径"的内存模型。读者读完后将掌握:该契约为什么刻意不携带任何内容数据、sync_health 与各游标/滞后的精确定义、规范十进制字符串为何绝不能经 JS number 传递,以及 push/pull/WebSocket/status 等不同路径在设备上限上的行为差异。

契约定位:控制面状态,而非内容

文档开篇给出这条契约的根本意图:SyncHub 是设备身份、last-seen 状态、同步游标与权威 Turbopuffer 投影检查点的唯一所有者。Pro 端(服务端订阅控制面)需要了解某用户的同步进展时,直接读这套 payload-free(无负载)状态,而不是去查内容表或 pro_sync_state

为什么要"无负载"?从 index.ts 头部注释 可以看到 SyncHub 的整体架构分工:

  • 每个用户的 SyncHub Durable Object(DO)执行零出站 I/O,只保存规范的顺序操作日志与检查点;
  • 无状态 Worker 承担认证与 Turbopuffer 投影调用;
  • 内容的投影结果由另一条 projection 通道(带负载)推给 Pro,而元数据通道只回答"同步到哪了、健康吗、有哪些设备"。

两条通道负载不同,契约也刻意分离。文档强调,元数据响应绝不包含内容、内容计数、本地 outbox 深度或迁移/回填遥测——因为这些属于投影通道的职责,混入控制面契约只会让读者方对数据源产生歧义。getMetadata() 返回结构中的确只含 epoch/head_seq/projected_seq/projection_lag_ops/sync_health/devices,与文档声明完全一致(见 SyncHub.getMetadata)。

两条路由的公共要求

两条内部路由均要求:

Authorization: Bearer <CMEM_INTERNAL_PROJECTOR_SECRET>
Content-Type: application/json

其强校验规则可总结为下表:

违规情形 HTTP 状态 说明
缺失或错误的凭据 401 hasInternalCredential,与普通用户 token 体系完全隔离
请求体含未知字段 / 键不精确 400 exactKeys 要求键集合精确相等,多一个字段也拒绝
POST 方法 405 index.ts 路由分派
未知设备的 rename 404 rename 永不创建"幻影设备"

exactKeys() 的实现(index.ts)把请求体视为精确版本化的对象:字段既不能缺也不能多。测试中直接用 include_content_counts: true 这样的"合理扩展"触发 400(见 sync-hub.test.ts),证明"未知字段返回 400"是硬性契约而非宽松解析。

Read metadata:读取 Hub 控制面状态

请求与响应

路由:POST /internal/v1/sync/metadata

请求体(仅两个字段,精确键):

{ "protocol_version": 1, "user_id": "canonical-user-id" }

成功响应体:

{
  "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"
    }
  ]
}

user_id 在服务端被 trim 后作为路由键,直接定位到该用户的 DO 实例(env.SYNC_HUB.getByName(userId)),因此它必须是跨设备一致、服务端认可的 canonical 用户标识(即认证时由 token 绑定出的规范 id,详见 DEPLOY.md §1.2 的绑定说明)。

每个字段的语义

  • epoch:Hub 实例的随机代数。在 initializePristineState 中首次引导时由 newEpoch() 生成 64 位随机值;reset 后重新生成。它用于隔离不同代际的日志——任何持旧游标的设备看到新 epoch 都必须重新引导。
  • head_seq:当前 Hub 顺序日志的头部游标(已接受操作的最新序号)。
  • projected_seq:权威投影检查点——Turbopuffer 侧已成功应用的序号。它由投影租约通道(acquire → page → advance CAS)在 advanceProjectionCheckpoint 中单调推进。
  • projection_lag_opshead_seqprojected_seq 的差,即尚未投影完成的操作数。
  • sync_health:当且仅当 projected_seq === head_seq 时为 healthy,否则恒为 projector_lagging。这正是 getMetadata 中 head === projected ? "healthy" : "projector_lagging" 的一行判定。
  • devices[]:该用户已注册设备的元数据数组,至多 64 个,按"最近 last_seen 优先,其次按 device_id 字典序"排序(见 getMetadata 的 ORDER BY 子句)。

设备对象内部字段

字段 类型 说明
device_id string 稳定设备 id(经 trim,1–128 字符)
name string | null 展示名(可为空,见下文命名策略)
last_seen_at string | null ISO 8601 UTC 时间(由毫秒时间戳转换而来,测试断言其以 Z 结尾)
last_seen_epoch_ms string | null 原始毫秒 epoch,十进制字符串
last_ack_seq string 该设备最近一次确认的拉取游标
cursor_lag_ops string head_seq 与该设备 last_ack_seq 的差
connection_state "connected" | "disconnected" 该设备当前是否存在被接受的咨询性 WebSocket

注意 last_seen_at 是展示用的 ISO 字符串,last_seen_epoch_ms 是数值等价物——两者同时暴露,因为数据链路上的数字永远是字符串,展示层才转成 ISO。测试逐一断言了二者的形态(见 sync-hub.test.ts)。

为什么这些值必须是无符号十进制字符串

文档给出的铁律值得单独强调:epoch、每一个序号/游标、每一个滞后量都是 canonical 无符号十进制字符串,永远不允许经 JS number 传递

其理由在源码中层层加固:

  • JS number 只能精确表示 2^53 以内的整数,而日志序号会跨多个设备长期增长,epoch 更是完整的 64 位随机值。精度丢失会破坏游标比较与租约语义。
  • canonical-content.ts 中的 assertCanonicalDecimal 用正则 ^(?:0|[1-9][0-9]*)$ 校验"无前导零的无符号十进制",并上限到 uint64compareCanonicalDecimals 先比长度再字典序,本质上就是逐位十进制大数比较。
  • 自增操作 incrementCanonicalDecimalBigInt 完成并转回十进制字符串,绝不触碰 number;滞后计算 decimalLag 同样基于 BigInt
  • 前端路由的启发式更强:注释"Parse only deliberately small control-plane integers, never uint64 data"——parseBoundedPositiveInteger 只用于解析 limit 这类有上限的小整数,since 参数则走完整十进制正则校验。

结论性建议:任何消费方(Pro、仪表盘、调试脚本)在 JSON 解析后都不得将 head_seq/epoch/lag 字段 Number() 化再比较,跨语言比较应使用十进制字符串或大整数库。

sync_health 的"免责声明"

sync_health 只回答"投影器是否追平了头部",是一个写路径健康信号。文档特别澄清:离线设备的 cursor lag 仅是信息性指标,不会让投影变"不健康"。离线设备只是没拉取,并不代表投影器出故障;区分这两件事正是把 projection_lag_ops(Hub 级)与 cursor_lag_ops(设备级)分开暴露的原因。测试断言同样验证了这一层级关系(见 sync-hub.test.ts 的 metadata 用例)。

设备名来源与 X-Device-Name 规则

客户端会随 Hub 请求发送 X-Device-Name 头(trim 后至多 80 字符)。命名语义上有两条精心设计的原则:

  1. 首见即收:Hub 保留该设备第一次非空客户端名(touchDevicename=COALESCE(devices.name, excluded.name),见 SyncHub.ts——已存在则保留旧名,新插入才用新名)。
  2. 仪表盘改名优先:一次 dashboard(内部路由)的 rename 不会被之后某个 hostname 头覆盖。测试 "renames only registered devices and preserves dashboard names over client headers" 专门验证了这一点:先 rename 为 Desk Mac,再以 Changed hostnameX-Device-Name 调用 status,元数据中名字仍是 Desk Mac(见 sync-hub.test.ts)。

名字也会被 trim:请求头中的 " Alex's Laptop " 会被规范化为 Alex's Laptop同名测试)。normalizeDeviceName 展示了完整逻辑:null/空/纯空白折叠为 null,超过 80 字符直接抛校验错误。

connection_state 是咨询性的

connection_state 反映的是读取时该设备是否存在一条已被接受的咨询性 WebSocket。它是纯提示:WS 是"hint lane",推送/拉取的权威永远在 HTTP 上(DO 构造函数中 ctx.setWebSocketAutoResponse,且 webSocketMessage/Close/Error 全部为空实现,见 SyncHub.ts)。connection_state 通过遍历当前 socket 的 deserializeAttachment().device_id 集合计算(getMetadata),正确性从不依赖它——即使状态滞后或缺失,也只是一个展示瑕疵。

Device admission bound:单用户 64 台设备上限

每个用户的 Hub 至多保存 64 个不同的设备 id,这一常量在源码中具名导出为 MAX_DEVICES_PER_USER = 64(见 SyncHub.ts),同时 DEVICE_LIMIT_ERROR = "device_limit_exceeded"(同文件第 29 行)就是稳定错误码。

准入路径 vs 非准入路径

文档用两条清单精确划分行为:

准入路径(会占设备名额):push(POST /v1/sync/ops)、pull(GET /v1/sync/changes)、WebSocket upgrade(GET /v1/sync/ws),包括这些请求可选的 X-Device-Name 头。任何一条路径上出现以前未见过的设备 id 都会触发设备注册。

非准入路径:metadata 读取(本文两条内部路由之一)与所有公开 status 请求(GET /v1/sync/status)。

路径 设备是否被准入 达上限时新设备表现 已注册设备表现
push / pull / WS 409 {"error":"device_limit_exceeded"} 正常更新 last-seen/游标,继续同步
status(公开) 被忽略,不持久化 可刷新 last-seen/名字
metadata(内部) 只读返回

关键实现是 touchDevicetouchExistingDevice 的分工:准入走 INSERT ... WHERE EXISTS(已存在) OR (SELECT COUNT(*) < 64) 的单条原子 SQL,rowsWritten === 0 即抛 deviceLimitError;而 status/metadata 只走 touchExistingDeviceUPDATE ... WHERE device_id = ?,对未知 id 的 UPDATE 影响 0 行、静默无害。

原子性:并发首见不会冲破 64

准入是在 per-user DO 内的一条原子 SQLite 语句完成的,因此并发首见请求不可能把数量冲到 64 以上。这是因为单条 SQL 的判断与插入在同一语句内原子完成,且整个 Hub 的存储层是单 DO 的同步事务。结合测试可见,DO 内部对设备上限的施加是在 transactionSync 中与操作提交一起完成的(pushOps 中先 touchDevice 再写日志)。

为何 status 探测不能耗尽名额

这是防止认证过的连通性探测(如常驻状态轮询、仪表盘心跳)把名额白白占光的守卫。测试 "concurrent status probes create zero devices and cannot exhaust admission" 并发发起 80 个不同设备名的 status 探测,全部 200,且 metadata 中设备数保持 0;随后 80 个不同 id 的 changes 拉取只有前 64 个被接受、后 16 个稳定返回 409 device_limit_exceeded(见 sync-hub.test.ts)。

达上限时的既有设备不受影响

已达上限后,已有设备仍然更新 last-seen/cursor 并继续同步;只有此前未见过的 id 才会收到 409。测试 "keeps existing devices writable/readable while new admitting paths are rejected at the cap" 验证:已有设备 status 正常、可读写,而新设备的 pull/push 稳定 409(sync-hub.test.ts)。从运维角度看,这个稳定的 409 是账户/设备管理条件而非可无限重试的传输失败——DEPLOY.md §1.4 对此有同样的告诫。

另外两个防御细节也值得记住:metadata 最多返回 64 台设备;给未知设备 rename 同样什么都不创建,返回 404("rename never creates a phantom device"),因为 rename 底层是 UPDATE devices SET name=? WHERE device_id=?,靠 rowsWritten > 0 判断是否命中(见 renameDevice)。

Rename a device:重命名已注册设备

路由:POST /internal/v1/sync/device-name

请求体(四个字段,精确键,顺序无关):

{
  "protocol_version": 1,
  "user_id": "canonical-user-id",
  "device_id": "a-stable-device-id",
  "name": "Desk Mac"
}

校验规则:

  • name:trim 后必须包含 1–80 字符(空名会被拒绝,80 与 X-Device-Name 的上限一致)。
  • device_id:trim 后必须包含 1–128 字符
  • 两层校验同时存在:Worker 路由层先做快速 400 校验(见 handleDeviceRename),DO 层 renameDevice 再做一次(SyncHub.ts)——防御纵深,任何一层漏过都会被另一层拦下。

已注册设备返回:

{
  "protocol_version": 1,
  "user_id": "canonical-user-id",
  "device_id": "a-stable-device-id",
  "name": "Desk Mac"
}

未知设备返回 404;重命名绝不创建幻影设备

需要留意 namedevice_id 都会被 trim,因此 " Desk Mac " 会存成 "Desk Mac",这与认证层对 X-Device-Id/X-Device-Name 的 trim 策略("dev-a " 与 "dev-a" 必须是同一逻辑设备,见 authenticateRequest)保持一致——传输层身份永远规范化,写入的 canonical 操作体则永不被改写

与整体架构的关系:谁在调用这套契约

本文讨论的内部路由是 sync-hub 无状态 Worker(index.ts)入口的一部分。从路由表(第 863–874 行)可见,同属 /internal/v1 命名空间、共用同一 CMEM_INTERNAL_PROJECTOR_SECRET 的还有:

  • POST /internal/v1/sync/reset —— 按用户彻底重置 Hub(预发布状态卫生用,见 DEPLOY.md §1.6);
  • POST /internal/v1/projection/drain —— Pro 侧定时修复任务,对滞后用户补齐投影。

它们的请求体契约与本文两条路由同构({protocol_version, user_id} 精确键 + Bearer 内部密钥),互证了这套内部契约的设计一致性。

本契约对应整体同步方案的落点可追溯到规划文档 plans/2026-07-17-phase5-two-lane-sync.md,其运行时行为由 sync-hub.test.ts(含"internal payload-free Hub metadata"专节)与 DEPLOY.md 交叉印证。若要扩展验证,可在仓库根目录运行 canonical 客户端矩阵 E2E(npm run e2e:sync-matrix,见 DEPLOY.md §1.5)。

消费方速查与最佳实践

面向要接入这套内部契约的 Pro 服务端/控制面开发者,将全篇收敛为可执行清单:

  1. 凭据:统一使用环境级随机共享密钥 CMEM_INTERNAL_PROJECTOR_SECRETwrangler secret put 注入,DEPLOY.md §1.3),绝不用普通用户 token;失败即 401
  2. 报文格式:请求体必须精确等于契约字段(metadata 只带 protocol_version:1,user_id),多带任何字段都会 400;方法固定 POST,否则 405
  3. 状态判定:只信 projected_seq/sync_health 判断投影健康度;不要把设备级 cursor_lag_ops 混入整体健康评估。
  4. 数字处理:所有游标/滞后字段按十进制字符串处理,比较/自增用大整数;Number() 是精度事故的根源。
  5. 设备管理:判断某设备是否可继续同步看它是否已注册(metadata 设备列表),注册用 push/pull 路径(409 即名额满),改名只走本文的 rename 路由,探测连通性用 status(永不占名额)。
  6. 不做的事:不要在元数据通道请求内容、计数或 outbox 深度——那是投影通道的职责,这套契约对它永久关闭。
登录后查看全文
热门项目推荐
相关项目推荐