@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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -2
  3. package/dist/SyncManager.d.ts +426 -0
  4. package/dist/SyncManager.d.ts.map +1 -0
  5. package/dist/branch-change-id.d.ts +31 -0
  6. package/dist/branch-change-id.d.ts.map +1 -0
  7. package/dist/branch-materialization-source.d.ts +22 -0
  8. package/dist/branch-materialization-source.d.ts.map +1 -0
  9. package/dist/branch-utils.d.ts +40 -0
  10. package/dist/branch-utils.d.ts.map +1 -0
  11. package/dist/bulk-sync.d.ts +117 -0
  12. package/dist/bulk-sync.d.ts.map +1 -0
  13. package/dist/cascade-blocking.d.ts +38 -0
  14. package/dist/cascade-blocking.d.ts.map +1 -0
  15. package/dist/check-repository-updates.d.ts +47 -0
  16. package/dist/check-repository-updates.d.ts.map +1 -0
  17. package/dist/cleanup-expired.d.ts +56 -0
  18. package/dist/cleanup-expired.d.ts.map +1 -0
  19. package/dist/dependency-graph.d.ts +128 -0
  20. package/dist/dependency-graph.d.ts.map +1 -0
  21. package/dist/get-all-repository-sync-status.d.ts +58 -0
  22. package/dist/get-all-repository-sync-status.d.ts.map +1 -0
  23. package/dist/get-repository-sync-status.d.ts +68 -0
  24. package/dist/get-repository-sync-status.d.ts.map +1 -0
  25. package/dist/index.d.ts +39 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +3773 -0
  28. package/dist/plugin.d.ts +74 -0
  29. package/dist/plugin.d.ts.map +1 -0
  30. package/dist/pull-ancestor-changes.d.ts +41 -0
  31. package/dist/pull-ancestor-changes.d.ts.map +1 -0
  32. package/dist/pull-batch.d.ts +16 -0
  33. package/dist/pull-batch.d.ts.map +1 -0
  34. package/dist/pull-conflict-utils.d.ts +43 -0
  35. package/dist/pull-conflict-utils.d.ts.map +1 -0
  36. package/dist/pull-repository.d.ts +55 -0
  37. package/dist/pull-repository.d.ts.map +1 -0
  38. package/dist/pull-round.d.ts +41 -0
  39. package/dist/pull-round.d.ts.map +1 -0
  40. package/dist/pull.d.ts +16 -0
  41. package/dist/pull.d.ts.map +1 -0
  42. package/dist/push-branch.d.ts +22 -0
  43. package/dist/push-branch.d.ts.map +1 -0
  44. package/dist/push-repository.d.ts +79 -0
  45. package/dist/push-repository.d.ts.map +1 -0
  46. package/dist/push.d.ts +14 -0
  47. package/dist/push.d.ts.map +1 -0
  48. package/dist/query-cache-outbox.d.ts +120 -0
  49. package/dist/query-cache-outbox.d.ts.map +1 -0
  50. package/dist/sync-branches.d.ts +81 -0
  51. package/dist/sync-branches.d.ts.map +1 -0
  52. package/dist/sync-listeners.d.ts +20 -0
  53. package/dist/sync-listeners.d.ts.map +1 -0
  54. package/dist/sync-manager.utils.d.ts +36 -0
  55. package/dist/sync-manager.utils.d.ts.map +1 -0
  56. package/dist/sync-repository.d.ts +64 -0
  57. package/dist/sync-repository.d.ts.map +1 -0
  58. package/dist/topological-sort.d.ts +126 -0
  59. package/dist/topological-sort.d.ts.map +1 -0
  60. package/package.json +60 -3
  61. package/src/SyncManager.ts +690 -0
  62. package/src/branch-change-id.ts +70 -0
  63. package/src/branch-materialization-source.ts +445 -0
  64. package/src/branch-utils.ts +74 -0
  65. package/src/bulk-sync.ts +349 -0
  66. package/src/cascade-blocking.ts +61 -0
  67. package/src/check-repository-updates.ts +143 -0
  68. package/src/cleanup-expired.ts +285 -0
  69. package/src/dependency-graph.ts +324 -0
  70. package/src/get-all-repository-sync-status.ts +115 -0
  71. package/src/get-repository-sync-status.ts +237 -0
  72. package/src/index.ts +58 -0
  73. package/src/plugin.ts +132 -0
  74. package/src/pull-ancestor-changes.ts +61 -0
  75. package/src/pull-batch.ts +386 -0
  76. package/src/pull-conflict-utils.ts +284 -0
  77. package/src/pull-repository.ts +700 -0
  78. package/src/pull-round.ts +103 -0
  79. package/src/pull.ts +204 -0
  80. package/src/push-branch.ts +62 -0
  81. package/src/push-repository.ts +1541 -0
  82. package/src/push.ts +106 -0
  83. package/src/query-cache-outbox.ts +939 -0
  84. package/src/sync-branches.ts +276 -0
  85. package/src/sync-listeners.ts +309 -0
  86. package/src/sync-manager.utils.ts +48 -0
  87. package/src/sync-repository.ts +307 -0
  88. package/src/topological-sort.ts +246 -0
@@ -0,0 +1,386 @@
1
+ /**
2
+ * @fileoverview 批量拉取实现
3
+ *
4
+ * 将多个实体的 pull 操作合并为单次 HTTP 请求,
5
+ * 替代原来的 bulkSync 逐实体拉取方式。
6
+ *
7
+ * 核心优化:
8
+ * - 一次请求获取所有实体变更(通过 OR 过滤不同实体的水位线)
9
+ * - 按依赖拓扑顺序处理结果(父 → 子)
10
+ * - 支持祖先分支数据拉取
11
+ */
12
+
13
+ import {
14
+ compactChanges,
15
+ type ConflictResolver,
16
+ declareTrustedWrite,
17
+ getEntityMetadata,
18
+ getOrCreateSyncRecord,
19
+ getSyncCapability,
20
+ getSyncType,
21
+ isRepositorySyncEnabled,
22
+ LWWConflictResolver,
23
+ type PullBatchRequest,
24
+ type PullResult,
25
+ type RemoteChange,
26
+ RxDBChange,
27
+ RxDBError,
28
+ type RxDBEvent,
29
+ RxDBPartialSyncError,
30
+ RxDBSync,
31
+ TrustedWriteIntent
32
+ } from '@aiao/rxdb';
33
+ import type { SyncManager } from './SyncManager.js';
34
+ import { getAncestorBranchIds } from './branch-utils.js';
35
+ import { buildDependencyGraph } from './dependency-graph.js';
36
+ import { pullAncestorBranchChanges } from './pull-ancestor-changes.js';
37
+ import {
38
+ countActions,
39
+ createConflictActionEntries,
40
+ createEmptyActions,
41
+ queryPendingLocalChanges,
42
+ resolveConflictsAndBuildActions
43
+ } from './pull-conflict-utils.js';
44
+ import { backfillOwnChangeRemoteIds, splitRemoteChangesByOrigin } from './pull-round.js';
45
+ import { topologicalSortForPull } from './topological-sort.js';
46
+
47
+ interface RepoSyncInfo {
48
+ namespace: string;
49
+ entity: string;
50
+ syncId: string;
51
+ repoSync: RxDBSync;
52
+ lastPullRemoteChangeId: number;
53
+ syncType: string;
54
+ }
55
+
56
+ /**
57
+ * 单轮拉取的最大轮数,防止远端水位线不推进时无限循环。
58
+ *
59
+ * 正常情况下每轮都会把 `lastPullRemoteChangeId` 推到本轮最大 id,循环必然收敛;
60
+ * 这个上限只在远端行为异常(返回的变更 id 不推进)时兜底。
61
+ */
62
+ const MAX_FETCH_ALL_ROUNDS = 1000;
63
+
64
+ /**
65
+ * 批量拉取所有实体的变更。
66
+ *
67
+ * @param sm - SyncManager 实例
68
+ * @param options.limit - 每个实体单轮的最大变更数,默认 1000
69
+ * @param options.fetchAll - 为真时循环拉取直到远端排空;默认只拉一轮并用 `hasMore` 告知还有剩余
70
+ * @returns 各轮累加后的拉取结果
71
+ */
72
+ export async function pullBatch(
73
+ sm: SyncManager,
74
+ options?: { limit?: number; fetchAll?: boolean; conflictResolver?: ConflictResolver }
75
+ ): Promise<PullResult> {
76
+ if (options?.fetchAll !== true) return await pullBatchOnce(sm, options?.limit, options?.conflictResolver);
77
+
78
+ const total: PullResult = {
79
+ pulled: 0,
80
+ compacted: 0,
81
+ applied: 0,
82
+ hasMore: false,
83
+ conflictsResolved: 0,
84
+ conflictsDeferred: 0,
85
+ persistedProgress: false,
86
+ historyInvalidated: false,
87
+ failures: []
88
+ };
89
+
90
+ const accumulate = (result: PullResult): void => {
91
+ total.pulled += result.pulled;
92
+ total.compacted += result.compacted;
93
+ total.applied += result.applied;
94
+ total.conflictsResolved += result.conflictsResolved;
95
+ total.conflictsDeferred += result.conflictsDeferred;
96
+ total.hasMore = result.hasMore;
97
+ total.persistedProgress ||= result.persistedProgress;
98
+ total.historyInvalidated ||= result.historyInvalidated;
99
+ total.failures.push(...result.failures);
100
+ };
101
+
102
+ for (let round = 0; round < MAX_FETCH_ALL_ROUNDS; round++) {
103
+ let result: PullResult;
104
+ try {
105
+ result = await pullBatchOnce(sm, options.limit, options.conflictResolver);
106
+ } catch (error) {
107
+ // 前几轮已经落库且水位线已推进,裸抛原始错误会让调用方以为什么都没发生。
108
+ // 把已完成部分的统计连同原始错误一起交出去;本轮内部的部分进度由
109
+ // pullBatchOnce 自己包成 RxDBPartialSyncError 带上来。
110
+ throw toPartialSyncError(error, total);
111
+ }
112
+ accumulate(result);
113
+
114
+ if (!result.hasMore) break;
115
+ // 一轮报了 hasMore 却一条都没拉到,说明水位线没推进,再循环也是同一批:
116
+ // 就此收手并如实保留 hasMore,避免空转到轮数上限
117
+ if (result.pulled === 0) break;
118
+ }
119
+
120
+ return total;
121
+ }
122
+
123
+ /**
124
+ * 判定「没有任何持久化进度」。
125
+ *
126
+ * 直接读 `persistedProgress` —— 计数不足以判定:`applied === 0` 的轮次也可能已经
127
+ * 回填了 `remoteId`、推进了水位线;`pulled > 0` 又可能是取回来但一条没写成。
128
+ */
129
+ const isEmptyProgress = (result: PullResult): boolean => !result.persistedProgress;
130
+
131
+ /**
132
+ * 把中断错误包装成带部分结果的错误。
133
+ *
134
+ * 已有进度才包装 —— 一条都没应用时包装只会让调用方多剥一层,原始错误更有用。
135
+ *
136
+ * 这里不解嵌套的 `RxDBPartialSyncError`:`pullBatchOnce` 把整批的应用与水位线推进
137
+ * 放在**同一个**事务里,抛出即回滚,本轮不可能留下部分进度,也就没有内层进度可并。
138
+ * (曾经有一段「合并内层结果」的分支,靠 `error.result as PullResult` 强转——
139
+ * 本仓任何路径都到不了,真来了外部形状反而算出 NaN,已删。)
140
+ */
141
+ function toPartialSyncError(error: unknown, accumulated: PullResult): unknown {
142
+ const normalized = error instanceof Error ? error : new Error(String(error));
143
+ return isEmptyProgress(accumulated) ? normalized : new RxDBPartialSyncError<PullResult>(accumulated, normalized);
144
+ }
145
+
146
+ /**
147
+ * 批量拉取所有实体的变更(单次 HTTP 请求)
148
+ */
149
+ async function pullBatchOnce(
150
+ sm: SyncManager,
151
+ limitOption?: number,
152
+ conflictResolverOption?: ConflictResolver
153
+ ): Promise<PullResult> {
154
+ const rxdb = sm.rxdb;
155
+ const limit = limitOption ?? 1000;
156
+
157
+ const remoteAdapterName = rxdb.config.sync?.remote?.adapter;
158
+ if (!remoteAdapterName) {
159
+ throw new RxDBError('Remote adapter not configured.');
160
+ }
161
+
162
+ const { adapter: remoteAdapter } = await sm.getRemoteRepositories();
163
+ const { adapter: localAdapter } = await sm.getLocalRepositories();
164
+ const branch = await sm.getCurrentBranch();
165
+ const branchId = branch.id;
166
+
167
+ // 获取祖先分支列表(包含自身)
168
+ const branchIds = await getAncestorBranchIds(sm, branchId);
169
+
170
+ // 1. 收集所有可同步实体的水位线
171
+ const repoSyncRepo = localAdapter.getRepository(RxDBSync);
172
+ const allRepos: RepoSyncInfo[] = [];
173
+
174
+ for (const EntityClass of rxdb.config.entities) {
175
+ const metadata = getEntityMetadata(EntityClass);
176
+ const syncType = getSyncType(metadata, rxdb.entitySync);
177
+
178
+ // 跳过口径从内联的 `none | local` 换成能力矩阵,与单仓/级联路径同源
179
+ if (!getSyncCapability(syncType).pull) continue;
180
+
181
+ // filter 同步类型需要逐仓库处理,不参与批量
182
+ if (syncType === 'filter') continue;
183
+
184
+ const repoSync = await getOrCreateSyncRecord(
185
+ repoSyncRepo,
186
+ {
187
+ namespace: metadata.namespace,
188
+ entity: metadata.name,
189
+ branchId,
190
+ syncType
191
+ },
192
+ () => rxdb.entityManager.instantiate(RxDBSync)
193
+ );
194
+
195
+ // 批量是枚举路径 —— 被关掉的仓库跳过而不是让整批失败
196
+ if (!isRepositorySyncEnabled(repoSync)) continue;
197
+
198
+ allRepos.push({
199
+ namespace: metadata.namespace,
200
+ entity: metadata.name,
201
+ syncId: repoSync.id,
202
+ repoSync,
203
+ lastPullRemoteChangeId: repoSync.lastPullRemoteChangeId ?? 0,
204
+ syncType
205
+ });
206
+ }
207
+
208
+ if (allRepos.length === 0) {
209
+ return {
210
+ pulled: 0,
211
+ compacted: 0,
212
+ applied: 0,
213
+ hasMore: false,
214
+ conflictsResolved: 0,
215
+ conflictsDeferred: 0,
216
+ persistedProgress: false,
217
+ historyInvalidated: false,
218
+ failures: []
219
+ };
220
+ }
221
+
222
+ // 2. 构建依赖图获取拓扑排序(父→子)
223
+ const entityMetadatas = rxdb.config.entities.map(e => getEntityMetadata(e));
224
+ const graph = buildDependencyGraph(entityMetadatas);
225
+ const sortedRepos = topologicalSortForPull(graph);
226
+
227
+ // 按拓扑顺序排列 allRepos
228
+ const repoMap = new Map(allRepos.map(r => [`${r.namespace}:${r.entity}`, r]));
229
+ const orderedRepos = sortedRepos
230
+ .map(sr => repoMap.get(`${sr.namespace}:${sr.entity}`))
231
+ .filter((r): r is RepoSyncInfo => r != null);
232
+
233
+ // 3. 单次请求获取所有变更
234
+ const totalLimit = limit * orderedRepos.length;
235
+ let allRemoteChanges: RemoteChange[];
236
+
237
+ const batchRequests: PullBatchRequest[] = orderedRepos.map(r => ({
238
+ namespace: r.namespace,
239
+ entity: r.entity,
240
+ sinceId: r.lastPullRemoteChangeId
241
+ }));
242
+
243
+ if (remoteAdapter.pullChangesBatch) {
244
+ allRemoteChanges = await remoteAdapter.pullChangesBatch(batchRequests, totalLimit, branchIds);
245
+ } else {
246
+ // 降级方案:逐实体拉取,避免 MIN(sinceId) 导致水位线差异时的巨量冗余数据。
247
+ // 「逐祖先分支拉 + 按仓库全局排序后截断」的口径与单仓路径共用同一份实现,
248
+ // 见 pullAncestorBranchChanges 的 @remarks
249
+ const perRepo = await Promise.all(
250
+ orderedRepos.map(repo =>
251
+ pullAncestorBranchChanges(
252
+ remoteAdapter,
253
+ { namespace: repo.namespace, entity: repo.entity, sinceId: repo.lastPullRemoteChangeId, limit },
254
+ branchIds
255
+ )
256
+ )
257
+ );
258
+ allRemoteChanges = perRepo.flat();
259
+ }
260
+
261
+ // 4. 按实体分组
262
+ const changesByEntity = new Map<string, RemoteChange[]>();
263
+ for (const change of allRemoteChanges) {
264
+ const key = `${change.namespace}:${change.entity}`;
265
+ if (!changesByEntity.has(key)) changesByEntity.set(key, []);
266
+ changesByEntity.get(key)!.push(change);
267
+ }
268
+
269
+ // 5. 按拓扑顺序处理每个实体的变更
270
+ let totalPulled = 0;
271
+ let totalCompacted = 0;
272
+ let totalApplied = 0;
273
+ let totalConflictsResolved = 0;
274
+ // 恒 0:MERGE / DEFER 在 resolveConflictsAndBuildActions 里整轮抛错回滚,
275
+ // 走不到累加路径(见 conflict.ts 对运行时可应用范围的说明)。
276
+ // 保留这个字段是为了与 pullWithBulkSync 路径的返回结构对齐。
277
+ const totalConflictsDeferred = 0;
278
+ let hasMore = false;
279
+ const clientId = rxdb.context.clientId;
280
+ // 调用方给了就用调用方的:此前这里写死 LWW,同一个自定义策略走 pullRepository 生效、
281
+ // 走 pull() 默认的批量路径静默失效,两条路径对同一份冲突给出不同结果。
282
+ const conflictResolver = conflictResolverOption === undefined ? new LWWConflictResolver() : conflictResolverOption;
283
+
284
+ // 整批仓库的应用与水位线推进必须在**一个**事务里。
285
+ //
286
+ // 否则第 k 个仓库失败时,前 k-1 个的数据已落库、`RxDBSync` 水位线也已前移:
287
+ // 重试会从新水位线继续,中间那段变更既不重放也不告警,静默消失。
288
+ // 事务内的读写一律经 executor —— 持有它才算在本事务内(C2)。
289
+ //
290
+ // 注意:远端拉取(pullChangesBatch / pullChanges)在事务之前就已完成,
291
+ // 事务里只有本地工作,不会把网络往返圈进事务窗口。
292
+ await localAdapter.transaction(async executor => {
293
+ // 仓库必须在事务体**内**经 executor 获取。事务外取到的句柄绑定在适配器上,
294
+ // 其读写会重新排队并排在自己这个事务后面(队列并发度 1)—— 翻转后即永久挂起。
295
+ const changeRepo = executor.getRepository(RxDBChange);
296
+ const txRepoSyncRepo = executor.getRepository(RxDBSync);
297
+
298
+ for (const repo of orderedRepos) {
299
+ const entityChanges = changesByEntity.get(`${repo.namespace}:${repo.entity}`) ?? [];
300
+
301
+ // 过滤已处理的变更(按实体水位线过滤)
302
+ const filteredChanges = entityChanges.filter(c => c.id > repo.lastPullRemoteChangeId);
303
+
304
+ if (filteredChanges.length === 0) continue;
305
+
306
+ totalPulled += filteredChanges.length;
307
+
308
+ // 分离自己推送的变更和他人的变更,再把自推变更的 remoteId 回填到本地行上。
309
+ // 两步与 pull-repository.ts 共用同一份实现(见 pull-round.ts 的 @packageDocumentation)。
310
+ const { ownChanges, otherChanges } = splitRemoteChangesByOrigin(filteredChanges, clientId);
311
+ await backfillOwnChangeRemoteIds(changeRepo, ownChanges);
312
+
313
+ // 压缩并应用他人的变更
314
+ if (otherChanges.length > 0) {
315
+ const localActions = createEmptyActions();
316
+ compactChanges(otherChanges, localActions);
317
+
318
+ const effectiveCount = countActions(localActions);
319
+ totalCompacted += otherChanges.length - effectiveCount;
320
+
321
+ const actionEntries = createConflictActionEntries(otherChanges, localActions);
322
+ const pendingLocalChanges = await queryPendingLocalChanges(
323
+ changeRepo,
324
+ repo.namespace,
325
+ repo.entity,
326
+ branchId,
327
+ repo.repoSync.lastPushedChangeId ?? null,
328
+ [...new Set(actionEntries.map(entry => entry.entityId))]
329
+ );
330
+
331
+ const { applyActions, conflictsResolved } = await resolveConflictsAndBuildActions(
332
+ actionEntries,
333
+ pendingLocalChanges,
334
+ {
335
+ changeRepo,
336
+ conflictResolver,
337
+ dispatchEvent: (event: RxDBEvent) => rxdb.dispatchEvent(event),
338
+ repoLabel: `${repo.namespace}:${repo.entity}`,
339
+ localClientId: rxdb.context.clientId
340
+ }
341
+ );
342
+ totalConflictsResolved += conflictsResolved;
343
+
344
+ const applyCount = countActions(applyActions);
345
+ if (applyCount > 0) {
346
+ // disableTriggers=true 确保不生成本地 RxDBChange 记录
347
+ // disableTriggers=true 压掉的是变更日志行,不是工作树单元:FR-046 要求拉取
348
+ // 照样落一个 origin='remote_sync' 的单元,所以挂载点 2 从 actions 派生,不看日志。
349
+ declareTrustedWrite(executor, {
350
+ file: 'pull-batch.ts',
351
+ symbol: 'pullBatchOnce',
352
+ intent: TrustedWriteIntent.remote_sync
353
+ });
354
+ await executor.mergeChanges(applyActions, undefined, true);
355
+ totalApplied += applyCount;
356
+ }
357
+ }
358
+
359
+ // 更新水位线(reduce 求最大值,避免 Math.max(...arr) 在超大数组上栈溢出)
360
+ const maxRemoteId = filteredChanges.reduce((max, c) => (c.id > max ? c.id : max), filteredChanges[0].id);
361
+ repo.repoSync = await txRepoSyncRepo.update(repo.repoSync, {
362
+ lastPullRemoteChangeId: maxRemoteId,
363
+ lastPulledAt: new Date(),
364
+ updatedAt: new Date()
365
+ });
366
+
367
+ // 判断该实体是否还有更多数据:使用每个实体的 limit 而非全量
368
+ if (filteredChanges.length >= limit) hasMore = true;
369
+ }
370
+ });
371
+
372
+ return {
373
+ pulled: totalPulled,
374
+ compacted: totalCompacted,
375
+ applied: totalApplied,
376
+ hasMore,
377
+ conflictsResolved: totalConflictsResolved,
378
+ conflictsDeferred: totalConflictsDeferred,
379
+ // 整批在同一个事务里:只要拉到过变更,水位线(以及 remoteId 回填)就已随事务提交,
380
+ // 因此 `totalPulled > 0` 等价于「已有写入落库」,与 applied 是否为 0 无关。
381
+ persistedProgress: totalPulled > 0,
382
+ // 只有真正 merge 进实体表才会让 undo 历史边界失效;全被压缩抵消的批次不算。
383
+ historyInvalidated: totalApplied > 0,
384
+ failures: []
385
+ };
386
+ }
@@ -0,0 +1,284 @@
1
+ import {
2
+ type Conflict,
3
+ ConflictDetectedEvent,
4
+ ConflictPendingEvent,
5
+ type ConflictResolver,
6
+ getRxDBChangeEntityIdQueryValues,
7
+ getRxDBChangeKey,
8
+ type IRepository,
9
+ type RemoteChange,
10
+ RxDBChange,
11
+ type RxDBChangeRuleGroup,
12
+ type RxDBEntityId,
13
+ RxDBError,
14
+ type RxDBEvent,
15
+ type SwitchVersionActions,
16
+ type SwitchVersionChange
17
+ } from '@aiao/rxdb';
18
+ export type ActionType = 'INSERT' | 'UPDATE' | 'DELETE';
19
+
20
+ export interface ConflictActionEntry {
21
+ key: string;
22
+ entityId: RxDBEntityId;
23
+ type: ActionType;
24
+ change: SwitchVersionChange;
25
+ remote: RemoteChange;
26
+ }
27
+
28
+ export function createEmptyActions(): SwitchVersionActions {
29
+ return {
30
+ deletes: new Map(),
31
+ updates: new Map(),
32
+ inserts: new Map()
33
+ };
34
+ }
35
+
36
+ export function countActions(actions: SwitchVersionActions): number {
37
+ return actions.inserts.size + actions.updates.size + actions.deletes.size;
38
+ }
39
+
40
+ export function setAction(actions: SwitchVersionActions, entry: ConflictActionEntry): void {
41
+ if (entry.type === 'INSERT') {
42
+ actions.inserts.set(entry.key, entry.change);
43
+ return;
44
+ }
45
+
46
+ if (entry.type === 'UPDATE') {
47
+ actions.updates.set(entry.key, entry.change);
48
+ return;
49
+ }
50
+
51
+ actions.deletes.set(entry.key, entry.change);
52
+ }
53
+
54
+ export function createConflictActionEntries(
55
+ otherChanges: RemoteChange[],
56
+ actions: SwitchVersionActions
57
+ ): ConflictActionEntry[] {
58
+ const latestRemoteByKey = new Map<string, RemoteChange>();
59
+
60
+ for (const remoteChange of otherChanges) {
61
+ const key = getRxDBChangeKey(remoteChange);
62
+ const existing = latestRemoteByKey.get(key);
63
+ if (!existing || remoteChange.id > existing.id) {
64
+ latestRemoteByKey.set(key, remoteChange);
65
+ }
66
+ }
67
+
68
+ const entries: ConflictActionEntry[] = [];
69
+
70
+ const collect = (map: Map<string, SwitchVersionChange>, type: ActionType) => {
71
+ for (const [key, change] of map) {
72
+ const latestRemote = latestRemoteByKey.get(key);
73
+ if (!latestRemote) {
74
+ continue;
75
+ }
76
+
77
+ entries.push({
78
+ key,
79
+ entityId: latestRemote.entityId,
80
+ type,
81
+ change,
82
+ remote: {
83
+ ...latestRemote,
84
+ type,
85
+ patch: change.patch ?? null,
86
+ inversePatch: change.inversePatch ?? null
87
+ }
88
+ });
89
+ }
90
+ };
91
+
92
+ collect(actions.inserts, 'INSERT');
93
+ collect(actions.updates, 'UPDATE');
94
+ collect(actions.deletes, 'DELETE');
95
+
96
+ return entries;
97
+ }
98
+
99
+ export async function queryPendingLocalChanges(
100
+ changeRepo: IRepository<typeof RxDBChange>,
101
+ namespace: string,
102
+ entity: string,
103
+ branchId: string,
104
+ lastPushedChangeId: number | null,
105
+ entityIds: RxDBEntityId[]
106
+ ): Promise<Map<string, RxDBChange[]>> {
107
+ if (entityIds.length === 0) {
108
+ return new Map();
109
+ }
110
+
111
+ const rules: RxDBChangeRuleGroup['rules'] = [
112
+ { field: 'namespace', operator: '=', value: namespace },
113
+ { field: 'entity', operator: '=', value: entity },
114
+ { field: 'branchId', operator: '=', value: branchId },
115
+ { field: 'remoteId', operator: '=', value: null },
116
+ // 被拒变更(rejectedAt 非空)的 remoteId 也一直是 null,但它永远不会再被推送,
117
+ // 必须和 remoteId = null 一起排除,否则会被当成待推的本地变更,和远端变更
118
+ // 撞上同一个 entityId 时误判出一次本不存在的冲突
119
+ { field: 'rejectedAt', operator: '=', value: null },
120
+ { field: 'revertChangeId', operator: '=', value: null },
121
+ { field: 'entityId', operator: 'in', value: getRxDBChangeEntityIdQueryValues(entityIds) }
122
+ ];
123
+
124
+ if (lastPushedChangeId !== null) {
125
+ rules.push({ field: 'id', operator: '>', value: lastPushedChangeId });
126
+ }
127
+
128
+ const pendingChanges = (await changeRepo.find({
129
+ where: {
130
+ combinator: 'and',
131
+ rules
132
+ },
133
+ orderBy: [{ field: 'id', sort: 'asc' }]
134
+ })) as RxDBChange[];
135
+
136
+ const grouped = new Map<string, RxDBChange[]>();
137
+ for (const change of pendingChanges) {
138
+ const key = getRxDBChangeKey(change);
139
+ if (!grouped.has(key)) {
140
+ grouped.set(key, []);
141
+ }
142
+ grouped.get(key)!.push(change);
143
+ }
144
+
145
+ return grouped;
146
+ }
147
+
148
+ export async function markLocalChangesSuperseded(
149
+ changeRepo: IRepository<typeof RxDBChange>,
150
+ localChanges: RxDBChange[],
151
+ remoteId: number
152
+ ): Promise<void> {
153
+ for (const localChange of localChanges) {
154
+ await changeRepo.update(localChange, {
155
+ remoteId,
156
+ updatedAt: new Date()
157
+ });
158
+ }
159
+ }
160
+
161
+ export interface ResolveConflictsContext {
162
+ changeRepo: IRepository<typeof RxDBChange>;
163
+ conflictResolver: ConflictResolver;
164
+ dispatchEvent: (event: RxDBEvent) => void;
165
+ repoLabel: string;
166
+ deferLocalChangeSupersession?: boolean;
167
+ /**
168
+ * 当前客户端 id,用于给冲突的本地侧打戳。
169
+ *
170
+ * @remarks
171
+ * `RxDBChange` 表没有 clientId 列,直接把本地行交给解决器时 `conflict.local.clientId`
172
+ * 恒为 `undefined`,LWW 的 tie-breaker 永不触发、永远退回 KEEP_LOCAL —— 两个副本各自
173
+ * 保留自己,永久分叉。本地未同步的 change 按定义就是本客户端产生的,
174
+ * 在这里打戳与加列等价,且不需要持久化格式变更。
175
+ */
176
+ localClientId?: string;
177
+ }
178
+
179
+ export interface LocalChangeSupersession {
180
+ localChanges: RxDBChange[];
181
+ remoteId: number;
182
+ }
183
+
184
+ export interface ResolveConflictsResult {
185
+ applyActions: SwitchVersionActions;
186
+ conflictsResolved: number;
187
+ localChangeSupersessions: LocalChangeSupersession[];
188
+ }
189
+
190
+ /**
191
+ * 给冲突的本地侧补上当前 clientId,供 LWW 的 tie-breaker 使用。
192
+ *
193
+ * @remarks
194
+ * 不能就地赋值:本地行是被 Proxy 跟踪的实体,写入会把它标脏并试图持久化一个不存在的列。
195
+ * 复制时保留原型,避免 DEFER 消费者从 `ConflictPendingEvent` 拿到的对象丢掉实体方法。
196
+ */
197
+ const withLocalClientId = (change: RxDBChange, clientId: string | undefined): RxDBChange => {
198
+ if (!clientId) return change;
199
+ return Object.assign(Object.create(Object.getPrototypeOf(change) as object) as RxDBChange, change, { clientId });
200
+ };
201
+
202
+ export async function resolveConflictsAndBuildActions(
203
+ actionEntries: ConflictActionEntry[],
204
+ pendingLocalChanges: Map<string, RxDBChange[]>,
205
+ context: ResolveConflictsContext
206
+ ): Promise<ResolveConflictsResult> {
207
+ const applyActions = createEmptyActions();
208
+ const detectedConflicts: Conflict[] = [];
209
+ const actionEntryByKey = new Map(actionEntries.map(entry => [entry.key, entry]));
210
+ const localChangeSupersessions: LocalChangeSupersession[] = [];
211
+
212
+ for (const actionEntry of actionEntries) {
213
+ const localChangesForEntity = pendingLocalChanges.get(actionEntry.key);
214
+
215
+ if (!localChangesForEntity || localChangesForEntity.length === 0) {
216
+ setAction(applyActions, actionEntry);
217
+ continue;
218
+ }
219
+
220
+ detectedConflicts.push({
221
+ entityKey: actionEntry.key,
222
+ local: withLocalClientId(localChangesForEntity[localChangesForEntity.length - 1], context.localClientId),
223
+ remote: actionEntry.remote
224
+ });
225
+ }
226
+
227
+ let conflictsResolved = 0;
228
+
229
+ if (detectedConflicts.length > 0) {
230
+ const { conflictResolver, changeRepo, dispatchEvent, repoLabel } = context;
231
+ const resolutions =
232
+ conflictResolver.resolveAll ?
233
+ await conflictResolver.resolveAll(detectedConflicts)
234
+ : await Promise.all(detectedConflicts.map(conflict => conflictResolver.resolve(conflict)));
235
+
236
+ const deferredConflicts: Conflict[] = [];
237
+ for (let index = 0; index < detectedConflicts.length; index += 1) {
238
+ const conflict = detectedConflicts[index];
239
+ const resolution = resolutions[index];
240
+ const actionEntry = actionEntryByKey.get(conflict.entityKey);
241
+ const localChangesForEntity = pendingLocalChanges.get(conflict.entityKey) ?? [];
242
+
243
+ if (!actionEntry) {
244
+ continue;
245
+ }
246
+
247
+ if (resolution.type === 'KEEP_LOCAL') {
248
+ conflictsResolved += 1;
249
+ continue;
250
+ }
251
+
252
+ if (resolution.type === 'KEEP_REMOTE') {
253
+ conflictsResolved += 1;
254
+ localChangeSupersessions.push({
255
+ localChanges: localChangesForEntity,
256
+ remoteId: actionEntry.remote.id
257
+ });
258
+ setAction(applyActions, actionEntry);
259
+ continue;
260
+ }
261
+
262
+ deferredConflicts.push(conflict);
263
+ }
264
+
265
+ if (deferredConflicts.length > 0) {
266
+ dispatchEvent(new ConflictDetectedEvent(detectedConflicts, 0, deferredConflicts.length));
267
+ dispatchEvent(new ConflictPendingEvent(deferredConflicts));
268
+ throw new RxDBError(
269
+ `Unsupported conflict resolution returned during pull for ${repoLabel}. ` +
270
+ `Only KEEP_LOCAL and KEEP_REMOTE can be applied automatically at runtime.`
271
+ );
272
+ }
273
+
274
+ dispatchEvent(new ConflictDetectedEvent(detectedConflicts, detectedConflicts.length, 0));
275
+
276
+ if (!context.deferLocalChangeSupersession) {
277
+ for (const supersession of localChangeSupersessions) {
278
+ await markLocalChangesSuperseded(changeRepo, supersession.localChanges, supersession.remoteId);
279
+ }
280
+ }
281
+ }
282
+
283
+ return { applyActions, conflictsResolved, localChangeSupersessions };
284
+ }