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

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 +79 -0
  4. package/dist/HistoryManager.d.ts.map +1 -0
  5. package/dist/VersionManager.d.ts +233 -0
  6. package/dist/VersionManager.d.ts.map +1 -0
  7. package/dist/create-branch.d.ts +29 -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 +45 -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 +35 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +1480 -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 +55 -0
  39. package/dist/scope-selection.d.ts.map +1 -0
  40. package/dist/switch-branch-actions.d.ts +16 -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 +57 -3
  49. package/src/HistoryManager.ts +770 -0
  50. package/src/VersionManager.ts +451 -0
  51. package/src/create-branch.ts +154 -0
  52. package/src/detached-event-error.ts +43 -0
  53. package/src/find-switch-branch-step.ts +277 -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 +46 -0
  59. package/src/merge-branch.ts +153 -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 +89 -0
  65. package/src/restore-entity.ts +97 -0
  66. package/src/scope-selection.ts +132 -0
  67. package/src/switch-branch-actions.ts +238 -0
  68. package/src/sync-history-bridge.ts +131 -0
  69. package/src/undo-redo-apply.ts +260 -0
  70. package/src/version-manager.utils.ts +43 -0
@@ -0,0 +1,189 @@
1
+ import {
2
+ EntityType,
3
+ getEntityMetadata,
4
+ type HistoryItem,
5
+ type HistoryScope,
6
+ type HistoryScopeAPI,
7
+ RxDBEntityId
8
+ } from '@aiao/rxdb';
9
+ import { firstValueFrom, map, Observable, shareReplay } from 'rxjs';
10
+ import { filterHistoriesByScope, filterUndoableHistories, getScopeKey } from './history-filters.js';
11
+ import type { ActiveUndoSession, UndoBoundary, UndoSession } from './history-undo-session.types.js';
12
+ /**
13
+ * `history()` 工厂所需的可变状态与委托。
14
+ *
15
+ * `history_cache` / `history_ref_counts` 是类持有的同一份 Map,sibling 的 set/delete 必须写回。
16
+ */
17
+ export interface HistoryScopeApiHost {
18
+ readonly history_cache: Map<string, HistoryScopeAPI>;
19
+ readonly history_ref_counts: Map<string, number>;
20
+ readonly histories$: Observable<HistoryItem[]>;
21
+ readonly undoHistories$: Observable<HistoryItem[]>;
22
+ readonly redoHistories$: Observable<HistoryItem[]>;
23
+ readonly undoSession$: { readonly value: UndoSession };
24
+ runSerialized<T>(task: () => Promise<T>): Promise<T>;
25
+ fetchLatestHistories(boundary: UndoBoundary): Promise<{
26
+ histories: HistoryItem[];
27
+ lastPushedMap: Map<string, number>;
28
+ }>;
29
+ isUndoSessionCurrent(session: ActiveUndoSession): boolean;
30
+ selectWholeTransactions(histories: HistoryItem[], scope: HistoryScope, step: number): HistoryItem[];
31
+ applyUndoRedoHistories(
32
+ operation: 'undo' | 'redo',
33
+ histories: HistoryItem[],
34
+ undoSession?: ActiveUndoSession
35
+ ): Promise<void>;
36
+ }
37
+
38
+ /**
39
+ * 解析 `history()` 入参:无参 → database;显式 HistoryScope;否则 EntityType / 实例。
40
+ */
41
+ export function resolveHistoryScope<T extends EntityType>(options?: T | InstanceType<T> | HistoryScope): HistoryScope {
42
+ if (!options) return { type: 'database' };
43
+ if (Object.getPrototypeOf(options) === Object.prototype && 'type' in options) {
44
+ return options as HistoryScope;
45
+ }
46
+
47
+ const metadata = getEntityMetadata(options as T | InstanceType<T>);
48
+ const id = 'id' in options ? (options as { id: unknown }).id : undefined;
49
+ const entityId: RxDBEntityId | undefined =
50
+ typeof id === 'string' || typeof id === 'number' || typeof id === 'bigint' ? id : undefined;
51
+ if (entityId !== undefined) {
52
+ return {
53
+ type: 'entity',
54
+ namespace: metadata.namespace,
55
+ entity: metadata.name,
56
+ entityId
57
+ };
58
+ }
59
+ return {
60
+ type: 'repository',
61
+ namespace: metadata.namespace,
62
+ entity: metadata.name
63
+ };
64
+ }
65
+
66
+ /**
67
+ * 创建(或从缓存取出)特定作用域的历史 API。
68
+ *
69
+ * @remarks
70
+ * 引用计数由 wrapObservable 对称管理(addRef / cleanup 成对出现),不再挂 finalize。
71
+ * 缓存条目的生死跟着引用计数走:第一个订阅者出现时入表,最后一个退订时摘掉。
72
+ * 因此「只调方法不订阅」的用法不产生任何常驻状态 —— 它拿到的是个短命对象,
73
+ * 用完即由 GC 回收。
74
+ */
75
+ export function createHistoryScopeApi<T extends EntityType>(
76
+ host: HistoryScopeApiHost,
77
+ options?: T | InstanceType<T> | HistoryScope
78
+ ): HistoryScopeAPI {
79
+ const scope = resolveHistoryScope(options);
80
+ const cacheKey = getScopeKey(scope);
81
+
82
+ const cached = host.history_cache.get(cacheKey);
83
+ if (cached) return cached;
84
+
85
+ const cleanup = () => {
86
+ const count = (host.history_ref_counts.get(cacheKey) || 0) - 1;
87
+ if (count <= 0) {
88
+ host.history_cache.delete(cacheKey);
89
+ host.history_ref_counts.delete(cacheKey);
90
+ } else {
91
+ host.history_ref_counts.set(cacheKey, count);
92
+ }
93
+ };
94
+
95
+ const addRef = () => {
96
+ const count = (host.history_ref_counts.get(cacheKey) || 0) + 1;
97
+ host.history_ref_counts.set(cacheKey, count);
98
+ // 入表推迟到第一个订阅者出现,与 `cleanup` 的摘除严格对称。
99
+ // 此前在函数末尾无条件入表:只调方法不订阅(`history(record).undo()`)的调用方
100
+ // 引用计数永远是 0,`cleanup` 永远不跑,条目就永久留下 —— 而 cacheKey 是按
101
+ // 记录 id 分的,每 undo 一条不同记录就多漏一条,没有上界。
102
+ host.history_cache.set(cacheKey, api);
103
+ };
104
+
105
+ const wrapObservable = <U>(source: Observable<U>): Observable<U> =>
106
+ new Observable(subscriber => {
107
+ addRef();
108
+ const sub = source.subscribe(subscriber);
109
+ return () => {
110
+ sub.unsubscribe();
111
+ cleanup();
112
+ };
113
+ });
114
+
115
+ const base_histories$ = host.histories$.pipe(
116
+ map(histories => filterHistoriesByScope(histories, scope)),
117
+ shareReplay({ bufferSize: 1, refCount: true })
118
+ );
119
+ const scoped_histories$ = wrapObservable(base_histories$);
120
+ const scoped_undo_histories$ = wrapObservable(
121
+ host.undoHistories$.pipe(
122
+ map(histories => filterHistoriesByScope(histories, scope)),
123
+ shareReplay({ bufferSize: 1, refCount: true })
124
+ )
125
+ );
126
+ const scoped_redo_histories$ = wrapObservable(
127
+ host.redoHistories$.pipe(
128
+ map(histories => filterHistoriesByScope(histories, scope)),
129
+ shareReplay({ bufferSize: 1, refCount: true })
130
+ )
131
+ );
132
+ const scoped_count$ = wrapObservable(
133
+ base_histories$.pipe(
134
+ map(h => h.length),
135
+ shareReplay({ bufferSize: 1, refCount: true })
136
+ )
137
+ );
138
+ const scoped_undo_count$ = wrapObservable(
139
+ scoped_undo_histories$.pipe(
140
+ map(h => h.length),
141
+ shareReplay({ bufferSize: 1, refCount: true })
142
+ )
143
+ );
144
+ const scoped_redo_count$ = wrapObservable(
145
+ scoped_redo_histories$.pipe(
146
+ map(h => h.length),
147
+ shareReplay({ bufferSize: 1, refCount: true })
148
+ )
149
+ );
150
+
151
+ const api: HistoryScopeAPI = {
152
+ type: scope.type,
153
+ histories$: scoped_histories$,
154
+ undoHistories$: scoped_undo_histories$,
155
+ redoHistories$: scoped_redo_histories$,
156
+ count$: scoped_count$,
157
+ undoCount$: scoped_undo_count$,
158
+ redoCount$: scoped_redo_count$,
159
+
160
+ undo: (step = 1) =>
161
+ host.runSerialized(async () => {
162
+ const undoSession = host.undoSession$.value;
163
+ if (undoSession.state === 'cleared') return;
164
+
165
+ const { histories, lastPushedMap } = await host.fetchLatestHistories(undoSession.boundary);
166
+ if (!host.isUndoSessionCurrent(undoSession)) return;
167
+
168
+ const undoable = filterUndoableHistories(
169
+ histories,
170
+ lastPushedMap,
171
+ undoSession.boundary.changeId,
172
+ undoSession.boundary.createdAfter
173
+ );
174
+ const toUndo = host.selectWholeTransactions(undoable, scope, step);
175
+ if (toUndo.length === 0) return;
176
+ await host.applyUndoRedoHistories('undo', toUndo, undoSession);
177
+ }),
178
+
179
+ redo: (step = 1) =>
180
+ host.runSerialized(async () => {
181
+ const histories = await firstValueFrom(host.redoHistories$);
182
+ const toRedo = host.selectWholeTransactions(histories, scope, step);
183
+ if (toRedo.length === 0) return;
184
+ await host.applyUndoRedoHistories('redo', toRedo);
185
+ })
186
+ };
187
+
188
+ return api;
189
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * undo session 类型:按分支隔离的撤销边界。
3
+ *
4
+ * @internal 供 {@link HistoryManager} 与 undo/redo sibling 共用
5
+ */
6
+
7
+ /** 同步清空后的永久撤销边界 */
8
+ export type UndoBoundary = Readonly<{
9
+ changeId: number;
10
+ createdAfter: Date | null;
11
+ }>;
12
+
13
+ /** 当前可撤销的 session */
14
+ export type ActiveUndoSession = Readonly<{
15
+ generation: number;
16
+ state: 'active';
17
+ boundary: UndoBoundary;
18
+ }>;
19
+
20
+ /** 同步后已清空、等待本地新写入再恢复的 session */
21
+ export type ClearedUndoSession = Readonly<{
22
+ generation: number;
23
+ state: 'cleared';
24
+ boundary: UndoBoundary;
25
+ clearedAt: Date;
26
+ }>;
27
+
28
+ export type UndoSession = ActiveUndoSession | ClearedUndoSession;
29
+
30
+ /** 本地写入事件携带的 session 归属信息 */
31
+ export type UndoSessionEvent = Readonly<{
32
+ generation: number | null;
33
+ recordAt: Date | null;
34
+ }>;
35
+
36
+ /** 未发生过清空时的默认边界 */
37
+ export const INITIAL_UNDO_BOUNDARY: UndoBoundary = { changeId: 0, createdAfter: null };
package/src/index.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @packageDocumentation
3
+ * `@aiao/rxdb-plugin-history` —— 历史、撤销重做与分支。
4
+ *
5
+ * @remarks
6
+ * 核心 `@aiao/rxdb` 只保留**原语**:三张系统表({@link RxDBBranch} / `RxDBChange` /
7
+ * `RxDBSync`)、变更编解码、冲突模型与同步资格判定。原因是这些东西并不属于历史子系统 ——
8
+ * 变更日志触发器直接写 `branchId`,`RxDBChange.branch` 会生成真实的
9
+ * `REFERENCES rxdb$rxdb_branch(id)` 外键,整条响应式增量链路都跑在 `rxdb_change` 上。
10
+ *
11
+ * 搬进本包的是**消费者**:读历史、撤销重做、建/切/合/删分支。
12
+ *
13
+ * 推拉同步的调度**不在这里**。US-025 阶段 D 之后它住在 `@aiao/rxdb-plugin-sync`:
14
+ * `rxdb.syncManager.push()` / `.pull()` / `.sync()`。那个插件 `inject: ['plugin:history']`,
15
+ * 反向则没有依赖——本包不认识同步包。
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * import { rxDBPluginHistory } from '@aiao/rxdb-plugin-history';
20
+ *
21
+ * rxdb.use(rxDBPluginHistory);
22
+ * await rxdb.connect();
23
+ *
24
+ * // 撤销重做走作用域 API:无参 = 整库,传实体类 = 该仓储,传实例 = 该行
25
+ * await rxdb.versionManager.history().undo();
26
+ * ```
27
+ */
28
+ export * from './plugin.js';
29
+ // 作用域 undo/redo 撞上跨作用域事务时抛给调用方的结构化错误。
30
+ // 选择谓词(isChangeInScope 等)是 HistoryManager 的内部实现,不进公开 API。
31
+ export { RxDBCrossScopeTransactionError } from './scope-selection.js';
32
+ // 实例由插件装配,用户不自己 new,因此只转类型不转类。
33
+ export type { VersionManager } from './VersionManager.js';
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // 以下四项只为 `@aiao/rxdb-plugin-sync` 而导出,全部标了 `@internal`:
37
+ // 同步插件是本包唯一的下游插件(它 `inject: ['plugin:history']`),一次 pull / push
38
+ // 结束时 undo 边界要作废、待拉计数要结算,而那些状态的主人在本包。接口窄且单向,
39
+ // 见 `sync-history-bridge.ts` 的文件头。应用代码不该引用它们。
40
+ // ---------------------------------------------------------------------------
41
+ export { isIgnorableDetachedVersionEventError } from './detached-event-error.js';
42
+ // 登记处是**类**导出而非类型导出:同步插件的 push 用例要 `new` 一个真的出来
43
+ // (替身会把要验的东西验掉),而两包之间只有 `dist` 一条路,没有深路径可走。
44
+ export { PushInFlightRegistry } from './push-inflight.js';
45
+ export type { PushInFlightSession } from './push-inflight.js';
46
+ export type { SyncHistoryBridge } from './sync-history-bridge.js';
@@ -0,0 +1,153 @@
1
+ import { getRxDBChangeKey, MergeBranchOptions, MergeBranchResult, RxDBError, SwitchVersionActions } from '@aiao/rxdb';
2
+ import { VersionManager } from './VersionManager.js';
3
+ import { remove_branch } from './remove-branch.js';
4
+ import { get_switch_version_actions } from './switch-branch-actions.js';
5
+
6
+ /**
7
+ * 合并分支
8
+ *
9
+ * 将源分支的变更合并到当前激活分支(目标分支)。
10
+ *
11
+ * 支持两种策略:
12
+ * - `squash`(默认):将源分支所有变更压缩为最小操作集后应用,过滤幽灵操作(源分支内
13
+ * INSERT 后又 DELETE 的实体不会影响目标分支)
14
+ * - `normal`:逐条应用源分支变更,每条变更在目标分支产生独立的变更记录,不过滤幽灵操作
15
+ *
16
+ * 合并后可选删除源分支。
17
+ *
18
+ * @param version - VersionManager 实例
19
+ * @param sourceBranchId - 源分支 ID
20
+ * @param currentBranchId - 当前激活分支 ID(由调用方提供,避免重复查询)
21
+ * @param options - 合并选项
22
+ * @returns 合并结果
23
+ */
24
+ export const merge_branch = async (
25
+ version: VersionManager,
26
+ sourceBranchId: string,
27
+ currentBranchId: string,
28
+ options?: MergeBranchOptions
29
+ ): Promise<MergeBranchResult> => {
30
+ const strategy = options?.strategy ?? 'squash';
31
+ const deleteSource = options?.deleteSource ?? false;
32
+
33
+ const { branchRepository, changeRepository, adapter } = await version.getLocalRepositories();
34
+
35
+ // 验证:源分支不能是目标分支
36
+ if (sourceBranchId === currentBranchId) {
37
+ throw new RxDBError(`Cannot merge branch '${sourceBranchId}' into itself`);
38
+ }
39
+
40
+ // 验证:源分支必须存在
41
+ const sourceBranch = (
42
+ await branchRepository.find({
43
+ where: {
44
+ combinator: 'and',
45
+ rules: [{ field: 'id', operator: '=', value: sourceBranchId }]
46
+ },
47
+ limit: 1
48
+ })
49
+ )[0];
50
+
51
+ if (!sourceBranch) {
52
+ throw new RxDBError(`Branch '${sourceBranchId}' not found`);
53
+ }
54
+
55
+ // 查询源分支从 fromChangeId 之后的所有有效变更
56
+ const sourceChanges = await changeRepository.find({
57
+ where: {
58
+ combinator: 'and',
59
+ rules: [
60
+ { field: 'branchId', operator: '=', value: sourceBranchId },
61
+ { field: 'revertChangeId', operator: '=', value: null },
62
+ ...(sourceBranch.fromChangeId != null ?
63
+ [{ field: 'id' as const, operator: '>' as const, value: sourceBranch.fromChangeId }]
64
+ : [])
65
+ ]
66
+ },
67
+ orderBy: [{ field: 'id', sort: 'asc' }]
68
+ });
69
+
70
+ /**
71
+ * 删源分支:合并落库之后的收尾动作。
72
+ *
73
+ * 失败不上抛 —— 合并已经落库且触发器已生成目标分支的变更记录,把它判成整体失败会让
74
+ * 调用方重试,normal 策略下就是二次合并。这里只如实返回「没删成」与原因。
75
+ */
76
+ const doDeleteSource = async (): Promise<{ deleted: boolean; error?: Error }> => {
77
+ if (!deleteSource) return { deleted: false };
78
+ try {
79
+ await remove_branch(version, sourceBranchId);
80
+ return { deleted: true };
81
+ } catch (error) {
82
+ return { deleted: false, error: error instanceof Error ? error : new Error(String(error)) };
83
+ }
84
+ };
85
+
86
+ const toResult = (mergedCount: number, deleteResult: { deleted: boolean; error?: Error }): MergeBranchResult => ({
87
+ merged: mergedCount,
88
+ strategy,
89
+ sourceDeleted: deleteResult.deleted,
90
+ ...(deleteResult.error ? { sourceDeleteError: deleteResult.error } : {})
91
+ });
92
+
93
+ // 空分支:无变更需要合并
94
+ if (sourceChanges.length === 0) {
95
+ return toResult(0, await doDeleteSource());
96
+ }
97
+
98
+ let merged: number;
99
+
100
+ if (strategy === 'normal') {
101
+ // normal 策略:逐条应用源分支变更,每条变更在目标分支产生独立的变更记录。
102
+ //
103
+ // 整个循环必须在**一个**事务里:单次 mergeChanges 在适配器内部虽已是事务,
104
+ // 但 N 次就是 N 个独立事务 —— 第 k 条失败时前 k-1 条已落库、触发器也已在目标分支
105
+ // 生成对应 RxDBChange,留下「合了一半」的状态。
106
+ //
107
+ // 循环内必须调 `executor.mergeChanges` 而非 `adapter.mergeChanges`:持有 executor 才算
108
+ // 在本事务内。旧写法靠适配器的环境态推断「已在事务中就复用」,那正是 C2 要拆掉的东西 ——
109
+ // 翻转后它会从「复用当前事务」变成「重新排队」,排在自己身后直接自锁。
110
+ await adapter.transaction(async executor => {
111
+ for (const change of sourceChanges) {
112
+ const singleActions: SwitchVersionActions = {
113
+ deletes: new Map(),
114
+ updates: new Map(),
115
+ inserts: new Map()
116
+ };
117
+ const key = getRxDBChangeKey(change);
118
+ if (change.type === 'INSERT') {
119
+ singleActions.inserts.set(key, { patch: change.patch!, inversePatch: null });
120
+ } else if (change.type === 'UPDATE') {
121
+ singleActions.updates.set(key, { patch: change.patch!, inversePatch: change.inversePatch! });
122
+ } else if (change.type === 'DELETE') {
123
+ singleActions.deletes.set(key, { patch: null, inversePatch: change.inversePatch! });
124
+ }
125
+ await executor.mergeChanges(singleActions, undefined, false);
126
+ }
127
+ });
128
+ merged = sourceChanges.length;
129
+ } else {
130
+ // squash 策略:压缩为目标分支最终状态的最小操作集
131
+ const actions = get_switch_version_actions(sourceChanges, true);
132
+
133
+ // 过滤幽灵删除:在源分支内创建后又删除的实体,对目标分支无净贡献
134
+ const createdInSource = new Set(sourceChanges.filter(c => c.type === 'INSERT').map(c => getRxDBChangeKey(c)));
135
+ for (const key of createdInSource) {
136
+ if (actions.deletes.has(key)) {
137
+ actions.deletes.delete(key);
138
+ }
139
+ }
140
+
141
+ merged = actions.inserts.size + actions.updates.size + actions.deletes.size;
142
+
143
+ if (merged === 0) {
144
+ return toResult(0, await doDeleteSource());
145
+ }
146
+
147
+ // 应用变更到当前分支的实体表
148
+ // disableTriggers=false:让数据库触发器自动生成目标分支的 RxDBChange 记录
149
+ await adapter.mergeChanges(actions, undefined, false);
150
+ }
151
+
152
+ return toResult(merged, await doDeleteSource());
153
+ };
package/src/plugin.ts ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @fileoverview RxDB 历史 / 撤销重做 / 分支插件
3
+ * 把 {@link VersionManager} 装进宿主的连接纪元,并把「可推送变更数」接回核心的同步状态枢纽。
4
+ *
5
+ * @module rxdb-plugin-history
6
+ */
7
+
8
+ import { IRxDBPlugin, Plugin, RxDB, RxDBPluginBase } from '@aiao/rxdb';
9
+ import type { LifecycleScope } from '@aiao/utils';
10
+ import { VersionManager } from './VersionManager.js';
11
+
12
+ /** 本插件当前不接受任何选项 */
13
+ export type RxDBPluginHistoryOptions = object;
14
+
15
+ /**
16
+ * 历史 / 撤销重做 / 分支插件。
17
+ *
18
+ * @remarks
19
+ * **不声明 `inject`**:`VersionManager.init()` 只挂事件监听与订阅,一条适配器读写都不发。
20
+ * 声明 `adapter:local` 会把安装推到引导链之后,而 `HistoryManager` 的分支流必须在第一条
21
+ * `rxdb_change` 事件到达之前就已经订阅上——装晚了,那一批变更不进历史。这也正是搬进插件
22
+ * 之前的时序:核心在构造器里 `new VersionManager()`、在 `init()` 里调 `init()`,
23
+ * 两者都早于 `schemaManager.init()`。
24
+ *
25
+ * 两处宿主改动都登记在 `scope` 上,断开连接时由宿主逆序释放:
26
+ *
27
+ * 1. `rxdb.versionManager` 这个实例槽位 —— 释放时连同 `destroy()` 一起撤掉,不给下一个
28
+ * 纪元留一个指向已拆事件总线的管理器。槽位排在最先获取,因而最后撤,`destroy()`
29
+ * 才跑得在槽位仍可读的窗口里;
30
+ * 2. {@link RxDB.syncState} 上的可推送计数订阅 —— 枢纽活得和实例一样长,计数却只在连接
31
+ * 期间有意义,因此走 `bindPushableCount()` 拿一个解绑函数,而不是在构造枢纽时传进去。
32
+ *
33
+ * 同一枢纽上的「重算待拉数」跳板**不在这里**:`refreshPullableCount()` 随 US-025 阶段 D
34
+ * 去了 `@aiao/rxdb-plugin-sync`,接住 `requestPullableRefresh()` 于是也是那个插件的活。
35
+ * 只装历史不装同步时那个请求发进空里,正是「没有同步就没有待拉数」的实情。
36
+ */
37
+ export class RxDBPluginHistory extends RxDBPluginBase implements IRxDBPlugin {
38
+ readonly lifecycle = 'scoped' as const;
39
+ name: Uncapitalize<string> = 'history';
40
+
41
+ install(scope: LifecycleScope) {
42
+ const versionManager = new VersionManager(this.rxdb);
43
+
44
+ // 槽位**先**获取,于是最后一个撤:撤销序是获取序的逆序,`destroy()` 因此跑在
45
+ // 「`rxdb.versionManager` 还读得到」的窗口里。反过来登记的话,正卡在 await 中途的
46
+ // 历史流(如 `HistoryManager` 的可撤销列表管线)恢复执行时读到 `undefined`,
47
+ // 一次正常断开就伪装成 TypeError 落进 `errors$`。
48
+ scope.acquire(() => {
49
+ Object.defineProperty(this.rxdb, 'versionManager', {
50
+ value: versionManager,
51
+ configurable: true,
52
+ enumerable: true
53
+ });
54
+ return () => void Reflect.deleteProperty(this.rxdb, 'versionManager');
55
+ }, 'history:slot');
56
+
57
+ // 构造器里就订阅了活跃分支流(`HistoryManager`),所以「造出来」本身已经是一次资源获取:
58
+ // 先把撤销登记上,再让它开始工作。`init()` 因此不需要自己一条 `acquire()` ——
59
+ // 它登记的事件监听与订阅全都由同一个 `destroy()` 收,中途抛错也够得着。
60
+ scope.acquire(() => () => versionManager.destroy(), 'history:versionManager');
61
+
62
+ versionManager.init();
63
+
64
+ scope.acquire(() => this.rxdb.syncState.bindPushableCount(versionManager.pushableCount$), 'history:pushableCount');
65
+ }
66
+ }
67
+
68
+ declare module '@aiao/rxdb' {
69
+ interface RxDB {
70
+ /**
71
+ * 历史 / 撤销重做 / 分支子系统。
72
+ *
73
+ * @remarks
74
+ * 由 `@aiao/rxdb-plugin-history` 在连接纪元内装配。**没装插件时这个槽位不存在**:
75
+ * 读它拿到 `undefined`,而不是一个什么都不做的空壳——核心不做 fallback 兜底。
76
+ */
77
+ versionManager: VersionManager;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * 历史插件工厂。
83
+ *
84
+ * @param db - 宿主实例
85
+ *
86
+ * @example
87
+ * ```ts
88
+ * import { rxDBPluginHistory } from '@aiao/rxdb-plugin-history';
89
+ *
90
+ * rxdb.use(rxDBPluginHistory);
91
+ * ```
92
+ */
93
+ export const rxDBPluginHistory: Plugin<RxDBPluginHistoryOptions> = (db: RxDB) => new RxDBPluginHistory(db);
@@ -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
+ }