@aiao/rxdb-plugin-sync 0.0.26 → 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 (56) hide show
  1. package/dist/SyncManager.d.ts +16 -2
  2. package/dist/SyncManager.d.ts.map +1 -1
  3. package/dist/branch-materialization-source.d.ts +22 -0
  4. package/dist/branch-materialization-source.d.ts.map +1 -0
  5. package/dist/branch-utils.d.ts +32 -0
  6. package/dist/branch-utils.d.ts.map +1 -1
  7. package/dist/bulk-sync.d.ts.map +1 -1
  8. package/dist/cleanup-expired.d.ts.map +1 -1
  9. package/dist/get-repository-sync-status.d.ts.map +1 -1
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1972 -1472
  13. package/dist/plugin.d.ts +5 -1
  14. package/dist/plugin.d.ts.map +1 -1
  15. package/dist/pull-batch.d.ts.map +1 -1
  16. package/dist/pull-conflict-utils.d.ts.map +1 -1
  17. package/dist/pull-repository.d.ts +20 -1
  18. package/dist/pull-repository.d.ts.map +1 -1
  19. package/dist/pull-round.d.ts +41 -0
  20. package/dist/pull-round.d.ts.map +1 -0
  21. package/dist/push-branch.d.ts +1 -1
  22. package/dist/push-branch.d.ts.map +1 -1
  23. package/dist/push-repository.d.ts +18 -2
  24. package/dist/push-repository.d.ts.map +1 -1
  25. package/dist/push.d.ts.map +1 -1
  26. package/dist/query-cache-outbox.d.ts.map +1 -1
  27. package/dist/sync-branches.d.ts +51 -5
  28. package/dist/sync-branches.d.ts.map +1 -1
  29. package/dist/sync-listeners.d.ts.map +1 -1
  30. package/dist/sync-repository.d.ts +3 -2
  31. package/dist/sync-repository.d.ts.map +1 -1
  32. package/package.json +7 -5
  33. package/src/SyncManager.ts +28 -0
  34. package/src/branch-materialization-source.ts +445 -0
  35. package/src/branch-utils.ts +42 -5
  36. package/src/bulk-sync.ts +22 -11
  37. package/src/check-repository-updates.ts +1 -1
  38. package/src/cleanup-expired.ts +17 -3
  39. package/src/get-repository-sync-status.ts +7 -7
  40. package/src/index.ts +7 -2
  41. package/src/plugin.ts +9 -1
  42. package/src/pull-batch.ts +16 -40
  43. package/src/pull-conflict-utils.ts +4 -0
  44. package/src/pull-repository.ts +43 -44
  45. package/src/pull-round.ts +103 -0
  46. package/src/pull.ts +1 -1
  47. package/src/push-branch.ts +4 -3
  48. package/src/push-repository.ts +674 -135
  49. package/src/push.ts +3 -1
  50. package/src/query-cache-outbox.ts +82 -9
  51. package/src/sync-branches.ts +130 -10
  52. package/src/sync-listeners.ts +30 -10
  53. package/src/sync-repository.ts +11 -6
  54. package/dist/version.utils.d.ts +0 -3
  55. package/dist/version.utils.d.ts.map +0 -1
  56. package/src/version.utils.ts +0 -20
@@ -6,14 +6,19 @@
6
6
  */
7
7
 
8
8
  import {
9
+ assertSingleActiveBranch,
9
10
  compactChanges,
11
+ declareTrustedWrite,
12
+ type EntityType,
10
13
  getEntityMetadata,
11
14
  getOrCreateSyncRecord,
15
+ getRxDBChangeEntityIdQueryValues,
12
16
  getRxDBChangeKey,
13
17
  getSyncType,
14
18
  IRepository,
15
19
  type PushRepositoryResult,
16
- type RemoteMergeResult,
20
+ type RemoteChangeRejection,
21
+ type RemoteChangeResult,
17
22
  repositoryKey,
18
23
  RepositorySyncBeginEvent,
19
24
  RepositorySyncCompleteEvent,
@@ -28,9 +33,13 @@ import {
28
33
  RxDBSync,
29
34
  type SwitchVersionActions,
30
35
  type SwitchVersionChange,
31
- type SyncFailure
36
+ type SyncFailure,
37
+ type SyncRejection,
38
+ type TransactionExecutor,
39
+ TrustedWriteIntent
32
40
  } from '@aiao/rxdb';
33
41
  import type { PushInFlightSession } from '@aiao/rxdb-plugin-history';
42
+ import { firstValueFrom } from 'rxjs';
34
43
  import { getAncestorBranchIds } from './branch-utils.js';
35
44
  import { findBlockingDependency } from './cascade-blocking.js';
36
45
  import { buildDependencyGraph, type DependencyGraph, type RepositoryIdentifier } from './dependency-graph.js';
@@ -140,8 +149,15 @@ function throwPushFailure(result: PushRepositoryResult): never {
140
149
  * @param namespace - 实体命名空间
141
150
  * @param entity - 实体名称
142
151
  * @param options - 推送选项
152
+ * @param rejectionSink - 被拒清单收集器。传入时本次调用只往里追加、不上报,由调用方在整次操作结束时
153
+ * 统一上报(`push()` / `bulkSync` 一轮多仓);不传时本次调用结束即自行上报。
143
154
  * @returns 推送结果
144
155
  *
156
+ * @remarks
157
+ * `reportRejections` 是整体替换语义:一轮操作里多次 `pushRepository` 各自上报会互相覆盖,
158
+ * 只剩最后一个仓库的清单,所以上报边界必须由整次操作的发起者持有。
159
+ * 无论成功、部分失败还是抛错,已落库的被拒都会交出去(追加进收集器或自行上报)。
160
+ *
145
161
  * @example
146
162
  * ```ts
147
163
  * // 在不进行级联的情况下推送 Todo 仓库
@@ -159,7 +175,8 @@ export async function pushRepository(
159
175
  sm: SyncManager,
160
176
  namespace: string,
161
177
  entity: string,
162
- options?: PushRepositoryOptions
178
+ options?: PushRepositoryOptions,
179
+ rejectionSink?: SyncRejection[]
163
180
  ): Promise<PushRepositoryResult> {
164
181
  const opts: Required<PushRepositoryOptions> = {
165
182
  batchSize: options?.batchSize === undefined ? DEFAULT_PUSH_REPOSITORY_OPTIONS.batchSize : options.batchSize,
@@ -174,10 +191,21 @@ export async function pushRepository(
174
191
  // 本轮 push 认领的「在飞」区间;undo 据此把还在往返途中的变更当成已推。
175
192
  // 从哪条路径提前返回都会经下面那个 finally,认领不会泄漏。
176
193
  const inFlight = sm.pushInFlight.session();
194
+ // 本次调用已落库的被拒;先于完成/错误事件交出去,监听者看到事件时清单已是最新
195
+ const rejections: SyncRejection[] = [];
196
+ const settleRejections = (): void => {
197
+ if (rejectionSink) rejectionSink.push(...rejections);
198
+ else reportPushRejections(sm, rejections);
199
+ };
177
200
 
178
201
  try {
179
202
  assertBatchSize(opts.batchSize);
180
- const result = await _pushRepositoryImpl(sm, namespace, entity, opts, inFlight);
203
+ let result: PushRepositoryResult;
204
+ try {
205
+ result = await _pushRepositoryImpl(sm, namespace, entity, opts, inFlight, rejections);
206
+ } finally {
207
+ settleRejections();
208
+ }
181
209
 
182
210
  // 触发完成事件
183
211
  rxdb.dispatchEvent(
@@ -206,7 +234,8 @@ async function _pushRepositoryImpl(
206
234
  namespace: string,
207
235
  entity: string,
208
236
  opts: Required<PushRepositoryOptions>,
209
- inFlight: PushInFlightSession
237
+ inFlight: PushInFlightSession,
238
+ rejections: SyncRejection[]
210
239
  ): Promise<PushRepositoryResult> {
211
240
  // 验证仓库是否存在
212
241
  const EntityType = sm.rxdb.config.entities.find(e => {
@@ -227,7 +256,7 @@ async function _pushRepositoryImpl(
227
256
  sm.rxdb,
228
257
  namespace,
229
258
  entity,
230
- getSyncType(metadata, sm.rxdb.config.sync)
259
+ getSyncType(metadata, sm.rxdb.entitySync)
231
260
  );
232
261
 
233
262
  if (ineligible) {
@@ -236,11 +265,11 @@ async function _pushRepositoryImpl(
236
265
 
237
266
  // 处理级联推送
238
267
  if (opts.includeRelated) {
239
- return await pushWithCascade(sm, namespace, entity, opts, inFlight);
268
+ return await pushWithCascade(sm, namespace, entity, opts, inFlight, rejections);
240
269
  }
241
270
 
242
271
  // 单仓库推送
243
- return await pushSingleRepository(sm, namespace, entity, opts, inFlight);
272
+ return await pushSingleRepository(sm, namespace, entity, opts, inFlight, rejections);
244
273
  }
245
274
 
246
275
  /**
@@ -253,7 +282,7 @@ async function _pushRepositoryImpl(
253
282
  *
254
283
  * 每个仓库的「查变更 + 压缩」只做一次({@link planRepositoryPush}),两个相位共用同一份
255
284
  * 计划;落库(写 `remoteId` + 推进水位线)也只做一次,在全部相位跑完后统一提交
256
- * ({@link commitRepositoryPush})。否则 `originalCount` / `compacted` 会被算两遍,
285
+ * ({@link commitPushPlans})。否则 `originalCount` / `compacted` 会被算两遍,
257
286
  * 且第一个相位就把水位线推到最大 change id,第二个相位的变更会被整批吞掉。
258
287
  *
259
288
  * @internal
@@ -263,7 +292,8 @@ async function pushWithCascade(
263
292
  namespace: string,
264
293
  entity: string,
265
294
  options: Required<PushRepositoryOptions>,
266
- inFlight: PushInFlightSession
295
+ inFlight: PushInFlightSession,
296
+ rejections: SyncRejection[]
267
297
  ): Promise<PushRepositoryResult> {
268
298
  // 构建依赖图
269
299
  const entities = sm.rxdb.config.entities.map(e => getEntityMetadata(e));
@@ -307,15 +337,25 @@ async function pushWithCascade(
307
337
  }
308
338
 
309
339
  // 相位全部跑完才落库:水位线必须一次推到位,中途推进会吞掉后一个相位的变更
310
- const results: PushRepositoryResult[] = [];
311
- const failures: SyncFailure[] = [];
312
-
313
- for (const repo of orderRepos('INSERT')) {
340
+ const insertOrder = orderRepos('INSERT').map(repo => {
314
341
  const node = nodes.get(repositoryKey(repo));
315
342
  // 每个相位都会遍历全部仓库,节点必然已建好
316
343
  if (!node) throw new RxDBError(`Internal error: cascade node missing for ${repositoryKey(repo)}`);
344
+ return node;
345
+ });
346
+ // 未定案的仓库一起提交:被拒对齐的删除要子先父后、恢复要父先子后,逐仓各开一个事务做不到
347
+ const committed = await commitPushPlans(
348
+ sm,
349
+ insertOrder.flatMap(node => (node.result ? [] : [node.plan!])),
350
+ rejections
351
+ );
317
352
 
318
- const result = node.result ?? (await commitRepositoryPush(node.plan!));
353
+ const results: PushRepositoryResult[] = [];
354
+ const failures: SyncFailure[] = [];
355
+ for (const node of insertOrder) {
356
+ const repo = node.repo;
357
+ const result = node.result ?? committed.get(node.plan!);
358
+ if (!result) throw new RxDBError(`Internal error: push result missing for ${repositoryKey(repo)}`);
319
359
  results.push(result);
320
360
 
321
361
  // 按策略跳过(`skipped` 且 `success`)不算失败,不进失败清单
@@ -432,7 +472,8 @@ async function runCascadePhase(
432
472
 
433
473
  // 本相位推失败:立刻定案,后续相位不再推,并把失败传导给依赖它的仓库
434
474
  if (node.plan.error) {
435
- node.result = await commitRepositoryPush(node.plan);
475
+ // 有条目没拿到回执:什么都不落,被拒也不算,下一轮整批重推
476
+ node.result = pushRepositoryResult(node.plan, node.plan.pushed, 0, node.plan.error);
436
477
  failedRepos.set(repoKey, node.plan.error);
437
478
  }
438
479
  }
@@ -458,7 +499,7 @@ async function cascadeNodeIneligibility(sm: SyncManager, repo: RepositoryIdentif
458
499
  sm.rxdb,
459
500
  repo.namespace,
460
501
  repo.entity,
461
- getSyncType(getEntityMetadata(EntityType), sm.rxdb.config.sync)
502
+ getSyncType(getEntityMetadata(EntityType), sm.rxdb.entitySync)
462
503
  );
463
504
  }
464
505
 
@@ -468,7 +509,7 @@ async function cascadeNodeIneligibility(sm: SyncManager, repo: RepositoryIdentif
468
509
  * @internal
469
510
  */
470
511
  function emptyPushProgress(): Omit<PushRepositoryResult, 'repository'> {
471
- return { pushed: 0, failed: 0, compacted: 0, originalCount: 0, failures: [] };
512
+ return { pushed: 0, rejected: 0, failed: 0, compacted: 0, originalCount: 0, failures: [] };
472
513
  }
473
514
 
474
515
  /**
@@ -479,7 +520,7 @@ function emptyPushProgress(): Omit<PushRepositoryResult, 'repository'> {
479
520
  * 于是「远端确实收到了几条,结果里记作 0」—— 而水位线又因为本轮失败不会推进,
480
521
  * 调用方从计数上完全看不出发生过部分推送,回头对账「远端为什么多出几条」时无从查起。
481
522
  *
482
- * `failed` 与 {@link commitRepositoryPush} 同一个算式(`effectiveCount - pushed`),
523
+ * `failed` 与 {@link pushRepositoryResult} 同一个算式(`effectiveCount - pushed`),
483
524
  * 保证「阻断」和「提交失败」两条路径交出的计数可以直接相加。
484
525
  *
485
526
  * 第一个相位就被阻断时没有 `plan`,各计数本就该是 0。
@@ -494,6 +535,8 @@ function blockedPushProgress(plan: RepositoryPushPlan | undefined): Omit<PushRep
494
535
 
495
536
  return {
496
537
  pushed: plan.pushed,
538
+ // 没落库的被拒不算被拒:标记没写、对齐没做,下一轮会原样重推
539
+ rejected: 0,
497
540
  failed: plan.effectiveCount - plan.pushed,
498
541
  compacted: plan.compacted,
499
542
  originalCount: plan.originalCount,
@@ -504,6 +547,8 @@ function blockedPushProgress(plan: RepositoryPushPlan | undefined): Omit<PushRep
504
547
  type CompactedActionKind = 'deletes' | 'updates' | 'inserts';
505
548
 
506
549
  interface CompactedPushEntry {
550
+ /** 源变更所属分支,也是这一条推送时的目标分支,见 {@link compactPushEntriesByBranch} */
551
+ branchId: string;
507
552
  actionKind: CompactedActionKind;
508
553
  key: string;
509
554
  action: SwitchVersionChange;
@@ -513,7 +558,6 @@ interface CompactedPushEntry {
513
558
  interface CompactedPushBatch {
514
559
  actions: SwitchVersionActions;
515
560
  sourceChanges: RxDBChange[];
516
- sourceChangesByLocalId: Map<number, RxDBChange[]>;
517
561
  }
518
562
 
519
563
  /**
@@ -541,7 +585,66 @@ const ALL_ACTION_KINDS: ReadonlySet<CompactedActionKind> = new Set<CompactedActi
541
585
  'inserts'
542
586
  ]);
543
587
 
544
- function buildCompactedPushEntries(localChanges: RxDBChange[], actions: SwitchVersionActions): CompactedPushEntry[] {
588
+ /**
589
+ * 按源变更**自己的**分支分组压缩,祖先在前(main 最先)。
590
+ *
591
+ * @remarks
592
+ * 不能把祖先分支上未推送的变更与当前分支的并在一起压缩、再以当前分支为目标推送:
593
+ * 远端只在目标为 main 时写业务表,其余分支只写日志;而 main 的配对校验要求每条 main 变更
594
+ * 都有对应的业务写入(RX002 `unpaired_change`)。以 feature 为目标会丢掉 main 变更的业务写入,
595
+ * 以 main 为目标又会把 feature 的改动写进 main,还要改写变更的 branchId——两条路都不对。
596
+ * 各自成组、按各自分支推送,两边才都成立。
597
+ *
598
+ * 祖先在前:后代分支的变更建立在祖先状态之上,远端也应先见到祖先那一段。
599
+ * 同一实体在不同分支上的变更因此各成一条,`effectiveCount` 按条累计。
600
+ *
601
+ * @param localChanges - 待推变更,`branchId` 都在 `branchIds` 内(查询时已按它过滤)
602
+ * @param branchIds - 当前分支及其祖先,自身在前({@link getAncestorBranchIds} 的顺序)
603
+ *
604
+ * @internal
605
+ */
606
+ function compactPushEntriesByBranch(localChanges: RxDBChange[], branchIds: readonly string[]): CompactedPushEntry[] {
607
+ const changesByBranch = new Map<string, RxDBChange[]>();
608
+ for (const change of localChanges) {
609
+ const { branchId } = change;
610
+ // 查询按 branchId =/IN 过滤,NULL 分支的行进不来;走到这里说明查询与本函数的约定被打破
611
+ if (branchId === undefined) throw new RxDBError(`Internal error: unpushed change ${change.id} has no branchId`);
612
+ const group = changesByBranch.get(branchId) ?? [];
613
+ group.push(change);
614
+ changesByBranch.set(branchId, group);
615
+ }
616
+ return [...branchIds].reverse().flatMap(branchId => {
617
+ const changes = changesByBranch.get(branchId);
618
+ return changes ? compactBranchChanges(branchId, changes) : [];
619
+ });
620
+ }
621
+
622
+ /** 单个分支内的压缩:与分组前的逻辑相同,只是范围限定在这一个分支的变更上。 */
623
+ function compactBranchChanges(branchId: string, changes: RxDBChange[]): CompactedPushEntry[] {
624
+ const actions: SwitchVersionActions = { deletes: new Map(), updates: new Map(), inserts: new Map() };
625
+ compactChanges(
626
+ changes.map(c => ({
627
+ id: c.id,
628
+ namespace: c.namespace,
629
+ entity: c.entity,
630
+ entityId: c.entityId,
631
+ type: c.type as 'INSERT' | 'UPDATE' | 'DELETE',
632
+ branchId: c.branchId,
633
+ patch: c.patch,
634
+ inversePatch: c.inversePatch,
635
+ createdAt: c.createdAt,
636
+ updatedAt: c.updatedAt
637
+ })),
638
+ actions
639
+ );
640
+ return buildCompactedPushEntries(branchId, changes, actions);
641
+ }
642
+
643
+ function buildCompactedPushEntries(
644
+ branchId: string,
645
+ localChanges: RxDBChange[],
646
+ actions: SwitchVersionActions
647
+ ): CompactedPushEntry[] {
545
648
  const changesByKey = new Map<string, RxDBChange[]>();
546
649
  for (const change of localChanges) {
547
650
  const key = getRxDBChangeKey(change);
@@ -559,6 +662,7 @@ function buildCompactedPushEntries(localChanges: RxDBChange[], actions: SwitchVe
559
662
  }
560
663
 
561
664
  pushEntries.push({
665
+ branchId,
562
666
  actionKind,
563
667
  key,
564
668
  action,
@@ -581,60 +685,124 @@ function createCompactedPushBatch(entries: CompactedPushEntry[]): CompactedPushB
581
685
  inserts: new Map()
582
686
  };
583
687
  const sourceChanges: RxDBChange[] = [];
584
- const sourceChangesByLocalId = new Map<number, RxDBChange[]>();
585
688
 
586
689
  for (const entry of entries) {
587
690
  actions[entry.actionKind].set(entry.key, entry.action);
588
691
  for (const sourceChange of entry.sourceChanges) {
589
692
  sourceChanges.push(sourceChange);
590
- sourceChangesByLocalId.set(sourceChange.id, [sourceChange]);
591
693
  }
592
694
  }
593
695
 
594
696
  sourceChanges.sort((left, right) => left.id - right.id);
595
697
  return {
596
698
  actions,
597
- sourceChanges,
598
- sourceChangesByLocalId
699
+ sourceChanges
599
700
  };
600
701
  }
601
702
 
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
- }
703
+ /**
704
+ * 覆盖检查:远端结果必须与本批源变更的 localId 集合一一对应。
705
+ *
706
+ * @remarks
707
+ * 缺项、重复、或多出未知 localId 都视为契约违反——远端没有按约定覆盖本批全部源变更,
708
+ * 整轮必须失败、水位线不能推进(AC#14、FR-017)。检查先于任何本地写入,
709
+ * 因此失败时本地不会留下任何 `remoteId`。
710
+ */
711
+ function assertMergeResultCoversBatch(results: RemoteChangeResult[], sourceChanges: RxDBChange[]): void {
712
+ const expectedLocalIds = new Set(sourceChanges.map(change => change.id));
713
+ const seenLocalIds = new Set<number>();
608
714
 
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}`);
715
+ for (const result of results) {
716
+ if (!expectedLocalIds.has(result.localId)) {
717
+ throw new RxDBError(`Remote merge result references unknown local change: ${result.localId}`);
617
718
  }
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);
719
+ if (seenLocalIds.has(result.localId)) {
720
+ throw new RxDBError(`Remote merge result duplicates local change: ${result.localId}`);
625
721
  }
722
+ seenLocalIds.add(result.localId);
626
723
  }
627
724
 
628
- return [...remoteIdsByChange].map(([change, remoteId]) => ({ change, remoteId }));
725
+ if (seenLocalIds.size !== expectedLocalIds.size) {
726
+ const missing = [...expectedLocalIds].filter(localId => !seenLocalIds.has(localId));
727
+ throw new RxDBError(`Remote merge result missing local changes: ${missing.join(', ')}`);
728
+ }
729
+ }
730
+
731
+ /**
732
+ * 被远端拒绝的一个压缩后条目:实体的全部源变更一起标记,共用同一份拒绝原因。
733
+ *
734
+ * @internal
735
+ */
736
+ interface RejectedPushEntry {
737
+ readonly entry: CompactedPushEntry;
738
+ readonly rejection: RemoteChangeRejection;
739
+ }
740
+
741
+ /**
742
+ * 一批回执按条目归类后的结果
743
+ *
744
+ * @internal
745
+ */
746
+ interface ClassifiedPushBatch {
747
+ readonly applied: Array<{ change: RxDBChange; remoteId: number }>;
748
+ readonly rejected: RejectedPushEntry[];
749
+ }
750
+
751
+ /**
752
+ * 判定一个条目是否被远端拒绝。
753
+ *
754
+ * @remarks
755
+ * 被拒以实体为单位(data-model §7):同一条目的源变更必须同进同退。一部分 applied、一部分被拒
756
+ * 说明远端把一次合并后的写拆开处理了——本地无论按哪一半落库都会和远端对不上,只能整轮失败。
757
+ * 这一步与覆盖检查一样先于任何本地写入。
758
+ *
759
+ * @returns 被拒时返回拒绝原因,全部 applied 时返回 `undefined`
760
+ */
761
+ function entryRejection(
762
+ entry: CompactedPushEntry,
763
+ resultByLocalId: ReadonlyMap<number, RemoteChangeResult>
764
+ ): RemoteChangeRejection | undefined {
765
+ const statuses = entry.sourceChanges.map(change => {
766
+ const result = resultByLocalId.get(change.id);
767
+ // 覆盖检查已确保本批每条源变更都恰有一项回执
768
+ if (!result) throw new RxDBError(`Remote merge result missing local changes: ${change.id}`);
769
+ return result;
770
+ });
771
+ const rejected = statuses.find(
772
+ (result): result is Extract<RemoteChangeResult, { status: 'rejected' }> => result.status === 'rejected'
773
+ );
774
+ if (!rejected) return undefined;
775
+ if (statuses.some(result => result.status === 'applied')) {
776
+ throw new RxDBError(`Remote merge result splits entity ${entry.key}: some source changes applied, some rejected`);
777
+ }
778
+ return rejected.rejection;
629
779
  }
630
780
 
631
781
  async function mergePushBatch(
632
782
  remoteAdapter: RxDBAdapterRemoteBase,
633
783
  branchId: string,
634
- batch: CompactedPushBatch
635
- ): Promise<Array<{ change: RxDBChange; remoteId: number }>> {
784
+ entries: CompactedPushEntry[]
785
+ ): Promise<ClassifiedPushBatch> {
786
+ const batch = createCompactedPushBatch(entries);
636
787
  const mergeResult = await remoteAdapter.mergeChanges(batch.actions, branchId, batch.sourceChanges);
637
- return mapRemoteIds(getChangeIdMapping(mergeResult), batch.sourceChangesByLocalId);
788
+ assertMergeResultCoversBatch(mergeResult.results, batch.sourceChanges);
789
+
790
+ const resultByLocalId = new Map(mergeResult.results.map(result => [result.localId, result]));
791
+ const changeByLocalId = new Map(batch.sourceChanges.map(change => [change.id, change]));
792
+ const classified: ClassifiedPushBatch = { applied: [], rejected: [] };
793
+ for (const entry of entries) {
794
+ const rejection = entryRejection(entry, resultByLocalId);
795
+ if (rejection) classified.rejected.push({ entry, rejection });
796
+ }
797
+ // applied 按回执顺序收集:被拒条目整体不会出现 applied(上面已拒绝拆分),这里只需按状态过滤
798
+ for (const result of mergeResult.results) {
799
+ if (result.status !== 'applied') continue;
800
+ const change = changeByLocalId.get(result.localId);
801
+ // 覆盖检查已确保 result.localId ∈ 本批源变更
802
+ if (!change) throw new RxDBError(`Remote merge result references unknown local change: ${result.localId}`);
803
+ classified.applied.push({ change, remoteId: result.remoteId });
804
+ }
805
+ return classified;
638
806
  }
639
807
 
640
808
  /**
@@ -648,19 +816,24 @@ async function mergePushBatch(
648
816
  */
649
817
  interface RepositoryPushPlan {
650
818
  readonly repository: RepositoryIdentifier;
819
+ readonly entityType: EntityType;
651
820
  readonly entries: CompactedPushEntry[];
652
821
  readonly localChanges: RxDBChange[];
653
822
  readonly repoSync: RxDBSync;
654
823
  readonly localAdapter: Awaited<ReturnType<SyncManager['getLocalRepositories']>>['adapter'];
655
824
  readonly remoteAdapter: RxDBAdapterRemoteBase;
656
825
  readonly branchId: string;
826
+ /** 规划时的 {@link SyncManager.branchSwitchGeneration};提交时据此识别远端往返期间的分支切换。 */
827
+ readonly branchGeneration: number;
657
828
  readonly originalCount: number;
658
829
  readonly effectiveCount: number;
659
830
  readonly compacted: number;
660
- /** 各相位累积推送成功的条目数。 */
831
+ /** 各相位累积被远端接受(applied)的条目数;被拒的不算。 */
661
832
  pushed: number;
662
833
  /** 各相位累积拿到的远端 id,最终一并落库。 */
663
834
  readonly remoteIdsByChange: Map<RxDBChange, number>;
835
+ /** 各相位累积被远端拒绝的条目,最终与远端 id 同一个事务落库。 */
836
+ readonly rejectedEntries: RejectedPushEntry[];
664
837
  error?: Error;
665
838
  }
666
839
 
@@ -689,6 +862,8 @@ async function planRepositoryPush(
689
862
  await sm.getRemoteRepositories(); // 确保远端已配置
690
863
  const { adapter: localAdapter } = await sm.getLocalRepositories();
691
864
 
865
+ // 代际先于分支读取:两者之间若插进一次切换,提交时代际对不上,宁可多拒一轮也不错配
866
+ const branchGeneration = sm.branchSwitchGeneration;
692
867
  // 获取当前分支
693
868
  const branch = await sm.getCurrentBranch();
694
869
 
@@ -699,8 +874,10 @@ async function planRepositoryPush(
699
874
  const meta = getEntityMetadata(e);
700
875
  return meta.namespace === namespace && meta.name === entity;
701
876
  });
702
- const metadata = getEntityMetadata(EntityType!);
703
- const syncType = getSyncType(metadata, sm.rxdb.config.sync);
877
+ // 调用方(单仓入口 / 级联资格闸门)已按同一条件确认过实体存在
878
+ if (!EntityType) throw new RxDBError(`Entity not found: ${namespace}:${entity}`);
879
+ const metadata = getEntityMetadata(EntityType);
880
+ const syncType = getSyncType(metadata, sm.rxdb.entitySync);
704
881
 
705
882
  const repoSync = await getOrCreateSyncRecord(
706
883
  repoSyncRepo,
@@ -751,30 +928,9 @@ async function planRepositoryPush(
751
928
  return { emptyResult: { ...emptyPushProgress(), repository: { namespace, entity } } };
752
929
  }
753
930
 
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;
931
+ // 压缩变更:按变更自己的分支分组,见 compactPushEntriesByBranch
932
+ const entries = compactPushEntriesByBranch(localChanges, branchIds);
933
+ const effectiveCount = entries.length;
778
934
  const compacted = originalCount - effectiveCount;
779
935
 
780
936
  // 无有效变更:整批被本地压缩抵消(如 INSERT → DELETE 且服务器从未见过这条数据)。
@@ -812,25 +968,28 @@ async function planRepositoryPush(
812
968
 
813
969
  return {
814
970
  repository: { namespace, entity },
815
- entries: buildCompactedPushEntries(localChanges, localActions),
971
+ entityType: EntityType,
972
+ entries,
816
973
  localChanges,
817
974
  repoSync,
818
975
  localAdapter,
819
976
  remoteAdapter,
820
977
  branchId: branch.id,
978
+ branchGeneration,
821
979
  originalCount,
822
980
  effectiveCount,
823
981
  compacted,
824
982
  pushed: 0,
825
- remoteIdsByChange: new Map<RxDBChange, number>()
983
+ remoteIdsByChange: new Map<RxDBChange, number>(),
984
+ rejectedEntries: []
826
985
  };
827
986
  }
828
987
 
829
988
  /**
830
989
  * 阶段二:把计划里属于本相位的条目发给远端,**不落库**。
831
990
  *
832
- * 成功推送的条目累加进 `plan.pushed`,拿到的远端 id 累加进 `plan.remoteIdsByChange`,
833
- * 都留给 {@link commitRepositoryPush} 一次性提交。
991
+ * applied 的条目累加进 `plan.pushed`,拿到的远端 id 累加进 `plan.remoteIdsByChange`,
992
+ * 被拒的条目累加进 `plan.rejectedEntries`,都留给 {@link commitPushPlans} 一次性提交。
834
993
  *
835
994
  * 前一个相位已失败时直接返回:继续推只会在一个已知不会落库的批次上白跑一趟远端。
836
995
  *
@@ -848,16 +1007,14 @@ async function pushPlanEntries(
848
1007
 
849
1008
  const { namespace, entity } = plan.repository;
850
1009
 
851
- for (let offset = 0; offset < entries.length; offset += batchSize) {
852
- const batchEntries = entries.slice(offset, offset + batchSize);
853
- const batch = createCompactedPushBatch(batchEntries);
854
-
1010
+ for (const { branchId, entries: batchEntries } of splitPushBatches(entries, batchSize)) {
855
1011
  try {
856
- const mappedChanges = await mergePushBatch(plan.remoteAdapter, plan.branchId, batch);
857
- for (const { change, remoteId } of mappedChanges) {
1012
+ const classified = await mergePushBatch(plan.remoteAdapter, branchId, batchEntries);
1013
+ for (const { change, remoteId } of classified.applied) {
858
1014
  plan.remoteIdsByChange.set(change, remoteId);
859
1015
  }
860
- plan.pushed += batchEntries.length;
1016
+ for (const rejected of classified.rejected) plan.rejectedEntries.push(rejected);
1017
+ plan.pushed += batchEntries.length - classified.rejected.length;
861
1018
  } catch (error) {
862
1019
  console.error(`Error pushing changes to remote for repository [${namespace}/${entity}]:`, error);
863
1020
  plan.error = error instanceof Error ? error : new Error(String(error));
@@ -867,70 +1024,447 @@ async function pushPlanEntries(
867
1024
  }
868
1025
 
869
1026
  /**
870
- * 阶段三:全部相位跑完后,把远端 id 和水位线一次性落库。
871
- *
872
- * 只有一条变更都没失败才落库:部分成功就推进水位线会让没推上去的那些永远丢失。
1027
+ * 一次 `mergeChanges` 的条目:同属一个目标分支。
873
1028
  *
874
1029
  * @internal
875
1030
  */
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;
1031
+ interface PushBatchSlice {
1032
+ readonly branchId: string;
1033
+ readonly entries: CompactedPushEntry[];
1034
+ }
882
1035
 
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);
1036
+ /**
1037
+ * 切批:一批只含一个分支的条目(一次 `mergeChanges` 只有一个目标分支),且不超过 `batchSize`。
1038
+ *
1039
+ * @remarks
1040
+ * 依赖条目已按分支成段排列({@link compactPushEntriesByBranch} 的输出,按动作类型过滤后仍成段),
1041
+ * 因此只需在分支变化处断开,批次顺序即祖先在前。
1042
+ *
1043
+ * @internal
1044
+ */
1045
+ function splitPushBatches(entries: readonly CompactedPushEntry[], batchSize: number): PushBatchSlice[] {
1046
+ const batches: PushBatchSlice[] = [];
1047
+ for (const entry of entries) {
1048
+ const last = batches.at(-1);
1049
+ if (last?.branchId === entry.branchId && last.entries.length < batchSize) {
1050
+ last.entries.push(entry);
1051
+ } else {
1052
+ batches.push({ branchId: entry.branchId, entries: [entry] });
1053
+ }
1054
+ }
1055
+ return batches;
1056
+ }
886
1057
 
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();
1058
+ /**
1059
+ * 阶段三:全部相位跑完后,把一组仓库的回执(远端 id + 被拒标记)、被拒实体的本地对齐和水位线一次性落库。
1060
+ *
1061
+ * @remarks
1062
+ * 只有每个条目都拿到了回执的仓库才落库:部分成功就推进水位线会让没推上去的那些永远丢失。
1063
+ * 被拒是回执的一种,不是失败——被拒条目照样越过水位线,标记为终态,不再重发(data-model §7)。
1064
+ *
1065
+ * 可落库的仓库**共用一个本地事务**,全有或全无:被拒新建要在本地删除(子先父后),被拒删除要在本地
1066
+ * 恢复(父先子后),两个方向相反,逐仓各开一个事务必然有一个方向先撞上本地外键。代价是同批任一仓库
1067
+ * 落库失败,其他仓库也一起不落库、下一轮重推。
1068
+ *
1069
+ * 落库成功后把被拒清单追加进 `rejections`,由调用方在整次推送结束时统一上报:
1070
+ * `reportRejections` 是整体替换语义,逐仓上报只会留下最后一个仓库的清单。
1071
+ *
1072
+ * @param sm - SyncManager 实例
1073
+ * @param plans - 已跑完全部相位的推送计划,按 INSERT 拓扑序(父先子后)
1074
+ * @param rejections - 落库成功后追加被拒清单的收集器
1075
+ * @returns 每个计划的推送结果
1076
+ *
1077
+ * @internal
1078
+ */
1079
+ async function commitPushPlans(
1080
+ sm: SyncManager,
1081
+ plans: readonly RepositoryPushPlan[],
1082
+ rejections: SyncRejection[]
1083
+ ): Promise<Map<RepositoryPushPlan, PushRepositoryResult>> {
1084
+ const results = new Map<RepositoryPushPlan, PushRepositoryResult>();
1085
+ const ready: RepositoryPushPlan[] = [];
1086
+ for (const plan of plans) {
1087
+ // 还有条目没拿到回执(某批失败):什么都不落,被拒也不算,下一轮整批重推
1088
+ if (plan.effectiveCount - plan.pushed - plan.rejectedEntries.length > 0) {
1089
+ results.set(plan, pushRepositoryResult(plan, plan.pushed, 0, plan.error));
1090
+ } else {
1091
+ ready.push(plan);
1092
+ }
1093
+ }
1094
+ if (ready.length === 0) return results;
894
1095
 
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;
1096
+ try {
1097
+ const committed = await persistPushReceipts(sm, ready);
1098
+ for (const rejection of committed) rejections.push(rejection);
1099
+ for (const plan of ready) {
1100
+ results.set(plan, pushRepositoryResult(plan, plan.pushed, plan.rejectedEntries.length, undefined));
919
1101
  }
1102
+ } catch (error) {
1103
+ const repositories = ready.map(plan => repositoryKey(plan.repository)).join(', ');
1104
+ console.error(`Error saving push state for repositories [${repositories}]:`, error);
1105
+ const normalized = error instanceof Error ? error : new Error(String(error));
1106
+ for (const plan of ready) results.set(plan, pushRepositoryResult(plan, 0, 0, normalized));
920
1107
  }
1108
+ return results;
1109
+ }
921
1110
 
1111
+ /**
1112
+ * 按计数组装仓库结果;`failed` 由不变式 `effectiveCount = pushed + failed + rejected` 反推。
1113
+ *
1114
+ * @internal
1115
+ */
1116
+ function pushRepositoryResult(
1117
+ plan: RepositoryPushPlan,
1118
+ pushed: number,
1119
+ rejected: number,
1120
+ error: Error | undefined
1121
+ ): PushRepositoryResult {
1122
+ const { repository } = plan;
1123
+ const failed = plan.effectiveCount - pushed - rejected;
922
1124
  return {
923
1125
  repository,
924
1126
  success: failed === 0,
925
- error: pushError,
1127
+ error,
926
1128
  pushed,
1129
+ rejected,
927
1130
  failed,
928
1131
  compacted: plan.compacted,
929
1132
  originalCount: plan.originalCount,
930
- failures: pushError ? [{ repository, error: pushError }] : []
1133
+ failures: error ? [{ repository, error }] : []
931
1134
  };
932
1135
  }
933
1136
 
1137
+ /**
1138
+ * 回执落库:applied 写 `remoteId`,被拒写 `rejectedAt` / `rejection`,对齐被拒实体,推进水位线——同一个本地事务。
1139
+ *
1140
+ * @remarks
1141
+ * 被拒实体的远端当前行在**事务外**先取齐(远端往返不能夹在本地事务里);取不到就整轮不提交,
1142
+ * 与提交失败同一个出口:内存里的标记回滚,水位线不动,下一轮重推会再拿一次回执。
1143
+ *
1144
+ * 对齐分两段:本地删除按计划逆序(子先父后),本地恢复按计划顺序(父先子后)。父行先删会撞上
1145
+ * RESTRICT,或经 CASCADE 带走子行、让子仓的删除拿不到前像(删除事件的前像取自删除时返回的本地行)。
1146
+ * 同一仓库内的自引用外键不在此列,仍按单次 `mergeChanges` 的顺序执行。
1147
+ *
1148
+ * @param sm - SyncManager 实例
1149
+ * @param plans - 回执齐全的推送计划,按 INSERT 拓扑序(父先子后)
1150
+ * @returns 落库成功后的被拒清单
1151
+ *
1152
+ * @internal
1153
+ */
1154
+ async function persistPushReceipts(sm: SyncManager, plans: readonly RepositoryPushPlan[]): Promise<SyncRejection[]> {
1155
+ const prepared: { plan: RepositoryPushPlan; remoteRows: Map<string, Record<string, unknown>> }[] = [];
1156
+ for (const plan of plans) prepared.push({ plan, remoteRows: await fetchRejectedRemoteRows(plan) });
1157
+
1158
+ const pushedAt = new Date();
1159
+ const restorers = plans.map(snapshotPushState);
1160
+
1161
+ try {
1162
+ // 计划里的本地适配器都来自同一个 `sm.getLocalRepositories()`
1163
+ await plans[0].localAdapter.transaction(async executor => {
1164
+ await assertPlansOnActiveBranch(sm, executor, plans);
1165
+ const alignments: RejectionAlignment[] = [];
1166
+ for (const { plan, remoteRows } of prepared) {
1167
+ await writePushReceipts(executor, plan, pushedAt);
1168
+ alignments.push(await collectRejectionAlignment(executor, plan, remoteRows));
1169
+ }
1170
+ for (const { removals } of [...alignments].reverse()) await alignRejectedEntities(executor, removals);
1171
+ for (const { restores } of alignments) await alignRejectedEntities(executor, restores);
1172
+ for (const plan of plans) {
1173
+ await executor.getRepository(RxDBSync).update(plan.repoSync, {
1174
+ lastPushedChangeId: maxPlanChangeId(plan),
1175
+ lastPushedAt: pushedAt,
1176
+ updatedAt: pushedAt
1177
+ });
1178
+ }
1179
+ });
1180
+ } catch (error) {
1181
+ for (const restore of restorers) restore();
1182
+ throw error;
1183
+ }
1184
+
1185
+ return plans.flatMap(plan => toSyncRejections(plan.rejectedEntries, pushedAt));
1186
+ }
1187
+
1188
+ /**
1189
+ * 本轮落库要动的变更:拿到远端 id 的,以及被拒条目的全部源变更。
1190
+ *
1191
+ * @internal
1192
+ */
1193
+ function pushTouchedChanges(plan: RepositoryPushPlan): RxDBChange[] {
1194
+ return [...plan.remoteIdsByChange.keys(), ...plan.rejectedEntries.flatMap(({ entry }) => entry.sourceChanges)];
1195
+ }
1196
+
1197
+ /**
1198
+ * 本计划全部源变更里最大的 change id,即落库后的水位线。
1199
+ *
1200
+ * @internal
1201
+ */
1202
+ function maxPlanChangeId(plan: RepositoryPushPlan): number {
1203
+ // 使用 reduce 求最大值,避免 Math.max(...arr) 在超大数组上触发调用栈溢出
1204
+ return plan.localChanges.reduce((max, c) => (c.id > max ? c.id : max), plan.localChanges[0].id);
1205
+ }
1206
+
1207
+ /**
1208
+ * 记下落库会改动的内存状态(变更的回执字段、同步记录的水位线),返回把它们还原的函数。
1209
+ *
1210
+ * @internal
1211
+ */
1212
+ function snapshotPushState(plan: RepositoryPushPlan): () => void {
1213
+ const { repoSync } = plan;
1214
+ const previousChanges = pushTouchedChanges(plan).map(change => ({
1215
+ change,
1216
+ remoteId: change.remoteId,
1217
+ rejectedAt: change.rejectedAt,
1218
+ rejection: change.rejection
1219
+ }));
1220
+ const previousSyncState = {
1221
+ lastPushedChangeId: repoSync.lastPushedChangeId,
1222
+ lastPushedAt: repoSync.lastPushedAt,
1223
+ updatedAt: repoSync.updatedAt
1224
+ };
1225
+ return () => {
1226
+ for (const previous of previousChanges) {
1227
+ previous.change.remoteId = previous.remoteId;
1228
+ previous.change.rejectedAt = previous.rejectedAt;
1229
+ previous.change.rejection = previous.rejection;
1230
+ }
1231
+ Object.assign(repoSync, previousSyncState);
1232
+ };
1233
+ }
1234
+
1235
+ /**
1236
+ * 在事务内写回执:applied 写 `remoteId`,被拒的源变更写 `rejectedAt` / `rejection`。
1237
+ *
1238
+ * @internal
1239
+ */
1240
+ async function writePushReceipts(executor: TransactionExecutor, plan: RepositoryPushPlan, at: Date): Promise<void> {
1241
+ for (const [change, remoteId] of plan.remoteIdsByChange) change.remoteId = remoteId;
1242
+ for (const { entry, rejection } of plan.rejectedEntries) {
1243
+ for (const change of entry.sourceChanges) {
1244
+ change.rejectedAt = at;
1245
+ change.rejection = rejection;
1246
+ }
1247
+ }
1248
+ const touched = pushTouchedChanges(plan);
1249
+ if (touched.length > 0) await executor.saveMany(touched);
1250
+ }
1251
+
1252
+ /**
1253
+ * 被拒对齐改写的是**当前 active 分支**的业务投影,提交前必须确认它仍是规划时的那个分支。
1254
+ *
1255
+ * @remarks
1256
+ * 远端往返期间切过分支时,把旧分支的被拒对齐写下去会覆盖另一个分支的投影(切到 feature),
1257
+ * 或改写一份被切走又重建过的投影(main→feature→main:分支 id 对得上,激活代际对不上)。
1258
+ * 此时在任何本地写入之前让整轮提交失败:内存标记回滚、水位线不动,回到该分支后的下一轮推送会再拿一次回执。
1259
+ *
1260
+ * active 分支经**本事务**读:走 `sm.getCurrentBranch()` 会排在已持有的事务后面。
1261
+ * 激活代际只覆盖本实例内的切换;跨标签页的切换只由持久化的 active 分支兜住,A→B→A 不可见。
1262
+ *
1263
+ * 没有被拒时不校验:只写回执与水位线、不碰业务投影,不值得为一次分支切换让正常推送整轮重推。
1264
+ *
1265
+ * @internal
1266
+ */
1267
+ async function assertPlansOnActiveBranch(
1268
+ sm: SyncManager,
1269
+ executor: TransactionExecutor,
1270
+ plans: readonly RepositoryPushPlan[]
1271
+ ): Promise<void> {
1272
+ if (!plans.some(plan => plan.rejectedEntries.length > 0)) return;
1273
+
1274
+ const active = await assertSingleActiveBranch(executor);
1275
+ const generation = sm.branchSwitchGeneration;
1276
+ const stale = plans.find(plan => plan.branchId !== active.id || plan.branchGeneration !== generation);
1277
+ if (stale) {
1278
+ throw new RxDBError(
1279
+ `Branch switched while pushing ${repositoryKey(stale.repository)} from branch ${stale.branchId}; ` +
1280
+ `rejected receipts are not applied to active branch ${active.id}.`
1281
+ );
1282
+ }
1283
+ }
1284
+
1285
+ /**
1286
+ * 向远端取被拒实体的当前行,按 {@link rejectedRowKey} 索引;远端没有的行不出现在结果里。
1287
+ *
1288
+ * @remarks
1289
+ * 按回执里的实体引用分组查(`namespace:entity` 作为 `findByIds` 的实体名),不假设被拒实体都属于本仓。
1290
+ * 本轮无被拒时不查远端。
1291
+ *
1292
+ * @internal
1293
+ */
1294
+ async function fetchRejectedRemoteRows(plan: RepositoryPushPlan): Promise<Map<string, Record<string, unknown>>> {
1295
+ const rows = new Map<string, Record<string, unknown>>();
1296
+ const idsByScope = new Map<string, string[]>();
1297
+ for (const { rejection } of plan.rejectedEntries) {
1298
+ const scope = `${rejection.entity.namespace}:${rejection.entity.entity}`;
1299
+ const ids = idsByScope.get(scope) ?? [];
1300
+ ids.push(rejection.entity.entityId);
1301
+ idsByScope.set(scope, ids);
1302
+ }
1303
+
1304
+ for (const [scope, ids] of idsByScope) {
1305
+ const found = await firstValueFrom(plan.remoteAdapter.findByIds<Record<string, unknown>>(scope, ids));
1306
+ for (const row of found) rows.set(`${scope}:${String(row['id'])}`, row);
1307
+ }
1308
+ return rows;
1309
+ }
1310
+
1311
+ /**
1312
+ * 被拒实体在 {@link fetchRejectedRemoteRows} 结果里的键:回执的实体引用,`entityId` 已是字符串形式。
1313
+ *
1314
+ * @internal
1315
+ */
1316
+ function rejectedRowKey(rejection: RemoteChangeRejection): string {
1317
+ const { namespace, entity, entityId } = rejection.entity;
1318
+ return `${namespace}:${entity}:${entityId}`;
1319
+ }
1320
+
1321
+ /**
1322
+ * 一个仓库被拒实体的本地对齐动作,按方向拆开:移除与恢复要分处两段执行,见 {@link persistPushReceipts}。
1323
+ *
1324
+ * @internal
1325
+ */
1326
+ interface RejectionAlignment {
1327
+ /** 远端无行:本地行移除(被拒的新建 / 更新) */
1328
+ readonly removals: SwitchVersionActions;
1329
+ /** 远端有行:本地行覆盖成远端值,被拒的删除即重建 */
1330
+ readonly restores: SwitchVersionActions;
1331
+ }
1332
+
1333
+ const emptyActions = (): SwitchVersionActions => ({ deletes: new Map(), updates: new Map(), inserts: new Map() });
1334
+
1335
+ /**
1336
+ * 算出被拒实体的本地对齐动作:远端有行就覆盖(被拒的删除即恢复),远端无行就移除。
1337
+ *
1338
+ * @remarks
1339
+ * 被拒实体在本批之外还有更新的待推变更(远端往返期间用户又改过)时**不对齐它**:
1340
+ * 覆盖掉会吞掉用户那次还没推的改动;它的下一轮推送会拿到自己的回执。这里不按分支过滤,
1341
+ * 任一分支上有更新的待推变更都算——宁可少对齐一次,也不覆盖用户改动。
1342
+ *
1343
+ * @internal
1344
+ */
1345
+ async function collectRejectionAlignment(
1346
+ executor: TransactionExecutor,
1347
+ plan: RepositoryPushPlan,
1348
+ remoteRows: ReadonlyMap<string, Record<string, unknown>>
1349
+ ): Promise<RejectionAlignment> {
1350
+ const alignment: RejectionAlignment = { removals: emptyActions(), restores: emptyActions() };
1351
+ if (plan.rejectedEntries.length === 0) return alignment;
1352
+
1353
+ const { namespace, entity } = plan.repository;
1354
+ const newerPending = await executor.getRepository(RxDBChange).find({
1355
+ where: {
1356
+ combinator: 'and',
1357
+ rules: [
1358
+ { field: 'namespace', operator: '=', value: namespace },
1359
+ { field: 'entity', operator: '=', value: entity },
1360
+ {
1361
+ field: 'entityId',
1362
+ operator: 'in',
1363
+ value: getRxDBChangeEntityIdQueryValues(
1364
+ plan.rejectedEntries.map(({ rejection }) => rejection.entity.entityId)
1365
+ )
1366
+ },
1367
+ { field: 'id', operator: '>', value: maxPlanChangeId(plan) },
1368
+ { field: 'revertChangeId', operator: '=', value: null },
1369
+ { field: 'remoteId', operator: '=', value: null },
1370
+ { field: 'rejectedAt', operator: '=', value: null }
1371
+ ]
1372
+ } as never
1373
+ });
1374
+ const skipped = new Set(newerPending.map(change => getRxDBChangeKey(change)));
1375
+
1376
+ for (const { entry, rejection } of plan.rejectedEntries) {
1377
+ if (!skipped.has(entry.key)) addAlignmentAction(alignment, entry, remoteRows.get(rejectedRowKey(rejection)));
1378
+ }
1379
+ return alignment;
1380
+ }
1381
+
1382
+ /**
1383
+ * 把一段对齐动作写进本地业务表。
1384
+ *
1385
+ * @remarks
1386
+ * 走 `executor.mergeChanges(…, true)` 关触发器写入:对齐是「把远端投影抄回本地」,不是一次用户改动,
1387
+ * 不能产生 `RxDBChange`,否则下一轮会把远端拒绝过的值又推一次。
1388
+ * 受信写入声明是一次性的,每次 `mergeChanges` 前都要重新声明。
1389
+ *
1390
+ * @internal
1391
+ */
1392
+ async function alignRejectedEntities(executor: TransactionExecutor, actions: SwitchVersionActions): Promise<void> {
1393
+ if (actions.inserts.size + actions.updates.size + actions.deletes.size === 0) return;
1394
+
1395
+ declareTrustedWrite(executor, {
1396
+ file: 'push-repository.ts',
1397
+ symbol: 'alignRejectedEntities',
1398
+ intent: TrustedWriteIntent.remote_sync
1399
+ });
1400
+ await executor.mergeChanges(actions, undefined, true);
1401
+ }
1402
+
1403
+ /**
1404
+ * 一个被拒条目对应的对齐动作:本地行要变成远端行(或在远端无行时消失)。
1405
+ *
1406
+ * @remarks
1407
+ * 移除动作不带前像:本地行此刻还在(删除子先父后保证它不会先被父行的 CASCADE 带走),
1408
+ * 前像由适配器在删除时从本地行取得,不在这里伪造。
1409
+ *
1410
+ * @internal
1411
+ */
1412
+ function addAlignmentAction(
1413
+ alignment: RejectionAlignment,
1414
+ entry: CompactedPushEntry,
1415
+ remoteRow: Record<string, unknown> | undefined
1416
+ ): void {
1417
+ if (remoteRow) {
1418
+ // 被拒的删除:本地行已经没了,按远端值重建;其余:本地行还在,覆盖成远端值
1419
+ const { restores } = alignment;
1420
+ const target = entry.actionKind === 'deletes' ? restores.inserts : restores.updates;
1421
+ target.set(entry.key, { patch: remoteRow, inversePatch: null });
1422
+ return;
1423
+ }
1424
+ // 远端无行且被拒的就是删除:两边都没有,无事可做
1425
+ if (entry.actionKind !== 'deletes') {
1426
+ alignment.removals.deletes.set(entry.key, { patch: null, inversePatch: null });
1427
+ }
1428
+ }
1429
+
1430
+ const ACTION_KIND_OP = {
1431
+ deletes: 'DELETE',
1432
+ updates: 'UPDATE',
1433
+ inserts: 'INSERT'
1434
+ } as const satisfies Record<CompactedActionKind, SyncRejection['op']>;
1435
+
1436
+ /**
1437
+ * 被拒条目 → 上报给 `SyncStateHub` 的清单项;`op` 取压缩后真正发给远端的那个操作。
1438
+ *
1439
+ * @internal
1440
+ */
1441
+ function toSyncRejections(rejectedEntries: readonly RejectedPushEntry[], at: Date): SyncRejection[] {
1442
+ return rejectedEntries.map(({ entry, rejection }) => ({
1443
+ namespace: rejection.entity.namespace,
1444
+ entity: rejection.entity.entity,
1445
+ entityId: rejection.entity.entityId,
1446
+ op: ACTION_KIND_OP[entry.actionKind],
1447
+ code: rejection.code,
1448
+ reason: rejection.reason,
1449
+ message: rejection.message,
1450
+ ...(rejection.dependsOn ? { dependsOn: rejection.dependsOn } : {}),
1451
+ at,
1452
+ changeIds: entry.sourceChanges.map(change => change.id)
1453
+ }));
1454
+ }
1455
+
1456
+ /**
1457
+ * 整次推送结束后把收集到的被拒清单上报一次;没有被拒就不报(不清掉上一轮的清单)。
1458
+ *
1459
+ * @param sm - SyncManager 实例
1460
+ * @param rejections - 整次操作已落库的被拒清单
1461
+ *
1462
+ * @internal
1463
+ */
1464
+ export function reportPushRejections(sm: SyncManager, rejections: readonly SyncRejection[]): void {
1465
+ if (rejections.length > 0) sm.rxdb.syncState.reportRejections(rejections);
1466
+ }
1467
+
934
1468
  /**
935
1469
  * 单仓库推送变更(内部实现)
936
1470
  *
@@ -943,13 +1477,15 @@ async function pushSingleRepository(
943
1477
  namespace: string,
944
1478
  entity: string,
945
1479
  options: Required<PushRepositoryOptions>,
946
- inFlight: PushInFlightSession
1480
+ inFlight: PushInFlightSession,
1481
+ rejections: SyncRejection[]
947
1482
  ): Promise<PushRepositoryResult> {
948
1483
  const planned = await planRepositoryPush(sm, namespace, entity, inFlight);
949
1484
  if ('emptyResult' in planned) return planned.emptyResult;
950
1485
 
951
1486
  await pushPlanEntries(planned, ALL_ACTION_KINDS, options.batchSize);
952
- const result = await commitRepositoryPush(planned);
1487
+ const result = (await commitPushPlans(sm, [planned], rejections)).get(planned);
1488
+ if (!result) throw new RxDBError(`Internal error: push result missing for ${namespace}:${entity}`);
953
1489
  // 与级联路径同一个失败出口,见 throwPushFailure 的 @remarks
954
1490
  if (!result.success) throwPushFailure(result);
955
1491
  return result;
@@ -970,6 +1506,9 @@ async function queryUnpushedChanges(
970
1506
  const baseRules: RxDBChangeRuleGroup['rules'] = [
971
1507
  { field: 'revertChangeId', operator: '=', value: null },
972
1508
  { field: 'remoteId', operator: '=', value: null },
1509
+ // 被拒变更(rejectedAt 非空)的 remoteId 也一直是 null,但它永远不会再被推送,
1510
+ // 必须和 remoteId = null 一起排除,否则会被当成待推变更反复捞出来
1511
+ { field: 'rejectedAt', operator: '=', value: null },
973
1512
  // 实体名在不同 namespace 下可以重名,只按 entity 过滤会把别的 namespace 的
974
1513
  // 变更一起捞出来错推,同时把对方的水位线推进导致后续漏推
975
1514
  { field: 'namespace', operator: '=', value: namespace }