@aiao/rxdb-plugin-sync 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 +81 -2
- package/dist/SyncManager.d.ts +426 -0
- package/dist/SyncManager.d.ts.map +1 -0
- package/dist/branch-change-id.d.ts +31 -0
- package/dist/branch-change-id.d.ts.map +1 -0
- package/dist/branch-materialization-source.d.ts +22 -0
- package/dist/branch-materialization-source.d.ts.map +1 -0
- package/dist/branch-utils.d.ts +40 -0
- package/dist/branch-utils.d.ts.map +1 -0
- package/dist/bulk-sync.d.ts +117 -0
- package/dist/bulk-sync.d.ts.map +1 -0
- package/dist/cascade-blocking.d.ts +38 -0
- package/dist/cascade-blocking.d.ts.map +1 -0
- package/dist/check-repository-updates.d.ts +47 -0
- package/dist/check-repository-updates.d.ts.map +1 -0
- package/dist/cleanup-expired.d.ts +56 -0
- package/dist/cleanup-expired.d.ts.map +1 -0
- package/dist/dependency-graph.d.ts +128 -0
- package/dist/dependency-graph.d.ts.map +1 -0
- package/dist/get-all-repository-sync-status.d.ts +58 -0
- package/dist/get-all-repository-sync-status.d.ts.map +1 -0
- package/dist/get-repository-sync-status.d.ts +68 -0
- package/dist/get-repository-sync-status.d.ts.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3773 -0
- package/dist/plugin.d.ts +74 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/pull-ancestor-changes.d.ts +41 -0
- package/dist/pull-ancestor-changes.d.ts.map +1 -0
- package/dist/pull-batch.d.ts +16 -0
- package/dist/pull-batch.d.ts.map +1 -0
- package/dist/pull-conflict-utils.d.ts +43 -0
- package/dist/pull-conflict-utils.d.ts.map +1 -0
- package/dist/pull-repository.d.ts +55 -0
- package/dist/pull-repository.d.ts.map +1 -0
- package/dist/pull-round.d.ts +41 -0
- package/dist/pull-round.d.ts.map +1 -0
- package/dist/pull.d.ts +16 -0
- package/dist/pull.d.ts.map +1 -0
- package/dist/push-branch.d.ts +22 -0
- package/dist/push-branch.d.ts.map +1 -0
- package/dist/push-repository.d.ts +79 -0
- package/dist/push-repository.d.ts.map +1 -0
- package/dist/push.d.ts +14 -0
- package/dist/push.d.ts.map +1 -0
- package/dist/query-cache-outbox.d.ts +120 -0
- package/dist/query-cache-outbox.d.ts.map +1 -0
- package/dist/sync-branches.d.ts +81 -0
- package/dist/sync-branches.d.ts.map +1 -0
- package/dist/sync-listeners.d.ts +20 -0
- package/dist/sync-listeners.d.ts.map +1 -0
- package/dist/sync-manager.utils.d.ts +36 -0
- package/dist/sync-manager.utils.d.ts.map +1 -0
- package/dist/sync-repository.d.ts +64 -0
- package/dist/sync-repository.d.ts.map +1 -0
- package/dist/topological-sort.d.ts +126 -0
- package/dist/topological-sort.d.ts.map +1 -0
- package/package.json +60 -3
- package/src/SyncManager.ts +690 -0
- package/src/branch-change-id.ts +70 -0
- package/src/branch-materialization-source.ts +445 -0
- package/src/branch-utils.ts +74 -0
- package/src/bulk-sync.ts +349 -0
- package/src/cascade-blocking.ts +61 -0
- package/src/check-repository-updates.ts +143 -0
- package/src/cleanup-expired.ts +285 -0
- package/src/dependency-graph.ts +324 -0
- package/src/get-all-repository-sync-status.ts +115 -0
- package/src/get-repository-sync-status.ts +237 -0
- package/src/index.ts +58 -0
- package/src/plugin.ts +132 -0
- package/src/pull-ancestor-changes.ts +61 -0
- package/src/pull-batch.ts +386 -0
- package/src/pull-conflict-utils.ts +284 -0
- package/src/pull-repository.ts +700 -0
- package/src/pull-round.ts +103 -0
- package/src/pull.ts +204 -0
- package/src/push-branch.ts +62 -0
- package/src/push-repository.ts +1541 -0
- package/src/push.ts +106 -0
- package/src/query-cache-outbox.ts +939 -0
- package/src/sync-branches.ts +276 -0
- package/src/sync-listeners.ts +309 -0
- package/src/sync-manager.utils.ts +48 -0
- package/src/sync-repository.ts +307 -0
- package/src/topological-sort.ts +246 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertUsableBranchId,
|
|
3
|
+
InvalidBranchIdError,
|
|
4
|
+
RxDBBranch,
|
|
5
|
+
RxDBChange,
|
|
6
|
+
RxDBError,
|
|
7
|
+
type IRepository
|
|
8
|
+
} from '@aiao/rxdb';
|
|
9
|
+
import { toLocalFromChangeId } from './branch-change-id.js';
|
|
10
|
+
import type { SyncManager } from './SyncManager.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* {@link SyncManager.syncBranches} 一轮的结果。
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* `created + updated + skipped.length` 不一定等于 `total`:本地已有、且早已标了 `remote` 的
|
|
17
|
+
* 分支既不计 `updated`,也不进 `skipped`。
|
|
18
|
+
*/
|
|
19
|
+
export interface SyncBranchesResult {
|
|
20
|
+
/** 本轮在本地新建的远端分支数。 */
|
|
21
|
+
created: number;
|
|
22
|
+
/** 本地已有、本轮补上 `remote: true` 标记的分支数。 */
|
|
23
|
+
updated: number;
|
|
24
|
+
/** 远端本轮交回的分支总数,含被跳过的。 */
|
|
25
|
+
total: number;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 本轮**未创建**的远端分支 id —— 具体原因见同一个 id 在 {@link skipReasons} 里的条目。
|
|
29
|
+
*
|
|
30
|
+
* 一条分支被跳过,可能是它自己的问题(id 不可用、或分叉点变更还没拉到本地翻译不出本地
|
|
31
|
+
* id),也可能单纯因为它的父本轮被跳过——后一种情形下这条分支自身完全合格,但父本轮
|
|
32
|
+
* 不会落库,若仍然照常创建,它的 `parentId` 就是一条指向「本轮不存在的父」的悬空外键
|
|
33
|
+
* (`rxdb_branch.parentId` 是 `PRAGMA defer_foreign_keys` 延迟到 COMMIT 才检查的外键,
|
|
34
|
+
* 届时会回滚整个事务,连同本轮所有本该成功的分支)。三种成因统一走同一条 `skipped`
|
|
35
|
+
* 通道,因为对调用方而言处理方式是一样的:这个 id 本轮没有对应的本地分支,仅此而已。
|
|
36
|
+
*
|
|
37
|
+
* **跳过不留持久标记**:每轮都全量重拉远端分支、从头再判一遍,`skipped` 与
|
|
38
|
+
* {@link skipReasons} 只报这一轮。`unresolved-from-change-id` 在分叉点变更拉到本地后自愈,
|
|
39
|
+
* 挂在它下面的 `ancestor-skipped` 随之自愈;`invalid-id` 则远端不改就每轮重拉、重跳,
|
|
40
|
+
* 一直占位——调用方要区分这两类,看 {@link skipReasons}。要不要记一个持久的「已知坏行」
|
|
41
|
+
* 标记,登记在 `requirements/roadmap.md`「epic-006 评审顺延的架构项」。
|
|
42
|
+
*/
|
|
43
|
+
skipped: string[];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `skipped` 里每一个 id 对应的具体跳过原因;key 覆盖 `skipped` 的每一项,一一对应。
|
|
47
|
+
*
|
|
48
|
+
* 单开一个字段而不是把原因塞进 `skipped` 数组本身:`skipped: string[]` 这个形状已经被
|
|
49
|
+
* 外部当「id 列表」消费,改成对象数组是破坏性变更;`Record<id, reason>` 是纯增量,
|
|
50
|
+
* 只读 `skipped` 的既有调用方不受影响。
|
|
51
|
+
*/
|
|
52
|
+
skipReasons: Record<string, SyncBranchSkipReason>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 一条远端分支本轮被跳过的具体原因。
|
|
57
|
+
*
|
|
58
|
+
* @remarks
|
|
59
|
+
* 三种成因互斥——处理顺序保证每条被跳过的分支只会落进其中一种:
|
|
60
|
+
* - `invalid-id`:分支自己的 id 落不进本地 `rxdb_branch.id`(见 {@link isUsableBranchId})。
|
|
61
|
+
* - `unresolved-from-change-id`:分叉点变更还没拉到本地,`fromChangeId` 翻译不出本地 id。
|
|
62
|
+
* - `ancestor-skipped`:分支自身没有问题,但它的**直接父**本轮已经被跳过(不管父是因为
|
|
63
|
+
* 哪一种成因被跳的)。`ancestorId` 记的是那个直接父的 id,不是链路最顶端的根因——
|
|
64
|
+
* 要追根因,沿 `skipReasons[ancestorId]` 递归上溯,直到查到的原因不再是
|
|
65
|
+
* `ancestor-skipped` 为止。
|
|
66
|
+
*/
|
|
67
|
+
export type SyncBranchSkipReason =
|
|
68
|
+
| { readonly cause: 'invalid-id' }
|
|
69
|
+
| { readonly cause: 'unresolved-from-change-id' }
|
|
70
|
+
| { readonly cause: 'ancestor-skipped'; readonly ancestorId: string };
|
|
71
|
+
|
|
72
|
+
/** `pullBranches()` 交回来的形状里,本函数只依赖这三个字段。 */
|
|
73
|
+
interface RemoteBranchRow {
|
|
74
|
+
id: string;
|
|
75
|
+
parentId?: string | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 远端分支 id 能否落进本地 `rxdb_branch.id`。
|
|
80
|
+
*
|
|
81
|
+
* @remarks
|
|
82
|
+
* 判定口径**只有**核心 {@link assertUsableBranchId} 一份——这里把它的抛错翻成布尔,
|
|
83
|
+
* 不是第二份规则。各写一遍的话,核心哪天多禁一个字符,同步这条导入路径会静默放行。
|
|
84
|
+
*
|
|
85
|
+
* 只吞 {@link InvalidBranchIdError}:别的异常说明校验自身坏了,那不是「这条远端行不合格」,
|
|
86
|
+
* 不该被记成一次 `skipped`。
|
|
87
|
+
*/
|
|
88
|
+
function isUsableBranchId(branchId: string): boolean {
|
|
89
|
+
try {
|
|
90
|
+
assertUsableBranchId(branchId);
|
|
91
|
+
return true;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error instanceof InvalidBranchIdError) return false;
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 把远端分支按「父在前」重排。
|
|
100
|
+
*
|
|
101
|
+
* `rxdb_branch.parentId` 是指向自己的外键,逐条落库时父必须先在库里。而 `pullBranches()`
|
|
102
|
+
* 返回的数组**没有顺序保证** —— 远端按 `updatedAt`、按主键、按它高兴的任何顺序返回都合法,
|
|
103
|
+
* 分页更是会把父子切到不同批次。所以顺序必须由本地重建,不能指望远端。
|
|
104
|
+
*
|
|
105
|
+
* 判定一个分支「可落库」的依据是它的父是否已经就绪:要么父本来就在本地,要么父在本批里
|
|
106
|
+
* 且已排到它前面。反复扫描直到某一轮一条都排不出去 —— 剩下的就是父确实找不到
|
|
107
|
+
* (远端删了父 / 父被分页切走)或互相成环,两者都无法安全落库。
|
|
108
|
+
*
|
|
109
|
+
* @param remoteBranches - 远端原样交回的分支数组,顺序不可信
|
|
110
|
+
* @param localIds - 已在本地的分支 id,它们可以充当父
|
|
111
|
+
* @returns 父一定排在子前面的新数组
|
|
112
|
+
* @throws RxDBError 存在父分支缺失或互相成环的记录时。整批放弃优于留下孤儿:
|
|
113
|
+
* 孤儿会被切分支时的「父节点缺失即当作到根」静默吞掉,损坏无声。
|
|
114
|
+
*/
|
|
115
|
+
function sortBranchesParentFirst<T extends RemoteBranchRow>(remoteBranches: T[], localIds: Set<string>): T[] {
|
|
116
|
+
const resolved = new Set(localIds);
|
|
117
|
+
// 本批自己带来的 id:用来把「父确实缺失」和「父只是还排在后面」区分开,好让报错说人话。
|
|
118
|
+
const incoming = new Set(remoteBranches.map(branch => branch.id));
|
|
119
|
+
const sorted: T[] = [];
|
|
120
|
+
let pending = remoteBranches;
|
|
121
|
+
|
|
122
|
+
while (pending.length > 0) {
|
|
123
|
+
const ready = pending.filter(branch => {
|
|
124
|
+
const parentId = branch.parentId ?? null;
|
|
125
|
+
return parentId === null || resolved.has(parentId);
|
|
126
|
+
});
|
|
127
|
+
if (ready.length === 0) break;
|
|
128
|
+
for (const branch of ready) {
|
|
129
|
+
sorted.push(branch);
|
|
130
|
+
resolved.add(branch.id);
|
|
131
|
+
}
|
|
132
|
+
pending = pending.filter(branch => !resolved.has(branch.id));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (pending.length > 0) {
|
|
136
|
+
const detail = pending
|
|
137
|
+
.map(branch => {
|
|
138
|
+
const parentId = branch.parentId ?? null;
|
|
139
|
+
// 父在本批里却始终排不出去 = 成环;否则就是父压根不存在。
|
|
140
|
+
const reason = parentId !== null && incoming.has(parentId) ? 'cycle' : 'missing parent';
|
|
141
|
+
return `'${branch.id}' -> '${String(parentId)}' (${reason})`;
|
|
142
|
+
})
|
|
143
|
+
.join(', ');
|
|
144
|
+
throw new RxDBError(`syncBranches: 远端分支拓扑无法解析,整批放弃:${detail}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return sorted;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 从远程拉取所有分支信息并同步到本地
|
|
152
|
+
*
|
|
153
|
+
* 规则:
|
|
154
|
+
* 1. 远程新分支 → 在本地创建(local: false, remote: true)
|
|
155
|
+
* 2. 本地已有的远程分支 → 更新 remote 标记为 true
|
|
156
|
+
* 3. 纯本地分支(remote=false)→ 不受影响
|
|
157
|
+
*
|
|
158
|
+
* @remarks
|
|
159
|
+
* 读本地、排序、落库全部在**同一个事务窗口**内完成:
|
|
160
|
+
*
|
|
161
|
+
* - 无事务时中途失败会留下半批已提交的分支,下次重试撞主键,同步就此永久卡死;
|
|
162
|
+
* - 读若在事务外做,快照与写之间存在窗口,并发建分支会让「本地已有」的判断过时,
|
|
163
|
+
* 重复 create 同样撞主键。
|
|
164
|
+
*
|
|
165
|
+
* 事务体内只能用 `executor` 作用域的仓库。绑在适配器上的那一份走并发度 1 的写队列,
|
|
166
|
+
* 在事务体内调用会排到自己这个事务后面 —— 直接死锁。
|
|
167
|
+
*/
|
|
168
|
+
export async function syncBranches(sm: SyncManager): Promise<SyncBranchesResult> {
|
|
169
|
+
const { adapter: remoteAdapter } = await sm.getRemoteRepositories();
|
|
170
|
+
|
|
171
|
+
if (!remoteAdapter.pullBranches) {
|
|
172
|
+
return { created: 0, updated: 0, total: 0, skipped: [], skipReasons: {} };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const remoteBranches = await remoteAdapter.pullBranches();
|
|
176
|
+
if (remoteBranches.length === 0) {
|
|
177
|
+
return { created: 0, updated: 0, total: 0, skipped: [], skipReasons: {} };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const { adapter } = await sm.getLocalRepositories();
|
|
181
|
+
|
|
182
|
+
return adapter.transaction(async executor => {
|
|
183
|
+
const branchRepository = executor.getRepository(RxDBBranch) as unknown as IRepository<typeof RxDBBranch>;
|
|
184
|
+
const changeRepository = executor.getRepository(RxDBChange) as unknown as IRepository<typeof RxDBChange>;
|
|
185
|
+
|
|
186
|
+
const localBranches = await branchRepository.find({
|
|
187
|
+
where: { combinator: 'and', rules: [] }
|
|
188
|
+
});
|
|
189
|
+
const localMap = new Map(localBranches.map(b => [b.id, b]));
|
|
190
|
+
|
|
191
|
+
let created = 0;
|
|
192
|
+
let updated = 0;
|
|
193
|
+
const skipped: string[] = [];
|
|
194
|
+
// 本轮已跳过的分支 id → 跳过原因。级联判断(下面对 `remoteParentId` 的检查)只能靠它,
|
|
195
|
+
// 不能靠 `localMap`——`localMap` 是本轮开始前的快照,而分支已经按父优先的拓扑序处理
|
|
196
|
+
// (`sortBranchesParentFirst`),父的跳过决定必然先于子被记录进这个集合。
|
|
197
|
+
//
|
|
198
|
+
// 用 Map 记、返回时才经 `Object.fromEntries` 转成对象:键是远端给的分支 id,`__proto__`
|
|
199
|
+
// 过得了 id 校验。对象字面量逐键赋值会命中 `Object.prototype.__proto__` 的 setter,
|
|
200
|
+
// 改写返回对象的原型而不是添一个键;`Object.fromEntries` 建的是自有数据属性,不走 setter。
|
|
201
|
+
const skippedThisRound = new Map<string, SyncBranchSkipReason>();
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 记一次跳过:本函数三处跳过点(id 不可用 / 分叉点翻译不出 / 祖先已跳过)共用同一套
|
|
205
|
+
* 记账。不分渠道是有意为之——不管一条分支这一轮为什么建不出来,它的后代都同样不能
|
|
206
|
+
* 创建,记账收敛成一个函数,下面对 `skippedThisRound` 的级联检查才能对三种成因
|
|
207
|
+
* 一次性生效,不用每加一种跳过原因就多写一遍级联判断。
|
|
208
|
+
*/
|
|
209
|
+
const recordSkip = (branchId: string, reason: SyncBranchSkipReason): void => {
|
|
210
|
+
skipped.push(branchId);
|
|
211
|
+
skippedThisRound.set(branchId, reason);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
for (const remote of sortBranchesParentFirst(remoteBranches, new Set(localMap.keys()))) {
|
|
215
|
+
// 子分支连带跳过必须最先判断:父本轮不会落库,子无论自身是否「合格」都不能创建——
|
|
216
|
+
// 创建了就是一条指向「本轮不存在的父」的悬空外键,真实落库会在 COMMIT 时被
|
|
217
|
+
// `PRAGMA defer_foreign_keys` 卡住,回滚整个事务(连同本轮所有本该成功的分支)。
|
|
218
|
+
// 父是因为 id 不可用被跳的、还是分叉点翻译不出被跳的、还是它自己的父被跳的,
|
|
219
|
+
// 这里都不关心——`skippedThisRound` 不区分渠道,只认「本轮是否已经决定跳过」。
|
|
220
|
+
const remoteParentId = remote.parentId ?? null;
|
|
221
|
+
if (remoteParentId !== null && skippedThisRound.has(remoteParentId)) {
|
|
222
|
+
recordSkip(remote.id, { cause: 'ancestor-skipped', ancestorId: remoteParentId });
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 远端分支行是外来数据,它的 id 没走过本地那条创建路径。不校验就直接落库,
|
|
227
|
+
// 一条叫 `*active*` 的远端分支会与 active 哨兵同形(`system/active-branch-guard.ts`)。
|
|
228
|
+
//
|
|
229
|
+
// 跳过而不是整批放弃:`skipped` 这条通道本来就是为「这一行本轮落不了库,别的行照常」
|
|
230
|
+
// 准备的。整批抛错会让一条坏的远端行把整个同步卡死,而本地这边一点办法都没有。
|
|
231
|
+
if (!isUsableBranchId(remote.id)) {
|
|
232
|
+
recordSkip(remote.id, { cause: 'invalid-id' });
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const local = localMap.get(remote.id);
|
|
237
|
+
if (local) {
|
|
238
|
+
if (!local.remote) {
|
|
239
|
+
await branchRepository.update(local, { remote: true, updatedAt: new Date() });
|
|
240
|
+
updated++;
|
|
241
|
+
}
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 远端 change id 必须翻译成本地 id 才能写进本地分支行
|
|
246
|
+
const remoteFromChangeId = remote.fromChangeId ?? null;
|
|
247
|
+
const fromChangeId =
|
|
248
|
+
remoteFromChangeId === null ? null : await toLocalFromChangeId(changeRepository, remoteFromChangeId);
|
|
249
|
+
if (remoteFromChangeId !== null && fromChangeId === null) {
|
|
250
|
+
recordSkip(remote.id, { cause: 'unresolved-from-change-id' });
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
await branchRepository.create({
|
|
255
|
+
id: remote.id,
|
|
256
|
+
activated: false,
|
|
257
|
+
// 显式写 NULL 而不是留空:两列必须同进同出(`system/branch.ts`)。
|
|
258
|
+
// 留空在两个后端上**眼下**也落成 NULL,但那是默认值的巧合,不是这一行的意图。
|
|
259
|
+
activeKey: null,
|
|
260
|
+
local: false,
|
|
261
|
+
remote: true,
|
|
262
|
+
fromChangeId,
|
|
263
|
+
parentId: remote.parentId ?? null
|
|
264
|
+
} as InstanceType<typeof RxDBBranch>);
|
|
265
|
+
created++;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
created,
|
|
270
|
+
updated,
|
|
271
|
+
total: remoteBranches.length,
|
|
272
|
+
skipped,
|
|
273
|
+
skipReasons: Object.fromEntries(skippedThisRound)
|
|
274
|
+
};
|
|
275
|
+
});
|
|
276
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ENTITY_REMOTE_CREATE_EVENT,
|
|
3
|
+
ENTITY_REMOTE_REMOVE_EVENT,
|
|
4
|
+
ENTITY_REMOTE_UPDATE_EVENT,
|
|
5
|
+
EntityRemoteCreatedEvent,
|
|
6
|
+
EntityRemoteRemovedEvent,
|
|
7
|
+
EntityRemoteUpdatedEvent,
|
|
8
|
+
type EntityType,
|
|
9
|
+
getEntityMetadata,
|
|
10
|
+
getSyncCapability,
|
|
11
|
+
getSyncType,
|
|
12
|
+
isSystemEntity,
|
|
13
|
+
type RepositoryIdentifier,
|
|
14
|
+
type SyncStateHub
|
|
15
|
+
} from '@aiao/rxdb';
|
|
16
|
+
import { isIgnorableDetachedVersionEventError } from '@aiao/rxdb-plugin-history';
|
|
17
|
+
import { combineLatest, merge, type Observable, Subscription } from 'rxjs';
|
|
18
|
+
import { distinctUntilChanged, exhaustMap, filter, map, withLatestFrom } from 'rxjs/operators';
|
|
19
|
+
import { countQueryCacheOutbox, flushQueryCacheOutbox } from './query-cache-outbox.js';
|
|
20
|
+
import type { SyncManager } from './SyncManager.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 远程实体事件类型集合(CREATE/UPDATE/REMOVE)
|
|
24
|
+
*/
|
|
25
|
+
type RemoteEntityEvent = EntityRemoteCreatedEvent | EntityRemoteUpdatedEvent | EntityRemoteRemovedEvent;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 跑一步回推动作,把失败报给面板后吞掉
|
|
29
|
+
*
|
|
30
|
+
* @param syncState - 失败的去处
|
|
31
|
+
* @param task - 这一步;返回值是这一步**自己**判定的成功与否(见 {@link flushRepository})
|
|
32
|
+
* @returns 这一步是否成功——异常通道与 `task` 自报的 `false` 都算失败
|
|
33
|
+
*
|
|
34
|
+
* @remarks
|
|
35
|
+
* 一轮里的各个仓库互不背书:一个仓库的出站队列重放不上,不代表别的仓库那条 REST 路
|
|
36
|
+
* 也走不通。任何一步的失败都只影响它自己,剩下的照跑。
|
|
37
|
+
*
|
|
38
|
+
* 吞错也是为了不掐断订阅 —— rxjs 里一个逃逸到流上的异常会终结整条链,此后再恢复
|
|
39
|
+
* 多少次网络都不会再有回推。真正的重试节奏交给
|
|
40
|
+
* {@link ReachabilityMonitor.wakeup$} 的退避。吞掉不等于藏起来:错误进
|
|
41
|
+
* {@link SyncStateHub.reportError},用户在面板上看得见。
|
|
42
|
+
*
|
|
43
|
+
* **只兜异常通道**:`flushQueryCacheOutbox` 对结构化失败(REST 403、网络错误)从不
|
|
44
|
+
* `reject`,失败躺在返回值的 `failures` 数组里(RV-052)。这里只负责「`task` 真的
|
|
45
|
+
* 抛了」这一种失败;`task` 自己上报 `syncState.reportError` 并返回 `false` 的那种
|
|
46
|
+
* 失败,原样作为返回值传上去,不重复上报。
|
|
47
|
+
*/
|
|
48
|
+
async function runQuietly(syncState: SyncStateHub, task: () => Promise<boolean>): Promise<boolean> {
|
|
49
|
+
try {
|
|
50
|
+
return await task();
|
|
51
|
+
} catch (error) {
|
|
52
|
+
syncState.reportError(error);
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 接入方声明的实体,即摘掉系统表之后的 `config.entities`
|
|
59
|
+
*
|
|
60
|
+
* @remarks
|
|
61
|
+
* 分派问的是「**用户的**数据该往哪走」,而 `config.entities` 里还混着
|
|
62
|
+
* {@link SchemaManager.init} 补进来的四张系统表。它们不带自己的 `sync`,于是跟随库级配置 ——
|
|
63
|
+
* 库级两端俱全时 {@link getSyncType} 会把它们判成 `full`,四张本地簿记表就这么被卷进
|
|
64
|
+
* 用户数据的分派里。系统表自己的同步由 {@link SyncManager} 直接安排,从不经过这里。
|
|
65
|
+
*/
|
|
66
|
+
function consumerEntities(sm: SyncManager): EntityType[] {
|
|
67
|
+
return sm.rxdb.config.entities.filter(EntityClass => !isSystemEntity(EntityClass));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 枚举需要走 REST 重放的仓库
|
|
72
|
+
*
|
|
73
|
+
* @remarks
|
|
74
|
+
* 判据是 `offlineWrite && !push`,也就是「能在离线时接受本地写,但没有 changelog 端点」——
|
|
75
|
+
* 现阶段只有 `querycache`。带 `push` 能力的仓库不在这里:它们由用户显式调
|
|
76
|
+
* {@link SyncManager.push},那条路自己按能力矩阵筛。
|
|
77
|
+
*
|
|
78
|
+
* 不在这里查 `RxDBSync.enabled`:那要为每个仓库读一次库,而
|
|
79
|
+
* {@link flushQueryCacheOutbox} 入口本来就会判并返回 `skipped`。判两遍等于把同一个口径
|
|
80
|
+
* 抄成两份。
|
|
81
|
+
*/
|
|
82
|
+
function queryCacheRepositories(sm: SyncManager): RepositoryIdentifier[] {
|
|
83
|
+
const repositories: RepositoryIdentifier[] = [];
|
|
84
|
+
|
|
85
|
+
for (const EntityClass of consumerEntities(sm)) {
|
|
86
|
+
const metadata = getEntityMetadata(EntityClass);
|
|
87
|
+
const capability = getSyncCapability(getSyncType(metadata, sm.rxdb.entitySync));
|
|
88
|
+
if (capability.offlineWrite && !capability.push) {
|
|
89
|
+
repositories.push({ namespace: metadata.namespace, entity: metadata.name });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return repositories;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 重放一个 QueryCache 仓库,把判负的实体和结构化失败都报给面板
|
|
98
|
+
*
|
|
99
|
+
* @returns 本仓库这一轮是否**真的**成功——`failures` 非空也算失败,即便没有任何异常抛出
|
|
100
|
+
*
|
|
101
|
+
* @remarks
|
|
102
|
+
* `conflicts`(解析器判 `KEEP_REMOTE`)进面板是用户离线时写的东西被远端盖掉了,
|
|
103
|
+
* 是需要他知道的一类;逐条上报而不是只报最后一条,`lastConflict` 自然停在最新的那条。
|
|
104
|
+
*
|
|
105
|
+
* **`failures` 同样必须上报(RV-052)**:`flushQueryCacheOutbox` 对 REST 403、网络错误
|
|
106
|
+
* 等结构化失败从不 `reject`——Promise 照常 resolve,失败躺在返回值的 `failures` 数组里
|
|
107
|
+
* (见 `query-cache-outbox.ts` 的 `replayEntry`/`runOutboxFlush`)。调用方如果只认「有没有
|
|
108
|
+
* 抛异常」,这类失败会被判成「没出错」,面板上 `lastError` 被清空、`reportSuccess()`
|
|
109
|
+
* 被调用,而出站队列其实原地没动。这里逐条把失败的原因上报给 `syncState`——
|
|
110
|
+
* 和 `conflicts` 同样的理由,`lastError` 自然停在本轮最新的那条——并把「有没有
|
|
111
|
+
* 失败」原样交还给调用方,参与整轮 {@link flushRepositories} 的成功聚合。
|
|
112
|
+
*/
|
|
113
|
+
async function flushRepository(sm: SyncManager, namespace: string, entity: string): Promise<boolean> {
|
|
114
|
+
const { syncState } = sm.rxdb;
|
|
115
|
+
const result = await flushQueryCacheOutbox(sm.rxdb, namespace, entity);
|
|
116
|
+
|
|
117
|
+
for (const entityId of result.conflicts) {
|
|
118
|
+
syncState.reportConflict({ namespace, entity, entityId, winner: 'remote' });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const failure of result.failures) {
|
|
122
|
+
syncState.reportError(failure.error);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return result.failures.length === 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 重放本轮所有 QueryCache 仓库
|
|
130
|
+
*
|
|
131
|
+
* @param sm - 当前 SyncManager
|
|
132
|
+
* @param repositories - 本轮的仓库名单,由 {@link queryCacheRepositories} 枚举
|
|
133
|
+
* @returns 是否每个仓库都成功
|
|
134
|
+
*/
|
|
135
|
+
async function flushRepositories(sm: SyncManager, repositories: RepositoryIdentifier[]): Promise<boolean> {
|
|
136
|
+
const { syncState } = sm.rxdb;
|
|
137
|
+
let allSucceeded = true;
|
|
138
|
+
|
|
139
|
+
for (const { namespace, entity } of repositories) {
|
|
140
|
+
const succeeded = await runQuietly(syncState, () => flushRepository(sm, namespace, entity));
|
|
141
|
+
allSucceeded = succeeded && allSucceeded;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return allSucceeded;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 重新数一遍 QueryCache 的出站积压,刷新面板
|
|
149
|
+
*
|
|
150
|
+
* @remarks
|
|
151
|
+
* 失败**不**走 {@link runQuietly}:这是一次读,读不到只意味着面板停在上一个数字,
|
|
152
|
+
* 而 `lastError` 说的是「你的数据没推上去」。把一次 COUNT 失败写进去会让用户
|
|
153
|
+
* 以为刚推成功的一轮出了问题。
|
|
154
|
+
*
|
|
155
|
+
* 每轮都数而不是只在推过之后数:水位线推进不写 `rxdb_change`,实时查询看不见这个数
|
|
156
|
+
* (见 {@link SyncStateHub.reportOutboxCount}),一轮一次重算是它唯一的纠偏时机。
|
|
157
|
+
*/
|
|
158
|
+
async function refreshOutboxCount(sm: SyncManager): Promise<void> {
|
|
159
|
+
try {
|
|
160
|
+
sm.rxdb.syncState.reportOutboxCount(await countQueryCacheOutbox(sm.rxdb));
|
|
161
|
+
} catch {
|
|
162
|
+
// 面板保留上一个数字
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 恢复联网后的一轮回推:逐个重放 QueryCache 仓库的出站队列
|
|
168
|
+
*
|
|
169
|
+
* @remarks
|
|
170
|
+
* 这一轮**只**管 QueryCache 那条 REST 路([US-020 D5-R](../../../../requirements/stories/core/US-020-querycache-repository.md))。
|
|
171
|
+
* changelog 那半边 —— {@link SyncManager.syncBranches} 与 {@link SyncManager.push} ——
|
|
172
|
+
* 不在这里自动跑:`push` 问的是「把我这条分支上攒的提交送到远端去」,和 git 的 `push`
|
|
173
|
+
* 一样是用户的决定。自动替他按下去,`pushableCount` 与界面上的 Push 按钮就成了摆设,
|
|
174
|
+
* 「写在本地、还没推」这个状态从此不存在。QueryCache 没有这层分支语义,它的出站队列
|
|
175
|
+
* 就是「这次写本该直接进远端,只是当时网断了」,联网补上才是它的正确行为。
|
|
176
|
+
*
|
|
177
|
+
* 各仓库串行而非并行 —— 它们打的是同一个远端,恢复瞬间并发只会把刚恢复的连接再压垮一次。
|
|
178
|
+
*
|
|
179
|
+
* 一个 QueryCache 仓库都没有时整轮不进:既省掉必然为空的一次 COUNT,也不让面板的
|
|
180
|
+
* `syncing` 为了一轮什么都不做的回推闪一下。
|
|
181
|
+
*
|
|
182
|
+
* `endRound` 放 `finally`:每一步都被 {@link runQuietly} 兜住了,但重算积压那步之外
|
|
183
|
+
* 若将来再加一步没兜住的,`syncing` 会永久卡在真上,面板从此显示「正在同步」。
|
|
184
|
+
*/
|
|
185
|
+
async function resumeSync(sm: SyncManager): Promise<void> {
|
|
186
|
+
const repositories = queryCacheRepositories(sm);
|
|
187
|
+
if (repositories.length === 0) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const { syncState } = sm.rxdb;
|
|
192
|
+
syncState.beginRound();
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
// 清账放在重算积压之前:先宣布本轮没出错,再把「还剩多少」更新上去
|
|
196
|
+
if (await flushRepositories(sm, repositories)) {
|
|
197
|
+
syncState.reportSuccess();
|
|
198
|
+
}
|
|
199
|
+
await refreshOutboxCount(sm);
|
|
200
|
+
} finally {
|
|
201
|
+
syncState.endRound();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 构造回推触发流
|
|
207
|
+
*
|
|
208
|
+
* @remarks
|
|
209
|
+
* 两个触发源,语义不同:
|
|
210
|
+
*
|
|
211
|
+
* - **可回推了**:`connected$`(适配器生命周期)与 `online$`(网络可达)**同时**为真。
|
|
212
|
+
* 两者缺一不可,也不能互相替代 —— HTTP 适配器的 `connect()` 不发任何网络请求,
|
|
213
|
+
* 断网时它照样报 connected;反过来网通了但适配器没连上,回推没有可用的本地仓储。
|
|
214
|
+
* - **再试一次**:离线期间的退避节拍。它只按 `connected$` 过滤,**不**按 `online$` ——
|
|
215
|
+
* 节拍存在的意义正是在判定为离线时驱动重试,用离线状态把它挡掉会让重试链彻底断掉。
|
|
216
|
+
*/
|
|
217
|
+
function createResumeTrigger(sm: SyncManager): Observable<unknown> {
|
|
218
|
+
const connected$ = sm.rxdb.connected$;
|
|
219
|
+
const ready$ = combineLatest([connected$, sm.rxdb.reachability.online$]).pipe(
|
|
220
|
+
map(([connected, online]) => connected && online),
|
|
221
|
+
distinctUntilChanged()
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
return merge(
|
|
225
|
+
ready$.pipe(filter(ready => ready)),
|
|
226
|
+
sm.rxdb.reachability.wakeup$.pipe(
|
|
227
|
+
withLatestFrom(connected$),
|
|
228
|
+
filter(([, connected]) => connected)
|
|
229
|
+
)
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 设置 SyncManager 的同步监听器
|
|
235
|
+
*
|
|
236
|
+
* 包含两条 reactive 链路:
|
|
237
|
+
* 1. 「已连接且网络可达」→ 自动重放 QueryCache 出站队列,离线期间由退避节拍驱动重试,
|
|
238
|
+
* 每个仓库失败仅吞错。changelog 的 `push` 不在其中,见 {@link resumeSync}
|
|
239
|
+
* 2. Remote 实体事件 → 累计 `pullableCount`(仅统计当前激活分支)
|
|
240
|
+
*
|
|
241
|
+
* 调用方负责管理返回的 subscriptions / removers 生命周期(destroy 时清理)。
|
|
242
|
+
*
|
|
243
|
+
* @param sm - 当前同步管理器
|
|
244
|
+
* @returns 已注册的 rxjs subscriptions 和事件解绑闭包
|
|
245
|
+
*/
|
|
246
|
+
export function setupSyncListeners(sm: SyncManager): {
|
|
247
|
+
subscriptions: Subscription[];
|
|
248
|
+
removers: Array<() => void>;
|
|
249
|
+
} {
|
|
250
|
+
const subscriptions: Subscription[] = [];
|
|
251
|
+
const removers: Array<() => void> = [];
|
|
252
|
+
|
|
253
|
+
// 1. 有远程适配器时,恢复联网后自动回推
|
|
254
|
+
const remoteAdapterName = sm.rxdb.config.sync?.remote?.adapter;
|
|
255
|
+
if (remoteAdapterName) {
|
|
256
|
+
// `exhaustMap` 而不是 `mergeMap`:恢复瞬间往往连着来好几个信号(`online` 事件、
|
|
257
|
+
// 退避节拍、用户手动重试),并发起两轮回推会把同一批变更重放两次 ——
|
|
258
|
+
// 第二轮读到的还是第一轮尚未推进的水位线。
|
|
259
|
+
const sub = createResumeTrigger(sm)
|
|
260
|
+
.pipe(
|
|
261
|
+
exhaustMap(() =>
|
|
262
|
+
// 一轮回推失败只终结这一轮。`resumeSync` 里 `runQuietly` 兜住的只有逐仓库 flush,
|
|
263
|
+
// 枚举仓库与面板上报都在兜底之外;它们抛错顺着 `exhaustMap` 冒到订阅上就会终结整条
|
|
264
|
+
// 触发流 —— 此后 online 事件、退避节拍、重新连接一律无效,自动回推永久停摆,
|
|
265
|
+
// 而离线恢复本就是最容易出错的场景。吞掉不等于藏起来:错误进面板,用户看得见。
|
|
266
|
+
resumeSync(sm).catch((error: unknown) => {
|
|
267
|
+
sm.rxdb.syncState.reportError(error);
|
|
268
|
+
})
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
.subscribe();
|
|
272
|
+
subscriptions.push(sub);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 2. 只累计当前激活分支的远程变更数量
|
|
276
|
+
const filterByBranch = async (entities: { branchId?: string }[]): Promise<number> => {
|
|
277
|
+
const branch = await sm.getCurrentBranch();
|
|
278
|
+
return entities.filter(e => !e.branchId || e.branchId === branch.id).length;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const makeRemoteHandler = (label: string) => {
|
|
282
|
+
return (event: RemoteEntityEvent) => {
|
|
283
|
+
void (async () => {
|
|
284
|
+
try {
|
|
285
|
+
const count = await filterByBranch(event.entities);
|
|
286
|
+
if (count > 0) sm.history.incrementPullableCount(count);
|
|
287
|
+
} catch (error) {
|
|
288
|
+
if (!isIgnorableDetachedVersionEventError(error)) {
|
|
289
|
+
console.error(`[SyncManager] ${label} failed:`, error);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
})();
|
|
293
|
+
};
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const remoteHandlers = [
|
|
297
|
+
{ type: ENTITY_REMOTE_CREATE_EVENT, handler: makeRemoteHandler('onRemoteCreate') },
|
|
298
|
+
{ type: ENTITY_REMOTE_UPDATE_EVENT, handler: makeRemoteHandler('onRemoteUpdate') },
|
|
299
|
+
{ type: ENTITY_REMOTE_REMOVE_EVENT, handler: makeRemoteHandler('onRemoteRemove') }
|
|
300
|
+
] as const;
|
|
301
|
+
|
|
302
|
+
for (const { type, handler } of remoteHandlers) {
|
|
303
|
+
sm.rxdb.addEventListener(type, handler);
|
|
304
|
+
// 在注册点闭包捕获已静态对齐的 type/handler,解绑时无需类型断言
|
|
305
|
+
removers.push(() => sm.rxdb.removeEventListener(type, handler));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return { subscriptions, removers };
|
|
309
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview 同步管理器的纯函数工具集
|
|
3
|
+
*
|
|
4
|
+
* 从 {@link SyncManager} 抽出的无状态辅助函数。US-025 阶段 D 之前它们住在
|
|
5
|
+
* `@aiao/rxdb-plugin-history` 的 `version-manager.utils.ts`,随 `bulkSync` 一起搬过来 ——
|
|
6
|
+
* 两个函数的形参都是 {@link SyncRepositoryResult},这是同步侧的类型。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { RxDBPartialSyncError } from '@aiao/rxdb';
|
|
10
|
+
import type { SyncRepositoryResult } from './sync-repository.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 「这次同步已经改写了本地数据」的共同判据形状。
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* 四个粒度的结果都满足它,所以判据只需要写一份:整库 / 单仓库的 `PullResult` 只有
|
|
17
|
+
* `historyInvalidated`,`SyncRepositoryResult` 还多一段 `pushResult`。两项都取可选,
|
|
18
|
+
* 缺哪一段就当那一段没有进度。
|
|
19
|
+
*/
|
|
20
|
+
interface SyncedProgress {
|
|
21
|
+
readonly historyInvalidated?: boolean;
|
|
22
|
+
readonly pushResult?: { readonly pushed: number };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 一次仓库同步是否改写了本地实体数据(undo 历史边界因此失效) */
|
|
26
|
+
export const hasSyncedData = (result: SyncedProgress | undefined): boolean =>
|
|
27
|
+
result?.historyInvalidated === true || (result?.pushResult?.pushed ?? 0) > 0;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 中断的同步里是否带着「已经落库 / 已经上行」的进度。
|
|
31
|
+
*
|
|
32
|
+
* @remarks
|
|
33
|
+
* 部分成功以 {@link RxDBPartialSyncError} 的形式抛出,进度只挂在它的 `result` 上。
|
|
34
|
+
* `pull` / `pullRepository` / `sync` / `syncRepository` 四个入口共用这一条判据 ——
|
|
35
|
+
* 各写各的 `instanceof` + 强转,迟早会有一个入口漏掉 `pushResult` 那一半。
|
|
36
|
+
*/
|
|
37
|
+
export const partialSyncInvalidatesHistory = (error: unknown): boolean =>
|
|
38
|
+
error instanceof RxDBPartialSyncError && hasSyncedData(error.result as SyncedProgress);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 取出失败项里携带的部分进度。
|
|
42
|
+
*
|
|
43
|
+
* @remarks
|
|
44
|
+
* 仓库在失败前可能已经提交了部分结果,它只存在于 {@link RxDBPartialSyncError.result}。
|
|
45
|
+
* 忽略它会让「远端数据已落库但 undo 边界没推进」的状态逃过检查。
|
|
46
|
+
*/
|
|
47
|
+
export const partialResultOf = (error: Error | undefined): SyncRepositoryResult | undefined =>
|
|
48
|
+
error instanceof RxDBPartialSyncError ? (error.result as SyncRepositoryResult) : undefined;
|