rxdb-plugin-working-tree
RxDB 本地工作树与提交历史插件:把「未提交的改动」与「提交历史」作为一等概念加进 @aiao/rxdb。
未装本包的库零成本——十张系统表、写捕获、提交图编解码全部随包走,核心侧只留下装卸口与两道转交门。
能力范围
- 写捕获:用户编辑落进工作树而不是直接改主数据,库自己的簿记写入不被捕获
status()/diff():读当前分支的未提交摘要与逐条改动commit():把工作树里的全部未提交单元提交成一次快照;CAS 落败走返回值而非异常discard():把工作树整体退回 HEADlistCommits():读当前分支的可达提交历史restore()/restoreSession():把一个可达历史提交的内容作为新的未提交变更写回工作树;不移动 HEAD、不删历史,四个被拒成因走返回值switchBranch()的两道可选前置(requireClean/expectedActivationRevision);挂在@aiao/rxdb-plugin-history的db.versionManager上,被拒走异常
用之前要知道的六件事
完整版见文档站的插件页,这里是压缩版:
- 提交能力是数据库级的显式开关——
enable()一次,整个库的所有实体、所有分支都按工作树语义运行。启用会留下一行能力水位,没装本包的客户端从此拒绝连接这个库;v1 没有disable()。 - 工作树 ≠ 草稿缓存。 工作树装的是「已经写进数据库、还没提交成快照」的变更,参与事务、被查询读到;草稿缓存(
@aiao/rxdb-plugin-workspace)装的是「还没保存」的编辑器 buffer,根本没进主库。两层不能合并,本包因此也不占用Workspace*前缀。 restore()不是 checkout。 它把历史内容作为新的未提交变更写回工作树:HEAD 不动、历史不删、工作树变脏,下一步是commit()或discard()。v1 没有 detached HEAD、没有checkout()。- 历史会原样保留敏感旧值。 写进过某次提交的字段永久留在那次提交的
ChangeSet里,之后改掉、清空、删行都不会动到它;v1 没有任何公开 API 能把它从历史里抠掉。不要把不该留痕的东西写进启用了提交能力的库。 - 加密边界:加密字段在提交、工作树与恢复会话里仍以 versioned envelope 落盘,持久化路径不先解密再写明文,错误与摘要也不带明文。但加密保护的是落盘的字节——它不消解第 4 条,第 4 条也不能替代它。
- 不改写历史:没有 amend / rebase / squash,没有「修改提交信息」,也没有 auto-baseline。提交图损坏时守卫只置
corrupted_read_only并留诊断,不动 HEAD、不删记录。
另外一条常被漏掉的:远端同步拉下来的变更和用户的编辑一样进工作树,在 status().byOrigin 里计为 origin = 'remote_sync'、不豁免,会让 clean 变成 false,并被下一次 commit() 一并提交(提交者是这次 commit() 的 authorId,v1 不伪造远端作者)。「同步之后工作树突然脏了」是正常行为。
能力边界:绕过 adapter 的写入拦不住
写捕获只覆盖经 adapter 的写路径与 adapter 公开的批量写方法。直接打开同一个 SQLite 文件、另起一个 PGlite 实例、DevTools 里手写 SQL——这些拦不住,v1 也不承诺拦得住,而且它们不进工作树、不进历史、status() 看不见。
因此启用了提交能力的数据库有一条硬约束:业务表只能经 RxDB 写入。写在这里不是免责声明的注脚——不假装拦得住比拦不住更重要:一道号称拦得住却拦不住的门禁,会让人把「没报错」当成「没被绕过」。
使用方式
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');
await db.workingTree.enable(); // 既有库上是一次迁移,新库上是一次幂等确认
const status = await db.workingTree.status();
const result = await db.workingTree.commit('保存', {
expectedBranch: {
branchId: status.branchId,
activationRevision: status.activationRevision
},
expectedHeadRevision: status.headRevision,
expectedWorkingTreeRevision: status.workingTreeRevision,
authorId: 'alice',
operationId: crypto.randomUUID()
});
if (!result.ok) {
// 别人先提交了:conflict 里带着新的 revision,重读 status 后重试即可
console.warn(result.conflict);
}
use() 必须排在 connect() 之前。 本插件声明 system(RxDBSystemContribution),
宿主要在建表之前读走它的实体、初始行与迁移;connect() 之后再 use() 已经赶不上建表,
核心会当场抛错而不是静默跳过。
switchBranch() 的那两道前置不在本包的入口上:db.versionManager 由
@aiao/rxdb-plugin-history 在连接纪元内挂载,core 不自动创建它。
只用工作树与提交不需要它;要用 switchBranch() 就把它一起 use() 上,否则 db.versionManager
是 undefined——core 不做 fallback 兜底。
import { rxDBPluginHistory } from '@aiao/rxdb-plugin-history';
db.use(rxDBPluginWorkingTree);
db.use(rxDBPluginHistory);
db.workingTree 是非可选成员,由 declare module '@aiao/rxdb' 增广而来:装了本包才存在这个入口,
没装时 db.workingTree 是编译错误,而不是运行期的 undefined。能力未启用时它照样在,
但每个方法以 commit_capability_disabled 拒绝。
未认领能力守卫
enable() 过的库会在 rxdb_migration 里留下一行能力水位(__rxdb_capability__:workingTree:1:@aiao/rxdb-plugin-working-tree)。
没装本包的客户端再打开这个库时,核心拒绝连接并把该装的包名原样报出来:
这个数据库启用了当前进程未认领的 RxDB 能力,缺少对应插件时写入不受该能力管辖,因此拒绝连接:
- workingTree v1 —— 安装 @aiao/rxdb-plugin-working-tree
这道守卫是本包从核心抽出来之后的核心收益:它接替了原先靠抬升 RXDB_SYSTEM_SCHEMA_VERSION
来锁旧客户端的做法,且对第三方插件同样有效——包名是插件自己写进水位行的,核心不需要认识它。
系统表
十张,顺序即建表顺序:
CommitCapabilityState、WorkingTreeActivationState、Commit、CommitChangeSet、CommitBranchRef、
WorkingTreeState、WorkingTreeEntry、WorkingTreeRestoreSession、WorkingTreeMaterializationStage、
WorkingTreeMaterializationPage。
它们经 registerSystemEntities() 进入核心的系统表身份集,因此 isSystemEntity() 认得它们——
@aiao/rxdb-adapter-http 等跨包消费者不会把它们当接入方数据推上远端。
框架绑定
- Angular ——
@aiao/rxdb-plugin-working-tree-angular - React ——
@aiao/rxdb-plugin-working-tree-react - Vue ——
@aiao/rxdb-plugin-working-tree-vue
三端 useWorkingTree() 同名同语义,只是状态容器形态不同(Signal / 渲染快照 / ComputedRef)。
一致性套件
@aiao/rxdb-plugin-working-tree/testing 导出跨后端一致性套件(capture + commit),
六个适配器包(electron / sqlite / sqlite-wasm / pglite / sqliteai / wa-sqlite)各引两条。
开发命令
pnpm nx run rxdb-plugin-working-tree:typecheck
pnpm nx run rxdb-plugin-working-tree:lint --max-warnings=0
pnpm nx test rxdb-plugin-working-tree
pnpm nx run rxdb-plugin-working-tree:build
License
Classes
| Class | Description |
|---|---|
| BranchNotMaterializedError | 物化依据不足,整次尝试以 branch_not_materialized 全量回滚(FR-044)。 |
| ColdReplayMismatchError | 冷重放不变量被破坏。 |
| CommitEncryptedAtRestError | 一个加密列的值没有以落库形态进入 commit(FR-038)。 |
| CommitValidationError | 入参没过校验;在任何读写之前抛出。 |
| MixedVersionedCacheTransactionError | tracked 与 untracked 实体混进了同一个事务单元 |
| RxDBPluginWorkingTree | 把工作树与提交历史装到单个 RxDB 实例上的插件生命周期对象。 |
| StaleActiveBranchError | 调用方手里的 active branch token 已经过期 |
| UnknownWorkingTreePatchEntityError | 目标实体未在本进程注册时,编码端抛出的错误。 |
| WorkingTreeCapabilityDisabledError | 在未启用提交能力的数据库上调用了受管成员。 |
| WorkingTreeCaptureRuntime | 捕获运行时 |
| WorkingTreeDirtyError | 工作树非空导致切换被拒(FR-017)。 |
| WorkingTreeEntryCountMismatchError | 冗余列与实际条目行数对不上。 |
| WorkingTreeManager | 工作树与提交历史的入口(契约见 contracts/core-api.md §1)。 |
| WorkingTreeReplayCorruptionError | 工作树单元违反了折叠不变量 |
| WorkingTreeWriteRejectedError | 一次被门禁拒下的写 |
Interfaces
| Interface | Description |
|---|---|
| ActiveBranchToken | 调用方在读取 / 实例化实体时捕获的 active 分支身份(FR-020) |
| BulkWriteGateRequest | 一次批量写的判定输入 |
| CapturedWrite | 一次被捕获的写 |
| ChangeCaptureSource | 捕获需要的 rxdb_change 列 |
| ColdReplayIdentity | 一行业务数据的身份,与工作树单元的唯一索引除 branchId 外的部分一致。 |
| ColdReplayInvariantInput | assertColdReplayInvariant 的入参。 |
| ColdReplayRow | 一行业务数据:身份 + 全部列值。 |
| CommitCapabilityInfo | 库里那一行能力状态的只读视图。 |
| CommitCapabilityVersions | 本进程支持的三个能力版本号。 |
| CommitChangeSetPage | commitChanges 返回的一页:一个 commit 的全部变更单元,按写入顺序。 |
| CommitChangeUnitContent | 进摘要的那部分单元内容——恰好是 CommitChangeSet 持久化的九列。 |
| CommitConflict | 一次被拒的提交 / 丢弃给出的类型化诊断(contracts/core-api.md §4.1)。 |
| CommitLogEntry | 历史里的一个提交节点。 |
| CommitLogOptions | 一次 listCommits() 的入参(contracts/core-api.md §5)。 |
| CommitLogPage | 一次 listCommits() 的结果。 |
| CommitOptions | 一次 commit() 的入参(contracts/core-api.md §4)。 |
| CommitPatchCodecContext | commit 侧 patch 编解码与 at-rest 校验所需的上下文。 |
| ExpectedActiveBranch | 调用方提出的那份 active 分支断言(StaleActiveBranchError.expected 的类型) |
| ExternalNotifyGateRequest | 一次外部更新通知的判定输入 |
| RawWriteJudgmentContext | 一次 raw 调用的判定上下文 |
| RestoreIncompatibility | 首个不兼容节点的稳定描述(FR-050)。 |
| RestoreVersionManifest | 版本 manifest:用户比对「我这边是多少」的那两个值。 |
| VersionedDomain | 版本化域的完整视图:表 / 列平面(继承自 VersionedDomainView)加上实体平面 |
| VersionedDomainEntityInput | 构造版本化域所需的单个实体登记 |
| VersionedDomainView | 版本化域在表 / 列平面上的投影,也就是 raw 写判定消费的那个端口 |
| VersionedTransactionGuard | 一个事务单元内的 tracked / untracked 混用守卫 |
| WorkingTreeActivationInfo | 激活态单行的只读视图。 |
| WorkingTreeAsyncStates | 三端入口持有的全部状态,一项能力一个字段。 |
| WorkingTreeCaptureBatch | 一批变更共用的捕获上下文 |
| WorkingTreeCaptureHost | createWorkingTreeCapturePort 需要的宿主能力。 |
| WorkingTreeCaptureMountPoint | 一条必须挂载工作树捕获的写原语。 |
| WorkingTreeCapturePort | 捕获所需的持久化操作 |
| WorkingTreeCaptureRuntimeOptions | 构造捕获运行时所需的宿主能力。 |
| WorkingTreeCommands | 三端入口对外暴露的十二个命令;签名与 WorkingTreeManager (以及 switchBranch 那一个 VersionManager)上的同名方法一致。 |
| WorkingTreeCredentials | 调用方在读取时捕获的三个位(FR-020/FR-031)。 |
| WorkingTreeDiff | 一次 diff() 的全部回答(contracts/core-api.md §3)。 |
| WorkingTreeDiffEntry | 一条未提交单元在 diff 里的样子。 |
| WorkingTreeDiffOptions | 一次 diff() 的可选项(contracts/core-api.md §3)。 |
| WorkingTreeDiffTransaction | 事务粒度下的一组单元。 |
| WorkingTreeEmptyState | 调用成功,且结果是有语义的空。 |
| WorkingTreeEntryKey | 工作树单元的身份三元组,对应唯一约束里除 branch 外的部分。 |
| WorkingTreeEntryRow | 折叠之后落盘的那一行(rxdb_working_tree_entry 的可变部分) |
| WorkingTreeErrorState | 调用抛错;错误已归一化成 Error,原实例原样透传。 |
| WorkingTreeIdleState | 还没人发起过这次调用。 |
| WorkingTreeLoadingState | 调用已经发出、还没落地。 |
| WorkingTreePatchCodecContext | 编解码所需的两个元数据解析器。 |
| WorkingTreePatchTarget | 一行工作树条目 / 提交变更集上用来定位目标实体的两列。 |
| WorkingTreeRestoreEntityRef | 一行的三段身份:命名空间、实体名、实体主键。 |
| WorkingTreeRestoreSessionInfo | 当前分支那个尚未结束的恢复会话(contracts/core-api.md §5)。 |
| WorkingTreeRestoreTarget | 恢复目标:要把工作树恢复到哪一个 commit、其中的哪些单元。 |
| WorkingTreeRevisionSnapshot | 命令在自己的写事务里读到的三个位的当前值。 |
| WorkingTreeStatus | 一次 status() 的全部回答(contracts/core-api.md §3)。 |
| WorkingTreeSuccessState | 调用成功,value 是它的返回值。 |
| WorkingTreeWritePrimitiveSignature | 一个写原语的签名身份 |
| WriteEntranceRequest | 交给 classifyWriteEntrance 的一次写 |
Type Aliases
Variables
| Variable | Description |
|---|---|
| COMMIT_ERROR_CODES | 全部提交错误码,顺序即 CommitErrorCode 的声明顺序 |
| CommitErrorCode | 提交能力的稳定错误码 |
| rxDBPluginWorkingTree | RxDB 工作树插件工厂。 |
| UNTRACKED_BOOKKEEPING_FIELDS | 第二类 untracked:实体行上的簿记字段,全域豁免 |
| WORKING_TREE_CAPABILITY_VERSION | 本能力写进水位行的版本号 |
| WORKING_TREE_CAPTURE_MOUNT_POINT_METHODS | 被挂载的写原语名,按契约 §1 的表格顺序。 |
| WORKING_TREE_CAPTURE_MOUNT_POINTS | 契约 §1 的 4 个挂载点,展平成行。 |
| WORKING_TREE_INITIAL_ASYNC_STATES | 十二项全部「还没人问过」;三端入口的初值只有这一份。 |
| WRITE_TARGET_CLASSES | 一次写所针对的表类别 |