@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,617 @@
1
+ import {
2
+ declareTrustedWrite,
3
+ ENTITY_LOCAL_CREATE_EVENT,
4
+ EntityLocalCreatedEvent,
5
+ EntityType,
6
+ getCurrentBranch,
7
+ getLocalSystemRepositories,
8
+ getRemoteSystemRepositories,
9
+ HistoryScopeAPI,
10
+ MergeBranchBeginEvent,
11
+ MergeBranchCommitEvent,
12
+ MergeBranchFailedEvent,
13
+ MergeBranchOptions,
14
+ MergeBranchResult,
15
+ RestoreEntityOptions,
16
+ RxDB,
17
+ RxDBBranch,
18
+ RxDBError,
19
+ SwitchBranchBeginEvent,
20
+ SwitchBranchCommitEvent,
21
+ SwitchBranchRollbackEvent,
22
+ takeDeclaredWrite,
23
+ TRANSACTION_BEGIN,
24
+ TRANSACTION_COMMIT,
25
+ TRANSACTION_ROLLBACK,
26
+ TrustedWriteIntent,
27
+ type RxDBAdapterLocalBase,
28
+ type RxDBBranchSwitchPreconditions,
29
+ type TransactionExecutor
30
+ } from '@aiao/rxdb';
31
+ import { create_branch } from './create-branch.js';
32
+ import { isIgnorableDetachedVersionEventError } from './detached-event-error.js';
33
+ import { HistoryManager } from './HistoryManager.js';
34
+ import { merge_branch } from './merge-branch.js';
35
+ import { PushInFlightRegistry } from './push-inflight.js';
36
+ import { remove_branch } from './remove-branch.js';
37
+ import { restore_entity } from './restore-entity.js';
38
+ import { switch_branch_actions } from './switch-branch-actions.js';
39
+ import { createSyncHistoryBridge, type SyncHistoryBridge } from './sync-history-bridge.js';
40
+ import { getEarliestRecordAt, getRxDBChangeEventId } from './version-manager.utils.js';
41
+ /**
42
+ * 版本管理器
43
+ *
44
+ * 负责管理数据库的分支、撤销/重做与版本历史:
45
+ * - 分支管理(创建、删除、切换、合并)
46
+ * - 撤销/重做操作(数据库级别)
47
+ * - Redo 栈的自动失效(当有新操作时)
48
+ *
49
+ * @remarks
50
+ * 推拉同步**不在**这里。US-025 阶段 D 之后它住在 `@aiao/rxdb-plugin-sync` 的
51
+ * `SyncManager` 上,两边只经 {@link VersionManager.syncBridge} 这一张窄接口相遇。
52
+ */
53
+
54
+ export class VersionManager {
55
+ #event_removers: Array<() => void> = [];
56
+ #transactionDepth = 0;
57
+ #transactionGeneration: number | null = null;
58
+ #transactionCleanupInitialized = false;
59
+ #historyManagerDestroyed = false;
60
+
61
+ /**
62
+ * 撤销/重做辅助类
63
+ * 管理历史记录和内存 redo 栈
64
+ */
65
+ private historyManager: HistoryManager;
66
+
67
+ /**
68
+ * 「哪些变更此刻正在飞往远端」的登记处。
69
+ *
70
+ * @remarks
71
+ * push 与 undo 唯一的会合点。push 在远端往返之前认领区间,undo 把认领区间当成已推 ——
72
+ * 没有它,往返窗口内的一次撤销会造成本地与远端永久分叉,见 {@link PushInFlightRegistry}。
73
+ *
74
+ * 挂在 `VersionManager` 上是因为两边都只经它相遇:`pushRepository(vm, …)` 直接拿到它,
75
+ * `HistoryManager` 经 `rxdb.versionManager` 拿到它。生命周期跟随实例,不跨实例共享。
76
+ *
77
+ * @internal
78
+ */
79
+ readonly pushInFlight = new PushInFlightRegistry();
80
+
81
+ /**
82
+ * 交给同步插件的那一小块历史能力。
83
+ *
84
+ * @remarks
85
+ * 传闭包而不是 `this.historyManager`:`init()` 会在断连重连后重建管理器,
86
+ * 桥必须每次现取,见 {@link createSyncHistoryBridge}。
87
+ *
88
+ * @internal
89
+ */
90
+ readonly syncBridge: SyncHistoryBridge = createSyncHistoryBridge(() => this.historyManager, this.pushInFlight);
91
+
92
+ /**
93
+ * 可 push 的变更数量流
94
+ *
95
+ * 实时追踪本地未推送的有效变更数量:
96
+ * - 排除已撤销的变更(revertChangeId != null)
97
+ * - 排除从远程 pull 来的变更(remoteId != null)
98
+ */
99
+ get pushableCount$() {
100
+ return this.historyManager.pushableCount$;
101
+ }
102
+
103
+ /**
104
+ * 远程待 pull 的变更数量流
105
+ *
106
+ * 通过监听远程事件(INSERT/UPDATE/DELETE)累计:
107
+ * - 收到远程变更事件时计数+1
108
+ * - pull 完成后计数重置为 0
109
+ *
110
+ * 用于 UI 显示"有 N 条远程更新可拉取"
111
+ */
112
+ get pullableCount$() {
113
+ return this.historyManager.pullableCount$;
114
+ }
115
+
116
+ /**
117
+ * @param rxdb - 宿主实例
118
+ *
119
+ * @remarks
120
+ * 构造即建 {@link HistoryManager},而它在自己的构造器里就订阅了活跃分支流 ——
121
+ * 所以「造出来」本身已经是一次资源获取,调用方必须把 {@link VersionManager.destroy}
122
+ * 登记在作用域上,哪怕后面的 {@link VersionManager.init} 还没跑。
123
+ */
124
+ constructor(public readonly rxdb: RxDB) {
125
+ this.historyManager = new HistoryManager(this.rxdb);
126
+ }
127
+
128
+ /**
129
+ * 挂上事务与本地写入的事件监听,开始记历史。
130
+ *
131
+ * @remarks
132
+ * 与 {@link VersionManager.destroy} 成对,可重入:上一轮 `destroy()` 过的
133
+ * `HistoryManager` 已经拆掉了订阅,这里重建一个而不是复用 —— 复用等于把新纪元的
134
+ * 变更喂给一条已 complete 的流。
135
+ *
136
+ * 不发任何适配器读写,也不会把实例连起来(分支流等 `connected$` 为真才查),因此可以
137
+ * (也必须)早于引导链跑:`HistoryManager` 的分支流要赶在第一条 `rxdb_change` 事件之前
138
+ * 订阅上,装晚了那一批变更就不进历史。
139
+ */
140
+ init() {
141
+ if (this.#historyManagerDestroyed) {
142
+ this.historyManager = new HistoryManager(this.rxdb);
143
+ this.#historyManagerDestroyed = false;
144
+ }
145
+
146
+ const onTransactionBegin = () => {
147
+ if (!this.#transactionCleanupInitialized) {
148
+ this.#transactionCleanupInitialized = true;
149
+ const onTransactionCommit = () => {
150
+ if (this.#transactionDepth > 1) {
151
+ this.#transactionDepth -= 1;
152
+ return;
153
+ }
154
+ this.#transactionDepth = 0;
155
+ this.#transactionGeneration = null;
156
+ };
157
+ const onTransactionRollback = () => {
158
+ this.#transactionDepth = 0;
159
+ this.#transactionGeneration = null;
160
+ };
161
+ this.rxdb.addEventListener(TRANSACTION_COMMIT, onTransactionCommit);
162
+ this.rxdb.addEventListener(TRANSACTION_ROLLBACK, onTransactionRollback);
163
+ this.#event_removers.push(
164
+ () => this.rxdb.removeEventListener(TRANSACTION_COMMIT, onTransactionCommit),
165
+ () => this.rxdb.removeEventListener(TRANSACTION_ROLLBACK, onTransactionRollback)
166
+ );
167
+ }
168
+ if (this.#transactionDepth === 0) {
169
+ this.#transactionGeneration = this.historyManager.undoSessionGeneration;
170
+ }
171
+ this.#transactionDepth += 1;
172
+ };
173
+ this.rxdb.addEventListener(TRANSACTION_BEGIN, onTransactionBegin);
174
+ this.#event_removers.push(() => this.rxdb.removeEventListener(TRANSACTION_BEGIN, onTransactionBegin));
175
+
176
+ // 监听 ENTITY_LOCAL_CREATE_EVENT 事件,自动失效 redo 栈(标准 undo/redo 语义)
177
+ // 当用户在 undo 后执行新操作时,所有可重做的历史项将被标记为失效
178
+ //
179
+ // 契约:本处**有意不过滤** `data.origin === 'cross-tab'`。
180
+ // 网关会给跨 tab 转发的实体打 `origin: 'cross-tab'`(见 `gateway/RxDBTabsGateway.ts`),
181
+ // `rxdb-plugin-workspace` 在 NEW 事件上读它来防广播成环;但 version 子系统**不读**。
182
+ // 理由:多 tab 共享同一份本地存储(同 dbName / OPFS),他 tab 的新写入对本 tab 而言
183
+ // 同样让 redo 栈失效、同样可能恢复 undo 边界——按本地写入处理才是正确语义。
184
+ const onLocalCreate = (event: EntityLocalCreatedEvent) => {
185
+ const rxdbChanges = event.entities?.filter(e => e.namespace === 'rxdb' && e.entity === 'RxDBChange') ?? [];
186
+ if (rxdbChanges.length > 0) {
187
+ // 只有在非 undo/redo 操作时才失效 redo 栈
188
+ if (!this.historyManager.isExecutingUndoRedo()) {
189
+ const changeIds = rxdbChanges.flatMap(change => {
190
+ const changeId = getRxDBChangeEventId(change);
191
+ return changeId === null ? [] : [changeId];
192
+ });
193
+ // 带上 id:变更表通知可能跨进程迟到(Tauri stdio 宿主),时间窗守卫挡不住,
194
+ // 由 invalidateRedoStack 按序列水位判定是否真是 undo 之后的新写入
195
+ this.#runDetachedEventTask(this.historyManager.invalidateRedoStack(changeIds), 'invalidateRedoStack');
196
+ const recordAt = getEarliestRecordAt(rxdbChanges);
197
+ if (this.#transactionGeneration !== null || recordAt !== null) {
198
+ this.historyManager.resetSyncCleared(changeIds, {
199
+ generation: this.#transactionGeneration,
200
+ recordAt
201
+ });
202
+ } else {
203
+ this.historyManager.resetSyncCleared(changeIds);
204
+ }
205
+ }
206
+ }
207
+ };
208
+ this.rxdb.addEventListener(ENTITY_LOCAL_CREATE_EVENT, onLocalCreate);
209
+ this.#event_removers.push(() => this.rxdb.removeEventListener(ENTITY_LOCAL_CREATE_EVENT, onLocalCreate));
210
+ }
211
+
212
+ /**
213
+ * 摘掉全部事件监听与订阅,并销毁 {@link HistoryManager}。
214
+ *
215
+ * @remarks
216
+ * 幂等。事务深度与代次一并清零:拆卸可能发生在事务中途,留着计数会让下一个纪元
217
+ * 以为自己开局就在一笔未结的事务里。
218
+ */
219
+ destroy() {
220
+ if (this.#historyManagerDestroyed) return;
221
+ this.#historyManagerDestroyed = true;
222
+ this.historyManager.destroy();
223
+ for (const remove of this.#event_removers) {
224
+ remove();
225
+ }
226
+ this.#event_removers.length = 0;
227
+ this.#transactionDepth = 0;
228
+ this.#transactionGeneration = null;
229
+ this.#transactionCleanupInitialized = false;
230
+ }
231
+
232
+ /**
233
+ * 作废本连接积累的历史上下文:清空全部分支的 undo 会话与待拉计数。
234
+ *
235
+ * @remarks
236
+ * 供切库 / 重连这类「本地数据整体换了一份」的场景调用。清的是**全部**分支而不只是
237
+ * 当前分支 —— 换掉的是整个数据源,其它分支上那些 undo 条目指向的行同样已经不在了。
238
+ */
239
+ resetSessionState() {
240
+ // 整个连接的历史上下文都作废了,不只是当前分支
241
+ this.historyManager.clearAllUndoHistory();
242
+ this.historyManager.resetPullableCount();
243
+ }
244
+
245
+ /**
246
+ * 创建新分支
247
+ * @param branchId 分支 id
248
+ * @param fromChangeId 从哪个 changeId 创建新分支
249
+ */
250
+ async createBranch(branchId: string, fromChangeId?: number): Promise<InstanceType<typeof RxDBBranch>> {
251
+ return create_branch(this, branchId, fromChangeId);
252
+ }
253
+
254
+ /**
255
+ * 删除分支
256
+ * @param branchId 分支 id
257
+ */
258
+ async removeBranch(branchId: string): Promise<void> {
259
+ return remove_branch(this, branchId);
260
+ }
261
+
262
+ /**
263
+ * 切换到指定分支
264
+ *
265
+ * @param branchId - 目标分支 ID
266
+ * @param preconditions - 【可选】切换前要成立的条件;不传时行为与以往逐字节一致
267
+ *
268
+ * @remarks
269
+ * 切换分支后会自动清空 redo 栈,因为 redo 历史在新分支中不再有效。
270
+ * 如果目标分支与当前分支相同,则直接返回,避免不必要的操作。
271
+ *
272
+ * 第二形参是**纯扩展**:既有调用点一个都不用改,不传即不表态。做成默认开启的话,
273
+ * `switchBranch(id)` 会在任何有未提交改动的库上开始抛错——而历史子系统自己就在调它
274
+ * (undo/redo 回放、redo 失效),那些路径上工作树恒非空,默认开启等于让 undo 在有改动时不可用。
275
+ *
276
+ * 条件由**能力插件**校验,本方法一个字段都不读:判据是
277
+ * `@aiao/rxdb-plugin-working-tree` 贡献的那几张表,这里既不认识也不该认识它们。
278
+ * 反方向(本包 import 那个插件)是条依赖环,nx 的图插件会把 `run-many` 当场拒掉。
279
+ */
280
+ async switchBranch(branchId: string, preconditions?: RxDBBranchSwitchPreconditions): Promise<void> {
281
+ const currentBranch = await this.getCurrentBranch();
282
+ // 若切换到相同分支则直接返回,避免不必要操作
283
+ if (currentBranch?.id === branchId) {
284
+ return;
285
+ }
286
+ let switchCommitted = false;
287
+ // 声明挂在**适配器实例**上、取用即清除;下面的 catch 要靠它原路清掉。
288
+ let declaredAdapter: RxDBAdapterLocalBase | undefined;
289
+ // 适配器是否真的调了 prepare。见下方「没调用就抛」那一段。
290
+ let prepared = false;
291
+ try {
292
+ this.rxdb.dispatchEvent(new SwitchBranchBeginEvent(branchId));
293
+ // 问一句这次切换要不要整个交给某个能力贡献方(见 RxDBSystemContribution.takeOverBranchSwitch)。
294
+ // 排在 `getLocalRepositories()` **之前**:接管方自己解析适配器、自己开事务,这里先取一个
295
+ // 出来只是让它在接管路径上白白闲置一个纪元引用。
296
+ if (await this.#take_over_branch_switch(currentBranch?.id ?? null, branchId, preconditions)) {
297
+ // 接管方已经把 active 切过去了(它自己的事务已提交),此后与普通路径同一个状态。
298
+ switchCommitted = true;
299
+ this.#settle_switched(branchId);
300
+ return;
301
+ }
302
+ const { adapter } = await this.getLocalRepositories();
303
+ const actions = await switch_branch_actions(this, branchId);
304
+ // 切分支重写的是实体表的**投影**,不是用户的编辑:矩阵行 4 要求它不产生工作树单元。
305
+ // 不声明的话挂载点只看见「有人在调 switchBranch」,与 undo/redo(行 6,必须产生单元)
306
+ // 完全同形,切一次分支就会把整批物化写记成一批未提交变更。
307
+ declareTrustedWrite(adapter, {
308
+ file: 'VersionManager.ts',
309
+ symbol: 'switchBranch',
310
+ intent: TrustedWriteIntent.branch_materialization
311
+ });
312
+ declaredAdapter = adapter;
313
+ const result = await adapter.switchBranch({
314
+ branchId: branchId,
315
+ actions,
316
+ // 前置校验跑在切换事务**内部**(见 #prepare_branch_switch)。`targetBranchId` 用
317
+ // 适配器解析出来的那一个而不是这里的 `branchId`:省略分支的调用点上只有它是对的。
318
+ prepare: async ({ executor, targetBranchId }) => {
319
+ prepared = true;
320
+ await this.#prepare_branch_switch(executor, currentBranch?.id ?? null, targetBranchId, preconditions);
321
+ }
322
+ });
323
+ // 适配器的 switchBranch 内部包了事务(见各适配器 version/switch_branch.ts),
324
+ // 走到这里说明已提交。此后任何失败都不该再发 Rollback —— 分支确实切过去了。
325
+ switchCommitted = true;
326
+
327
+ this.#settle_switched(branchId);
328
+ // 适配器吞掉 `prepare` 不会以任何别的方式显形:分支照切、actions 照套、事件照发,
329
+ // 只是这一次切换没验过目标分支的提交图、没判过调用方提的条件、也没推进激活代际。
330
+ // 上面那几步记账照做完 —— 分支确实切过去了,把内存视图留在旧分支只是再坏一件事;
331
+ // 抛出只为一件事:不让这条契约违背被当成一次正常切换吞掉。
332
+ if (!prepared) {
333
+ throw new RxDBError(
334
+ `适配器 ${adapter.constructor.name} 的 switchBranch 没有调用 options.prepare:` +
335
+ `分支已经切到 ${branchId},但这次切换一条前置条件都没校验过。` +
336
+ '适配器必须在解析出目标分支之后、动第一行之前 await 它(见 rxdb-adapter.ts › SwitchBranchOptions.prepare)。'
337
+ );
338
+ }
339
+ return result;
340
+ } catch (error) {
341
+ // 前置校验现在在事务内判,被拒是一条**日常**路径(工作树不干净就该拒),而那条路径上
342
+ // 一个写原语都没跑过——声明还原封不动挂在适配器上。留着的话,这个适配器的下一次
343
+ // mergeChanges 会把它取走,那次合并于是按 projection_rewrite 判定:一个工作树单元都不产生,
344
+ // 拉回来的远端改动凭空消失。
345
+ if (declaredAdapter) takeDeclaredWrite(declaredAdapter);
346
+ if (!switchCommitted) {
347
+ this.rxdb.dispatchEvent(new SwitchBranchRollbackEvent(branchId));
348
+ // 诊断落盘排在**回滚之后**、重新抛出之前:判定失败发生在那次注定回滚的事务里,
349
+ // 写在里面的标记会跟着一起消失(见 RxDBSystemContribution.settleBranchSwitchFailure)。
350
+ // 只在没提交的那条路径上跑——切换已经成立之后的失败不是「没切成」。
351
+ await this.#settle_branch_switch_failure(currentBranch?.id ?? null, branchId, error);
352
+ }
353
+ throw error;
354
+ }
355
+ }
356
+
357
+ /**
358
+ * 合并分支
359
+ *
360
+ * 将源分支的变更合并到当前激活分支(目标分支)。
361
+ *
362
+ * @param sourceBranchId - 源分支 ID
363
+ * @param options - 合并选项
364
+ * @returns 合并结果
365
+ *
366
+ * @remarks
367
+ * 支持两种策略:
368
+ * - `squash`(默认):将源分支所有变更压缩为最小操作集后一次性应用,目标分支只产生一组变更记录
369
+ * - `normal`:逐条应用源分支变更,每条变更在目标分支产生独立的变更记录(保留历史细节)
370
+ *
371
+ * 合并后会清空 undo/redo 历史,因为合并操作不可逆。
372
+ * 可选通过 `deleteSource: true` 在合并后删除源分支。
373
+ *
374
+ * @example
375
+ * ```typescript
376
+ * // 压缩合并(默认)
377
+ * const result = await rxdb.versionManager.mergeBranch('feature-x');
378
+ * console.log(`Merged ${result.merged} changes`);
379
+ *
380
+ * // 普通合并
381
+ * const result = await rxdb.versionManager.mergeBranch('feature-x', {
382
+ * strategy: 'normal'
383
+ * });
384
+ *
385
+ * // 合并后删除源分支
386
+ * const result = await rxdb.versionManager.mergeBranch('feature-x', {
387
+ * deleteSource: true
388
+ * });
389
+ * ```
390
+ */
391
+ async mergeBranch(sourceBranchId: string, options?: MergeBranchOptions): Promise<MergeBranchResult> {
392
+ const currentBranch = await this.getCurrentBranch();
393
+ if (!currentBranch) {
394
+ throw new RxDBError('No active branch found');
395
+ }
396
+ const targetBranchId = currentBranch.id;
397
+ try {
398
+ this.rxdb.dispatchEvent(new MergeBranchBeginEvent(sourceBranchId, targetBranchId));
399
+
400
+ const result = await merge_branch(this, sourceBranchId, targetBranchId, options);
401
+
402
+ // 合并有变更时清空 undo/redo 历史
403
+ if (result.merged > 0) {
404
+ this.historyManager.clearUndoHistory();
405
+ }
406
+
407
+ this.rxdb.dispatchEvent(new MergeBranchCommitEvent(sourceBranchId, targetBranchId));
408
+ return result;
409
+ } catch (error) {
410
+ this.rxdb.dispatchEvent(new MergeBranchFailedEvent(sourceBranchId, targetBranchId));
411
+ throw error;
412
+ }
413
+ }
414
+
415
+ /**
416
+ * 获取特定作用域的历史记录 API
417
+ *
418
+ * @param options - 作用域选项:
419
+ * - 不传参数 → database 作用域(整个数据库)
420
+ * - EntityType 类 → repository 作用域(该实体的所有记录)
421
+ * - 实体实例 → entity 作用域(该实例的历史)
422
+ * @returns 统一的历史记录 API 接口
423
+ *
424
+ * @example
425
+ * ```ts
426
+ * // 数据库级别
427
+ * const dbHistory = versionManager.history();
428
+ *
429
+ * // 仓库级别(所有 User 记录)
430
+ * const userHistory = versionManager.history(User);
431
+ *
432
+ * // 实体级别(单个用户)
433
+ * const entityHistory = versionManager.history(userInstance);
434
+ * ```
435
+ */
436
+ history<T extends EntityType>(options?: T | InstanceType<T>): HistoryScopeAPI {
437
+ return this.historyManager.history(options);
438
+ }
439
+
440
+ /**
441
+ * 恢复被删除的实体
442
+ *
443
+ * 根据 RxDBChange 记录中的 inversePatch 重新插入实体,
444
+ * 恢复操作本身会生成新的 RxDBChange 记录(可被 push 到远程)。
445
+ *
446
+ * @param entity - 被删除的实体实例(需要包含 metadata 信息)
447
+ * @param options - 恢复选项,包含 changeId(DELETE 类型的 RxDBChange 记录 ID)
448
+ * @returns 恢复后的实体实例
449
+ */
450
+ async restoreEntity<T extends EntityType>(
451
+ entity: InstanceType<T>,
452
+ options: RestoreEntityOptions
453
+ ): Promise<InstanceType<T>> {
454
+ return restore_entity(this, entity, options);
455
+ }
456
+
457
+ /**
458
+ * 取本地适配器上的系统表仓库。
459
+ *
460
+ * @returns `branchRepository` / `changeRepository` 与它们所属的本地适配器
461
+ *
462
+ * @remarks
463
+ * 每次都经 `localAdapter$` 重新解析,不缓存:适配器随连接纪元换,缓下来的那一份
464
+ * 会在重连之后指向已拆的旧实例。
465
+ */
466
+ async getLocalRepositories() {
467
+ return getLocalSystemRepositories(this.rxdb);
468
+ }
469
+
470
+ /**
471
+ * 取远端适配器上的系统表仓库。
472
+ *
473
+ * @returns `branchRepository` / `changeRepository` 与它们所属的远端适配器
474
+ *
475
+ * @remarks
476
+ * 未配置远端时 `remoteAdapter$` 不发值,调用会一直挂着 —— 这是有意的:同步路径
477
+ * 本来就只在配了远端时才走,返回一个空壳只会把「没配远端」推迟到更深的地方才报。
478
+ */
479
+ async getRemoteRepositories() {
480
+ return getRemoteSystemRepositories(this.rxdb);
481
+ }
482
+
483
+ /**
484
+ * 取当前分支;没有激活分支时激活(或新建)`main`。
485
+ *
486
+ * @remarks
487
+ * 分两段是有意的:
488
+ *
489
+ * - **热路径**(已有激活分支)不开事务。同步插件的远端事件链路里每条事件都要调它
490
+ * (`sync-listeners` 的 `filterByBranch`),把整段包进事务能修好并发,
491
+ * 但会让每次读都去抢并发度 1 的写队列槽位。
492
+ * - **冷路径**(查不到激活分支)才开事务,并在事务内**重做一遍检查**(双重检查)。
493
+ * 否则两个并发调用会双双走到 `create`,第二个撞主键报错。
494
+ */
495
+ async getCurrentBranch() {
496
+ return getCurrentBranch(this.rxdb);
497
+ }
498
+
499
+ /**
500
+ * 逐个问贡献方:这次切换能不能发生;能的话,把该在同一个事务里做掉的事做掉。
501
+ *
502
+ * @param executor - **切换事务本身**的执行器,由适配器经 `SwitchBranchOptions.prepare` 交过来
503
+ * @param currentBranchId - 当前分支 id;一条 active 分支都没有时为 `null`
504
+ * @param targetBranchId - 适配器解析之后的目标分支 id
505
+ * @param preconditions - 调用方提出的条件;没提出时是 `undefined`
506
+ *
507
+ * @remarks
508
+ * **跑在切换事务内部。** 它以前自己开一个只读事务、在 `adapter.switchBranch()` **之前**跑完:
509
+ * 那个事务提交到切换事务开始之间是一段没有任何东西守着的窗口,窗口里的一次写能让刚判过的
510
+ * 「工作树干净」重新变脏,而切换照样完成。搬进来之后,「校验通过」与「切换完成」不再是
511
+ * 两件可以分开发生的事——同一个事务,要么一起成立,要么一起没发生。
512
+ *
513
+ * 代价是顺序反了过来:`switch_branch_actions()` 现在排在校验**之前**(它算出来的重放指令
514
+ * 得先交给适配器才谈得上开事务),目标分支的链已知损坏时会白算一遍。那一遍只读不写,
515
+ * 被拒时一行都还没动——拿一次白算换掉上面那段窗口。
516
+ *
517
+ * **每一次真正发生的切换上都跑,与调用方提没提条件无关。** 目标分支的提交图可达损坏
518
+ * 与有没有 `preconditions` 无关(SC-013:三条入口各自返回 `commit_graph_corrupted`);
519
+ * 只在带选项时跑的话,日常那条不带选项的切换会一路切进一份重放不出来的历史。
520
+ *
521
+ * 「真正发生」是字面意思:A→A 的调用在 {@link VersionManager.switchBranch} 里就早返回了
522
+ * (同文件上方的 `currentBranch?.id === branchId`),根本走不到这里。那条路径上没有要防的
523
+ * 东西——分支没换,目标分支的历史也没有被重放,工作树连一行都不会动。把早返回去掉好让
524
+ * 守卫「真的无条件」是反向的:切到当前分支会因为它自己的历史损坏而失败,而这次切换
525
+ * 本来什么都不做。
526
+ *
527
+ * 串行而非 `Promise.all`:`executor` 是并发度为 1 的队列,并行发起只会让读取顺序取决于
528
+ * 各贡献方内部 await 的排布。一个贡献方都没有时这里是一次空循环——没装能力插件的库上,
529
+ * 这条扩展不多开事务、也不多发一条查询。
530
+ */
531
+ /**
532
+ * 切换成立之后那几步与库无关的记账。
533
+ *
534
+ * @param branchId - 已经切过去的分支 id
535
+ *
536
+ * @remarks
537
+ * 抽出来只为一件事:**接管路径与普通路径必须记同一笔账**。两处各抄一遍的那天,
538
+ * 接管路径上漏掉的 `setUndoBranch()` 不会以任何形式报错——它只是让切换后紧接着的
539
+ * 一次 `undo()` 撤到上一条分支的 session 上去。
540
+ *
541
+ * 三步的顺序是:redo 栈先清(它在新分支上不再有效),undo 视图再切(按分支存,
542
+ * 等 `current_branch$` 补发会让紧接着的 `undo()` 撞上中途换 session 的竞态),
543
+ * 事件最后发(订阅者醒来时这两样都该已经是新分支的)。
544
+ */
545
+ #settle_switched(branchId: string): void {
546
+ this.historyManager.clearRedoStack();
547
+ this.historyManager.setUndoBranch(branchId);
548
+ this.rxdb.dispatchEvent(new SwitchBranchCommitEvent(branchId));
549
+ }
550
+
551
+ /**
552
+ * 问各贡献方要不要接管这次切换,第一个答应的就停。
553
+ *
554
+ * @param currentBranchId - 当前分支 id
555
+ * @param targetBranchId - 目标分支 id
556
+ * @param preconditions - 调用方提出的前置条件
557
+ * @returns 有人接管了就是 `true`,此时 active 已经切过去了
558
+ *
559
+ * @remarks
560
+ * 问到第一个 `'switched'` 为止:两个贡献方都接管等于 active 被切两次,而第二次看到的
561
+ * 现场已经是第一次的结果。串行的理由与 {@link VersionManager.#prepare_branch_switch} 同源。
562
+ */
563
+ async #take_over_branch_switch(
564
+ currentBranchId: string | null,
565
+ targetBranchId: string,
566
+ preconditions: RxDBBranchSwitchPreconditions | undefined
567
+ ): Promise<boolean> {
568
+ for (const contribution of this.rxdb.systemContributions) {
569
+ const verdict = await contribution.takeOverBranchSwitch({ currentBranchId, targetBranchId, preconditions });
570
+ if (verdict === 'switched') return true;
571
+ }
572
+ return false;
573
+ }
574
+
575
+ /**
576
+ * 切换事务回滚之后,让各贡献方落下自己的失败诊断。
577
+ *
578
+ * @param currentBranchId - 仍然 active 的那条分支
579
+ * @param targetBranchId - 没切成的目标分支
580
+ * @param error - 让这次切换失败的那个错误
581
+ *
582
+ * @remarks
583
+ * 调用点在 `catch` 里,紧接着就要把 `error` 重新抛出去——所以贡献方那一侧带着一条
584
+ * 「不得抛出」的硬契约(见 {@link RxDBSystemContribution.settleBranchSwitchFailure})。
585
+ * 这里**不**加 try/catch 替它兜:兜住就等于宣布这个契约可以不遵守,而真正被兜掉的那个
586
+ * 异常从此没有任何一处会显形。
587
+ */
588
+ async #settle_branch_switch_failure(
589
+ currentBranchId: string | null,
590
+ targetBranchId: string,
591
+ error: unknown
592
+ ): Promise<void> {
593
+ for (const contribution of this.rxdb.systemContributions) {
594
+ await contribution.settleBranchSwitchFailure({ currentBranchId, targetBranchId, error });
595
+ }
596
+ }
597
+
598
+ async #prepare_branch_switch(
599
+ executor: TransactionExecutor,
600
+ currentBranchId: string | null,
601
+ targetBranchId: string,
602
+ preconditions: RxDBBranchSwitchPreconditions | undefined
603
+ ): Promise<void> {
604
+ for (const contribution of this.rxdb.systemContributions) {
605
+ await contribution.prepareBranchSwitch({ executor, currentBranchId, targetBranchId, preconditions });
606
+ }
607
+ }
608
+
609
+ #runDetachedEventTask(task: Promise<void>, label: string) {
610
+ void task.catch(error => {
611
+ if (isIgnorableDetachedVersionEventError(error)) {
612
+ return;
613
+ }
614
+ console.error(`[VersionManager] ${label} failed:`, error);
615
+ });
616
+ }
617
+ }