同步引擎
同步引擎
Bolt 内置一个 原生同步引擎 ——不是插件、附加缓存或第三方复制层。本页介绍它在底层如何工作:副本、变更流与传输。应用所编写的读写 API 参见 实时数据。
心智模型:你的浏览器就是一个副本
可以把它想象成你的数据库在 浏览器内部 运行着第二份拷贝——一份属于当前登录者的拷贝。你的应用像读取本地数据库一样读取这份拷贝,像写入本地数据库一样写入它,而引擎让它与组织中的其他人保持同步。
SERVER DATABASE (Postgres) THIS BROWSER
┌────────────────────────────────┐ ┌────────────────────────────┐
│ collections │ snapshot │ PGlite replica in │
│ policies · hooks │ ─────────► │ IndexedDB — same tables, │
│ approvals · audit │ │ same rows, filtered to │
│ bolt_sync_outbox (change log) │ sync.diff │ THIS user's policy scope │
└────────────────────────────────┘ ─────────► └────────────────────────────┘
▲ ▲
│ client mutation pipeline │
└────────────────────────────────────────────┘
the write path also publishes on the `bolt.sync` topic;
the host fans that out to the tenant's open connections,
and a replica that hears it asks for the changes it names 两条流让这份拷贝保持真实。 下行: 写入路径告知宿主有内容发生变化,宿主唤醒浏览器,副本随即索取并应用那些已提交的变更。 上行: 你的写入以普通集合命令传输,由服务器提交或拒绝——服务器始终是权威。
在 Colony 上,这一机制的服务器端运行在你的租户运行时中,经由代理的 HTTP 传输;引擎本身是 Bolt 的关注点,在任何宿主上行为一致。
副本如何保持最新
服务器不会被定时轮询。每条已提交的变更还会在同一事务中向变更日志( bolt_sync_outbox )写入一行;该事务一旦提交,写入路径就把集合名发布到宿主负责扩散的主题上:
someone commits a mutation
│ row written to bolt_sync_outbox in the SAME transaction
▼
COMMIT ──► publish on `bolt.sync` ──► host fans it out to the
tenant's open connections
│
▼
GET /api/bolt/sync/stream event: sync (the collection names)
event: ready (connected, or reconnected)
│
▼
the leading tab calls sync.diff from its cursor and
applies the batch — create | update | delete | reset —
then re-runs the affected live queries ──► UI updates - 流携带一个持久的 游标 ,由事务 id 与序号组成,因此断开的连接会从断点处恢复。日志只会读取到「仍在进行中的最早事务」之下,正是这一点把按插入顺序排列的表变成按提交顺序排列的流:客户端只会被未完成的写入拖慢,而绝不会被夺走一条变更。
- 如果客户端落后太多——它的游标低于压缩所保留的范围——它取回的批次就是单独一条
reset变更,副本会依据一份新的快照自行重建。 - 每个批次在发送前都会在 SQL 中按读取者的 策略范围 过滤,主体不可读的列则按直接读取所走的同一条规则做掩码。身份表——
user以及与它并列的会话、账户与验证表——无论读取者的授权多宽,都被彻底排除在复制之外。 - 空闲的流不产生成本,因为没有任何东西依赖定时器:在有写入向主题发布之前,连接始终是安静的。
首次加载、刷新与多标签页
FIRST VISIT RELOAD / SECOND TAB
─────────── ───────────────────
sync.provisioning builds tables are already warm —
the tables from the first frame renders from
tenant's own migrations local data, no spinner
│ │
sync.snapshot pages every the leading tab reopens the
readable collection stream and drains from the
│ persisted cursor
the stream subscribes at │
the cursor the snapshot live queries re-run, UI
handed back catches up in the same
frame as any new changes - 副本不会自行生成任何 DDL。它依据租户自己的迁移谱系完成置备,因此浏览器中某一列的类型就是服务端该列的类型,而不是一份「今天恰好对得上」的映射。
- 存储以
<tenant>::<environment>::<accessScope>作键,因此同一浏览器登录两个工作区时绝不会把两者指向同一批表——包括由同一模板构建的两个工作区,它们的模式指纹相同,别的手段根本发现不了。 - 一个标签页持有数据库,其余标签页代理给它,领导者由 Web Locks API 选举产生。只有领导者订阅流、也只有它执行拉取,因此一个浏览器只持有一条连接而不是每个标签页一条;每应用完一批变更,它会通过共享的那个数据库把集合名广播给其余标签页。
- 由于副本及其游标持久保存在 IndexedDB 中,刷新后会重新打开已填充的副本,首帧直接由本地数据渲染——然后在保存的游标处恢复流。
副本会回答哪些读取
持有这些行,并不等于能回答这个问题。只有当副本能给出完全一致的答案时它才回答,而这取决于查询的形状:
- 本地回答 ——集合、过滤、排序与限制。它们由服务端自己的 where 与 order by 编译器编译,是直接引用而非重新实现,因此本地路径产出相同的 SQL,不会对「一个过滤条件意味着什么」产生第二种意见。
- 交给服务器 ——关联展开、全文搜索、聚合与历史。这些行为位于集合运行时而非编译器中,因此副本不会去尝试。
本地读取器无法识别的查询选项会被拒绝而不是忽略,正是这一点让日后新增的查询能力不会被悄悄当成另一个问题来回答。
bolt sync CLI ——在构建前生成 .norbital/ 类型与注册表的文件系统编译器同步。 工作区工作室预览同步 ——应用 DDL 并记录不可变发布产物的发布产物构建。参见 工作区工作室。
线上发生了什么
同步引擎没有自己的路由。它是一组命令,走的是其他所有 Bolt 命令共用的那条通道 /api/bolt/command/<command> ,外加宿主在 /api/bolt/sync/stream 上保持打开的一条流——它只承载集合名,别无其他:没有行,没有游标,也没有操作类型:
| 命令 | 用途 |
|---|---|
sync.provisioning | 该租户置备时所用的有序 DDL,以及模式指纹与集合形状 |
sync.shape | 该主体可以复制的集合 |
sync.snapshot | 单个集合的一个 keyset 页—— { collection, rows, cursor, nextAfter } |
sync.head | 日志可以读取到的最新游标 |
sync.diff | 某个游标之后的变更,已按策略过滤与列掩码,按提交顺序排列 |
sync.compact | 合并被覆盖的日志行,并清理超出保留窗口的部分 |
低于压缩所保留范围的游标会得到单独一条 reset 变更,客户端随即依据一份新的快照重建副本。完整协议记录在 公开的 Bolt 同步引擎文档 中。