@aiao/rxdb-plugin-sync 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 (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -2
  3. package/dist/SyncManager.d.ts +412 -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-utils.d.ts +8 -0
  8. package/dist/branch-utils.d.ts.map +1 -0
  9. package/dist/bulk-sync.d.ts +117 -0
  10. package/dist/bulk-sync.d.ts.map +1 -0
  11. package/dist/cascade-blocking.d.ts +38 -0
  12. package/dist/cascade-blocking.d.ts.map +1 -0
  13. package/dist/check-repository-updates.d.ts +47 -0
  14. package/dist/check-repository-updates.d.ts.map +1 -0
  15. package/dist/cleanup-expired.d.ts +56 -0
  16. package/dist/cleanup-expired.d.ts.map +1 -0
  17. package/dist/dependency-graph.d.ts +128 -0
  18. package/dist/dependency-graph.d.ts.map +1 -0
  19. package/dist/get-all-repository-sync-status.d.ts +58 -0
  20. package/dist/get-all-repository-sync-status.d.ts.map +1 -0
  21. package/dist/get-repository-sync-status.d.ts +68 -0
  22. package/dist/get-repository-sync-status.d.ts.map +1 -0
  23. package/dist/index.d.ts +39 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +3273 -0
  26. package/dist/plugin.d.ts +70 -0
  27. package/dist/plugin.d.ts.map +1 -0
  28. package/dist/pull-ancestor-changes.d.ts +41 -0
  29. package/dist/pull-ancestor-changes.d.ts.map +1 -0
  30. package/dist/pull-batch.d.ts +16 -0
  31. package/dist/pull-batch.d.ts.map +1 -0
  32. package/dist/pull-conflict-utils.d.ts +43 -0
  33. package/dist/pull-conflict-utils.d.ts.map +1 -0
  34. package/dist/pull-repository.d.ts +36 -0
  35. package/dist/pull-repository.d.ts.map +1 -0
  36. package/dist/pull.d.ts +16 -0
  37. package/dist/pull.d.ts.map +1 -0
  38. package/dist/push-branch.d.ts +22 -0
  39. package/dist/push-branch.d.ts.map +1 -0
  40. package/dist/push-repository.d.ts +63 -0
  41. package/dist/push-repository.d.ts.map +1 -0
  42. package/dist/push.d.ts +14 -0
  43. package/dist/push.d.ts.map +1 -0
  44. package/dist/query-cache-outbox.d.ts +120 -0
  45. package/dist/query-cache-outbox.d.ts.map +1 -0
  46. package/dist/sync-branches.d.ts +35 -0
  47. package/dist/sync-branches.d.ts.map +1 -0
  48. package/dist/sync-listeners.d.ts +20 -0
  49. package/dist/sync-listeners.d.ts.map +1 -0
  50. package/dist/sync-manager.utils.d.ts +36 -0
  51. package/dist/sync-manager.utils.d.ts.map +1 -0
  52. package/dist/sync-repository.d.ts +63 -0
  53. package/dist/sync-repository.d.ts.map +1 -0
  54. package/dist/topological-sort.d.ts +126 -0
  55. package/dist/topological-sort.d.ts.map +1 -0
  56. package/dist/version.utils.d.ts +3 -0
  57. package/dist/version.utils.d.ts.map +1 -0
  58. package/package.json +58 -3
  59. package/src/SyncManager.ts +662 -0
  60. package/src/branch-change-id.ts +70 -0
  61. package/src/branch-utils.ts +37 -0
  62. package/src/bulk-sync.ts +338 -0
  63. package/src/cascade-blocking.ts +61 -0
  64. package/src/check-repository-updates.ts +143 -0
  65. package/src/cleanup-expired.ts +271 -0
  66. package/src/dependency-graph.ts +324 -0
  67. package/src/get-all-repository-sync-status.ts +115 -0
  68. package/src/get-repository-sync-status.ts +237 -0
  69. package/src/index.ts +53 -0
  70. package/src/plugin.ts +124 -0
  71. package/src/pull-ancestor-changes.ts +61 -0
  72. package/src/pull-batch.ts +410 -0
  73. package/src/pull-conflict-utils.ts +280 -0
  74. package/src/pull-repository.ts +701 -0
  75. package/src/pull.ts +204 -0
  76. package/src/push-branch.ts +61 -0
  77. package/src/push-repository.ts +1002 -0
  78. package/src/push.ts +104 -0
  79. package/src/query-cache-outbox.ts +866 -0
  80. package/src/sync-branches.ts +156 -0
  81. package/src/sync-listeners.ts +289 -0
  82. package/src/sync-manager.utils.ts +48 -0
  83. package/src/sync-repository.ts +302 -0
  84. package/src/topological-sort.ts +246 -0
  85. package/src/version.utils.ts +20 -0
@@ -0,0 +1,1002 @@
1
+ /**
2
+ * @fileoverview 仓库级别的推送操作
3
+ *
4
+ * 在仓库(实体类型)级别提供细粒度的推送控制,
5
+ * 支持通过依赖图分析的级联同步。
6
+ */
7
+
8
+ import {
9
+ compactChanges,
10
+ getEntityMetadata,
11
+ getOrCreateSyncRecord,
12
+ getRxDBChangeKey,
13
+ getSyncType,
14
+ IRepository,
15
+ type PushRepositoryResult,
16
+ type RemoteMergeResult,
17
+ repositoryKey,
18
+ RepositorySyncBeginEvent,
19
+ RepositorySyncCompleteEvent,
20
+ RepositorySyncErrorEvent,
21
+ resolvePushIneligibility,
22
+ type RxDBAdapterRemoteBase,
23
+ RxDBChange,
24
+ RxDBChangeRuleGroup,
25
+ RxDBDependencyFailedError,
26
+ RxDBError,
27
+ RxDBPartialSyncError,
28
+ RxDBSync,
29
+ type SwitchVersionActions,
30
+ type SwitchVersionChange,
31
+ type SyncFailure
32
+ } from '@aiao/rxdb';
33
+ import type { PushInFlightSession } from '@aiao/rxdb-plugin-history';
34
+ import { getAncestorBranchIds } from './branch-utils.js';
35
+ import { findBlockingDependency } from './cascade-blocking.js';
36
+ import { buildDependencyGraph, type DependencyGraph, type RepositoryIdentifier } from './dependency-graph.js';
37
+ import type { SyncManager } from './SyncManager.js';
38
+ import { dependencyEdgeForAction, type SortActionKind, topologicalSortForAction } from './topological-sort.js';
39
+ /**
40
+ * 推送仓库选项
41
+ */
42
+ export interface PushRepositoryOptions {
43
+ /**
44
+ * 每批次推送的最大变更数量
45
+ * @default 1000
46
+ */
47
+ batchSize?: number;
48
+
49
+ /**
50
+ * 是否包含相关实体(级联同步)
51
+ *
52
+ * 当为 true 时:
53
+ * - 自动推送所有子实体(引用当前实体的实体)
54
+ * - 遵循反向拓扑顺序:子 -> 父
55
+ * - 示例:推送 User 时会自动推送 Post
56
+ *
57
+ * 当为 false 时:
58
+ * - 仅同步指定仓库
59
+ * - 可能会导致远端数据不完整
60
+ *
61
+ * @default true
62
+ */
63
+ includeRelated?: boolean;
64
+ }
65
+
66
+ // `PushRepositoryResult` 此前在本文件和 `VersionManager.interface.ts`
67
+ // 各有一份完全相同的定义,改一处漏一处。唯一定义收敛到对外导出的那一份,这里只做转发
68
+ // (与 `pull-repository.ts` 的 `PullRepositoryResult` 同一处理)。
69
+ export type { PushRepositoryResult };
70
+
71
+ /**
72
+ * 默认推送仓库选项
73
+ */
74
+ const DEFAULT_PUSH_REPOSITORY_OPTIONS: Required<PushRepositoryOptions> = {
75
+ batchSize: 1000,
76
+ includeRelated: true
77
+ };
78
+
79
+ function assertBatchSize(batchSize: number): void {
80
+ if (!Number.isSafeInteger(batchSize) || batchSize <= 0) {
81
+ throw new RangeError('batchSize must be a positive safe integer');
82
+ }
83
+ }
84
+
85
+ /**
86
+ * 从异常里取出推送侧的部分进度。
87
+ *
88
+ * 与 `partialRepositoryProgressOf`(pull 侧)对称:按 `PushRepositoryResult` 独有的
89
+ * `pushed` + `originalCount` 两个数值字段判形,`PullRepositoryResult` /
90
+ * `SyncRepositoryResult` 都没有它们,不会误判。
91
+ *
92
+ * @param error - 任意异常
93
+ * @returns 携带的推送结果;异常不是 `RxDBPartialSyncError` 或负载形状不符时返回 `undefined`
94
+ */
95
+ export function partialPushProgressOf(error: Error): PushRepositoryResult | undefined {
96
+ if (!(error instanceof RxDBPartialSyncError)) return undefined;
97
+ const result: unknown = error.result;
98
+ if (!result || typeof result !== 'object') return undefined;
99
+ const candidate = result as Partial<PushRepositoryResult>;
100
+ return typeof candidate.pushed === 'number' && typeof candidate.originalCount === 'number' ?
101
+ (candidate as PushRepositoryResult)
102
+ : undefined;
103
+ }
104
+
105
+ /**
106
+ * 推送失败的唯一出口:一律抛,不 resolve 出 `success: false`。
107
+ *
108
+ * @remarks
109
+ * 此前级联路径抛错、单仓路径 resolve 出 `success: false`,两种形状并存。
110
+ * `bulkSync` 只看「有没有抛」来判定成败,于是单仓路径的失败被记成成功,
111
+ * `BulkSyncResult.failed` 恒为 0 —— 推送失败在聚合层完全消失。
112
+ *
113
+ * 抛什么则按有没有真的发出去东西分:
114
+ *
115
+ * - `pushed === 0`:一条都没到远端,包一层只会让调用方多剥一层,直接抛原始错误
116
+ * (与 `pull.ts` / `pull-batch.ts` 的既有约定一致);
117
+ * - `pushed > 0`:这些条目已经落在远端且**不会**因为本次抛错而回滚,
118
+ * 正是 {@link RxDBPartialSyncError} 的语义,进度挂在 `result` 上交出去。
119
+ *
120
+ * @param result - 失败的推送结果,`success` 必须为 `false`
121
+ * @throws 恒抛
122
+ * @internal
123
+ */
124
+ function throwPushFailure(result: PushRepositoryResult): never {
125
+ const { error } = result;
126
+ // `success: false` 必然带 error(提交失败、远端失败、依赖阻断三条路径都会写)。
127
+ // 真出现缺失只可能是内部状态坏了,不能静默当成功。
128
+ if (!error) {
129
+ throw new RxDBError(`Internal error: repository ${repositoryKey(result.repository)} failed without an error`);
130
+ }
131
+
132
+ if (result.pushed === 0) throw error;
133
+ throw new RxDBPartialSyncError<PushRepositoryResult>(result, error);
134
+ }
135
+
136
+ /**
137
+ * 为单个仓库推送变更
138
+ *
139
+ * @param sm - SyncManager 实例
140
+ * @param namespace - 实体命名空间
141
+ * @param entity - 实体名称
142
+ * @param options - 推送选项
143
+ * @returns 推送结果
144
+ *
145
+ * @example
146
+ * ```ts
147
+ * // 在不进行级联的情况下推送 Todo 仓库
148
+ * const result = await pushRepository(sm, 'public', 'Todo', {
149
+ * includeRelated: false
150
+ * });
151
+ *
152
+ * // 推送 Todo 以及所有依赖它的实体(如 Comment)
153
+ * const result = await pushRepository(sm, 'public', 'Todo', {
154
+ * includeRelated: true
155
+ * });
156
+ * ```
157
+ */
158
+ export async function pushRepository(
159
+ sm: SyncManager,
160
+ namespace: string,
161
+ entity: string,
162
+ options?: PushRepositoryOptions
163
+ ): Promise<PushRepositoryResult> {
164
+ const opts: Required<PushRepositoryOptions> = {
165
+ batchSize: options?.batchSize === undefined ? DEFAULT_PUSH_REPOSITORY_OPTIONS.batchSize : options.batchSize,
166
+ includeRelated:
167
+ options?.includeRelated === undefined ? DEFAULT_PUSH_REPOSITORY_OPTIONS.includeRelated : options.includeRelated
168
+ };
169
+ const rxdb = sm.rxdb;
170
+
171
+ // 触发开始事件
172
+ rxdb.dispatchEvent(new RepositorySyncBeginEvent('push', namespace, entity, opts.includeRelated));
173
+
174
+ // 本轮 push 认领的「在飞」区间;undo 据此把还在往返途中的变更当成已推。
175
+ // 从哪条路径提前返回都会经下面那个 finally,认领不会泄漏。
176
+ const inFlight = sm.pushInFlight.session();
177
+
178
+ try {
179
+ assertBatchSize(opts.batchSize);
180
+ const result = await _pushRepositoryImpl(sm, namespace, entity, opts, inFlight);
181
+
182
+ // 触发完成事件
183
+ rxdb.dispatchEvent(
184
+ new RepositorySyncCompleteEvent('push', namespace, entity, {
185
+ pushed: result.pushed,
186
+ compacted: result.compacted,
187
+ failed: result.failed
188
+ })
189
+ );
190
+
191
+ return result;
192
+ } catch (error) {
193
+ // 触发错误事件
194
+ rxdb.dispatchEvent(new RepositorySyncErrorEvent('push', namespace, entity, error as Error));
195
+ throw error;
196
+ } finally {
197
+ inFlight.release();
198
+ }
199
+ }
200
+
201
+ /**
202
+ * pushRepository 的内部实现
203
+ */
204
+ async function _pushRepositoryImpl(
205
+ sm: SyncManager,
206
+ namespace: string,
207
+ entity: string,
208
+ opts: Required<PushRepositoryOptions>,
209
+ inFlight: PushInFlightSession
210
+ ): Promise<PushRepositoryResult> {
211
+ // 验证仓库是否存在
212
+ const EntityType = sm.rxdb.config.entities.find(e => {
213
+ const meta = getEntityMetadata(e);
214
+ return meta.namespace === namespace && meta.name === entity;
215
+ });
216
+
217
+ if (!EntityType) {
218
+ throw new RxDBError(`Entity not found: ${namespace}:${entity}`);
219
+ }
220
+
221
+ const metadata = getEntityMetadata(EntityType);
222
+
223
+ // 检查同步类型(支持全局配置回退)。资格判定与级联路径共用 `resolvePushIneligibility`,
224
+ // 避免两条路径各写一份而漂移。
225
+ // 同一处叠加 `RxDBSync.enabled`(此前推送路径从不读它)
226
+ const ineligible = await resolvePushIneligibility(
227
+ sm.rxdb,
228
+ namespace,
229
+ entity,
230
+ getSyncType(metadata, sm.rxdb.config.sync)
231
+ );
232
+
233
+ if (ineligible) {
234
+ throw new RxDBError(`Cannot push repository ${namespace}:${entity}: ${ineligible}.`);
235
+ }
236
+
237
+ // 处理级联推送
238
+ if (opts.includeRelated) {
239
+ return await pushWithCascade(sm, namespace, entity, opts, inFlight);
240
+ }
241
+
242
+ // 单仓库推送
243
+ return await pushSingleRepository(sm, namespace, entity, opts, inFlight);
244
+ }
245
+
246
+ /**
247
+ * 级联推送(包含依赖)
248
+ *
249
+ * @remarks
250
+ * 这里**按相位**扫两遍仓库,而不是按单一拓扑序扫一遍。
251
+ * 理由见 {@link topologicalSortForAction} —— INSERT 要父先、DELETE 要子先,
252
+ * 一个顺序不可能同时满足。
253
+ *
254
+ * 每个仓库的「查变更 + 压缩」只做一次({@link planRepositoryPush}),两个相位共用同一份
255
+ * 计划;落库(写 `remoteId` + 推进水位线)也只做一次,在全部相位跑完后统一提交
256
+ * ({@link commitRepositoryPush})。否则 `originalCount` / `compacted` 会被算两遍,
257
+ * 且第一个相位就把水位线推到最大 change id,第二个相位的变更会被整批吞掉。
258
+ *
259
+ * @internal
260
+ */
261
+ async function pushWithCascade(
262
+ sm: SyncManager,
263
+ namespace: string,
264
+ entity: string,
265
+ options: Required<PushRepositoryOptions>,
266
+ inFlight: PushInFlightSession
267
+ ): Promise<PushRepositoryResult> {
268
+ // 构建依赖图
269
+ const entities = sm.rxdb.config.entities.map(e => getEntityMetadata(e));
270
+ const graph = buildDependencyGraph(entities);
271
+
272
+ // 查找目标仓库及其依赖项
273
+ const targetKey = `${namespace}:${entity}`;
274
+ const dep = graph.get(targetKey);
275
+
276
+ if (!dep) {
277
+ throw new RxDBError(`Repository ${namespace}:${entity} not found in dependency graph`);
278
+ }
279
+
280
+ // 收集所有需要推送的仓库(目标 + 其依赖)
281
+ const reposToPush = new Set<string>([targetKey]);
282
+ const collectDependents = (key: string): void => {
283
+ const d = graph.get(key);
284
+ if (!d) return;
285
+
286
+ for (const child of d.requiredBy) {
287
+ const childKey = `${child.namespace}:${child.entity}`;
288
+ if (!reposToPush.has(childKey)) {
289
+ reposToPush.add(childKey);
290
+ collectDependents(childKey);
291
+ }
292
+ }
293
+ };
294
+
295
+ collectDependents(targetKey);
296
+
297
+ const orderRepos = (action: SortActionKind): RepositoryIdentifier[] =>
298
+ topologicalSortForAction(graph, action).filter(repo => reposToPush.has(repositoryKey(repo)));
299
+
300
+ const nodes = new Map<string, CascadeNode>();
301
+ const failedRepos = new Map<string, Error>();
302
+
303
+ for (const phase of PUSH_PHASES) {
304
+ for (const repo of orderRepos(phase.action)) {
305
+ await runCascadePhase(sm, graph, repo, phase, options, nodes, failedRepos, inFlight);
306
+ }
307
+ }
308
+
309
+ // 相位全部跑完才落库:水位线必须一次推到位,中途推进会吞掉后一个相位的变更
310
+ const results: PushRepositoryResult[] = [];
311
+ const failures: SyncFailure[] = [];
312
+
313
+ for (const repo of orderRepos('INSERT')) {
314
+ const node = nodes.get(repositoryKey(repo));
315
+ // 每个相位都会遍历全部仓库,节点必然已建好
316
+ if (!node) throw new RxDBError(`Internal error: cascade node missing for ${repositoryKey(repo)}`);
317
+
318
+ const result = node.result ?? (await commitRepositoryPush(node.plan!));
319
+ results.push(result);
320
+
321
+ // 按策略跳过(`skipped` 且 `success`)不算失败,不进失败清单
322
+ if (result.error) {
323
+ failures.push({ repository: repo, error: result.error });
324
+ }
325
+ }
326
+
327
+ // 返回目标仓库结果,并附带相关仓库的结果
328
+ const targetResult = results.find(r => r.repository.namespace === namespace && r.repository.entity === entity);
329
+ if (!targetResult) {
330
+ throw new RxDBError(`Internal error: target repository result not found`);
331
+ }
332
+
333
+ targetResult.relatedResults = results.filter(r => r !== targetResult);
334
+ // 目标仓的 `failures` 是整次级联的聚合(含目标仓自身);
335
+ // `relatedResults` 里每一项的 `failures` 只覆盖它自己那一次子调用
336
+ targetResult.failures = failures;
337
+
338
+ // 如果目标失败,则抛出。关联仓失败但目标仓推送成功时不抛:
339
+ // 已经发到远端的变更不会因为抛错而回滚,调用方重试只会重复推送;
340
+ // 失败清单通过 `failures` 交出去。
341
+ if (!targetResult.success) throwPushFailure(targetResult);
342
+
343
+ return targetResult;
344
+ }
345
+
346
+ /**
347
+ * 级联推送中一个仓库的跨相位状态
348
+ *
349
+ * `result` 一旦落定就不再进入后续相位:可能是被闸门挡下(跳过 / 依赖失败)、
350
+ * 无变更可推、或某个相位推失败了。否则 `plan` 一直留到所有相位跑完再统一提交。
351
+ *
352
+ * @internal
353
+ */
354
+ interface CascadeNode {
355
+ readonly repo: RepositoryIdentifier;
356
+ plan?: RepositoryPushPlan;
357
+ result?: PushRepositoryResult;
358
+ }
359
+
360
+ /**
361
+ * 让一个仓库走完某一个相位:先过依赖闸门,再过同步资格闸门,然后只推本相位那几类动作
362
+ *
363
+ * @internal
364
+ */
365
+ async function runCascadePhase(
366
+ sm: SyncManager,
367
+ graph: DependencyGraph,
368
+ repo: RepositoryIdentifier,
369
+ phase: PushPhase,
370
+ options: Required<PushRepositoryOptions>,
371
+ nodes: Map<string, CascadeNode>,
372
+ failedRepos: Map<string, Error>,
373
+ inFlight: PushInFlightSession
374
+ ): Promise<void> {
375
+ const repoKey = repositoryKey(repo);
376
+ let node = nodes.get(repoKey);
377
+ if (!node) {
378
+ node = { repo };
379
+ nodes.set(repoKey, node);
380
+ }
381
+
382
+ // 上一个相位已经定案(跳过 / 无变更 / 推失败),不再往下推
383
+ if (node.result) return;
384
+
385
+ // 阻断边随相位翻转:DELETE 相位子先父后(被 requiredBy 阻断),
386
+ // INSERT 相位父先子后(被 dependsOn 阻断)。理由见 findBlockingDependency 的 @remarks。
387
+ const blocked = findBlockingDependency(graph, repoKey, failedRepos, dependencyEdgeForAction(phase.action));
388
+ if (blocked) {
389
+ const error = new RxDBDependencyFailedError(repo, blocked.dependency, blocked.cause);
390
+ node.result = {
391
+ ...blockedPushProgress(node.plan),
392
+ repository: repo,
393
+ success: false,
394
+ skipped: `dependency ${repositoryKey(blocked.dependency)} failed`,
395
+ error
396
+ };
397
+ failedRepos.set(repoKey, error);
398
+ return;
399
+ }
400
+
401
+ if (!node.plan) {
402
+ // 级联节点必须走和单仓路径同一份资格校验,否则 `remote` / `local` / `none`
403
+ // 的关联仓会被无差别推去远端
404
+ const ineligible = await cascadeNodeIneligibility(sm, repo);
405
+ if (ineligible) {
406
+ node.result = { ...emptyPushProgress(), repository: repo, success: true, skipped: ineligible };
407
+ return;
408
+ }
409
+
410
+ try {
411
+ const planned = await planRepositoryPush(sm, repo.namespace, repo.entity, inFlight);
412
+ if ('emptyResult' in planned) {
413
+ node.result = { ...planned.emptyResult, success: planned.emptyResult.success ?? true };
414
+ return;
415
+ }
416
+ node.plan = planned;
417
+ } catch (error) {
418
+ const normalized = error instanceof Error ? error : new Error(String(error));
419
+ node.result = {
420
+ ...emptyPushProgress(),
421
+ repository: repo,
422
+ success: false,
423
+ error: normalized,
424
+ failures: [{ repository: repo, error: normalized }]
425
+ };
426
+ failedRepos.set(repoKey, normalized);
427
+ return;
428
+ }
429
+ }
430
+
431
+ await pushPlanEntries(node.plan, phase.kinds, options.batchSize);
432
+
433
+ // 本相位推失败:立刻定案,后续相位不再推,并把失败传导给依赖它的仓库
434
+ if (node.plan.error) {
435
+ node.result = await commitRepositoryPush(node.plan);
436
+ failedRepos.set(repoKey, node.plan.error);
437
+ }
438
+ }
439
+
440
+ /**
441
+ * 判定级联节点是否没有推送资格
442
+ *
443
+ * @internal
444
+ */
445
+ async function cascadeNodeIneligibility(sm: SyncManager, repo: RepositoryIdentifier): Promise<string | undefined> {
446
+ const EntityType = sm.rxdb.config.entities.find(e => {
447
+ const meta = getEntityMetadata(e);
448
+ return meta.namespace === repo.namespace && meta.name === repo.entity;
449
+ });
450
+
451
+ // 依赖图由 `config.entities` 构建,节点必然能反查回实体类
452
+ if (!EntityType) {
453
+ throw new RxDBError(`Internal error: entity not found for cascade node ${repositoryKey(repo)}`);
454
+ }
455
+
456
+ // 关联仓被单独关掉时同样跳过 —— 级联不是绕开开关的后门
457
+ return resolvePushIneligibility(
458
+ sm.rxdb,
459
+ repo.namespace,
460
+ repo.entity,
461
+ getSyncType(getEntityMetadata(EntityType), sm.rxdb.config.sync)
462
+ );
463
+ }
464
+
465
+ /**
466
+ * 仓库级「零进度」基线:跳过或失败前一条都没推送时用它填充计数字段
467
+ *
468
+ * @internal
469
+ */
470
+ function emptyPushProgress(): Omit<PushRepositoryResult, 'repository'> {
471
+ return { pushed: 0, failed: 0, compacted: 0, originalCount: 0, failures: [] };
472
+ }
473
+
474
+ /**
475
+ * 依赖失败时,把这个节点**已经发给远端**的进度如实交出去。
476
+ *
477
+ * 阻断是逐相位判定的:DELETE 相位可能已经把删除动作推上去了,
478
+ * 到 INSERT 相位才撞上依赖失败。此前这里无条件铺 {@link emptyPushProgress},
479
+ * 于是「远端确实收到了几条,结果里记作 0」—— 而水位线又因为本轮失败不会推进,
480
+ * 调用方从计数上完全看不出发生过部分推送,回头对账「远端为什么多出几条」时无从查起。
481
+ *
482
+ * `failed` 与 {@link commitRepositoryPush} 同一个算式(`effectiveCount - pushed`),
483
+ * 保证「阻断」和「提交失败」两条路径交出的计数可以直接相加。
484
+ *
485
+ * 第一个相位就被阻断时没有 `plan`,各计数本就该是 0。
486
+ *
487
+ * @param plan - 该节点已完成的推送计划;尚未进入规划阶段时为 `undefined`
488
+ * @returns 除 `repository` 外的进度字段
489
+ *
490
+ * @internal
491
+ */
492
+ function blockedPushProgress(plan: RepositoryPushPlan | undefined): Omit<PushRepositoryResult, 'repository'> {
493
+ if (!plan) return emptyPushProgress();
494
+
495
+ return {
496
+ pushed: plan.pushed,
497
+ failed: plan.effectiveCount - plan.pushed,
498
+ compacted: plan.compacted,
499
+ originalCount: plan.originalCount,
500
+ failures: []
501
+ };
502
+ }
503
+
504
+ type CompactedActionKind = 'deletes' | 'updates' | 'inserts';
505
+
506
+ interface CompactedPushEntry {
507
+ actionKind: CompactedActionKind;
508
+ key: string;
509
+ action: SwitchVersionChange;
510
+ sourceChanges: RxDBChange[];
511
+ }
512
+
513
+ interface CompactedPushBatch {
514
+ actions: SwitchVersionActions;
515
+ sourceChanges: RxDBChange[];
516
+ sourceChangesByLocalId: Map<number, RxDBChange[]>;
517
+ }
518
+
519
+ /**
520
+ * 一个推送相位:本相位提交哪些动作类型,以及仓库该按什么顺序走。
521
+ *
522
+ * 分相位的理由见 {@link topologicalSortForAction}:INSERT 要父先、DELETE 要子先,
523
+ * 一遍扫描不可能同时满足,只能拆成两趟。DELETE 先走完再走 INSERT/UPDATE ——
524
+ * 反过来的话,「删掉旧父行 + 新建子行指向新父行」这种批次会在中途出现
525
+ * 子行指向一个即将被删的父行的瞬态。
526
+ */
527
+ interface PushPhase {
528
+ readonly action: SortActionKind;
529
+ readonly kinds: ReadonlySet<CompactedActionKind>;
530
+ }
531
+
532
+ const PUSH_PHASES: readonly PushPhase[] = [
533
+ { action: 'DELETE', kinds: new Set<CompactedActionKind>(['deletes']) },
534
+ { action: 'INSERT', kinds: new Set<CompactedActionKind>(['updates', 'inserts']) }
535
+ ];
536
+
537
+ /** 单仓路径不涉及跨仓顺序,三类一次推完。 */
538
+ const ALL_ACTION_KINDS: ReadonlySet<CompactedActionKind> = new Set<CompactedActionKind>([
539
+ 'deletes',
540
+ 'updates',
541
+ 'inserts'
542
+ ]);
543
+
544
+ function buildCompactedPushEntries(localChanges: RxDBChange[], actions: SwitchVersionActions): CompactedPushEntry[] {
545
+ const changesByKey = new Map<string, RxDBChange[]>();
546
+ for (const change of localChanges) {
547
+ const key = getRxDBChangeKey(change);
548
+ const groupedChanges = changesByKey.get(key) ?? [];
549
+ groupedChanges.push(change);
550
+ changesByKey.set(key, groupedChanges);
551
+ }
552
+
553
+ const pushEntries: CompactedPushEntry[] = [];
554
+ const appendEntries = (actionKind: CompactedActionKind, entries: SwitchVersionActions['inserts']): void => {
555
+ for (const [key, action] of entries) {
556
+ const sourceChanges = changesByKey.get(key);
557
+ if (!sourceChanges?.length) {
558
+ throw new RxDBError(`Missing source changes for compacted action: ${key}`);
559
+ }
560
+
561
+ pushEntries.push({
562
+ actionKind,
563
+ key,
564
+ action,
565
+ sourceChanges
566
+ });
567
+ }
568
+ };
569
+
570
+ appendEntries('deletes', actions.deletes);
571
+ appendEntries('updates', actions.updates);
572
+ appendEntries('inserts', actions.inserts);
573
+
574
+ return pushEntries;
575
+ }
576
+
577
+ function createCompactedPushBatch(entries: CompactedPushEntry[]): CompactedPushBatch {
578
+ const actions: SwitchVersionActions = {
579
+ deletes: new Map(),
580
+ updates: new Map(),
581
+ inserts: new Map()
582
+ };
583
+ const sourceChanges: RxDBChange[] = [];
584
+ const sourceChangesByLocalId = new Map<number, RxDBChange[]>();
585
+
586
+ for (const entry of entries) {
587
+ actions[entry.actionKind].set(entry.key, entry.action);
588
+ for (const sourceChange of entry.sourceChanges) {
589
+ sourceChanges.push(sourceChange);
590
+ sourceChangesByLocalId.set(sourceChange.id, [sourceChange]);
591
+ }
592
+ }
593
+
594
+ sourceChanges.sort((left, right) => left.id - right.id);
595
+ return {
596
+ actions,
597
+ sourceChanges,
598
+ sourceChangesByLocalId
599
+ };
600
+ }
601
+
602
+ function getChangeIdMapping(
603
+ mergeResult: RemoteMergeResult | number | void
604
+ ): NonNullable<RemoteMergeResult['changeIdMapping']> {
605
+ if (typeof mergeResult !== 'object' || mergeResult === null) return [];
606
+ return mergeResult.changeIdMapping ?? [];
607
+ }
608
+
609
+ function mapRemoteIds(
610
+ mapping: NonNullable<RemoteMergeResult['changeIdMapping']>,
611
+ sourceChangesByLocalId: Map<number, RxDBChange[]>
612
+ ): Array<{ change: RxDBChange; remoteId: number }> {
613
+ const resolvedMappings = mapping.map(({ localId, remoteId }) => {
614
+ const sourceChanges = sourceChangesByLocalId.get(localId);
615
+ if (!sourceChanges) {
616
+ throw new RxDBError(`Remote change mapping references unknown local change: ${localId}`);
617
+ }
618
+ return { remoteId, sourceChanges };
619
+ });
620
+ const remoteIdsByChange = new Map<RxDBChange, number>();
621
+
622
+ for (const { remoteId, sourceChanges } of resolvedMappings) {
623
+ for (const change of sourceChanges) {
624
+ remoteIdsByChange.set(change, remoteId);
625
+ }
626
+ }
627
+
628
+ return [...remoteIdsByChange].map(([change, remoteId]) => ({ change, remoteId }));
629
+ }
630
+
631
+ async function mergePushBatch(
632
+ remoteAdapter: RxDBAdapterRemoteBase,
633
+ branchId: string,
634
+ batch: CompactedPushBatch
635
+ ): Promise<Array<{ change: RxDBChange; remoteId: number }>> {
636
+ const mergeResult = await remoteAdapter.mergeChanges(batch.actions, branchId, batch.sourceChanges);
637
+ return mapRemoteIds(getChangeIdMapping(mergeResult), batch.sourceChangesByLocalId);
638
+ }
639
+
640
+ /**
641
+ * 一个仓库这一次推送的全部状态:查一次、压缩一次,之后各相位只从这里取自己那部分。
642
+ *
643
+ * 拆出这层是分相位的前提。若让两个相位各自调一遍
644
+ * 「查变更 → 压缩 → 推 → 推进水位线」,`originalCount` / `compacted` 会被算两遍,
645
+ * 水位线也会在第一个相位就提前推进,把第二个相位的变更整批吞掉。
646
+ *
647
+ * @internal
648
+ */
649
+ interface RepositoryPushPlan {
650
+ readonly repository: RepositoryIdentifier;
651
+ readonly entries: CompactedPushEntry[];
652
+ readonly localChanges: RxDBChange[];
653
+ readonly repoSync: RxDBSync;
654
+ readonly localAdapter: Awaited<ReturnType<SyncManager['getLocalRepositories']>>['adapter'];
655
+ readonly remoteAdapter: RxDBAdapterRemoteBase;
656
+ readonly branchId: string;
657
+ readonly originalCount: number;
658
+ readonly effectiveCount: number;
659
+ readonly compacted: number;
660
+ /** 各相位累积推送成功的条目数。 */
661
+ pushed: number;
662
+ /** 各相位累积拿到的远端 id,最终一并落库。 */
663
+ readonly remoteIdsByChange: Map<RxDBChange, number>;
664
+ error?: Error;
665
+ }
666
+
667
+ /**
668
+ * 阶段一:查出待推变更并压缩,但**一条都不推**。
669
+ *
670
+ * 返回 `{ emptyResult }` 表示这个仓库这一轮无事可做 —— 要么本就没有未推变更,
671
+ * 要么整批被本地压缩抵消;后者该推进的水位线已在此推进。
672
+ *
673
+ * @internal
674
+ */
675
+ async function planRepositoryPush(
676
+ sm: SyncManager,
677
+ namespace: string,
678
+ entity: string,
679
+ inFlight: PushInFlightSession
680
+ ): Promise<RepositoryPushPlan | { emptyResult: PushRepositoryResult }> {
681
+ const rxdb = sm.rxdb;
682
+
683
+ // 验证远端适配器
684
+ const remoteAdapterName = rxdb.config.sync?.remote?.adapter;
685
+ if (!remoteAdapterName) {
686
+ throw new RxDBError('Remote adapter not configured.');
687
+ }
688
+
689
+ await sm.getRemoteRepositories(); // 确保远端已配置
690
+ const { adapter: localAdapter } = await sm.getLocalRepositories();
691
+
692
+ // 获取当前分支
693
+ const branch = await sm.getCurrentBranch();
694
+
695
+ // 获取或创建 RxDBSync 记录
696
+ const repoSyncRepo = localAdapter.getRepository(RxDBSync);
697
+
698
+ const EntityType = sm.rxdb.config.entities.find(e => {
699
+ const meta = getEntityMetadata(e);
700
+ return meta.namespace === namespace && meta.name === entity;
701
+ });
702
+ const metadata = getEntityMetadata(EntityType!);
703
+ const syncType = getSyncType(metadata, sm.rxdb.config.sync);
704
+
705
+ const repoSync = await getOrCreateSyncRecord(
706
+ repoSyncRepo,
707
+ {
708
+ namespace,
709
+ entity,
710
+ branchId: branch.id,
711
+ syncType
712
+ },
713
+ () => rxdb.entityManager.instantiate(RxDBSync)
714
+ );
715
+
716
+ const lastPushedChangeId: number | null = repoSync.lastPushedChangeId;
717
+
718
+ // 获取祖先分支列表(包含自身),查询所有祖先分支的未推送变更
719
+ const branchIds = await getAncestorBranchIds(sm, branch.id);
720
+
721
+ const changeRepo = localAdapter.getRepository(RxDBChange);
722
+
723
+ let localChanges: RxDBChange[];
724
+ if (branchIds.length === 1) {
725
+ // 单分支(main 或无父分支),沿用原逻辑
726
+ localChanges = await queryUnpushedChanges(changeRepo, branchIds, lastPushedChangeId, namespace, [entity]);
727
+ } else {
728
+ // 多分支:收集每个祖先分支的水位线,用最小值查询
729
+ let minWatermark: number | null = lastPushedChangeId;
730
+ for (const ancestorBranchId of branchIds.slice(1)) {
731
+ const ancestorSyncId = `${namespace}:${entity}:${ancestorBranchId}`;
732
+ const ancestorSyncResults = await repoSyncRepo.find({
733
+ where: { combinator: 'and', rules: [{ field: 'id', operator: '=', value: ancestorSyncId }] },
734
+ limit: 1
735
+ });
736
+ const ancestorWatermark = ancestorSyncResults[0]?.lastPushedChangeId ?? null;
737
+ if (ancestorWatermark === null) {
738
+ minWatermark = null;
739
+ break;
740
+ }
741
+ if (minWatermark === null || ancestorWatermark < minWatermark) {
742
+ minWatermark = ancestorWatermark;
743
+ }
744
+ }
745
+ localChanges = await queryUnpushedChanges(changeRepo, branchIds, minWatermark, namespace, [entity]);
746
+ }
747
+
748
+ const originalCount = localChanges.length;
749
+
750
+ if (originalCount === 0) {
751
+ return { emptyResult: { ...emptyPushProgress(), repository: { namespace, entity } } };
752
+ }
753
+
754
+ // 压缩变更
755
+ const localActions: SwitchVersionActions = {
756
+ deletes: new Map(),
757
+ updates: new Map(),
758
+ inserts: new Map()
759
+ };
760
+
761
+ compactChanges(
762
+ localChanges.map(c => ({
763
+ id: c.id,
764
+ namespace: c.namespace,
765
+ entity: c.entity,
766
+ entityId: c.entityId,
767
+ type: c.type as 'INSERT' | 'UPDATE' | 'DELETE',
768
+ branchId: c.branchId,
769
+ patch: c.patch,
770
+ inversePatch: c.inversePatch,
771
+ createdAt: c.createdAt,
772
+ updatedAt: c.updatedAt
773
+ })),
774
+ localActions
775
+ );
776
+
777
+ const effectiveCount = localActions.inserts.size + localActions.updates.size + localActions.deletes.size;
778
+ const compacted = originalCount - effectiveCount;
779
+
780
+ // 无有效变更:整批被本地压缩抵消(如 INSERT → DELETE 且服务器从未见过这条数据)。
781
+ //
782
+ // 仍然必须推进水位线:这些变更的 `remoteId` / `revertChangeId` 永远保持 null,
783
+ // 后续任何一次 push 都不可能把它们发出去。不推进的话每次 push 都会重新查出、
784
+ // 重新压缩同一批,历史越长开销越大;`calculatePushableCount` 用的也是这条水位线,
785
+ // 会把它们永远算作「待推送」。成功路径同样是用**全部** localChanges 的最大 id
786
+ // (而非实际推送的那些)推进水位线,这里保持一致。
787
+ //
788
+ // 不写 `lastPushedAt`:没有任何数据真的发到远端,那是纯展示字段,不该被伪造。
789
+ if (effectiveCount === 0) {
790
+ const maxChangeId = localChanges.reduce((max, c) => (c.id > max ? c.id : max), localChanges[0].id);
791
+ await repoSyncRepo.update(repoSync, { lastPushedChangeId: maxChangeId, updatedAt: new Date() });
792
+
793
+ return {
794
+ emptyResult: {
795
+ ...emptyPushProgress(),
796
+ repository: { namespace, entity },
797
+ compacted: originalCount,
798
+ originalCount
799
+ }
800
+ };
801
+ }
802
+
803
+ // 获取远端适配器
804
+ const { adapter: remoteAdapter } = await sm.getRemoteRepositories();
805
+
806
+ // 认领必须在**返回计划之前**:调用方拿到计划的下一步就是往远端发,
807
+ // 认领晚一拍就等于把那一拍重新暴露给 undo。
808
+ inFlight.claim(
809
+ `${namespace}:${entity}`,
810
+ localChanges.reduce((max, c) => (c.id > max ? c.id : max), localChanges[0].id)
811
+ );
812
+
813
+ return {
814
+ repository: { namespace, entity },
815
+ entries: buildCompactedPushEntries(localChanges, localActions),
816
+ localChanges,
817
+ repoSync,
818
+ localAdapter,
819
+ remoteAdapter,
820
+ branchId: branch.id,
821
+ originalCount,
822
+ effectiveCount,
823
+ compacted,
824
+ pushed: 0,
825
+ remoteIdsByChange: new Map<RxDBChange, number>()
826
+ };
827
+ }
828
+
829
+ /**
830
+ * 阶段二:把计划里属于本相位的条目发给远端,**不落库**。
831
+ *
832
+ * 成功推送的条目累加进 `plan.pushed`,拿到的远端 id 累加进 `plan.remoteIdsByChange`,
833
+ * 都留给 {@link commitRepositoryPush} 一次性提交。
834
+ *
835
+ * 前一个相位已失败时直接返回:继续推只会在一个已知不会落库的批次上白跑一趟远端。
836
+ *
837
+ * @internal
838
+ */
839
+ async function pushPlanEntries(
840
+ plan: RepositoryPushPlan,
841
+ kinds: ReadonlySet<CompactedActionKind>,
842
+ batchSize: number
843
+ ): Promise<void> {
844
+ if (plan.error) return;
845
+
846
+ const entries = plan.entries.filter(entry => kinds.has(entry.actionKind));
847
+ if (entries.length === 0) return;
848
+
849
+ const { namespace, entity } = plan.repository;
850
+
851
+ for (let offset = 0; offset < entries.length; offset += batchSize) {
852
+ const batchEntries = entries.slice(offset, offset + batchSize);
853
+ const batch = createCompactedPushBatch(batchEntries);
854
+
855
+ try {
856
+ const mappedChanges = await mergePushBatch(plan.remoteAdapter, plan.branchId, batch);
857
+ for (const { change, remoteId } of mappedChanges) {
858
+ plan.remoteIdsByChange.set(change, remoteId);
859
+ }
860
+ plan.pushed += batchEntries.length;
861
+ } catch (error) {
862
+ console.error(`Error pushing changes to remote for repository [${namespace}/${entity}]:`, error);
863
+ plan.error = error instanceof Error ? error : new Error(String(error));
864
+ return;
865
+ }
866
+ }
867
+ }
868
+
869
+ /**
870
+ * 阶段三:全部相位跑完后,把远端 id 和水位线一次性落库。
871
+ *
872
+ * 只有一条变更都没失败才落库:部分成功就推进水位线会让没推上去的那些永远丢失。
873
+ *
874
+ * @internal
875
+ */
876
+ async function commitRepositoryPush(plan: RepositoryPushPlan): Promise<PushRepositoryResult> {
877
+ const { repository, localChanges, repoSync, localAdapter, remoteIdsByChange, effectiveCount } = plan;
878
+ const { namespace, entity } = repository;
879
+ let pushed = plan.pushed;
880
+ let pushError = plan.error;
881
+ let failed = effectiveCount - pushed;
882
+
883
+ if (failed === 0) {
884
+ // 使用 reduce 求最大值,避免 Math.max(...arr) 在超大数组上触发调用栈溢出
885
+ const maxChangeId = localChanges.reduce((max, c) => (c.id > max ? c.id : max), localChanges[0].id);
886
+
887
+ const previousRemoteIds = new Map([...remoteIdsByChange].map(([change]) => [change, change.remoteId]));
888
+ const previousSyncState = {
889
+ lastPushedChangeId: repoSync.lastPushedChangeId,
890
+ lastPushedAt: repoSync.lastPushedAt,
891
+ updatedAt: repoSync.updatedAt
892
+ };
893
+ const pushedAt = new Date();
894
+
895
+ try {
896
+ await localAdapter.transaction(async executor => {
897
+ const changesToSave = [...remoteIdsByChange].map(([change, remoteId]) => {
898
+ change.remoteId = remoteId;
899
+ return change;
900
+ });
901
+ if (changesToSave.length > 0) {
902
+ await executor.saveMany(changesToSave);
903
+ }
904
+ await executor.getRepository(RxDBSync).update(repoSync, {
905
+ lastPushedChangeId: maxChangeId,
906
+ lastPushedAt: pushedAt,
907
+ updatedAt: pushedAt
908
+ });
909
+ });
910
+ } catch (error) {
911
+ for (const [change, remoteId] of previousRemoteIds) {
912
+ change.remoteId = remoteId;
913
+ }
914
+ Object.assign(repoSync, previousSyncState);
915
+ console.error(`Error saving push state for repository [${namespace}/${entity}]:`, error);
916
+ pushError = error instanceof Error ? error : new Error(String(error));
917
+ pushed = 0;
918
+ failed = effectiveCount;
919
+ }
920
+ }
921
+
922
+ return {
923
+ repository,
924
+ success: failed === 0,
925
+ error: pushError,
926
+ pushed,
927
+ failed,
928
+ compacted: plan.compacted,
929
+ originalCount: plan.originalCount,
930
+ failures: pushError ? [{ repository, error: pushError }] : []
931
+ };
932
+ }
933
+
934
+ /**
935
+ * 单仓库推送变更(内部实现)
936
+ *
937
+ * 不涉及跨仓顺序,三类动作一趟推完。
938
+ *
939
+ * @internal
940
+ */
941
+ async function pushSingleRepository(
942
+ sm: SyncManager,
943
+ namespace: string,
944
+ entity: string,
945
+ options: Required<PushRepositoryOptions>,
946
+ inFlight: PushInFlightSession
947
+ ): Promise<PushRepositoryResult> {
948
+ const planned = await planRepositoryPush(sm, namespace, entity, inFlight);
949
+ if ('emptyResult' in planned) return planned.emptyResult;
950
+
951
+ await pushPlanEntries(planned, ALL_ACTION_KINDS, options.batchSize);
952
+ const result = await commitRepositoryPush(planned);
953
+ // 与级联路径同一个失败出口,见 throwPushFailure 的 @remarks
954
+ if (!result.success) throwPushFailure(result);
955
+ return result;
956
+ }
957
+
958
+ /**
959
+ * 查询指定仓库的未推送变更
960
+ *
961
+ * @internal
962
+ */
963
+ async function queryUnpushedChanges(
964
+ changeRepo: IRepository<typeof RxDBChange>,
965
+ branchIds: string[],
966
+ lastPushedChangeId: number | null,
967
+ namespace: string,
968
+ entityFilter: string[]
969
+ ): Promise<RxDBChange[]> {
970
+ const baseRules: RxDBChangeRuleGroup['rules'] = [
971
+ { field: 'revertChangeId', operator: '=', value: null },
972
+ { field: 'remoteId', operator: '=', value: null },
973
+ // 实体名在不同 namespace 下可以重名,只按 entity 过滤会把别的 namespace 的
974
+ // 变更一起捞出来错推,同时把对方的水位线推进导致后续漏推
975
+ { field: 'namespace', operator: '=', value: namespace }
976
+ ];
977
+
978
+ // 分支过滤(支持多分支)
979
+ if (branchIds.length === 1) {
980
+ baseRules.push({ field: 'branchId', operator: '=', value: branchIds[0] });
981
+ } else {
982
+ baseRules.push({ field: 'branchId', operator: 'in', value: branchIds });
983
+ }
984
+
985
+ // 添加仓库过滤(实体名)
986
+ if (entityFilter && entityFilter.length > 0) {
987
+ baseRules.push({ field: 'entity', operator: 'in', value: entityFilter });
988
+ }
989
+
990
+ // 添加变更 ID 过滤
991
+ if (lastPushedChangeId !== null) {
992
+ baseRules.push({ field: 'id', operator: '>', value: lastPushedChangeId });
993
+ }
994
+
995
+ return await changeRepo.find({
996
+ where: {
997
+ combinator: 'and',
998
+ rules: baseRules
999
+ },
1000
+ orderBy: [{ field: 'id', sort: 'asc' }]
1001
+ });
1002
+ }