Skip to content

离线平台

父切面:入口数据面(入口↔栖息地数据面;统一原则与最佳实践见该文)。

FreeAnima 卫星壳离线能力按读/写能力划分(勿再使用 Tier 编号):

能力叫法机制模块
只读本地副本snapshot(只读快照)IndexedDB KVEmail、Notification、Vault meta、Habitat、Dream 等
可写 + 写队列CRUD outboxoutbox + 乐观 KVDiary、Task、Project、Note、Calendar
可写 + 写队列Hybrid outboxoutbox + localStorage LWWPomodoro(active 计时)
可写 + 写队列Stream outboxSAP 流式 flushChat send

可写模块统一用 offlineWritable: true 标记(outbox 模块)。

概念叫法代码含义
只读本地副本snapshotoffline-cache / withOfflineCache栖息地不可用时只读;在线必打 Habitat 并回写
可写离线模块outbox 模块 / offlineWritableregisterOfflineModuleCap({ offlineWritable: true })离线可改,失败入队,重连 flush
写队列条目outboxoffline-outbox / OfflineOutboxOp待同步写操作
写路径形状CRUD / Hybrid / Streamkind: "rpc"、Pomodoro LWW、kind: "stream"如何本地写与如何 flush
在线写优先preferOnlineWrite同名在线直连 Habitat;仅传输失败回退 outbox
推队列flushflushOfflineModule / flushAllOfflineModules把 outbox 打到 Habitat
重连编排syncoffline-sync / OfflineSyncBootstrap重连/可见时 flush + 模块 refreshAll
用户拉视图refresh页头刷新 / pull-to-refresh与 sync 职责分离
连接状态 UIconnectivityconnectivity-notice / ShellConnectivityBar断网 vs Habitat 未连 vs 弱网本地优先;不是 outbox
弱网本地优先localPreferlocal-prefer / isLocalPreferActive短窗连续传输失败后跳过 RPC,立刻走 snapshot / outbox
  • offline-cache — snapshot KV
  • offline-outbox — 跨模块写队列
  • offline-id-map / offline-temp-id — 本地负 id → server id;subscribeIdMappings 供 UI remap
  • offline-module-registry — Rpc / Stream 双适配器注册
  • offline-sync — 重连/可见时 orchestrator flush + refreshAll;flush 锁尾触发;compact 后删除被吸收的 IDB op
  • offline-cache-firstwithOfflineCache()在线栖息地优先 / 离线只读快照(可与 IDB 并行读作失败回退)
  • prefer-online-writepreferOnlineWrite():在线写优先 Habitat RPC;仅网络/传输失败回退 outbox;业务错误直接抛出
  • habitat-fetch-gateisHabitatFetchAvailable():断网、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 outboxDiary / Task / Project / Note / CalendarpreferOnlineWrite → 直连 Habitat RPC(带 client_op_id),响应回写本地 KV,不入 outbox;create 直接得到服务端正 id乐观 KV + outbox + scheduleFlush(含仍为未映射的 temp id)
Hybrid outboxPomodoropreferOnlineWrite → Habitat RPC(config / active / session),不入 outbox;本地 LWW 仍即时写enqueue outbox,重连 flush
Stream outboxChat栖息地可用:内存 client_op_id + 直发 message stream,不入 outboxenqueue outbox;仅传输失败从在线直发回退入队
  • 业务校验 / 尾冲突等错误抛给 UI,不进「可自动重试」队列
  • 模块接线:gate / 错误分流认 sdk(preferOnlineWrite / isRetriableOfflineWriteError);不抽泛型 CRUD 框架
  • 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 DevtoolsOfflineOutboxDevtools):展示 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」作为查找键:

  1. Lookup 前解析:update / delete / append 若传入 temp id,先经 getIdMapping 解析再查本地列表
  2. Flush 后立刻改写 KV:create 成功写 id-map 的同时,把本地列表中的 temp 行改成 server id(不要只靠 refreshAll 丢掉 temp)
  3. UI remap:订阅 subscribeIdMappings,把选中态 / 列表中的 temp 换成 real
  4. Compact 持久化compactOutbox 吸收的原始 op 必须从 IDB 删除
  5. Allocator seed:写前 seedTempIdAllocatorFromIdMap,避免刷新后复用仍映射中的负 id

在线直写 create(write-through)不产生 temp id,不受本契约约束。番茄钟(Hybrid)不使用 temp entity id;session 用 client_op_id 幂等。

  1. Habitat create/append/send 写 RPC 支持 client_op_id(若尚未有);实体写入顶层列
  2. 实现 features/<slug>/ui/spa/lib/offline-store.ts(或 stream adapter)
  3. registerOfflineModule(adapter) + registerOfflineModuleCap({ offlineWritable: true })
  4. api.ts:读走 withOfflineCache(或同语义);写委托 offline-store,入口包 preferOnlineWrite
  5. 更新 docs/ops/remote-access.md(若边界变化)

冲突策略:单设备;flush 后 refreshAll,以 Habitat 为准。

offline-sync / OfflineSyncBootstrap 负责 sync(重连与可见时 flush + 有 outbox 模块的 refreshAll)。用户点「刷新」或下拉刷新是 refresh(当前页重新拉取视图),二者职责分离。产品页矩阵与交互约定见 页面刷新