工作树拆包:核心 → @aiao/rxdb-plugin-working-tree
工作树与提交历史(epic-006)从 @aiao/rxdb 核心抽成了独立插件包,下一个发布版本起生效:十张系统表、写捕获、提交图编解码、0004-working-tree-commits 迁移全部随 @aiao/rxdb-plugin-working-tree 走,核心侧只留下装卸口与「未认领能力守卫」。
不涉及数据迁移。 磁盘上的库文件、表结构、rxdb_change 链、已经写进 rxdb_migration 的迁移名——一样都没动。要改的只有依赖清单、一行 use()、以及(对用过工作树的库)一次 enable() 收敛。按版本与 API 稳定性策略,0.x 期间次版本即可包含破坏性变更。
为什么拆
工作树是「装上才存在」的能力:十张表与一层写捕获,对不用它的库来说全是成本。拆出之前,这些成本由核心包承担,而且核心只能用抬升 RXDB_SYSTEM_SCHEMA_VERSION 来锁住旧客户端——一次工作树侧的小改也要让所有旧客户端拒绝连接,不装这功能的库跟着一起被锁。
拆出之后:未装本包的库零成本——系统表、捕获与编解码全部随包走;锁旧客户端由「未认领能力守卫」接管,只锁启用过该能力的库(见第 4 节),且对第三方插件同样有效。
1. 安装
pnpm add @aiao/rxdb-plugin-working-tree
# 框架绑定(按需选其一)
pnpm add @aiao/rxdb-plugin-working-tree-angular
pnpm add @aiao/rxdb-plugin-working-tree-react
pnpm add @aiao/rxdb-plugin-working-tree-vue
不打算用工作树的应用不需要装,也不会有任何行为差异。
2. use() 必须排在 connect() 之前
这是唯一一处运行期可见的接入变化:
import { RxDB } from '@aiao/rxdb';
import { rxDBPluginWorkingTree } from '@aiao/rxdb-plugin-working-tree';
const db = new RxDB(config);
db.use(rxDBPluginWorkingTree); // ← 必须在 connect() 之前
await db.connect('sqlite-wasm');
本插件声明 system(RxDBSystemContribution),宿主要在建表之前读走它的实体、初始行与迁移;connect() 之后再 use() 已经赶不上建表,核心会当场抛错而不是静默跳过。
类型入口也随之变化:db.workingTree 是非可选成员,由 declare module '@aiao/rxdb' 增广而来——没装本包时 db.workingTree 是编译错误,而不是运行期的 undefined。装没装插件是构建期属性,能不能用才是数据库的属性。
3. 既有库:enable() 收敛一次
连接之后调用一次 db.workingTree.enable()——幂等,既有库与新库同一条调用路径:
await db.workingTree.enable();
它的语义是「把库收敛到已启用该有的形状」:翻 CommitCapabilityState 的能力位,并给每条本地分支补上提交图根节点(同一个事务)。已启用过的库重复调用是一条语句都不发的 no-op。迁移抛错时整笔回滚,能力位退回未启用,重试面对的还是同一个起点。
拆包前已经跑过 0004-working-tree-commits 的库不会重跑:那条迁移的名字是发布时就钉死的历史编号(当时它还长在核心里),rxdb_migration 的唯一索引拿它当仲裁键,名字永不能改。没跑过的老库(比工作树更早的版本建的)在 connect() 时由插件贡献的迁移链补跑。
4. 未认领能力守卫取代版本号锁
升级并 enable() 之后,库里多出一行能力水位(__rxdb_capability__:workingTree:1:@aiao/rxdb-plugin-working-tree)。此后没装本包的客户端再打开这个库,核心拒绝连接,并把该装的包名原样报出来:
这个数据库启用了当前进程未认领的 RxDB 能力,缺少对应插件时写入不受该能力管辖,因此拒绝连接:
- workingTree v1 —— 安装 @aiao/rxdb-plugin-working-tree
行为对比(旧 → 新):
| 场景 | 旧(核心内置 + 版本号锁) | 新(插件 + 能力守卫) |
|---|---|---|
| 工作树侧任何改动 | 抬升 RXDB_SYSTEM_SCHEMA_VERSION,所有旧客户端被锁 | 只锁启用过该能力的库 |
| 没装插件的客户端打开启用过的库 | 靠版本号恰好挡住(顺带挡住一切) | 点名拒绝并报出该装的包名 |
| 从未启用工作树的库,客户端不装插件 | 行为不变 | 零成本:不建表、不装捕获、无守卫触发 |
| 第三方插件同样需要锁旧客户端 | 无此机制 | 包名由插件自己写进水位行,核心不需要认识它 |
部署多端应用时注意:只要有一个客户端
enable()过,所有打开同一个库的客户端(含旧版本 bundle)都必须装上本插件并先use(),否则连接被守卫拒绝——这正是守卫接替版本号锁的本意。
5. 框架绑定
三端 useWorkingTree() 同名、同字段、同方法签名,只有状态容器形态不同(Signal / 渲染快照 / ComputedRef):
| 框架 | 包 | 入口 |
|---|---|---|
| Angular | @aiao/rxdb-plugin-working-tree-angular | useWorkingTree() |
| React | @aiao/rxdb-plugin-working-tree-react | useWorkingTree() |
| Vue | @aiao/rxdb-plugin-working-tree-vue | useWorkingTree() |
装插件仍在库侧完成(db.use(rxDBPluginWorkingTree)),绑定包只负责读写。十个状态字段的初值全是 idle(创建入口一次 IO 都不发),类型与错误类一律从插件包直接 import。
6. 捕获挂载点注册表改名并下沉到核心
挂载点清单(契约 §1 那张表)此前在核心与本插件各写一遍,插件那份靠读核心源码逐字比对形参名:核心改名只会让测试红,类型与运行时都不响。现在唯一定义处在 @aiao/rxdb,本插件只是转出口——导入路径不变,名字变了:
| 旧名 | 新名 |
|---|---|
CAPTURE_MOUNT_POINTS | WORKING_TREE_CAPTURE_MOUNT_POINTS |
CaptureMountPoint | WorkingTreeCaptureMountPoint |
CaptureMountPointOrdinal | WorkingTreeCaptureMountPointOrdinal |
WritePrimitiveSignature | WorkingTreeWritePrimitiveSignature |
isCaptureMountPoint | isWorkingTreeCaptureMountPoint |
// 旧
import { CAPTURE_MOUNT_POINTS, isCaptureMountPoint } from '@aiao/rxdb-plugin-working-tree';
// 新:路径照旧,改名字即可;从 `@aiao/rxdb` 直接取也等价(是同一个对象)
import { WORKING_TREE_CAPTURE_MOUNT_POINTS, isWorkingTreeCaptureMountPoint } from '@aiao/rxdb-plugin-working-tree';
改名是为了让核心扁平的导出清单说清这些名字属于哪个特性——CaptureMountPoint 在核心里读不出「捕获什么」,而它的同族 WorkingTreeCaptureHook / WorkingTreeCaptureMountTarget 早就带着前缀(门禁:scripts/audit/api-surface.mjs 的 NAMING,规则见 contracts/core-api.md §0)。
同时新增 WORKING_TREE_CAPTURE_MOUNT_POINT_METHODS:installWorkingTreeCapture() / uninstallWorkingTreeCapture() 装卸时遍历的就是它,于是「注册表少一行」与「有个写原语没被包住」从此是同一件事,而不再是一份没人调用的自述。
7. 分支物化来源由同步插件自动登记
metadata_only 分支(syncBranches() 拉下来、本地还没有数据的分支)第一次 switchBranch() 要从远端物化快照。此前这份来源要调用方自己实现 BranchMaterializationSource 并调 db.workingTree.registerMaterializationSource() 登记,而生产代码里没有任何人登记它——结果是 syncBranches() 之后的第一次切换恒抛 BranchNotMaterializedError(source_unavailable)。
现在 @aiao/rxdb-plugin-sync 在每个连接纪元的 install() 里自动登记来源、断开时撤销,同时 use() 了同步插件与本插件就够了,不需要任何手动接线:
import { rxDBPluginHistory } from '@aiao/rxdb-plugin-history';
import { rxDBPluginSync } from '@aiao/rxdb-plugin-sync';
import { rxDBPluginWorkingTree } from '@aiao/rxdb-plugin-working-tree';
db.use(rxDBPluginHistory);
db.use(rxDBPluginSync);
db.use(rxDBPluginWorkingTree);
await db.connect('sqlite-wasm');
await db.syncManager.syncBranches();
await db.versionManager.switchBranch('feature'); // 首次切换即物化
随之变化的公开面:
旧(@aiao/rxdb-plugin-working-tree) | 新 |
|---|---|
db.workingTree.registerMaterializationSource(source) | 删除;自定义来源改用 db.branchMaterializationSource(source, scope?) |
db.workingTree.materializationSource | 删除;读取用 db.getBranchMaterializationSource() |
BranchMaterializationSource / BranchMaterializationIntent | 移到 @aiao/rxdb,成员改为 freezeIntent / pages / resolveIntentDrift / projectPage / settle |
BranchMaterializationPagePayload / BranchMaterializationPageRequest | 移到 @aiao/rxdb |
branchMaterializationPageFingerprint | 移到 @aiao/rxdb(算法不变) |
BranchMaterializationApplyContext | 删除;由 BranchMaterializationBarrierContext / BranchMaterializationProjectionContext 取代 |
takeOverBranchSwitchWithMaterialization | 删除;插件已经在 takeOverBranchSwitch 里接好,经 switchBranch() 触发 |
来源槽一条连接至多一个:装了同步插件的库槽位已经被它占住,再调 db.branchMaterializationSource() 会抛错。只有不装同步插件、自带远端的应用才需要自己实现并登记。BranchNotMaterializedError 与 BranchNotMaterializedReason 仍从本包导出,失败时来源分支保持 active、已拉的页留在 staging,下次切换接着续传。
参考
- 工作树与提交历史插件:完整用法与 API
- 插件作用域契约迁移:
install(scope)新契约与本插件lifecycle: 'scoped'的含义 - 版本与 API 稳定性策略:废弃周期与公开 API 范围
- 兼容矩阵:框架版本与 RxJS 版本对应关系