@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.
- package/LICENSE +21 -0
- package/README.md +70 -2
- package/dist/HistoryManager.d.ts +87 -0
- package/dist/HistoryManager.d.ts.map +1 -0
- package/dist/VersionManager.d.ts +243 -0
- package/dist/VersionManager.d.ts.map +1 -0
- package/dist/create-branch.d.ts +35 -0
- package/dist/create-branch.d.ts.map +1 -0
- package/dist/detached-event-error.d.ts +28 -0
- package/dist/detached-event-error.d.ts.map +1 -0
- package/dist/find-switch-branch-step.d.ts +94 -0
- package/dist/find-switch-branch-step.d.ts.map +1 -0
- package/dist/history-filters.d.ts +78 -0
- package/dist/history-filters.d.ts.map +1 -0
- package/dist/history-item-builder.d.ts +25 -0
- package/dist/history-item-builder.d.ts.map +1 -0
- package/dist/history-scope-api.d.ts +41 -0
- package/dist/history-scope-api.d.ts.map +1 -0
- package/dist/history-undo-session.types.d.ts +32 -0
- package/dist/history-undo-session.types.d.ts.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1565 -0
- package/dist/merge-branch.d.ts +22 -0
- package/dist/merge-branch.d.ts.map +1 -0
- package/dist/plugin.d.ts +58 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/pullable-count.d.ts +37 -0
- package/dist/pullable-count.d.ts.map +1 -0
- package/dist/push-inflight.d.ts +60 -0
- package/dist/push-inflight.d.ts.map +1 -0
- package/dist/redo-stack.d.ts +56 -0
- package/dist/redo-stack.d.ts.map +1 -0
- package/dist/remove-branch.d.ts +16 -0
- package/dist/remove-branch.d.ts.map +1 -0
- package/dist/restore-entity.d.ts +15 -0
- package/dist/restore-entity.d.ts.map +1 -0
- package/dist/scope-selection.d.ts +56 -0
- package/dist/scope-selection.d.ts.map +1 -0
- package/dist/switch-branch-actions.d.ts +87 -0
- package/dist/switch-branch-actions.d.ts.map +1 -0
- package/dist/sync-history-bridge.d.ts +87 -0
- package/dist/sync-history-bridge.d.ts.map +1 -0
- package/dist/undo-redo-apply.d.ts +47 -0
- package/dist/undo-redo-apply.d.ts.map +1 -0
- package/dist/version-manager.utils.d.ts +30 -0
- package/dist/version-manager.utils.d.ts.map +1 -0
- package/package.json +59 -3
- package/src/HistoryManager.ts +800 -0
- package/src/VersionManager.ts +617 -0
- package/src/create-branch.ts +178 -0
- package/src/detached-event-error.ts +43 -0
- package/src/find-switch-branch-step.ts +313 -0
- package/src/history-filters.ts +157 -0
- package/src/history-item-builder.ts +101 -0
- package/src/history-scope-api.ts +189 -0
- package/src/history-undo-session.types.ts +37 -0
- package/src/index.ts +61 -0
- package/src/merge-branch.ts +192 -0
- package/src/plugin.ts +93 -0
- package/src/pullable-count.ts +67 -0
- package/src/push-inflight.ts +91 -0
- package/src/redo-stack.ts +79 -0
- package/src/remove-branch.ts +100 -0
- package/src/restore-entity.ts +124 -0
- package/src/scope-selection.ts +133 -0
- package/src/switch-branch-actions.ts +319 -0
- package/src/sync-history-bridge.ts +131 -0
- package/src/undo-redo-apply.ts +270 -0
- package/src/version-manager.utils.ts +43 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { getRxDBEntityIdentityKey, HistoryItem, RxDBChange, UUID } from '@aiao/rxdb';
|
|
2
|
+
import { isNil } from '@aiao/utils';
|
|
3
|
+
/**
|
|
4
|
+
* 生成历史项的描述文本
|
|
5
|
+
*
|
|
6
|
+
* 规则:
|
|
7
|
+
* - 单条 INSERT/UPDATE/DELETE → 「创建 Foo」「更新 Foo」「删除 Foo」
|
|
8
|
+
* - 事务(多条变更)→ 「事务: 创建2条, 更新3条」
|
|
9
|
+
*/
|
|
10
|
+
export function generateHistoryDescription(changes: RxDBChange[]): string {
|
|
11
|
+
if (changes.length === 1) {
|
|
12
|
+
const change = changes[0];
|
|
13
|
+
const action =
|
|
14
|
+
change.type === 'INSERT' ? '创建'
|
|
15
|
+
: change.type === 'UPDATE' ? '更新'
|
|
16
|
+
: '删除';
|
|
17
|
+
return `${action} ${change.entity}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const operations: Record<string, number> = {};
|
|
21
|
+
for (const change of changes) {
|
|
22
|
+
const key = change.type.toLowerCase();
|
|
23
|
+
operations[key] = (operations[key] ?? 0) + 1;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const parts: string[] = [];
|
|
27
|
+
if (operations['insert']) parts.push(`创建${operations['insert']}条`);
|
|
28
|
+
if (operations['update']) parts.push(`更新${operations['update']}条`);
|
|
29
|
+
if (operations['delete']) parts.push(`删除${operations['delete']}条`);
|
|
30
|
+
|
|
31
|
+
return `事务: ${parts.join(', ')}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 从一组同事务的变更构造单个 HistoryItem
|
|
36
|
+
*
|
|
37
|
+
* @param changes - 属于同一事务的变更记录(至少一条)
|
|
38
|
+
* @returns 结构化的历史项,包含描述、类型、状态等信息
|
|
39
|
+
*/
|
|
40
|
+
export function createHistoryItem(changes: RxDBChange[]): HistoryItem {
|
|
41
|
+
const first_change = changes[0]!;
|
|
42
|
+
const count = changes.length;
|
|
43
|
+
const transactionId = first_change.transactionId ?? null;
|
|
44
|
+
const createdAt = first_change.createdAt!;
|
|
45
|
+
const namespace = first_change.namespace;
|
|
46
|
+
const entity = first_change.entity;
|
|
47
|
+
const type = transactionId ? 'TRANSACTION' : first_change.type;
|
|
48
|
+
const fingerprint = [type, getRxDBEntityIdentityKey(first_change.entityId), createdAt.getTime()].join(':');
|
|
49
|
+
const reverted = changes.some(c => isNil(c.revertChangeId) === false);
|
|
50
|
+
const redoInvalidated = changes.some(c => isNil(c.redoInvalidatedAt) === false);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
transactionId,
|
|
54
|
+
changeId: changes.reduce((max, c) => (c.id > max ? c.id : max), changes[0].id),
|
|
55
|
+
fingerprint,
|
|
56
|
+
changes,
|
|
57
|
+
type,
|
|
58
|
+
count,
|
|
59
|
+
createdAt,
|
|
60
|
+
description: generateHistoryDescription(changes),
|
|
61
|
+
namespace,
|
|
62
|
+
entity,
|
|
63
|
+
reverted,
|
|
64
|
+
redoInvalidated
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 把按 id 降序排列的变更列表,转换为按事务分组的 HistoryItem 数组
|
|
70
|
+
*
|
|
71
|
+
* 转换逻辑:
|
|
72
|
+
* - 相同 transactionId 的连续变更合并为一个 HistoryItem
|
|
73
|
+
* - 无 transactionId 的变更单独成为一个 HistoryItem
|
|
74
|
+
*/
|
|
75
|
+
export function convertChangesToHistories(changes: RxDBChange[]): HistoryItem[] {
|
|
76
|
+
const histories: HistoryItem[] = [];
|
|
77
|
+
const next_transaction: RxDBChange[] = [];
|
|
78
|
+
let _transaction_id: UUID | undefined | null = undefined;
|
|
79
|
+
|
|
80
|
+
changes.forEach(d => {
|
|
81
|
+
if (d.transactionId && d.transactionId === _transaction_id) {
|
|
82
|
+
next_transaction.push(d);
|
|
83
|
+
} else {
|
|
84
|
+
_transaction_id = d.transactionId;
|
|
85
|
+
if (next_transaction.length) {
|
|
86
|
+
histories.push(createHistoryItem([...next_transaction]));
|
|
87
|
+
next_transaction.length = 0;
|
|
88
|
+
}
|
|
89
|
+
if (d.transactionId) {
|
|
90
|
+
next_transaction.push(d);
|
|
91
|
+
} else {
|
|
92
|
+
histories.push(createHistoryItem([d]));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
if (next_transaction.length) {
|
|
97
|
+
histories.push(createHistoryItem([...next_transaction]));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return histories;
|
|
101
|
+
}
|
|
@@ -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,61 @@
|
|
|
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';
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// 以下六项只为 `@aiao/rxdb-plugin-working-tree` 而导出,全部标了 `@internal`:
|
|
50
|
+
// 工作树插件的 commit 迁移(`commit/enable-migration.ts`)要在放行一条分支之前,
|
|
51
|
+
// 先按 `switchBranch` 的同一口径问两件事——「父链自不自洽」与「这条分支现在停在哪」;
|
|
52
|
+
// 它的物化屏障(`working-tree/materialize-branch.ts`)要在切换事务里按同一口径撤掉
|
|
53
|
+
// 来源分支的投影,手上只有事务执行器的仓库。另写一条同义查询,迟早会在「回滚标记算不算」
|
|
54
|
+
// 或「排序列是哪一个」上与切换路径分叉,表现为迁移放行了一条 `switchBranch` 走不通的分支
|
|
55
|
+
// (research.md R11),或物化后留下一截来源分支的数据。
|
|
56
|
+
// 两包之间只有 `dist` 一条路,没有深路径可走,所以从桶里出。应用代码不该引用它们。
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
export { find_branch_path_to_root, find_switch_branch_step } from './find-switch-branch-step.js';
|
|
59
|
+
export type { SwitchBranchStep } from './find-switch-branch-step.js';
|
|
60
|
+
export { compute_switch_branch_actions, get_branch_max_change } from './switch-branch-actions.js';
|
|
61
|
+
export type { SwitchBranchActionReaders } from './switch-branch-actions.js';
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import {
|
|
2
|
+
declareTrustedWrite,
|
|
3
|
+
getRxDBChangeKey,
|
|
4
|
+
MergeBranchOptions,
|
|
5
|
+
MergeBranchResult,
|
|
6
|
+
RxDBError,
|
|
7
|
+
SwitchVersionActions,
|
|
8
|
+
TrustedWriteIntent
|
|
9
|
+
} from '@aiao/rxdb';
|
|
10
|
+
import { VersionManager } from './VersionManager.js';
|
|
11
|
+
import { remove_branch } from './remove-branch.js';
|
|
12
|
+
import { get_switch_version_actions } from './switch-branch-actions.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 合并分支
|
|
16
|
+
*
|
|
17
|
+
* 将源分支的变更合并到当前激活分支(目标分支)。
|
|
18
|
+
*
|
|
19
|
+
* 支持两种策略:
|
|
20
|
+
* - `squash`(默认):将源分支所有变更压缩为最小操作集后应用,过滤幽灵操作(源分支内
|
|
21
|
+
* INSERT 后又 DELETE 的实体不会影响目标分支)
|
|
22
|
+
* - `normal`:逐条应用源分支变更,每条变更在目标分支产生独立的变更记录,不过滤幽灵操作
|
|
23
|
+
*
|
|
24
|
+
* 合并后可选删除源分支。
|
|
25
|
+
*
|
|
26
|
+
* @param version - VersionManager 实例
|
|
27
|
+
* @param sourceBranchId - 源分支 ID
|
|
28
|
+
* @param currentBranchId - 当前激活分支 ID(由调用方提供,避免重复查询)
|
|
29
|
+
* @param options - 合并选项
|
|
30
|
+
* @returns 合并结果
|
|
31
|
+
*/
|
|
32
|
+
export const merge_branch = async (
|
|
33
|
+
version: VersionManager,
|
|
34
|
+
sourceBranchId: string,
|
|
35
|
+
currentBranchId: string,
|
|
36
|
+
options?: MergeBranchOptions
|
|
37
|
+
): Promise<MergeBranchResult> => {
|
|
38
|
+
const strategy = options?.strategy ?? 'squash';
|
|
39
|
+
const deleteSource = options?.deleteSource ?? false;
|
|
40
|
+
|
|
41
|
+
const { branchRepository, changeRepository, adapter } = await version.getLocalRepositories();
|
|
42
|
+
|
|
43
|
+
// 验证:源分支不能是目标分支
|
|
44
|
+
if (sourceBranchId === currentBranchId) {
|
|
45
|
+
throw new RxDBError(`Cannot merge branch '${sourceBranchId}' into itself`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 验证:源分支必须存在
|
|
49
|
+
const sourceBranch = (
|
|
50
|
+
await branchRepository.find({
|
|
51
|
+
where: {
|
|
52
|
+
combinator: 'and',
|
|
53
|
+
rules: [{ field: 'id', operator: '=', value: sourceBranchId }]
|
|
54
|
+
},
|
|
55
|
+
limit: 1
|
|
56
|
+
})
|
|
57
|
+
)[0];
|
|
58
|
+
|
|
59
|
+
if (!sourceBranch) {
|
|
60
|
+
throw new RxDBError(`Branch '${sourceBranchId}' not found`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 查询源分支从 fromChangeId 之后的所有有效变更
|
|
64
|
+
const sourceChanges = await changeRepository.find({
|
|
65
|
+
where: {
|
|
66
|
+
combinator: 'and',
|
|
67
|
+
rules: [
|
|
68
|
+
{ field: 'branchId', operator: '=', value: sourceBranchId },
|
|
69
|
+
{ field: 'revertChangeId', operator: '=', value: null },
|
|
70
|
+
...(sourceBranch.fromChangeId != null ?
|
|
71
|
+
[{ field: 'id' as const, operator: '>' as const, value: sourceBranch.fromChangeId }]
|
|
72
|
+
: [])
|
|
73
|
+
]
|
|
74
|
+
},
|
|
75
|
+
orderBy: [{ field: 'id', sort: 'asc' }]
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 删源分支:合并落库之后的收尾动作。
|
|
80
|
+
*
|
|
81
|
+
* 失败不上抛 —— 合并已经落库且触发器已生成目标分支的变更记录,把它判成整体失败会让
|
|
82
|
+
* 调用方重试,normal 策略下就是二次合并。这里只如实返回「没删成」与原因。
|
|
83
|
+
*/
|
|
84
|
+
const doDeleteSource = async (): Promise<{ deleted: boolean; error?: Error }> => {
|
|
85
|
+
if (!deleteSource) return { deleted: false };
|
|
86
|
+
try {
|
|
87
|
+
await remove_branch(version, sourceBranchId);
|
|
88
|
+
return { deleted: true };
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return { deleted: false, error: error instanceof Error ? error : new Error(String(error)) };
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const toResult = (mergedCount: number, deleteResult: { deleted: boolean; error?: Error }): MergeBranchResult => ({
|
|
95
|
+
merged: mergedCount,
|
|
96
|
+
strategy,
|
|
97
|
+
sourceDeleted: deleteResult.deleted,
|
|
98
|
+
...(deleteResult.error ? { sourceDeleteError: deleteResult.error } : {})
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// 空分支:无变更需要合并
|
|
102
|
+
if (sourceChanges.length === 0) {
|
|
103
|
+
return toResult(0, await doDeleteSource());
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let merged: number;
|
|
107
|
+
|
|
108
|
+
if (strategy === 'normal') {
|
|
109
|
+
// normal 策略:逐条应用源分支变更,每条变更在目标分支产生独立的变更记录。
|
|
110
|
+
//
|
|
111
|
+
// 整个循环必须在**一个**事务里:单次 mergeChanges 在适配器内部虽已是事务,
|
|
112
|
+
// 但 N 次就是 N 个独立事务 —— 第 k 条失败时前 k-1 条已落库、触发器也已在目标分支
|
|
113
|
+
// 生成对应 RxDBChange,留下「合了一半」的状态。
|
|
114
|
+
//
|
|
115
|
+
// 循环内必须调 `executor.mergeChanges` 而非 `adapter.mergeChanges`:持有 executor 才算
|
|
116
|
+
// 在本事务内。旧写法靠适配器的环境态推断「已在事务中就复用」,那正是 C2 要拆掉的东西 ——
|
|
117
|
+
// 翻转后它会从「复用当前事务」变成「重新排队」,排在自己身后直接自锁。
|
|
118
|
+
await adapter.transaction(async executor => {
|
|
119
|
+
for (const change of sourceChanges) {
|
|
120
|
+
const singleActions: SwitchVersionActions = {
|
|
121
|
+
deletes: new Map(),
|
|
122
|
+
updates: new Map(),
|
|
123
|
+
inserts: new Map()
|
|
124
|
+
};
|
|
125
|
+
const key = getRxDBChangeKey(change);
|
|
126
|
+
if (change.type === 'INSERT') {
|
|
127
|
+
singleActions.inserts.set(key, { patch: change.patch!, inversePatch: null });
|
|
128
|
+
} else if (change.type === 'UPDATE') {
|
|
129
|
+
singleActions.updates.set(key, { patch: change.patch!, inversePatch: change.inversePatch! });
|
|
130
|
+
} else if (change.type === 'DELETE') {
|
|
131
|
+
singleActions.deletes.set(key, { patch: null, inversePatch: change.inversePatch! });
|
|
132
|
+
}
|
|
133
|
+
// 声明写在循环体内:声明是取用即清除的,提到循环外只有第一条变更带得上身份。
|
|
134
|
+
declareTrustedWrite(executor, {
|
|
135
|
+
file: 'merge-branch.ts',
|
|
136
|
+
symbol: 'merge_branch',
|
|
137
|
+
intent: TrustedWriteIntent.merge_per_change
|
|
138
|
+
});
|
|
139
|
+
await executor.mergeChanges(singleActions, undefined, false);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
merged = sourceChanges.length;
|
|
143
|
+
} else {
|
|
144
|
+
// squash 策略:压缩为目标分支最终状态的最小操作集
|
|
145
|
+
const actions = get_switch_version_actions(sourceChanges, true);
|
|
146
|
+
|
|
147
|
+
// 过滤幽灵删除:在源分支内创建后又删除的实体,对目标分支无净贡献
|
|
148
|
+
const createdInSource = new Set(sourceChanges.filter(c => c.type === 'INSERT').map(c => getRxDBChangeKey(c)));
|
|
149
|
+
for (const key of createdInSource) {
|
|
150
|
+
if (actions.deletes.has(key)) {
|
|
151
|
+
actions.deletes.delete(key);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
merged = actions.inserts.size + actions.updates.size + actions.deletes.size;
|
|
156
|
+
|
|
157
|
+
if (merged === 0) {
|
|
158
|
+
return toResult(0, await doDeleteSource());
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 应用变更到当前分支的实体表
|
|
162
|
+
// disableTriggers=false:让数据库触发器自动生成目标分支的 RxDBChange 记录
|
|
163
|
+
//
|
|
164
|
+
// 压缩只有一次写,本来不需要事务。开事务是为了**拿到一个执行器当声明的作用域**:
|
|
165
|
+
// 声明存在一个 WeakMap 里,每个作用域只存一条(`trusted-write-scope.ts`),而工作树的
|
|
166
|
+
// `interceptMergeChanges()` 是排队拿到事务之后才取声明的(`capture-hook.ts`)。绑在
|
|
167
|
+
// 适配器实例上时,两个并发的适配器级写会互相覆盖:先执行的取到后声明者的意图,
|
|
168
|
+
// 后执行的取不到声明被当作未知入口拒绝(`__tests__/trusted-write-concurrency.spec.ts`)。
|
|
169
|
+
// 执行器是「这一次写」独有的对象,于是并发与否都不会串台。
|
|
170
|
+
//
|
|
171
|
+
// 与逐条出口同一个符号、不同意图:登记表把它们分成两行,因为压缩与逐条的判定不同,
|
|
172
|
+
// 合成一行会让其中一条策略失去登记。
|
|
173
|
+
//
|
|
174
|
+
// 第二个参数(transactionLog)显式传 false:这一层只借执行器当声明作用域,不该把合并记成
|
|
175
|
+
// 一次事务化的历史条目。默认 true 会为本次事务启用一个 transactionId(SQLite 系后端按分支
|
|
176
|
+
// 重建全部触发器,PGlite 设一个事务局部变量),写出的每条 change 行都盖上它,
|
|
177
|
+
// `history-item-builder.ts` 的 `type = transactionId ? 'TRANSACTION' : first_change.type`
|
|
178
|
+
// 随即把 squash 产生的多条 change 折叠成一条 `'TRANSACTION'`——undo 粒度从「按实体」
|
|
179
|
+
// 变成「整次合并一起撤销」。传 false,历史记账与不包这层事务时一致(调用契约锁在
|
|
180
|
+
// `__tests__/merge-branch.spec.ts`,分组结果锁在 pglite 的 `version/merge_branch.spec.ts`)。
|
|
181
|
+
await adapter.transaction(async executor => {
|
|
182
|
+
declareTrustedWrite(executor, {
|
|
183
|
+
file: 'merge-branch.ts',
|
|
184
|
+
symbol: 'merge_branch',
|
|
185
|
+
intent: TrustedWriteIntent.merge_squash
|
|
186
|
+
});
|
|
187
|
+
await executor.mergeChanges(actions, undefined, false);
|
|
188
|
+
}, false);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return toResult(merged, await doDeleteSource());
|
|
192
|
+
};
|
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);
|