跳到主要内容

工作树拆包:核心 → @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-angularuseWorkingTree()
React@aiao/rxdb-plugin-working-tree-reactuseWorkingTree()
Vue@aiao/rxdb-plugin-working-tree-vueuseWorkingTree()

装插件仍在库侧完成(db.use(rxDBPluginWorkingTree)),绑定包只负责读写。十个状态字段的初值全是 idle(创建入口一次 IO 都不发),类型与错误类一律从插件包直接 import。

6. 捕获挂载点注册表改名并下沉到核心​

挂载点清单(契约 §1 那张表)此前在核心与本插件各写一遍,插件那份靠读核心源码逐字比对形参名:核心改名只会让测试红,类型与运行时都不响。现在唯一定义处在 @aiao/rxdb,本插件只是转出口——导入路径不变,名字变了:

旧名新名
CAPTURE_MOUNT_POINTSWORKING_TREE_CAPTURE_MOUNT_POINTS
CaptureMountPointWorkingTreeCaptureMountPoint
CaptureMountPointOrdinalWorkingTreeCaptureMountPointOrdinal
WritePrimitiveSignatureWorkingTreeWritePrimitiveSignature
isCaptureMountPointisWorkingTreeCaptureMountPoint
// 旧
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,下次切换接着续传。

参考​