跳到主要内容

rxdb-plugin-working-tree

RxDB 本地工作树与提交历史插件:把「未提交的改动」与「提交历史」作为一等概念加进 @aiao/rxdb。

未装本包的库零成本——十张系统表、写捕获、提交图编解码全部随包走,核心侧只留下装卸口与两道转交门。

能力范围​

  • 写捕获:用户编辑落进工作树而不是直接改主数据,库自己的簿记写入不被捕获
  • status() / diff():读当前分支的未提交摘要与逐条改动
  • commit():把工作树里的全部未提交单元提交成一次快照;CAS 落败走返回值而非异常
  • discard():把工作树整体退回 HEAD
  • listCommits():读当前分支的可达提交历史
  • restore() / restoreSession():把一个可达历史提交的内容作为新的未提交变更写回工作树;不移动 HEAD、不删历史,四个被拒成因走返回值
  • switchBranch() 的两道可选前置(requireClean / expectedActivationRevision);挂在 @aiao/rxdb-plugin-history 的 db.versionManager 上,被拒走异常

用之前要知道的六件事​

完整版见文档站的插件页,这里是压缩版:

  1. 提交能力是数据库级的显式开关——enable() 一次,整个库的所有实体、所有分支都按工作树语义运行。启用会留下一行能力水位,没装本包的客户端从此拒绝连接这个库;v1 没有 disable()。
  2. 工作树 ≠ 草稿缓存。 工作树装的是「已经写进数据库、还没提交成快照」的变更,参与事务、被查询读到;草稿缓存(@aiao/rxdb-plugin-workspace)装的是「还没保存」的编辑器 buffer,根本没进主库。两层不能合并,本包因此也不占用 Workspace* 前缀。
  3. restore() 不是 checkout。 它把历史内容作为新的未提交变更写回工作树:HEAD 不动、历史不删、工作树变脏,下一步是 commit() 或 discard()。v1 没有 detached HEAD、没有 checkout()。
  4. 历史会原样保留敏感旧值。 写进过某次提交的字段永久留在那次提交的 ChangeSet 里,之后改掉、清空、删行都不会动到它;v1 没有任何公开 API 能把它从历史里抠掉。不要把不该留痕的东西写进启用了提交能力的库。
  5. 加密边界:加密字段在提交、工作树与恢复会话里仍以 versioned envelope 落盘,持久化路径不先解密再写明文,错误与摘要也不带明文。但加密保护的是落盘的字节——它不消解第 4 条,第 4 条也不能替代它。
  6. 不改写历史:没有 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 等跨包消费者不会把它们当接入方数据推上远端。

框架绑定​

三端 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​

MIT

Classes​

ClassDescription
BranchNotMaterializedError物化依据不足,整次尝试以 branch_not_materialized 全量回滚(FR-044)。
ColdReplayMismatchError冷重放不变量被破坏。
CommitEncryptedAtRestError一个加密列的值没有以落库形态进入 commit(FR-038)。
CommitValidationError入参没过校验;在任何读写之前抛出。
MixedVersionedCacheTransactionErrortracked 与 untracked 实体混进了同一个事务单元
RxDBPluginWorkingTree把工作树与提交历史装到单个 RxDB 实例上的插件生命周期对象。
StaleActiveBranchError调用方手里的 active branch token 已经过期
UnknownWorkingTreePatchEntityError目标实体未在本进程注册时,编码端抛出的错误。
WorkingTreeCapabilityDisabledError在未启用提交能力的数据库上调用了受管成员。
WorkingTreeCaptureRuntime捕获运行时
WorkingTreeDirtyError工作树非空导致切换被拒(FR-017)。
WorkingTreeEntryCountMismatchError冗余列与实际条目行数对不上。
WorkingTreeManager工作树与提交历史的入口(契约见 contracts/core-api.md §1)。
WorkingTreeReplayCorruptionError工作树单元违反了折叠不变量
WorkingTreeWriteRejectedError一次被门禁拒下的写

Interfaces​

InterfaceDescription
ActiveBranchToken调用方在读取 / 实例化实体时捕获的 active 分支身份(FR-020)
BulkWriteGateRequest一次批量写的判定输入
CapturedWrite一次被捕获的写
ChangeCaptureSource捕获需要的 rxdb_change 列
ColdReplayIdentity一行业务数据的身份,与工作树单元的唯一索引除 branchId 外的部分一致。
ColdReplayInvariantInputassertColdReplayInvariant 的入参。
ColdReplayRow一行业务数据:身份 + 全部列值。
CommitCapabilityInfo库里那一行能力状态的只读视图。
CommitCapabilityVersions本进程支持的三个能力版本号。
CommitChangeSetPagecommitChanges 返回的一页:一个 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)。
CommitPatchCodecContextcommit 侧 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一批变更共用的捕获上下文
WorkingTreeCaptureHostcreateWorkingTreeCapturePort 需要的宿主能力。
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​

Type AliasDescription
BranchNotMaterializedReason一次物化被拒的成因;调用方据此决定重试、重新拉一遍还是放弃。
BulkWriteOperation受门禁约束的两个 adapter 公开批量写方法。
ColdReplayMismatch重放结果与业务表实际内容的一处差异。
ColdReplaySnapshot一组业务数据行
CommitChangeSetRow一行 rxdb_commit_change_set 中参与还原的那九列。
CommitChangeUnitOperation变更单元的操作类型,与 CommitChangeSet.operation 同一取值域。
CommitChangeUnitOrigin变更单元的来源,与 CommitChangeSet.origin 同一取值域。
CommitConflictKind三次比较各自对应的冲突种类。
CommitEncryptedAtRestReason一次 at-rest 命中的成因。
CommitEncryptedAtRestRecognizer判定一个值是否已处于「加密后的落库形态」。
CommitErrorCodeCommitErrorCode 的值联合
CommitKind提交节点的种类
CommitPatchColumn承载变更内容的两列,两者同权:反向数据泄漏与正向数据泄漏是同一件事。
CommitResult一次 commit() 的结果。
CommitValidationReason提交被拒的原因;每一个都对应 FR-008 / FR-009 的一条硬约束。
FoldOutcome折叠的结论;三种 kind 与 entryCountDelta 一一对应,调用方不必自己数行。
NoCaptureReason放行但不产生单元的理由;每一条对应矩阵里一组不同的行。
RawWriteAllowReason放行的理由;每一条对应四步里的一步。
RawWriteJudgment判定落在第几步;第 3 步是唯一会拒绝的一步。
RestoreCompatibility预检结论。
RestoreReplayDirection重放方向。
VersionedEntityClass实体在版本化域中的归类
WorkingTreeCaptureMountPointOrdinal契约 §1 表格里的行号,1–4。
WorkingTreeCommandState命令的可观测状态:没有 empty(§4)。
WorkingTreeDiffGranularity摊开的粒度:一条单元一行,或按事务收成组。
WorkingTreeDiscardOptions一次 discard() 的入参(contracts/core-api.md §3)。
WorkingTreeDiscardResult一次 discard() 的结果。
WorkingTreeEnableIfEmptyResultWorkingTreeManager.enableIfEmpty 的三种结局。
WorkingTreeEntryOperation单元相对 HEAD 的净操作。
WorkingTreeOriginBreakdown未提交条目按来源的分布。
WorkingTreePatch字段级 patch;null 表示「这一侧没有值」(insert 的逆、delete 的正向)。
WorkingTreeQueryState查询的可观测状态:比命令恰好多一个 empty(§4)。
WorkingTreeRestoreFailureReason一次 restore() 被拒的成因。
WorkingTreeRestoreOptions一次 restore() 的入参:三个捕获位,没有第四个。
WorkingTreeRestoreResult一次 restore() 的结果。
WorkingTreeStatePatch往某一格状态里写一个新相位。
WorkingTreeStateSink状态的去处;三端各自把它接到本框架的响应式原语上。
WorkingTreeSwitchBranchOptionsswitchBranch() 的可选第二形参(FR-017、contracts/core-api.md §6)。
WriteColumns这次写碰了哪些列
WriteEntranceDecisionclassifyWriteEntrance 的结论。
WriteEntryOrigin单元的来源
WriteOperation一次写的操作种类;只有 update 可能构不成净变化。
WriteTargetClassWRITE_TARGET_CLASSES 的值联合

Variables​

VariableDescription
COMMIT_ERROR_CODES全部提交错误码,顺序即 CommitErrorCode 的声明顺序
CommitErrorCode提交能力的稳定错误码
rxDBPluginWorkingTreeRxDB 工作树插件工厂。
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一次写所针对的表类别

Functions​

FunctionDescription
allocateBranchGeneration发放下一个分支代际:让库把 branchGenerationSeq 就地 +1,再把新值读回来。
applyRawWriteJudgment判定并在放行时执行语句;捕获运行时 gateRawWrite() 的实现体
assertColdReplayInvariant核验冷重放不变量
assertCommitUnitsEncryptedAtRest校验一批变更单元的加密列都已处于落库形态(FR-038)。
assertSwitchBranchPreconditions校验调用方显式提出的那些前置条件(FR-017、FR-020)。
assertSwitchTargetIntact校验目标分支的提交图可达完好(FR-051、SC-013)。
assertWorkingTreeEntryCountIntact校验冗余列与实际条目行数一致(data-model.md §2.6)。
buildVersionedDomain从实体登记算出版本化域
captureChanges把一批变更日志捕获成工作树单元(整批共享一个 unitId)
captureCrudWrite把一次普通 CRUD 写入包进工作树捕获的四步(FR-039)
checkRestoreCompatibility判断「把工作树恢复到目标 commit」在当前客户端是否可行。
classifyWriteEntrance判定一次写该不该落成工作树单元
commitWorkingTree提交当前分支工作树里的全部未提交单元(FR-041)。
createWorkingTreeActivationRow造出激活态初始行(未落库)。
createWorkingTreeCaptureBatch建一批变更共用的捕获上下文
createWorkingTreeCapturePort把一个事务执行器包成单个身份的 WorkingTreeCapturePort
createWorkingTreeCaptureRuntime按一个库的实体登记造出捕获运行时
createWorkingTreeCommands把十二个命令接到状态格子上。
createWorkingTreeCommitsInitialRows造出 epic-006 持久层的全部初始行(§8 第 2–3 步)
decodeCommitChangeSetUnit把一行 rxdb_commit_change_set 还原成变更单元内容。
decodeCommitChangeSetUnitsdecodeCommitChangeSetUnit 的批量版本,保持入参顺序。
decodeWorkingTreePatchencodeWorkingTreePatch 的逆运算。
diffColdReplay比对重放结果与业务表实际内容
discardWorkingTree把当前分支的工作树整体退回当前 HEAD(FR-016)。
encodeWorkingTreePatch把一份 patch 编码成可落进 patch / inversePatch json 列的形态。
findCommitConflict比一遍三个捕获位,产出第一处不匹配(FR-031)。
findFirstReplayIncompatibility沿一条已选定的路径找首个不兼容节点。
fingerprintOf单元当前状态的指纹(FNV-1a/32,十六进制定长 8 位)。
foldWorkingTreeEntry把一次捕获折进已有的工作树单元
gateBulkWrite判定一次批量写,放行时才调用写原语工厂
gateExternalNotify判定一次外部更新通知,放行时才执行通知体
isCommitChangeSetPageEmptycommitChanges() 的空:这个 commit 没有任何变更单元(基线节点就是这种)。
isCommitErrorCode判定一个值是否为已登记的提交错误码
isCommitLogPageEmptylistCommits() 的空:这个分支还没有历史。
isWorkingTreeCaptureMountPoint-
isWorkingTreeDiffEmptydiff() 的空:这一页没有可展示的改动。
isWorkingTreeRestoreSessionEmptyrestoreSession() 的空:当前分支没有未结束的恢复会话。
isWorkingTreeStatusEmptystatus() 的空:没有未提交变更。
judgeRawWrite判定一次 raw 调用该不该下发
producesWorkingTreeEntry这条受信路径会产生工作树单元吗
readActiveBranchToken读当前的 active 分支令牌
readActiveRestoreSession读当前分支那个未结束的恢复会话,没有就是 null。
readBranchEntries读一条分支当前全部未提交条目,按 id 升序
readCommitChangeSetPage读一个 commit 的全部变更单元。
readCommitLogPage读一个分支的提交历史,翻译成公开面的一页。
readWorkingTreeActivationState读激活态单行。
readWorkingTreeDiff摊开一条分支的 HEAD ↔ 工作树 差异(FR-005)。
readWorkingTreeStateRow取本分支的工作树状态行。
readWorkingTreeStatus读当前分支的工作树摘要(FR-004)。
replayWorkingTree用 HEAD 投影 + 全部工作树单元 重放出净状态
restoreWorkingTree把目标 commit 的内容作为新的未提交变更写回当前工作树(FR-013)。
selectRestoreReplayPath选定「恢复到目标节点」要回退的那条确定性路径。
toCapturedWrite把一条变更日志折成一次待捕获的写
trackWorkingTreeCommand跑一次命令,沿途发出 loading → success
trackWorkingTreeQuery跑一次查询,沿途发出 loading → success