离线平台
父切面:入口数据面(入口↔栖息地数据面;统一原则与最佳实践见该文)。
FreeAnima 卫星壳离线能力按读/写能力划分(勿再使用 Tier 编号):
| 能力 | 叫法 | 机制 | 模块 |
|---|---|---|---|
| 只读本地副本 | snapshot(只读快照) | IndexedDB KV | Email、Notification、Vault meta、Habitat、Dream 等 |
| 可写 + 写队列 | CRUD outbox | outbox + 乐观 KV | Diary、Task、Project、Note、Calendar |
| 可写 + 写队列 | Hybrid outbox | outbox + localStorage LWW | Pomodoro(active 计时) |
| 可写 + 写队列 | Stream outbox | SAP 流式 flush | Chat send |
可写模块统一用 offlineWritable: true 标记(outbox 模块)。
| 概念 | 叫法 | 代码 | 含义 |
|---|---|---|---|
| 只读本地副本 | snapshot | offline-cache / withOfflineCache | 栖息地不可用时只读;在线必打 Habitat 并回写 |
| 可写离线模块 | outbox 模块 / offlineWritable | registerOfflineModuleCap({ offlineWritable: true }) | 离线可改,失败入队,重连 flush |
| 写队列条目 | outbox | offline-outbox / OfflineOutboxOp | 待同步写操作 |
| 写路径形状 | CRUD / Hybrid / Stream | kind: "rpc"、Pomodoro LWW、kind: "stream" | 如何本地写与如何 flush |
| 在线写优先 | preferOnlineWrite | 同名 | 在线直连 Habitat;仅传输失败回退 outbox |
| 推队列 | flush | flushOfflineModule / flushAllOfflineModules | 把 outbox 打到 Habitat |
| 重连编排 | sync | offline-sync / OfflineSyncBootstrap | 重连/可见时 flush + 模块 refreshAll |
| 用户拉视图 | refresh | 页头刷新 / pull-to-refresh | 与 sync 职责分离 |
| 连接状态 UI | connectivity | connectivity-notice / ShellConnectivityBar | 断网 vs Habitat 未连 vs 弱网本地优先;不是 outbox |
| 弱网本地优先 | localPrefer | local-prefer / isLocalPreferActive | 短窗连续传输失败后跳过 RPC,立刻走 snapshot / outbox |
平台原语(portal-sdk)
Section titled “平台原语(portal-sdk)”offline-cache— snapshot KVoffline-outbox— 跨模块写队列offline-id-map/offline-temp-id— 本地负 id → server id;subscribeIdMappings供 UI remapoffline-module-registry— Rpc / Stream 双适配器注册offline-sync— 重连/可见时 orchestrator flush + refreshAll;flush 锁尾触发;compact 后删除被吸收的 IDB opoffline-cache-first—withOfflineCache():在线栖息地优先 / 离线只读快照(可与 IDB 并行读作失败回退)prefer-online-write—preferOnlineWrite():在线写优先 Habitat RPC;仅网络/传输失败回退 outbox;业务错误直接抛出habitat-fetch-gate—isHabitatFetchAvailable():断网、Habitat 非connected、或弱网 localPrefer 时不发起 Habitat RPC 读/flush,只读 IndexedDB 快照local-prefer— 短窗内连续传输失败(超时等)后进入本地优先;壳层 toast「尝试恢复」或自动重试后退出
在线栖息地优先 / 离线本地优先
Section titled “在线栖息地优先 / 离线本地优先”isHabitatFetchAvailable() = navigator.onLine !== false 且
getHabitatRpcConnectionState() === "connected" 且 !isLocalPreferActive()。
弱网时浏览器 onLine 与 Habitat WS 常仍为真,读/写会先卡 RPC 超时再回落。连续传输失败达到阈值后开启 localPrefer,后续请求立刻走 snapshot / outbox,不再空等超时。用户点连接 toast「尝试恢复」或等待自动重试后退出;退出且 Habitat connected 时 flush。
- 栖息地可用:必打 Habitat(缓存命中不短路);成功后异步写回本地 KV;fetch 失败回退快照
- 栖息地不可用:只读本地;无缓存则抛 offlineError
- snapshot / outbox 模块 list·get:优先
withOfflineCache();手写路径须同语义 - Vault:仅 meta list/search 可入 IDB;
include_secrets/ 明文密钥响应禁止写 snapshot
写(全体 outbox 模块:在线不入 outbox)
Section titled “写(全体 outbox 模块:在线不入 outbox)”| 形状 | 模块 | 在线 | 离线 / 传输失败 |
|---|---|---|---|
| CRUD outbox | Diary / Task / Project / Note / Calendar | preferOnlineWrite → 直连 Habitat RPC(带 client_op_id),响应回写本地 KV,不入 outbox;create 直接得到服务端正 id | 乐观 KV + outbox + scheduleFlush(含仍为未映射的 temp id) |
| Hybrid outbox | Pomodoro | preferOnlineWrite → Habitat RPC(config / active / session),不入 outbox;本地 LWW 仍即时写 | enqueue outbox,重连 flush |
| Stream outbox | Chat | 栖息地可用:内存 client_op_id + 直发 message stream,不入 outbox | enqueue outbox;仅传输失败从在线直发回退入队 |
- 业务校验 / 尾冲突等错误抛给 UI,不进「可自动重试」队列
- 模块接线:gate / 错误分流认 sdk(
preferOnlineWrite/isRetriableOfflineWriteError);不抽泛型 CRUD 框架
Flush / 同步 UI
Section titled “Flush / 同步 UI”flushOfflineModule/flushAllOfflineModules:gate 为 false 时 no-op,重连后由OfflineSyncBootstrap触发;flush 锁尾触发- 自动 flush 连续失败达到 5 次后停止重试
- Bootstrap toast:展示 failed / stale(任意连接状态);纯 pending 仅在 Habitat
未
connected时展示。connected下正常排队/在线直发不得弹「重新连接并全部重试」 - 失败/冲突 toast:单条 重试 + 丢弃;「重连并全部重试」仅用于未连接时的纯 pending toast
- chat 流式 flush 在离开聊天页时回退到无头 context(headless),全局 bar 仍可 flush
写 RPC:仅 create / append(新建 block)/ message.send 可选 client_op_id;patch/delete 等不再接受。实体侧存 entities.client_op_id(非 body);Chat 仍在 messages.payload。flush 重试使用同一 id;create 响应含完整 item 供 id-map 写入。
Outbox 状态:portal-sdk subscribeOutboxChanges + useGlobalOutboxSummary / useModuleOutboxSummary(事件驱动,无轮询)。
只读 Outbox Devtools(OfflineOutboxDevtools):展示 scope、pending/failed/stale 与各 op id;Vite DEV 默认可用,生产经设置「调试 → 离线 Outbox 调试面板」或 localStorage freeanima.debug.offlineOutboxDevtools=1。不提供写操作(重试/丢弃仍走同步 toast)。
Temp id 生命周期契约(CRUD outbox)
Section titled “Temp id 生命周期契约(CRUD outbox)”create flush 成功后,本地世界里不得再以「裸 temp id」作为查找键:
- Lookup 前解析:update / delete / append 若传入 temp id,先经
getIdMapping解析再查本地列表 - Flush 后立刻改写 KV:create 成功写 id-map 的同时,把本地列表中的 temp 行改成 server
id(不要只靠
refreshAll丢掉 temp) - UI remap:订阅
subscribeIdMappings,把选中态 / 列表中的 temp 换成 real - Compact 持久化:
compactOutbox吸收的原始 op 必须从 IDB 删除 - Allocator seed:写前
seedTempIdAllocatorFromIdMap,避免刷新后复用仍映射中的负 id
在线直写 create(write-through)不产生 temp id,不受本契约约束。番茄钟(Hybrid)不使用 temp entity
id;session 用 client_op_id 幂等。
- Habitat create/append/send 写 RPC 支持
client_op_id(若尚未有);实体写入顶层列 - 实现
features/<slug>/ui/spa/lib/offline-store.ts(或 stream adapter) registerOfflineModule(adapter)+registerOfflineModuleCap({ offlineWritable: true })api.ts:读走withOfflineCache(或同语义);写委托 offline-store,入口包preferOnlineWrite- 更新
docs/ops/remote-access.md(若边界变化)
冲突策略:单设备;flush 后 refreshAll,以 Habitat 为准。
同步 vs 页面刷新
Section titled “同步 vs 页面刷新”offline-sync / OfflineSyncBootstrap 负责 sync(重连与可见时 flush + 有 outbox
模块的 refreshAll)。用户点「刷新」或下拉刷新是 refresh(当前页重新拉取视图),二者职责分离。产品页矩阵与交互约定见
页面刷新。