@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,67 @@
1
+ /**
2
+ * @packageDocumentation
3
+ * pullable 计数的结算规则
4
+ *
5
+ * `pullableCount$` 是「远端还有多少条没拉下来」。它由远端事件累加,由一次 pull 结算。
6
+ * 结算的难点全在「这次 pull 到底覆盖了多少」:
7
+ *
8
+ * - `repositoryFilter` 只拉了指定仓库,其余仓库的待拉量原封不动;
9
+ * - `hasMore` 说明这一页拉完了还有下一页;
10
+ * - `failures` 里的仓库一条都没拉成。
11
+ *
12
+ * 这三种情况下把全局计数归零,界面就会显示「已经同步干净了」,而远端明明还有东西。
13
+ * 本模块把判据和结算算式拆成纯函数,让 {@link VersionManager.pull} 和
14
+ * {@link VersionManager.sync} 两条路径共用同一份口径 —— 此前后者根本不结算。
15
+ */
16
+
17
+ import type { PullOptions, PullResult } from '@aiao/rxdb';
18
+
19
+ /**
20
+ * 结算一次 pull 所需的全部输入
21
+ */
22
+ export interface PullableSettlement {
23
+ /** 本次 pull 是否覆盖了当前分支的完整仓库集合,且没有遗留分页或失败 */
24
+ complete: boolean;
25
+
26
+ /** 结算令牌签发之后是否又有远端事件到达 */
27
+ concurrent: boolean;
28
+
29
+ /** 本次 pull 实际取回的变更数 */
30
+ pulled: number;
31
+ }
32
+
33
+ /**
34
+ * 判断一次 pull 是否算「完整同步」
35
+ *
36
+ * @param options - 发起 pull 时的选项
37
+ * @param result - pull 的结果
38
+ * @returns 覆盖了全部仓库、没有下一页、也没有失败仓库时返回 `true`
39
+ *
40
+ * @remarks
41
+ * `repositoryFilter` 传了空数组等价于不过滤:判成不完整会让绝大多数正常 pull 永远清不掉计数。
42
+ */
43
+ export function isCompletePull(options: PullOptions | undefined, result: PullResult): boolean {
44
+ if (options?.repositoryFilter !== undefined && options.repositoryFilter.length > 0) {
45
+ return false;
46
+ }
47
+ return !result.hasMore && result.failures.length === 0;
48
+ }
49
+
50
+ /**
51
+ * 计算结算后的 pullable 计数
52
+ *
53
+ * @param current - 结算前的计数
54
+ * @param settlement - 结算输入
55
+ * @returns 结算后的计数(不会为负)
56
+ *
57
+ * @remarks
58
+ * 只有「完整同步」且「期间没有新的远端事件」才归零。`concurrent` 这一关是必须的:
59
+ * pull 期间到达的事件描述的是快照之后的新变更,本次 pull 没有覆盖它们,
60
+ * 归零等于把它们吞掉,界面从此再也不会提示这批数据。
61
+ */
62
+ export function settledPullableCount(current: number, settlement: PullableSettlement): number {
63
+ if (settlement.complete && !settlement.concurrent) {
64
+ return 0;
65
+ }
66
+ return Math.max(0, current - settlement.pulled);
67
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * 推送在飞登记:push 把「正在往远端送」的变更区间挂出来,undo 认这个区间。
3
+ *
4
+ * @module rxdb/version/push-inflight
5
+ */
6
+
7
+ /**
8
+ * 一次 push 调用期间持有的认领句柄。
9
+ *
10
+ * 一次 `pushRepository` 可能同时推多个仓库(级联),因此 `claim` 可以调多次,
11
+ * 而 {@link release} 一次把这一轮的全部认领撤干净 —— 谁开的谁关,中途从哪条路径
12
+ * 提前返回都不会漏。
13
+ */
14
+ export interface PushInFlightSession {
15
+ /**
16
+ * 声明本轮正在把某仓库 id ≤ `maxChangeId` 的变更送往远端。
17
+ *
18
+ * @param repositoryKey - `namespace:entity`
19
+ * @param maxChangeId - 本轮送出的最大 change id
20
+ *
21
+ * @remarks
22
+ * 同一仓库重复认领取**较大**者:级联路径分相位推同一份计划,后一相位不该把
23
+ * 前一相位已经罩住的区间缩回去。
24
+ */
25
+ claim(repositoryKey: string, maxChangeId: number): void;
26
+
27
+ /** 撤销本 session 的全部认领。重复调用是空操作。 */
28
+ release(): void;
29
+ }
30
+
31
+ /**
32
+ * 「哪些变更此刻正在飞往远端」的进程内登记处。
33
+ *
34
+ * @remarks
35
+ * 存在的理由是一个时序缺口:撤销的判据是「还没推上去」,而这件事的数据源
36
+ * `RxDBSync.lastPushedChangeId` 只在 push **提交**时才写。push 的远端往返在事务之外,
37
+ * 于是「已经发出去了、水位线还没推进」这段窗口里,`undo()` 会放行一条其实已经到达
38
+ * 远端的变更。
39
+ *
40
+ * 撤销**不产生新的变更行**(`undo-redo-apply.ts` 只给原行打 `revertChangeId` 标记),
41
+ * 而出站查询过滤 `revertChangeId = null` —— 那条变更从此远端有、本地无,且没有任何
42
+ * 一次 push 会把这个差异送出去。本登记处把那段窗口填上:在飞即视同已推。
43
+ *
44
+ * **只覆盖本进程。** 多 tab / Tauri 多进程共享同一份本地库时,另一个进程的 push
45
+ * 在这里是看不见的。跨进程要的是数据库里的一条租约行,而 push 路径目前整体就没有
46
+ * 跨进程互斥(两个进程会各自推同一批变更),那是另一个问题,不在这里假装解决。
47
+ */
48
+ export class PushInFlightRegistry {
49
+ /** sessionId -> (repositoryKey -> 该 session 认领的最大 change id) */
50
+ readonly #sessions = new Map<number, Map<string, number>>();
51
+ #nextSessionId = 0;
52
+
53
+ /**
54
+ * 开一轮认领。
55
+ *
56
+ * @returns 本轮的认领句柄;调用方必须在 `finally` 里 {@link PushInFlightSession.release}
57
+ */
58
+ session(): PushInFlightSession {
59
+ const sessionId = ++this.#nextSessionId;
60
+ const claims = new Map<string, number>();
61
+ this.#sessions.set(sessionId, claims);
62
+
63
+ return {
64
+ claim: (repositoryKey: string, maxChangeId: number): void => {
65
+ const existing = claims.get(repositoryKey);
66
+ if (existing !== undefined && existing >= maxChangeId) return;
67
+ claims.set(repositoryKey, maxChangeId);
68
+ },
69
+ release: (): void => {
70
+ this.#sessions.delete(sessionId);
71
+ }
72
+ };
73
+ }
74
+
75
+ /**
76
+ * 当前全部在飞认领,按仓库取最大值。
77
+ *
78
+ * @returns `namespace:entity` -> 正在飞的最大 change id;没有任何认领时是空 Map
79
+ */
80
+ snapshot(): ReadonlyMap<string, number> {
81
+ const merged = new Map<string, number>();
82
+ for (const claims of this.#sessions.values()) {
83
+ for (const [repositoryKey, maxChangeId] of claims) {
84
+ const existing = merged.get(repositoryKey);
85
+ if (existing !== undefined && existing >= maxChangeId) continue;
86
+ merged.set(repositoryKey, maxChangeId);
87
+ }
88
+ }
89
+ return merged;
90
+ }
91
+ }
@@ -0,0 +1,79 @@
1
+ import type { HistoryItem } from '@aiao/rxdb';
2
+ import { BehaviorSubject, Observable } from 'rxjs';
3
+
4
+ /**
5
+ * Redo 栈容量上限(会话内)
6
+ *
7
+ * 超过则丢弃最旧的项(尾部),避免长会话下无界增长。
8
+ */
9
+ export const MAX_REDO_STACK_SIZE = 1000;
10
+
11
+ /**
12
+ * 会话级 Redo 栈
13
+ *
14
+ * 从 HistoryManager 抽离的 redo 栈管理子模块:
15
+ * - 持有内存 `BehaviorSubject<HistoryItem[]>`(不持久化)
16
+ * - 新项总在头部(push 后第一个)
17
+ * - 容量上限 `MAX_REDO_STACK_SIZE`
18
+ *
19
+ * 设计理由:
20
+ * - HistoryManager 原本 1120 行,redo 栈状态 + 操作占 60+ 行且与 history 查询/undo 流程正交
21
+ * - 独立后可单测,并避免泄漏 BehaviorSubject 引用
22
+ *
23
+ * @internal 由 HistoryManager 持有;外部通过 HistoryManager 公共 API 访问
24
+ */
25
+ export class RedoStack {
26
+ readonly #subject = new BehaviorSubject<HistoryItem[]>([]);
27
+
28
+ /**
29
+ * 当前 redo 栈的可观察流(新项在前)
30
+ */
31
+ get items$(): Observable<HistoryItem[]> {
32
+ return this.#subject.asObservable();
33
+ }
34
+
35
+ /**
36
+ * 当前栈快照
37
+ */
38
+ get value(): HistoryItem[] {
39
+ return this.#subject.value;
40
+ }
41
+
42
+ /**
43
+ * 将一组历史项压入栈顶(items 在前)
44
+ *
45
+ * 超过 MAX_REDO_STACK_SIZE 时丢弃尾部最旧项。
46
+ */
47
+ push(items: HistoryItem[]): void {
48
+ const merged = [...items, ...this.#subject.value];
49
+ const capped = merged.length > MAX_REDO_STACK_SIZE ? merged.slice(0, MAX_REDO_STACK_SIZE) : merged;
50
+ this.#subject.next(capped);
51
+ }
52
+
53
+ /**
54
+ * 按稳定身份(`fingerprint`)移除已应用的项。
55
+ *
56
+ * @param items - 已经 redo 完成、需要出栈的项
57
+ * @returns 实际被移除的项(按栈中原顺序);不在栈中的项被忽略
58
+ *
59
+ * @remarks
60
+ * **不能用「移除 N 项」代替身份匹配**:作用域 redo 会先按 scope 筛出本作用域的项再应用,
61
+ * 这些项未必位于栈顶。按数量截栈顶会删掉栈头那些**未被 redo** 的项(它们从此丢失),
62
+ * 而真正已 redo 的项仍留在栈里可以被重复执行。
63
+ */
64
+ remove(items: HistoryItem[]): HistoryItem[] {
65
+ const targets = new Set(items.map(item => item.fingerprint));
66
+ const current = this.#subject.value;
67
+ const removed = current.filter(item => targets.has(item.fingerprint));
68
+ if (removed.length === 0) return removed;
69
+ this.#subject.next(current.filter(item => !targets.has(item.fingerprint)));
70
+ return removed;
71
+ }
72
+
73
+ /**
74
+ * 清空栈
75
+ */
76
+ clear(): void {
77
+ this.#subject.next([]);
78
+ }
79
+ }
@@ -0,0 +1,100 @@
1
+ import {
2
+ type LocalRxDBBranchRepository,
3
+ type LocalRxDBChangeRepository,
4
+ MAIN_BRANCH_ID,
5
+ RxDBBranch,
6
+ RxDBChange,
7
+ RxDBError
8
+ } from '@aiao/rxdb';
9
+ import { VersionManager } from './VersionManager.js';
10
+
11
+ /**
12
+ * 移除一个分支
13
+ * @param version
14
+ * @param branchId
15
+ *
16
+ * @remarks
17
+ * 「查子分支 → 查 change → 删」整段必须在一个事务窗口内完成。
18
+ * 拆开之后,检查与删除之间新建的子分支会漏判,留下 `parentId` 指向不存在分支的孤儿,
19
+ * 而孤儿又会被切分支时的「父节点缺失即当作到根」静默吞掉,损坏无声。
20
+ *
21
+ * 事务体内一律用 `executor.getRepository(...)`:绑在适配器上的仓库会重新入队,
22
+ * 排在自己这个事务后面(队列并发度 1),永久挂起。
23
+ */
24
+ export const remove_branch = async (version: VersionManager, branchId: string) => {
25
+ const { adapter } = await version.getLocalRepositories();
26
+ // 检查是否为主分支
27
+ if (branchId === MAIN_BRANCH_ID) {
28
+ throw new RxDBError(`Cannot remove main branch`);
29
+ }
30
+
31
+ // 校验失败**返回**而不是就地抛出:抛出会被适配器的事务包装层裹成适配器错误类型
32
+ // (`RxDBAdapterSqliteError` 等),调用方拿到的异常身份就跟着实现细节变了。
33
+ // 校验失败时本事务一行都没写,回不回滚无所谓,留到事务外抛更稳。
34
+ const rejection = await adapter.transaction(async executor => {
35
+ const branchRepository = executor.getRepository(RxDBBranch) as unknown as LocalRxDBBranchRepository;
36
+ const changeRepository = executor.getRepository(RxDBChange) as unknown as LocalRxDBChangeRepository;
37
+
38
+ // 检查分支是否存在
39
+ const branch = (
40
+ await branchRepository.find({
41
+ where: {
42
+ combinator: 'and',
43
+ rules: [{ field: 'id', operator: '=', value: branchId }]
44
+ },
45
+ limit: 1
46
+ })
47
+ )[0];
48
+
49
+ if (!branch) {
50
+ return `Branch '${branchId}' not found`;
51
+ }
52
+
53
+ // 检查分支是否为激活状态
54
+ if (branch.activated) {
55
+ return `Cannot remove active branch '${branchId}'. Switch to another branch first.`;
56
+ }
57
+
58
+ // 检查是否有子分支依赖于该分支
59
+ //
60
+ // 必须直接查 `parentId`:拿「父分支有哪些 change、子分支的 fromChangeId 是否落在其中」
61
+ // 反推拓扑有两个漏洞 —— 父分支一条 change 都没有时整段检查被跳过;子分支的 fromChangeId
62
+ // 指向的 change 若已被清理也会漏判。两者都留下 `parentId` 指向不存在分支的孤儿。
63
+ const childBranches = await branchRepository.find({
64
+ where: {
65
+ combinator: 'and',
66
+ rules: [
67
+ { field: 'parentId', operator: '=', value: branchId },
68
+ { field: 'id', operator: '!=', value: branchId }
69
+ ]
70
+ },
71
+ limit: 1
72
+ });
73
+ if (childBranches?.length) {
74
+ return `Cannot remove branch '${branchId}' because it has child branches`;
75
+ }
76
+
77
+ const branchChanges = await changeRepository.find({
78
+ where: {
79
+ combinator: 'and',
80
+ rules: [{ field: 'branchId', operator: '=', value: branchId }]
81
+ }
82
+ });
83
+ // 贡献方挂在这条分支名下的行清在**这一个**事务里,且排在分支行自己被删之前:
84
+ // 分处两个事务的话,中间失败留下的是一条「分支没了、贡献行还在」的残留,而那些行按 id
85
+ // 挂靠——同名重建出来的新分支会逐字命中它们,带着上一条分支的 HEAD 与未提交条目出生。
86
+ //
87
+ // 串行而非 `Promise.all`,与 `create_branch` 同理:`executor` 是一条并发度为 1 的队列,
88
+ // 并行发起只会让删除顺序取决于各贡献方内部 await 的排布,出问题时复现不出来。
89
+ for (const contribution of version.rxdb.systemContributions) {
90
+ await contribution.removeBranchRows({ executor, branchId });
91
+ }
92
+
93
+ await executor.removeMany([...branchChanges, branch]);
94
+ return undefined;
95
+ });
96
+
97
+ if (rejection) {
98
+ throw new RxDBError(rejection);
99
+ }
100
+ };
@@ -0,0 +1,124 @@
1
+ /**
2
+ * @fileoverview 恢复被删除实体的纯逻辑
3
+ *
4
+ * 从 {@link VersionManager.restoreEntity} 抽出的独立实现,遵循
5
+ * `create_branch` / `merge_branch` 的「接收 VersionManager 实例」模式。
6
+ */
7
+
8
+ import {
9
+ declareTrustedWrite,
10
+ EntityType,
11
+ getEntityMetadata,
12
+ RestoreEntityOptions,
13
+ RxDBError,
14
+ TrustedWriteIntent
15
+ } from '@aiao/rxdb';
16
+ import { get_switch_version_actions } from './switch-branch-actions.js';
17
+ import type { VersionManager } from './VersionManager.js';
18
+
19
+ /**
20
+ * 恢复被删除的实体。
21
+ *
22
+ * 根据 RxDBChange 记录中的 inversePatch 重新插入实体,
23
+ * 恢复操作本身会生成新的 RxDBChange 记录(可被 push 到远程)。
24
+ *
25
+ * @param vm - 版本管理器实例
26
+ * @param entity - 被删除的实体实例(需要包含 metadata 信息)
27
+ * @param options - 恢复选项,包含 changeId(DELETE 类型的 RxDBChange 记录 ID)
28
+ * @returns 恢复后的实体实例
29
+ */
30
+ export async function restore_entity<T extends EntityType>(
31
+ vm: VersionManager,
32
+ entity: InstanceType<T>,
33
+ options: RestoreEntityOptions
34
+ ): Promise<InstanceType<T>> {
35
+ const { changeRepository, adapter } = await vm.getLocalRepositories();
36
+
37
+ const changes = await changeRepository.find({
38
+ where: {
39
+ combinator: 'and',
40
+ rules: [{ field: 'id', operator: '=', value: Number(options.changeId) }]
41
+ },
42
+ limit: 1
43
+ });
44
+
45
+ if (changes.length === 0) {
46
+ throw new RxDBError(`RxDBChange not found: ${options.changeId}`);
47
+ }
48
+
49
+ const change = changes[0];
50
+
51
+ if (change.type !== 'DELETE') {
52
+ throw new RxDBError(`Cannot restore from non-DELETE change (type=${change.type})`);
53
+ }
54
+
55
+ if (!change.inversePatch) {
56
+ throw new RxDBError(`RxDBChange ${options.changeId} has no inversePatch`);
57
+ }
58
+
59
+ const EntityType = entity.constructor as T;
60
+ const metadata = getEntityMetadata(EntityType);
61
+ const currentBranch = await vm.getCurrentBranch();
62
+
63
+ // 身份校验必须在写入之前:只校验 changeId/type/inversePatch 时,传 A 的实体配 B 的 changeId
64
+ // 会真的把 B 恢复出来,再用 A 的 constructor 去查 —— 返回 undefined,而返回类型声明是非空的
65
+ // InstanceType<T>。分支同理:别的分支的 change 不能应用到当前分支。
66
+ if (change.namespace !== metadata.namespace || change.entity !== metadata.name) {
67
+ throw new RxDBError(
68
+ `RxDBChange ${options.changeId} belongs to ${change.namespace}:${change.entity}, ` +
69
+ `not ${metadata.namespace}:${metadata.name}`
70
+ );
71
+ }
72
+ if (change.branchId != null && change.branchId !== currentBranch.id) {
73
+ throw new RxDBError(
74
+ `RxDBChange ${options.changeId} belongs to branch '${change.branchId}', current branch is '${currentBranch.id}'`
75
+ );
76
+ }
77
+
78
+ const actions = get_switch_version_actions([change], false);
79
+ // 不能走 switchBranch:各适配器的 switch_branch 第一步就是 remove_all_triggers_sql
80
+ // (见 sqlite-core / pglite 的 version/switch_branch.ts),恢复出来的行不会产生任何 change 行——
81
+ // 本函数的 TSDoc 说「恢复操作本身会生成新的 RxDBChange 记录(可被 push 到远程)」直接落空,
82
+ // 远端永远停在「已删除」,本地与远端静默分叉。
83
+ // 改走 mergeChanges(actions, undefined, false):与 merge_branch 的 squash 出口同路,
84
+ // disableTriggers=false 让数据库触发器照常记账。
85
+ // 恢复是重算领域状态,不是一次新的用户编辑。
86
+ //
87
+ // 只有一次写,本来不需要事务。开事务是为了**拿到一个执行器当声明的作用域**:声明存在一个
88
+ // WeakMap 里,每个作用域只存一条(`trusted-write-scope.ts`),而工作树的
89
+ // `interceptMergeChanges()` 是排队拿到事务之后才取声明的(`capture-hook.ts`)。绑在适配器
90
+ // 实例上时,一次恢复与一次并发的压缩合并会互相覆盖:先执行的取到后声明者的意图,
91
+ // 后执行的取不到声明被当作未知入口拒绝(`merge-branch.ts` 的压缩出口同理,两边的并发用例在
92
+ // `__tests__/trusted-write-concurrency.spec.ts`)。执行器是「这一次写」独有的对象。
93
+ //
94
+ // 第二个参数(transactionLog)显式传 false,理由同 `merge-branch.ts` 的压缩出口:这一层只借
95
+ // 执行器当声明作用域。默认 true 会给这次写出的 change 行盖上 transactionId,
96
+ // `history-item-builder.ts` 于是把这条恢复显示成 `'TRANSACTION'` 而不是它自己的类型(如
97
+ // INSERT)——TSDoc 承诺的是一条普通的 RxDBChange 记录,不是被特殊分组的事务记录。
98
+ await adapter.transaction(async executor => {
99
+ declareTrustedWrite(executor, {
100
+ file: 'restore-entity.ts',
101
+ symbol: 'restore_entity',
102
+ intent: TrustedWriteIntent.restore_entity
103
+ });
104
+ await executor.mergeChanges(actions, undefined, false);
105
+ }, false);
106
+
107
+ const repo = adapter.getRepository(EntityType);
108
+ const restored = await repo.find({
109
+ where: {
110
+ combinator: 'and',
111
+ rules: [{ field: 'id', operator: '=', value: change.entityId }]
112
+ },
113
+ limit: 1
114
+ });
115
+
116
+ // 返回类型是非空的 InstanceType<T>,恢复不出行时必须抛错而不是让 undefined 冒充实体
117
+ if (!restored[0]) {
118
+ throw new RxDBError(
119
+ `Restore produced no row for ${metadata.namespace}:${metadata.name} id=${String(change.entityId)}`
120
+ );
121
+ }
122
+
123
+ return restored[0] as InstanceType<T>;
124
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * 作用域历史选择(纯函数)
3
+ *
4
+ * @packageDocumentation
5
+ *
6
+ * `history(scope).undo()` 的选择口径与展示口径**不是同一件事**:
7
+ *
8
+ * - 展示(`filterHistoriesByScope`)可以把一个事务裁成「与你有关的那几条」,
9
+ * 用户在 User 的历史面板上看到的就该是 User 的部分;
10
+ * - 执行**不能**这么裁。事务是原子的,只回滚它的一半会留下一个从未存在过的中间态
11
+ * (订单被撤销、订单行还在)。
12
+ *
13
+ * 因此这里把「选哪几条」和「能不能整条应用」分开:`selectScopedHistories` 只返回
14
+ * **完整未裁剪**的历史项,凡是触及作用域但同时改了作用域外数据的事务,一律进
15
+ * `crossScope` 由调用方拒绝({@link RxDBCrossScopeTransactionError})。
16
+ *
17
+ * 为什么是「拒绝」而不是「连带撤销作用域外的部分」:作用域的契约是**只影响这个作用域**。
18
+ * 悄悄扩大影响面,调用方无从知道自己刚刚还回滚了别的仓库;而静默跳过则是点了撤销
19
+ * 什么都没发生。报错是唯一一个既不越权、又不骗人的选项,且可恢复——调用方改用
20
+ * `history()`(database 作用域)就能完整撤销这个事务。
21
+ */
22
+
23
+ import { HistoryItem, HistoryScope, RxDBChange, RxDBError } from '@aiao/rxdb';
24
+ /** 判定作用域归属只需要这三个字段 */
25
+ type ScopedChange = Pick<RxDBChange, 'entity' | 'entityId' | 'namespace'>;
26
+
27
+ /**
28
+ * 单条变更是否落在作用域内
29
+ *
30
+ * @remarks
31
+ * entity 作用域用 `===` 比较 id,`0` / `''` 这类假值同样是合法 id——
32
+ * 判空必须用 `undefined` 而不是真值判断,否则 `entityId = 0` 会退化成 repository 作用域。
33
+ */
34
+ export function isChangeInScope(change: ScopedChange, scope: HistoryScope): boolean {
35
+ if (scope.type === 'database') return true;
36
+ if (change.namespace !== scope.namespace || change.entity !== scope.entity) return false;
37
+ return scope.type === 'repository' || change.entityId === scope.entityId;
38
+ }
39
+
40
+ /** 历史项是否**至少有一条**变更落在作用域内(决定它是否出现在该作用域的历史里) */
41
+ export function historyTouchesScope(history: HistoryItem, scope: HistoryScope): boolean {
42
+ return history.changes.some(change => isChangeInScope(change, scope));
43
+ }
44
+
45
+ /** 历史项是否**全部**变更都落在作用域内(决定它能否被该作用域整条应用) */
46
+ export function isHistoryWithinScope(history: HistoryItem, scope: HistoryScope): boolean {
47
+ return history.changes.every(change => isChangeInScope(change, scope));
48
+ }
49
+
50
+ /** {@link selectScopedHistories} 的结果 */
51
+ export interface ScopedHistorySelection {
52
+ /** 触及作用域、但同时改了作用域外数据的事务:不可部分应用 */
53
+ crossScope: HistoryItem[];
54
+ /** 完全落在作用域内、可以整条应用的历史项(未被裁剪) */
55
+ selected: HistoryItem[];
56
+ }
57
+
58
+ /**
59
+ * 在作用域内挑出接下来 `step` 条要 undo / redo 的历史项
60
+ *
61
+ * @param histories - 候选历史(已按可撤销 / 可重做过滤,最新在前)
62
+ * @param scope - 作用域
63
+ * @param step - 取几条;非正数取 0 条
64
+ *
65
+ * @remarks
66
+ * `step` 数的是「触及作用域的历史项」,越界事务同样占一个名额而不是被跳过——
67
+ * undo 必须后进先出,跳过它去撤更早的那条会把顺序弄乱。
68
+ */
69
+ export function selectScopedHistories(
70
+ histories: HistoryItem[],
71
+ scope: HistoryScope,
72
+ step: number
73
+ ): ScopedHistorySelection {
74
+ const window = histories.filter(history => historyTouchesScope(history, scope)).slice(0, Math.max(0, step));
75
+ const crossScope: HistoryItem[] = [];
76
+ const selected: HistoryItem[] = [];
77
+
78
+ for (const history of window) {
79
+ if (isHistoryWithinScope(history, scope)) {
80
+ selected.push(history);
81
+ } else {
82
+ crossScope.push(history);
83
+ }
84
+ }
85
+
86
+ return { crossScope, selected };
87
+ }
88
+
89
+ const describeScope = (scope: HistoryScope): string => {
90
+ if (scope.type === 'database') return 'database';
91
+ if (scope.type === 'repository') return `${scope.namespace}:${scope.entity}`;
92
+ return `${scope.namespace}:${scope.entity}#${String(scope.entityId)}`;
93
+ };
94
+
95
+ const describeOutsiders = (histories: readonly HistoryItem[], scope: HistoryScope): string => {
96
+ const outsiders = new Set<string>();
97
+ for (const history of histories) {
98
+ for (const change of history.changes) {
99
+ if (isChangeInScope(change, scope)) continue;
100
+ outsiders.add(
101
+ scope.type === 'entity' ?
102
+ `${change.namespace}:${change.entity}#${String(change.entityId)}`
103
+ : `${change.namespace}:${change.entity}`
104
+ );
105
+ }
106
+ }
107
+ return [...outsiders].join(', ');
108
+ };
109
+
110
+ /**
111
+ * 作用域 undo / redo 撞上跨作用域事务
112
+ *
113
+ * @remarks
114
+ * 拿到本错误说明选中的历史项里有事务同时改了作用域内外的数据。作用域**不会**替你决定
115
+ * 是牺牲原子性还是越权撤销:要完整撤销这个事务,改用不带参数的
116
+ * `rxdb.versionManager.history()`(database 作用域)重新取一次历史再 undo。
117
+ * `VersionManager.historyManager` 是实现细节,不是绕开本错误的出口。
118
+ */
119
+ export class RxDBCrossScopeTransactionError extends RxDBError {
120
+ constructor(
121
+ /** 发起 undo / redo 的作用域 */
122
+ readonly scope: HistoryScope,
123
+ /** 越界的历史项(完整未裁剪) */
124
+ readonly histories: readonly HistoryItem[]
125
+ ) {
126
+ super(
127
+ `Scoped history operation would split a transaction: scope ${describeScope(scope)} also changes ` +
128
+ `${describeOutsiders(histories, scope)}. Use the database scope to apply the whole transaction.`
129
+ );
130
+ this.name = 'RxDBCrossScopeTransactionError';
131
+ Object.setPrototypeOf(this, RxDBCrossScopeTransactionError.prototype);
132
+ }
133
+ }