@aiao/rxdb-plugin-history 0.0.0-stage → 0.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -2
  3. package/dist/HistoryManager.d.ts +87 -0
  4. package/dist/HistoryManager.d.ts.map +1 -0
  5. package/dist/VersionManager.d.ts +243 -0
  6. package/dist/VersionManager.d.ts.map +1 -0
  7. package/dist/create-branch.d.ts +35 -0
  8. package/dist/create-branch.d.ts.map +1 -0
  9. package/dist/detached-event-error.d.ts +28 -0
  10. package/dist/detached-event-error.d.ts.map +1 -0
  11. package/dist/find-switch-branch-step.d.ts +94 -0
  12. package/dist/find-switch-branch-step.d.ts.map +1 -0
  13. package/dist/history-filters.d.ts +78 -0
  14. package/dist/history-filters.d.ts.map +1 -0
  15. package/dist/history-item-builder.d.ts +25 -0
  16. package/dist/history-item-builder.d.ts.map +1 -0
  17. package/dist/history-scope-api.d.ts +41 -0
  18. package/dist/history-scope-api.d.ts.map +1 -0
  19. package/dist/history-undo-session.types.d.ts +32 -0
  20. package/dist/history-undo-session.types.d.ts.map +1 -0
  21. package/dist/index.d.ts +39 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +1565 -0
  24. package/dist/merge-branch.d.ts +22 -0
  25. package/dist/merge-branch.d.ts.map +1 -0
  26. package/dist/plugin.d.ts +58 -0
  27. package/dist/plugin.d.ts.map +1 -0
  28. package/dist/pullable-count.d.ts +37 -0
  29. package/dist/pullable-count.d.ts.map +1 -0
  30. package/dist/push-inflight.d.ts +60 -0
  31. package/dist/push-inflight.d.ts.map +1 -0
  32. package/dist/redo-stack.d.ts +56 -0
  33. package/dist/redo-stack.d.ts.map +1 -0
  34. package/dist/remove-branch.d.ts +16 -0
  35. package/dist/remove-branch.d.ts.map +1 -0
  36. package/dist/restore-entity.d.ts +15 -0
  37. package/dist/restore-entity.d.ts.map +1 -0
  38. package/dist/scope-selection.d.ts +56 -0
  39. package/dist/scope-selection.d.ts.map +1 -0
  40. package/dist/switch-branch-actions.d.ts +87 -0
  41. package/dist/switch-branch-actions.d.ts.map +1 -0
  42. package/dist/sync-history-bridge.d.ts +87 -0
  43. package/dist/sync-history-bridge.d.ts.map +1 -0
  44. package/dist/undo-redo-apply.d.ts +47 -0
  45. package/dist/undo-redo-apply.d.ts.map +1 -0
  46. package/dist/version-manager.utils.d.ts +30 -0
  47. package/dist/version-manager.utils.d.ts.map +1 -0
  48. package/package.json +59 -3
  49. package/src/HistoryManager.ts +800 -0
  50. package/src/VersionManager.ts +617 -0
  51. package/src/create-branch.ts +178 -0
  52. package/src/detached-event-error.ts +43 -0
  53. package/src/find-switch-branch-step.ts +313 -0
  54. package/src/history-filters.ts +157 -0
  55. package/src/history-item-builder.ts +101 -0
  56. package/src/history-scope-api.ts +189 -0
  57. package/src/history-undo-session.types.ts +37 -0
  58. package/src/index.ts +61 -0
  59. package/src/merge-branch.ts +192 -0
  60. package/src/plugin.ts +93 -0
  61. package/src/pullable-count.ts +67 -0
  62. package/src/push-inflight.ts +91 -0
  63. package/src/redo-stack.ts +79 -0
  64. package/src/remove-branch.ts +100 -0
  65. package/src/restore-entity.ts +124 -0
  66. package/src/scope-selection.ts +133 -0
  67. package/src/switch-branch-actions.ts +319 -0
  68. package/src/sync-history-bridge.ts +131 -0
  69. package/src/undo-redo-apply.ts +270 -0
  70. package/src/version-manager.utils.ts +43 -0
@@ -0,0 +1,178 @@
1
+ import {
2
+ assertUsableBranchId,
3
+ type LocalRxDBBranchRepository,
4
+ type LocalRxDBChangeRepository,
5
+ resolve_current_branch,
6
+ RxDBBranch,
7
+ RxDBChange,
8
+ RxDBError
9
+ } from '@aiao/rxdb';
10
+ import { VersionManager } from './VersionManager.js';
11
+
12
+ /**
13
+ * 创建一个新分支
14
+ * @param version
15
+ * @param branchId
16
+ * @param fromChangeId
17
+ * @returns
18
+ *
19
+ * @remarks
20
+ * 「查重 → 解析分叉点 → 写入」在一个事务窗口内完成。只把 `remove_branch`
21
+ * 包进事务是不够的:一个在删除之前就解析完父分支的 `create_branch`,仍会在删除之后
22
+ * 把子分支写进去,留下孤儿。两侧同处一个写队列窗口,「只能有一方成功」才成立。
23
+ *
24
+ * 远端 `branchExists` 那一趟**留在事务外**:它是网络往返,放进事务会让并发度 1 的
25
+ * 写队列被一次 RTT 堵住。它本来也只是尽力而为的预检,真正的互斥由本地主键约束兜底。
26
+ *
27
+ * **一条分支未必只有一行。** 贡献系统能力的插件可以在同一个事务里追写自己那几行
28
+ * ({@link RxDBSystemContribution.writeBranchRows})——装了 `@aiao/rxdb-plugin-working-tree`
29
+ * 时是 `CommitBranchRef` 与 `WorkingTreeState` 两行。同属一个事务,正是为了不让
30
+ * 「建了分支却没有对应视图」这种半成品状态存在:那样一条分支与一条正常的老分支
31
+ * 在形状上分辨不出来,只会在下一次用到它时抛一句读不出主语的错。
32
+ */
33
+ export const create_branch = async (version: VersionManager, branchId: string, fromChangeId?: number) => {
34
+ // 挡在所有 I/O 之前。排在查重之后的话,一条从第一个字符起就不可用的 id 会先跑完本地
35
+ // 查重、再跑一趟远端 RTT 才被拒——而这两趟的答案与结论无关。
36
+ assertUsableBranchId(branchId);
37
+
38
+ const { branchRepository: queuedBranchRepository, adapter } = await version.getLocalRepositories();
39
+ // 检查分支 ID 是否存在(本地)
40
+ //
41
+ // 事务里还会再查一次。这一次是快速失败:它挡在远端往返之前,
42
+ // 保证「本地已存在」比「远端已存在」先报,错误信息的优先级不因加事务而改变。
43
+ const branchEntity = await queuedBranchRepository.find({
44
+ where: {
45
+ combinator: 'and',
46
+ rules: [{ field: 'id', operator: '=', value: branchId }]
47
+ },
48
+ limit: 1
49
+ });
50
+ if (branchEntity.length) throw new RxDBError(`Branch id (${branchId}) already exists`);
51
+
52
+ // 检查分支 ID 是否存在(远程)
53
+ const remoteAdapterName = version.rxdb.config.sync?.remote?.adapter;
54
+ if (remoteAdapterName) {
55
+ const { adapter: remoteAdapter } = await version.getRemoteRepositories();
56
+ if (remoteAdapter.branchExists) {
57
+ const existsOnRemote = await remoteAdapter.branchExists(branchId);
58
+ if (existsOnRemote) throw new RxDBError(`Branch id (${branchId}) already exists on remote`);
59
+ }
60
+ }
61
+
62
+ // 校验失败返回而不是就地抛出,理由同 `remove_branch`:不让适配器的事务包装层改写异常身份。
63
+ const result = await adapter.transaction(async executor => {
64
+ const branchRepository = executor.getRepository(RxDBBranch) as unknown as LocalRxDBBranchRepository;
65
+ const changeRepository = executor.getRepository(RxDBChange) as unknown as LocalRxDBChangeRepository;
66
+
67
+ const existed = await branchRepository.find({
68
+ where: {
69
+ combinator: 'and',
70
+ rules: [{ field: 'id', operator: '=', value: branchId }]
71
+ },
72
+ limit: 1
73
+ });
74
+ if (existed.length) return `Branch id (${branchId}) already exists`;
75
+
76
+ let fromBranch: InstanceType<typeof RxDBBranch> | undefined;
77
+ let fromChange: InstanceType<typeof RxDBChange> | undefined;
78
+
79
+ // 找出当前分支
80
+ if (fromChangeId) {
81
+ // 从 RxDBChange 表查找包含指定 changeId 的记录,获取其 branchId
82
+ const changeEntity = (
83
+ await changeRepository.find({
84
+ where: {
85
+ combinator: 'and',
86
+ rules: [{ field: 'id', operator: '=', value: fromChangeId }]
87
+ },
88
+ limit: 1
89
+ })
90
+ )[0];
91
+ if (!changeEntity) return `Change ID (${fromChangeId}) not found`;
92
+
93
+ // 保存找到的 change 作为 fromChange
94
+ fromChange = changeEntity;
95
+
96
+ // 通过 branchId 查找分支
97
+ fromBranch = (
98
+ await branchRepository.find({
99
+ where: {
100
+ combinator: 'and',
101
+ rules: [{ field: 'id', operator: '=', value: changeEntity.branchId! }]
102
+ },
103
+ limit: 1
104
+ })
105
+ )[0];
106
+ } else {
107
+ // 不能调 `version.getCurrentBranch()`:那一份走绑在适配器上的仓库,
108
+ // 会排在自己这个事务后面(队列并发度 1)而挂死。共用同一段逻辑,换成事务内的仓库。
109
+ fromBranch = await resolve_current_branch(branchRepository, version.rxdb);
110
+ fromChange = await get_current_branch_last_change(changeRepository, fromBranch.id);
111
+ }
112
+ if (!fromBranch) return 'Source branch not found';
113
+
114
+ // 创建新分支
115
+ //
116
+ // 用事务内仓库的 `create` 而非 `branch.save()`:后者经 entityManager 打到适配器上,
117
+ // 正是事务体内不能碰的那条队列。
118
+ const branch = version.rxdb.entityManager.instantiate(RxDBBranch);
119
+ branch.id = branchId;
120
+ branch.activated = false;
121
+ // 冗余列与 `activated` 必须同写(`system/branch.ts` 的唯一索引就架在它上面)。
122
+ // 漏写一处,那一行就绕过唯一约束,而 schema 那一半的保护正好在这种漏写上失效。
123
+ branch.activeKey = null;
124
+ branch.local = true;
125
+ branch.remote = false;
126
+ branch.fromChangeId = fromChange?.id ?? null;
127
+ branch.parentId = fromBranch.id;
128
+ await branchRepository.create(branch);
129
+
130
+ // 贡献方的分支级行写在**这一个**事务里,不另开一个:分支行与贡献行分处两个事务的话,
131
+ // 中间失败留下的是一条「分支在、贡献行不在」的记录,而这种半条分支与一条正常的老分支
132
+ // 在形状上分辨不出来。抛错就让整条 `create_branch` 回滚,这是对的。
133
+ //
134
+ // 串行而非 `Promise.all`:`executor` 是一条并发度为 1 的队列,并行发起只会让
135
+ // 写入顺序取决于各贡献方内部 await 的排布,出问题时复现不出来。
136
+ for (const contribution of version.rxdb.systemContributions) {
137
+ await contribution.writeBranchRows(version.rxdb.entityManager, { executor, branchId });
138
+ }
139
+ return branch;
140
+ });
141
+
142
+ if (typeof result === 'string') throw new RxDBError(result);
143
+ return result;
144
+ };
145
+
146
+ /**
147
+ * 获取分支最新一笔可作为分叉点的变更记录
148
+ *
149
+ * @param changeRepository - 变更仓库。由调用方决定它属于哪个事务 ——
150
+ * `create_branch` 传的是事务内的那一份,不能在这里自己去 `getLocalRepositories()`。
151
+ * @param currentBranchId - 分支 ID;不传则按「当前激活分支」查
152
+ *
153
+ * 独立导出而非内联,是为了被 `__tests__/version/create-branch.spec.ts` 单独测试。
154
+ */
155
+ export const get_current_branch_last_change = async (
156
+ changeRepository: LocalRxDBChangeRepository,
157
+ currentBranchId?: string
158
+ ) => {
159
+ return (
160
+ await changeRepository.find({
161
+ where: {
162
+ combinator: 'and',
163
+ rules: [
164
+ currentBranchId ?
165
+ { field: 'branchId', operator: '=', value: currentBranchId }
166
+ : { field: 'branch.activated', operator: '=', value: true },
167
+ {
168
+ field: 'revertChangeId',
169
+ operator: '=',
170
+ value: null
171
+ }
172
+ ]
173
+ },
174
+ orderBy: [{ field: 'id', sort: 'desc' }],
175
+ limit: 1
176
+ })
177
+ )[0];
178
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @fileoverview 游离事件任务的错误判据
3
+ *
4
+ * 事件监听器里发起的异步任务没有调用方接得住错误({@link VersionManager} 的
5
+ * `#runDetachedEventTask`、{@link setupSyncListeners} 的远端事件处理器都是即发即忘)。
6
+ * 连接正在拆除时这些任务必然撞上一批「适配器已经走了」的错误,它们是拆除过程的正常噪音,
7
+ * 不该打进控制台当故障看。
8
+ *
9
+ * 判据留在历史插件、由同步插件经 `@aiao/rxdb-plugin-history` 引用:同步插件本就
10
+ * `inject: ['plugin:history']`,这条 import 不新增依赖边;放进核心则要为一个纯谓词
11
+ * 再撑大一圈公共表面,与 US-025「核心只留原语」的方向相反。
12
+ */
13
+
14
+ import { isAdapterShutdownError } from '@aiao/rxdb';
15
+
16
+ /**
17
+ * 判断游离事件任务的错误是否可忽略
18
+ *
19
+ * @param error - 任务抛出的错误
20
+ * @returns 属于连接拆除噪音时返回 `true`
21
+ *
22
+ * @remarks
23
+ * 包括:
24
+ * - errno 44 = ENODEV(Emscripten IDBFS 连接关闭)
25
+ * - name === 'AbortError'
26
+ * - adapter shutdown 错误(统一字符串匹配模式)
27
+ *
28
+ * @internal
29
+ */
30
+ export const isIgnorableDetachedVersionEventError = (error: unknown): boolean => {
31
+ if (!error || typeof error !== 'object') {
32
+ return false;
33
+ }
34
+
35
+ const name = 'name' in error ? String(error.name) : '';
36
+ const errno = 'errno' in error ? Number((error as { errno?: unknown }).errno) : undefined;
37
+
38
+ if (errno === 44 || name === 'AbortError') {
39
+ return true;
40
+ }
41
+
42
+ return isAdapterShutdownError(error);
43
+ };
@@ -0,0 +1,313 @@
1
+ import { RxDBBranch, RxDBError } from '@aiao/rxdb';
2
+ /**
3
+ * 分支切换步骤
4
+ *
5
+ * @internal
6
+ */
7
+ export interface SwitchBranchStep {
8
+ /**
9
+ * 分支对象
10
+ */
11
+ branch: RxDBBranch;
12
+ /**
13
+ * 开始变更 ID
14
+ */
15
+ fromChangeId: number;
16
+
17
+ /**
18
+ * 目标变更 ID
19
+ */
20
+ toChangeId: number;
21
+ }
22
+
23
+ /**
24
+ * 查找分支切换路径的参数
25
+ */
26
+ export interface FindBranchPathOptions {
27
+ /**
28
+ * 所需分支列表
29
+ */
30
+ branches: RxDBBranch[];
31
+
32
+ /**
33
+ * 当前分支
34
+ */
35
+ currentBranch: RxDBBranch;
36
+
37
+ /**
38
+ * 当前分支的变更 ID
39
+ */
40
+ currentChangeId: number | null;
41
+
42
+ /**
43
+ * 目标分支
44
+ */
45
+ nextBranch: RxDBBranch;
46
+
47
+ /**
48
+ * 目标分支的变更 ID
49
+ */
50
+ nextChangeId: number | null;
51
+ }
52
+
53
+ /**
54
+ * 读取分支的分叉点变更 id。
55
+ *
56
+ * `fromChangeId === null` 的含义是**分叉于根**(这条分支不是从某条变更上长出来的)。
57
+ * 变更 id 从 1 开始递增,所以用 0 表示「根」既不会和任何真实变更撞上,又能让它
58
+ * 直接参与后面的区间运算。
59
+ *
60
+ * 抽成具名函数是为了让这个约定只存在一处:从前它以 `branch.fromChangeId ?? 0` 的形式
61
+ * 在本文件出现十余次,读的人无法判断哪一处是「表达根」、哪一处只是随手兜底。
62
+ *
63
+ * @param branch - 分支行
64
+ * @returns 分叉点变更 id,分叉于根时为 0
65
+ */
66
+ const forkPointOf = (branch: RxDBBranch): number => branch.fromChangeId ?? 0;
67
+
68
+ /**
69
+ * 沿 `parentId` 从一条分支走到它所在树的根,返回途经的全部分支(含自己,根在最后)。
70
+ *
71
+ * 断链(`parentId` 指不到分支)与成环都是**持久化完整性错误**:调用方传进来的分支集合
72
+ * 是全量无过滤查询的结果,不存在「父分支被过滤掉了」这种正常情况。
73
+ * 曾经这两种情况都是 `break` —— 相当于把坏数据当成「已经到根」,继续算出一条通往
74
+ * 并不存在的根的完整 action 序列,调用方照单全收地写库,半条路径被应用而没有任何报错。
75
+ * 切分支失败远好过静默切错,因此这里必须终止且不产生任何 action。
76
+ *
77
+ * 从 {@link find_switch_branch_step} 的闭包里提出来成为模块级导出,是因为
78
+ * 「父链自不自洽」这一问的答案必须**全局只有一个**:`enable-migration.ts` 的
79
+ * 「分支可否完整物化」判定用的就是它(research.md R11)。留在闭包里够不着,
80
+ * 调用方只能另写一套遍历,于是迁移期与运行期会对同一个库给出两种答案。
81
+ *
82
+ * 它**够不到** {@link find_switch_branch_step} 的两处提前返回:变更 id 相同(含两边
83
+ * 都没有变更、都落在根上)时那个函数直接返回空步骤,根本走不到这里。所以要检查父链
84
+ * 完整性的调用方必须**自己**调它一次,不能指望 `find_switch_branch_step` 顺带检查。
85
+ *
86
+ * @param branch - 起点分支
87
+ * @param branchMap - 分支 id → 分支;必须是全量集合
88
+ * @returns 从 `branch` 到根的路径,`branch` 在第 0 位
89
+ * @throws {@link RxDBError} 父链成环或指向不存在的分支时
90
+ *
91
+ * @internal
92
+ */
93
+ export const find_branch_path_to_root = (
94
+ branch: RxDBBranch,
95
+ branchMap: ReadonlyMap<string, RxDBBranch>
96
+ ): RxDBBranch[] => {
97
+ const path: RxDBBranch[] = [branch];
98
+ const visited = new Set<string>([branch.id]);
99
+ let current = branch;
100
+ while (current.parentId) {
101
+ if (visited.has(current.parentId)) {
102
+ throw new RxDBError(
103
+ `Branch history is corrupt: cycle detected at branch '${current.id}' (parentId '${current.parentId}' already visited)`
104
+ );
105
+ }
106
+ const parent = branchMap.get(current.parentId);
107
+ if (!parent) {
108
+ throw new RxDBError(
109
+ `Branch history is corrupt: branch '${current.id}' references missing parent '${current.parentId}'`
110
+ );
111
+ }
112
+ visited.add(parent.id);
113
+ path.push(parent);
114
+ current = parent;
115
+ }
116
+ return path;
117
+ };
118
+
119
+ /**
120
+ * 算出树上两个节点之间的切换路径
121
+ *
122
+ * @param options - 起点分支、目标分支、两侧当前变更 id,以及**全量**分支集合
123
+ * @returns 按执行顺序排好的步骤;两侧停在同一个变更 id 时为空数组
124
+ * @throws {@link RxDBError} 父链成环或指向不存在的分支时(经 {@link find_branch_path_to_root})
125
+ *
126
+ * @remarks
127
+ * 规则四条:
128
+ *
129
+ * 1. 两个节点有共同祖先时,路径是「当前节点 → 共同祖先 → 目标节点」
130
+ * 2. 没有共同祖先时,路径是「当前节点 → 根 → 目标节点」
131
+ * 3. 路径里每个节点都要算出自己那一段的变更区间,即 `fromChangeId → toChangeId`
132
+ * 4. `changeId` 在每条分支上都递增,所以**逆向还原**的那些步骤 `fromChangeId > toChangeId`,
133
+ * 正向应用的则相反——方向由这两个数的大小关系表达,步骤上没有第二个方向字段
134
+ *
135
+ * 有两处**提前返回**会跳过父链遍历——变更 id 相同时返回空步骤,同分支不同变更 id 时返回
136
+ * 单步——因此本函数不是父链完整性的检查点,理由见 {@link find_branch_path_to_root}。
137
+ *
138
+ * @internal
139
+ */
140
+ export const find_switch_branch_step = (options: FindBranchPathOptions): SwitchBranchStep[] => {
141
+ const { branches, currentBranch, nextBranch } = options;
142
+
143
+ // 如果分支没有自己的变更记录,使用 fromChangeId 作为当前状态
144
+ // 否则使用该分支的最大 changeId
145
+ const currentChangeId = options.currentChangeId ?? forkPointOf(currentBranch);
146
+ const nextChangeId = options.nextChangeId ?? forkPointOf(nextBranch);
147
+
148
+ // 任意分支只要变更 id 相同,都无需切换数据
149
+ if (currentChangeId === nextChangeId) {
150
+ return [];
151
+ }
152
+
153
+ // 如果是同一个分支但变更 id 不同,直接在当前分支上移动
154
+ if (currentBranch.id === nextBranch.id) {
155
+ return [
156
+ {
157
+ branch: currentBranch,
158
+ fromChangeId: currentChangeId,
159
+ toChangeId: nextChangeId
160
+ }
161
+ ];
162
+ }
163
+
164
+ // 构建分支映射表
165
+ const branchMap = new Map<string, RxDBBranch>();
166
+ for (const branch of branches) {
167
+ branchMap.set(branch.id, branch);
168
+ }
169
+
170
+ // 获取当前分支和目标分支到根的路径
171
+ const currentPath = find_branch_path_to_root(currentBranch, branchMap);
172
+ const nextPath = find_branch_path_to_root(nextBranch, branchMap);
173
+
174
+ // 找到最近的共同祖先
175
+ let commonAncestor: RxDBBranch | null = null;
176
+ let commonAncestorIndex = -1;
177
+
178
+ for (let i = 0; i < currentPath.length; i++) {
179
+ const idx = nextPath.findIndex(b => b.id === currentPath[i].id);
180
+ if (idx !== -1) {
181
+ commonAncestor = currentPath[i];
182
+ commonAncestorIndex = i;
183
+ break;
184
+ }
185
+ }
186
+
187
+ const steps: SwitchBranchStep[] = [];
188
+
189
+ if (commonAncestor) {
190
+ // 从当前分支回退到共同祖先
191
+ for (let i = 0; i < commonAncestorIndex; i++) {
192
+ const branch = currentPath[i];
193
+ const fromId = i === 0 ? currentChangeId : forkPointOf(currentPath[i - 1]);
194
+ const toId = forkPointOf(branch);
195
+
196
+ // 只有当需要实际变更时才添加步骤
197
+ if (fromId !== toId) {
198
+ steps.push({
199
+ branch,
200
+ fromChangeId: fromId,
201
+ toChangeId: toId
202
+ });
203
+ }
204
+ }
205
+
206
+ // 在共同祖先上可能也需要移动
207
+ const ancestorIndexInNext = nextPath.findIndex(b => b.id === commonAncestor!.id);
208
+
209
+ if (ancestorIndexInNext === 0) {
210
+ // 共同祖先就是目标分支,需要移动到目标 changeId
211
+ const ancestorFromId =
212
+ commonAncestorIndex === 0 ? currentChangeId : forkPointOf(currentPath[commonAncestorIndex - 1]);
213
+ const ancestorToId = nextChangeId;
214
+
215
+ if (ancestorFromId !== ancestorToId) {
216
+ steps.push({
217
+ branch: commonAncestor,
218
+ fromChangeId: ancestorFromId,
219
+ toChangeId: ancestorToId
220
+ });
221
+ }
222
+ } else if (ancestorIndexInNext > 0) {
223
+ // 需要从当前位置移动到目标分支的 fromChangeId
224
+ const ancestorFromId =
225
+ commonAncestorIndex === 0 ? currentChangeId : forkPointOf(currentPath[commonAncestorIndex - 1]);
226
+ const ancestorToId = forkPointOf(nextPath[ancestorIndexInNext - 1]);
227
+
228
+ if (ancestorFromId !== ancestorToId) {
229
+ steps.push({
230
+ branch: commonAncestor,
231
+ fromChangeId: ancestorFromId,
232
+ toChangeId: ancestorToId
233
+ });
234
+ }
235
+ }
236
+
237
+ // 从共同祖先前进到目标分支
238
+ const forwardPath = nextPath.slice(0, ancestorIndexInNext).reverse();
239
+
240
+ for (let i = 0; i < forwardPath.length; i++) {
241
+ const branch = forwardPath[i];
242
+ const fromId = forkPointOf(branch);
243
+ const toId = i === forwardPath.length - 1 ? nextChangeId : forkPointOf(forwardPath[i + 1]);
244
+
245
+ // 只有当需要实际变更时才添加步骤
246
+ if (fromId !== toId) {
247
+ steps.push({
248
+ branch,
249
+ fromChangeId: fromId,
250
+ toChangeId: toId
251
+ });
252
+ }
253
+ }
254
+ } else {
255
+ // 没有共同祖先,回退到当前分支的根,然后前进到目标分支
256
+ // 1. 回退当前分支的所有节点到根
257
+ for (let i = 0; i < currentPath.length; i++) {
258
+ const branch = currentPath[i];
259
+ const fromId = i === 0 ? currentChangeId : forkPointOf(currentPath[i - 1]);
260
+ const toId = forkPointOf(branch);
261
+
262
+ if (fromId !== toId) {
263
+ steps.push({
264
+ branch,
265
+ fromChangeId: fromId,
266
+ toChangeId: toId
267
+ });
268
+ }
269
+ }
270
+
271
+ // 2. 将当前树的根节点回退到 0
272
+ const currentRoot = currentPath[currentPath.length - 1];
273
+ const currentRootFromId = forkPointOf(currentRoot);
274
+
275
+ if (currentRootFromId !== 0) {
276
+ steps.push({
277
+ branch: currentRoot,
278
+ fromChangeId: currentRootFromId,
279
+ toChangeId: 0
280
+ });
281
+ }
282
+
283
+ // 3. 从目标树的根节点开始前进到目标分支
284
+ const nextRoot = nextPath[nextPath.length - 1];
285
+ const nextRootTargetId = nextPath.length > 1 ? forkPointOf(nextPath[nextPath.length - 2]) : nextChangeId;
286
+
287
+ if (nextRootTargetId !== 0) {
288
+ steps.push({
289
+ branch: nextRoot,
290
+ fromChangeId: 0,
291
+ toChangeId: nextRootTargetId
292
+ });
293
+ }
294
+
295
+ // 4. 前进目标树的所有子节点
296
+ const forwardPath = nextPath.slice(0, nextPath.length - 1).reverse();
297
+ for (let i = 0; i < forwardPath.length; i++) {
298
+ const branch = forwardPath[i];
299
+ const fromId = forkPointOf(branch);
300
+ const toId = i === forwardPath.length - 1 ? nextChangeId : forkPointOf(forwardPath[i + 1]);
301
+
302
+ if (fromId !== toId) {
303
+ steps.push({
304
+ branch,
305
+ fromChangeId: fromId,
306
+ toChangeId: toId
307
+ });
308
+ }
309
+ }
310
+ }
311
+
312
+ return steps;
313
+ };
@@ -0,0 +1,157 @@
1
+ /**
2
+ * @fileoverview 历史记录过滤与作用域键的纯函数集合
3
+ *
4
+ * 这些函数不持有任何状态,供 {@link HistoryManager} 与单元测试共用:
5
+ * - {@link filterHistoriesByScope}:按作用域裁切历史
6
+ * - {@link buildLastPushedMap}:合成每个仓库的「视同已推」水位线
7
+ * - {@link filterUndoableHistories}:过滤可撤销的历史
8
+ * - {@link getScopeKey}:生成作用域缓存键
9
+ *
10
+ * @module rxdb/version/history-filters
11
+ */
12
+
13
+ import { getRxDBEntityIdentityKey, type HistoryItem, type HistoryScope } from '@aiao/rxdb';
14
+ import { generateHistoryDescription } from './history-item-builder.js';
15
+ /**
16
+ * 生成 repository 的缓存键(纯函数)
17
+ *
18
+ * @param repository - 带 namespace / entity 的仓储标识
19
+ * @returns 'namespace:entity' 形式的缓存键
20
+ *
21
+ * @internal 供 HistoryManager 与同文件的过滤函数使用
22
+ */
23
+ export const getRepositoryKey = (repository: { namespace: string; entity: string }): string =>
24
+ `${repository.namespace}:${repository.entity}`;
25
+
26
+ /**
27
+ * 合成「每个仓库推到哪了」的水位线映射:已提交的 `RxDBSync` 水位线与正在飞的认领取较大者。
28
+ *
29
+ * @param repoSyncs - 当前分支下的 `RxDBSync` 行
30
+ * @param inFlight - `PushInFlightRegistry.snapshot()`,`namespace:entity` -> 正在飞的最大 change id
31
+ * @returns `namespace:entity` -> 该仓库「视同已推」的最大 change id
32
+ *
33
+ * @remarks
34
+ * `undoHistories$` 与 `fetchLatestHistories` 两条路径都要这份映射,此前各写一份循环。
35
+ * 两份必须逐字一致:一边认在飞、另一边不认,反应式的「可撤销」列表就会和真正执行撤销时
36
+ * 的判定分家 —— UI 上还亮着的那一项点下去什么也不会发生,或者更糟,真的撤销了一条
37
+ * 已经在飞的变更。
38
+ *
39
+ * 取最大值而不是「有在飞就用在飞」:上一轮推到 30、这一轮只在飞到 20 时,
40
+ * 水位线不许倒退。
41
+ *
42
+ * 在飞认领先进 map,因此**没有** `RxDBSync` 行的仓库也报得出来:`RxDBSync` 行由
43
+ * `planRepositoryPush` 现建,行还没落到本次查询的结果集里时,认领仍然必须生效。
44
+ */
45
+ export function buildLastPushedMap(
46
+ repoSyncs: readonly { namespace: string; entity: string; lastPushedChangeId: number | null }[],
47
+ inFlight: ReadonlyMap<string, number>
48
+ ): Map<string, number> {
49
+ const lastPushedMap = new Map(inFlight);
50
+
51
+ for (const repoSync of repoSyncs) {
52
+ if (repoSync.lastPushedChangeId === null) continue;
53
+ const key = getRepositoryKey(repoSync);
54
+ const claimed = lastPushedMap.get(key);
55
+ if (claimed !== undefined && claimed >= repoSync.lastPushedChangeId) continue;
56
+ lastPushedMap.set(key, repoSync.lastPushedChangeId);
57
+ }
58
+
59
+ return lastPushedMap;
60
+ }
61
+
62
+ /**
63
+ * 按作用域过滤历史记录(纯函数)
64
+ *
65
+ * @param histories - 原始历史记录数组
66
+ * @param scope - 作用域配置
67
+ * @returns 过滤后的历史记录数组
68
+ *
69
+ * @internal 供 HistoryManager 和单元测试使用
70
+ */
71
+ export function filterHistoriesByScope(histories: HistoryItem[], scope: HistoryScope): HistoryItem[] {
72
+ if (scope.type === 'database') {
73
+ return histories;
74
+ }
75
+
76
+ return histories
77
+ .map(history => {
78
+ const filtered_changes = history.changes.filter(change => {
79
+ if (scope.type === 'repository') {
80
+ return change.namespace === scope.namespace && change.entity === scope.entity;
81
+ }
82
+ // 实体作用域
83
+ return (
84
+ change.namespace === scope.namespace && change.entity === scope.entity && change.entityId === scope.entityId
85
+ );
86
+ });
87
+
88
+ if (filtered_changes.length === 0) return null;
89
+
90
+ return {
91
+ ...history,
92
+ changes: filtered_changes,
93
+ count: filtered_changes.length,
94
+ description: generateHistoryDescription(filtered_changes)
95
+ };
96
+ })
97
+ .filter((h): h is HistoryItem => h !== null);
98
+ }
99
+
100
+ /**
101
+ * 过滤出可撤销的历史记录(纯函数)
102
+ *
103
+ * @param histories - 原始历史记录数组
104
+ * @param lastPushedMap - namespace:entity -> lastPushedChangeId 的 repository 级水位线映射
105
+ * @param undoBoundaryChangeId - 同步清空后的永久撤销边界
106
+ * @param undoBoundaryCreatedAfter - 无数字 change id 时使用的严格时间边界
107
+ * @returns 可撤销的历史记录数组
108
+ *
109
+ * @remarks
110
+ * undoHistories$(UI 展示)与 undo()(实际执行)共用同一套规则,
111
+ * 保证 UI 上看不到的历史不会被 undo() 撤销:
112
+ * 1. history.reverted == false(按 updatedAt 水位合并持久态与本地态,未被撤销)
113
+ * 2. remoteId == null(只有本地创建的变更才能撤销)
114
+ * 3. id > 该 repository 的 lastPushedChangeId(未推送到远程;按 repository 独立判断)
115
+ * 4. id > undoBoundaryChangeId(同步清空前的历史永久不可撤销)
116
+ * 5. createdAt > undoBoundaryCreatedAfter(无数字 id 时保守隔离旧 session)
117
+ *
118
+ * @internal 供 HistoryManager 和单元测试使用
119
+ */
120
+ export function filterUndoableHistories(
121
+ histories: HistoryItem[],
122
+ lastPushedMap: Map<string, number>,
123
+ undoBoundaryChangeId = 0,
124
+ undoBoundaryCreatedAfter: Date | null = null
125
+ ): HistoryItem[] {
126
+ return histories.filter(history => {
127
+ if (history.reverted) return false;
128
+
129
+ return history.changes.every(change => {
130
+ const isLocal = change.remoteId == null;
131
+ const lastPushedId = lastPushedMap.get(getRepositoryKey(change));
132
+ const notPushed = lastPushedId == null || change.id > lastPushedId;
133
+ const afterUndoBoundary = change.id > undoBoundaryChangeId;
134
+ const afterUndoTimeBoundary =
135
+ undoBoundaryCreatedAfter === null || change.createdAt.getTime() > undoBoundaryCreatedAfter.getTime();
136
+ return isLocal && notPushed && afterUndoBoundary && afterUndoTimeBoundary;
137
+ });
138
+ });
139
+ }
140
+
141
+ /**
142
+ * 生成作用域缓存键(纯函数)
143
+ *
144
+ * @param scope - 作用域配置
145
+ * @returns 缓存键字符串:'database' | 'namespace:entity' | 'namespace:entity:id'
146
+ *
147
+ * @internal 供 HistoryManager 和单元测试使用
148
+ */
149
+ export function getScopeKey(scope: HistoryScope): string {
150
+ if (scope.type === 'database') {
151
+ return 'database';
152
+ }
153
+ if (scope.type === 'repository') {
154
+ return `${scope.namespace}:${scope.entity}`;
155
+ }
156
+ return `${scope.namespace}:${scope.entity}:${getRxDBEntityIdentityKey(scope.entityId)}`;
157
+ }