16 KiB
OneTalk 耐久同步与连接生命周期契约
1. Scope / Trigger
当 OneTalk 页面观察需要经过 Service Worker 进入 Bright,并在浏览器或 Service Worker 重启后恢复历史、增量、实时消息和 ACK 状态时,必须遵循本契约。
页面桥和命令路由遵循 OneTalk 页面桥、Port 与命令路由。设备安装实例身份遵循 OneTalk 设备身份。
2. Signatures
Durable keys and records
消息幂等范围:
```text channelAccountId + conversationId + messageId ```
同步检查点范围:
```text channelAccountId + conversationId ```
页面观察写入边界:
```ts type PersistObservedBatch = { channelAccountId: string; conversationId: string; messages: ObservedOneTalkMessage[]; mode: "full" | "incremental"; receivedAt?: number; }; ```
检查点至少表达:
- `phase`
- `pageTimeStamp`
- `historyComplete`
- `latestMessageId`
- `completionSent`
- 分页位置
- 同步模式
- 锚点状态
- 最近观察时间
- 会话结果
候选状态至少区分:
```text awaiting_anchor pending_ack confirmed anomaly rejected ```
设备、binding、workspace 和用户字段只能作为授权/来源上下文,不能创建设备独立消息或锚点副本。
Profile ledger (independent state machine)
联系人资料不使用消息 candidate/checkpoint/anomaly store。ONE_TALK_SYNC_DATABASE_VERSION=10:从 oldVersion < 7 升级时,transaction 必须先清空五个 OneTalk store(消息、candidate、checkpoint、anomaly、onetalk_contact_profiles),不重键或回补旧 ledger;从 v7 升级到 v8 时只新增 onetalk_conversation_bootstraps store,必须保留五个既有 store 的记录;从 v7/v8 升级到 v9 时只为既有消息、checkpoint、candidate、anomaly、profile records 建立账号、会话与状态查询索引,六个 store 的记录逐条保留;v9→v10 只在统一 trade-message-center schema 新增 onetalk_buyer_facts store,并为 onetalk_rendered_card_ledger 新增受限查询 indexes,逐条保留所有既有 stores、records 与 indexes。数据库名、版本与 upgrade handler 只由 storage.ts 拥有;buyer ledger 通过该主库路径打开,不得创建独立数据库。legacy clear 完成后才建 index,避免为必然删除的 records 建索引。Chrome 配置、deviceId、binding 与其它渠道存储不属于该删除范围;首次 pre-v7 升级后必须重新采集 profile 并从 clean state 执行 full sync,v7→v8/v9/v10 不得因为 schema 演进清理既有事实。v7 checkpoint 新增 OneTalk 会话列表来源的 latestMessageAtMs,用于同步完成后更新服务端会话活动时间。
清空后的 onetalk_contact_profiles 业务键为 channelAccountId + conversationId;记录包含 key、账号、conversationId、资料字段中的 aliId、lastUploadedFingerprint、uploaded/rejected observed high-water mark、updatedAt、lastUploadedAt 和最新 pending 清洗 profile。资料上传到 Bright persistence,不调用 Mind profile HTTP。
Scope-bounded reads and the v10 rendered-card ledger
checkpoint使用by_account;candidate 使用by_account_status、by_account_conversation与by_account_conversation_status;message/anomaly 使用by_account_conversation;profile 使用by_account和by_account_pending_observed_at。bootstrap 继续只按稳定主键读取,不为无消费者的查询建 index。- rendered-card ledger 使用
by_account_status读取精确[channelAccountId, "pending_ack"],使用by_account_conversation在会话重建 transaction 中删除精确[channelAccountId, conversationId]records;这两种读取均不得恢复objectStore.getAll()或内存过滤其它账号。 - 已知 ACK
candidateKey必须直接主键读取,且只接纳仍为pending_ack的 candidate;没有 request key 时才兼容查找(channelAccountId, conversationId, messageId)。确认后只比较 checkpoint 当前latestMessageIdcandidate 与本次 confirmed candidate 的candidateOrder,不得重建全会话候选列表。 - profile pending 读取必须使用
[channelAccountId, pending.observedAtMs]的合法账号前缀IDBKeyRange;缺少pending.observedAtMs的 uploaded/rejected record 没有该 index entry,不得由调用方再过滤整表。 - 会话重建必须在一个 readwrite transaction 中,对目标 scope index 执行
getAllKeys()并删除 message/candidate/anomaly/rendered-card record,再直接删除该会话 checkpoint;不得在 transaction 外读后删,也不得使用 object-storegetAll()。 - v10 database 一旦打开,v9 binary 打开同一数据库会因版本较低失败。发布回滚只能通过 v11 前向迁移,或保留 v10 schema 并仅禁用非 schema 行为;不得重发 v9 或清除 durable ledger。
Observation 先写最新 pending,再由现有 Bright WebSocket 发送 contact.profile.observed。同 fingerprint 且无 pending 时只推进更高 observed high-water mark;任何不高于 uploaded/rejected high-water mark 的不同 fingerprint 也跳过。断线、Service Worker 重启或新页面连接只从 pending 重建发送。收到 contact.profile.ack 后,必须等待 readwrite transaction oncomplete,且只确认仍匹配的 fingerprint 和 observedAtMs;迟到旧 ACK 不得删除新 pending。收到 ws.error profile_observed_at_future 时只丢弃该 request 的精确 pending,避免无限重试。
消息与 profile 的耐久状态仍然分离:历史/实时消息中的 business_card 只持久化 { version: 1, kind: "business_card" } marker。由于 __conversationListData__ 与消息采集可能异步,消息候选不得等待或读取 profile ledger;当前客户资料只在服务端读取时按 [channelAccountId, conversationId] 以内存方式组合到对外 view。没有 profile 时保留 marker,不能 fallback 到登录人资料。
3. Contracts
Durable-first observation
页面观察的顺序固定为:
```text page observation -> validate and normalize -> IndexedDB durable write -> Bright upload -> per-message ACK ```
每批消息先写 IndexedDB,再允许上传或通知上层。durable write 失败时不上传该批次、不调用上层观察回调,并通过稳定错误暴露。
观察消息缺少 `messageId`、conversation ID、sender ID 或其它必填字段时,写独立 anomaly,不写正常消息事实表;后续合法消息仍可继续处理。
Full / incremental / live
- 没有活动增量检查点时,被动到达的 history observation 按 full 事实处理。
- 增量扫描在找到旧锚点前,候选保持 `awaiting_anchor`,不得上传、写入 Bright 或通知 TradeMind。
- 找到旧锚点后,候选按旧到新上传;Bright 逐条确认后才能标记本地记录已确认。
- 只有页面明确历史结束、全部有效消息获确认且没有待确认有效消息时,才能推进会话锚点。
- 每页完成后发布 `historyProgress`;还有下一页时保存下一次请求的 `nextTimeStamp`,历史结束或命中锚点时保存为 `null`;同时持久化会话列表来源的
latestMessageAtMs,不得从已过滤后的业务消息反算。 - 增量期间到达的 live observation 仍先持久化并等待锚点;锚点命中后释放历史/live 候选。
- 已完成增量会话的新 live observation 按正常待确认事实上传。
Completion and anchor evidence
服务端没有 `sync.complete` 成功 ACK。客户端发送完成声明后:
- 只保存 `completionSent=true`;
- checkpoint 保持 `uploading`;
- 等待后续 `anchor.snapshot`;
- 只有 snapshot 的会话和 `latestMessageId` 精确匹配时,才写入 `completed` 并推进本地锚点。
断线重连时重新发送会话发现、未确认事实和待锚点确认的完成声明。
Connection lifecycle
- Service Worker 建立唯一插件-Bright WebSocket,并在连接、heartbeat 和每个操作前复核 binding/授权版本。
- WebSocket 心跳用于保持 Bright 连接,不得把页面 `runtime.Port` 当作持久化或 keepalive 机制。
- Bright 连接断开时停止发送/接收;发送尝试后的结果为 `delivery_unknown`,不得自动重发。
- Service Worker 重启后重新认证和注册页面;服务器 command 不从旧内存队列恢复。
- `delivery_unknown` 不创建发送任务记录;迟到的有效页面消息仍可作为普通事实进入既有 observation pipeline。
Service Worker restart
Service Worker 重启后必须从 IndexedDB 恢复:
- 候选消息;
- 分页位置;
- 同步模式;
- checkpoint phase;
- anchor state;
- completion state。
不能从内存游标继续,也不能因重启重新生成消息或锚点。
4. Validation & Error Matrix
| 条件 | 行为 |
|---|---|
| binding 缺失、撤销或 Mind 授权服务不可用 | fail closed;拒绝连接、同步或发送 |
| IndexedDB durable write 失败 | 不上传、不通知上层;暴露稳定错误,保留会话未完成 |
| 页面消息缺少必填字段 | 记录独立 anomaly,不写正常消息表 |
| 增量锚点未找到 | 候选保持 `awaiting_anchor`,记录 `incremental_anchor_not_found` |
| Bright 逐条确认缺失 | 不标记 confirmed,不推进锚点 |
| `sync.complete` 只有 socket send 成功 | 只保留 `completionSent=true`,等待匹配 `anchor.snapshot` |
| Bright 连接断开 | 停止发送/接收;发送尝试返回 `delivery_unknown`,不自动重试 |
| Service Worker 重启 | 从 IndexedDB checkpoint、candidate 和 mode 恢复 |
| 设备切换 | 复用账号/会话范围的消息和锚点,不创建 device 副本 |
| 页面同步 route 无精确会话 | 由页面桥规范返回明确同步 route reason |
| Profile ledger 写入/commit 失败 | 不发送 profile;pending 不被伪造为已上传 |
| Profile ACK fingerprint 与当前 pending 不匹配 | no-op;保留当前 pending |
| Profile ACK observedAtMs 与当前 pending 不匹配 | no-op;保留当前 pending |
| Profile observed time 超过 Bright receive time 五分钟 | 整批不落库/不 ACK;只丢弃匹配 pending |
| Profile snapshot command 未返回 | 不阻塞消息 bootstrap;只产生独立 profile diagnostic |
5. Good / Base / Bad Cases
- Good:观察批次先 durable write,再按消息上传并等待 ACK;重复观察按复合业务键幂等。
- Good:增量命中旧锚点后才释放等待中的历史/live 候选。
- Base:Service Worker 暂停或重启后恢复 `awaiting_anchor` 和分页位置,不信任旧内存状态。
- Base:完成声明已发送但尚未收到匹配锚点快照时保持 `uploading`。
- Bad:在 durable write 前上传、把 socket send 成功当作同步完成、或将发送 unknown 写成自动重试任务。
- Bad:按 deviceId 复制消息/锚点,或者使用内存 cursor 跳过 IndexedDB 恢复。
- Good:资料 ledger 独立于消息 stores,按 [channelAccountId, conversationId] 严格 observedAt latest-wins;旧 ACK 只影响同 fingerprint、同 observation time。
- Bad:把 profile ACK 当成 message ACK、用 store.put 完成前返回已确认、按 aliId 建事实键,或用无限 timer 重试 future-skew/未响应的上传。
6. Tests Required
- 两条 WebSocket 的连接类型、消息方向和状态互不混用。
- durable write 完成前不会调用上传或上层回调;写入失败不会产生成功 ACK。
- 同一 `channelAccountId + conversationId + messageId` 只有一条消息事实。
- full/incremental/live 的候选状态和锚点等待边界正确。
- `historyProgress` 每页都能恢复,重启后从持久化游标继续。
- `sync.complete` 后没有匹配 snapshot 时仍为 `uploading`,匹配后才为 `completed`。
- 断线恢复会重新发现会话并恢复未确认事实和 completion 声明。
- `delivery_unknown` 不创建发送任务、不自动重发;迟到消息继续进入普通 observation。
- 页面同步路由、Bright ACK 和 IndexedDB 事务顺序在重启/断线下保持一致。
oldVersion < 7升级到 v10 必须断言五个 OneTalk store 均为空、没有 profile 重键/重试分支,且下一次 bootstrap 重新采集 profile、会话活动时间并执行 full sync;v7→v8 必须断言新增 bootstrap store 且五个既有 store 逐条保留;v8→v9 必须断言六个 OneTalk store 的 records 逐条保留、目标 index 存在且读取不调用objectStore.getAll();v9→v10 必须断言所有既有 OneTalk records/indexes 逐条保留,新增onetalk_buyer_facts,rendered-card records 保留且by_account_status与by_account_conversation同时存在,pending read 只返回目标账号的pending_ack,history cleanup 只删除目标账号/会话的 rendered-card record;配置、deviceId 与其它渠道数据在所有升级路径保持不变。
Scenario: v10 rendered-card ledger scoped indexes
1. Scope / Trigger
当 rendered-card pending recovery、会话重建或 ONE_TALK_SYNC_DATABASE_VERSION 发生变化时,必须按此场景实现 v10。它修复索引重构遗漏,不得改变 ledger 的 durable-first、first-content-wins、ACK 或 conflict 规则。
2. Signatures
const ONE_TALK_SYNC_DATABASE_VERSION = 10;
type OneTalkRenderedCardLedgerStore = {
listPending: (channelAccountId: string) => Promise<OneTalkRenderedCardLedgerRecord[]>;
};
onetalk_rendered_card_ledger 的 v10 indexes 是 by_account_status: [channelAccountId, status] 与 by_account_conversation: [channelAccountId, conversationId]。
3. Contracts
- v9→v10 只创建上述两个 index;不能清库、重键、回填或改写既有 record。
listPending(channelAccountId)只能通过by_account_status查询[channelAccountId, "pending_ack"]。clearConversationHistory(channelAccountId, conversationId)在既有 readwrite transaction 中,以by_account_conversation.getAllKeys([channelAccountId, conversationId])删除 rendered-card record。readAll、objectStore.getAll()和跨账号内存过滤都不是 rendered-card pending/rebuild 的兼容 fallback。
4. Validation & Error Matrix
| 条件 | 必须行为 |
|---|---|
| v9 数据库含 rendered-card record | 升级后 record 保留,两个 v10 index 存在 |
listPending("account-a") |
仅返回 account-a 的 pending_ack record |
| 同账号 confirmed/rejected record | pending read 不返回 |
| history rebuild 清理 account-a/conversation-1 | 只删除该 scope 的 rendered-card record |
| 缺 index、全表读取或未带 account 的 pending read | 显式失败或测试失败;不得扫描其它账号 |
5. Good / Base / Bad Cases
- Good:v9 record 不变地升级到 v10,pending upload 只读取当前账号。
- Base:同一账号的其它会话与其它账号的同名会话均保留。
- Bad:为恢复编译而重新引入
readAll,或重建时遗漏 rendered-card ledger。
6. Tests Required
- storage migration 覆盖 v9 record 保留和两个 index 存在。
- pending read 覆盖两个账号、
pending_ack与 terminal status,并断言没有 object-storegetAll()。 - history cleanup 覆盖目标会话、同账号另一会话与另一账号同名会话。
- 运行 storage/coordinator focused tests、extension typecheck、format 和 diff check。
7. Wrong vs Correct
// Wrong: the removed helper scans every account and the rebuild loop omits cards.
const pending = await readAll(database, ONE_TALK_RENDERED_CARD_LEDGER_STORE_NAME);
// Correct: both recovery and rebuild use an exact durable scope index.
await readByIndex(database, ONE_TALK_RENDERED_CARD_LEDGER_STORE_NAME, "by_account_status", [
channelAccountId,
"pending_ack",
]);
7. Wrong vs Correct
```ts // Wrong: 尚未持久化就上传。 await bright.upload(candidate); await store.putCandidate(candidate);
// Correct: durable write 是上传前置条件。 await store.putCandidate(candidate); await bright.upload(candidate); ```
```ts // Wrong: socket.send() 成功就推进同步锚点。 checkpoint.phase = "completed";
// Correct: 等待服务端 anchor.snapshot 证据。 checkpoint.phase = "uploading"; checkpoint.completionSent = true; ```