@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
package/src/push.ts CHANGED
@@ -54,6 +54,7 @@ export async function push(sm: SyncManager, options?: PushOptions): Promise<Push
54
54
  // 汇总结果
55
55
  const pushResult: PushResult = {
56
56
  pushed: 0,
57
+ rejected: 0,
57
58
  failed: 0,
58
59
  compacted: 0,
59
60
  originalCount: 0,
@@ -67,6 +68,7 @@ export async function push(sm: SyncManager, options?: PushOptions): Promise<Push
67
68
  const repoResult = result.result?.pushResult;
68
69
  if (repoResult) {
69
70
  pushResult.pushed += repoResult.pushed;
71
+ pushResult.rejected += repoResult.rejected;
70
72
  pushResult.failed += repoResult.failed;
71
73
  pushResult.compacted += repoResult.compacted;
72
74
  pushResult.originalCount += repoResult.originalCount;
@@ -75,7 +77,7 @@ export async function push(sm: SyncManager, options?: PushOptions): Promise<Push
75
77
  if (result.success) continue;
76
78
 
77
79
  // 失败**不折算成 failed += 1**:failed 的单位是变更条数,混进仓库计数会破坏
78
- // `originalCount = pushed + failed + compacted`。失败结构化保留在 failures 里。
80
+ // `originalCount = pushed + failed + rejected + compacted`。失败结构化保留在 failures 里。
79
81
  const failure =
80
82
  result.error ??
81
83
  new RxDBError(`Repository push failed: ${result.repository.namespace}:${result.repository.entity}`);
@@ -41,6 +41,7 @@ import {
41
41
  isNetworkError,
42
42
  isRepositorySyncEnabled,
43
43
  LWWConflictResolver,
44
+ type QueryCacheEntity,
44
45
  type QueryCacheRemoteAdapter,
45
46
  type RxDB,
46
47
  RxDBChange,
@@ -327,7 +328,12 @@ async function runOutboxFlush(
327
328
  if (metadata) {
328
329
  await replayPhases(entries, metadata, { rxdb, entity, remoteAdapter, conflictResolver, run });
329
330
  }
330
- await repairLocalCache(localAdapter, remoteAdapter, entity, run);
331
+ await repairLocalCache(localAdapter, remoteAdapter, entity, run, {
332
+ changeRepo,
333
+ namespace,
334
+ branchId: branch.id,
335
+ maxChangeId
336
+ });
331
337
 
332
338
  if (run.failures.length > 0) {
333
339
  // 整批没推完,但已经结算掉的那一段可以让水位线跟上去。不跟的话,下一轮会把这些
@@ -407,7 +413,7 @@ export async function countQueryCacheOutbox(rxdb: RxDB): Promise<number> {
407
413
  where: { combinator: 'and', rules: [{ field: 'branchId', operator: '=', value: branch.id }] }
408
414
  });
409
415
 
410
- const repoRules = buildOfflineWriteRepositoryRules(rxdb.config.entities, rxdb.config.sync, repoSyncs);
416
+ const repoRules = buildOfflineWriteRepositoryRules(rxdb.config.entities, rxdb.entitySync, repoSyncs);
411
417
  if (repoRules.length === 0) {
412
418
  return 0;
413
419
  }
@@ -423,6 +429,9 @@ export async function countQueryCacheOutbox(rxdb: RxDB): Promise<number> {
423
429
  { field: 'branchId', operator: '=', value: branch.id },
424
430
  { field: 'revertChangeId', operator: '=', value: null },
425
431
  { field: 'remoteId', operator: '=', value: null },
432
+ // 被拒变更(rejectedAt 非空)的 remoteId 也一直是 null,但它永远不会再被推送,
433
+ // 必须和 remoteId = null 一起排除,否则会被当成积压计进出站队列
434
+ { field: 'rejectedAt', operator: '=', value: null },
426
435
  { combinator: 'or', rules: repoRules }
427
436
  ]
428
437
  }
@@ -489,7 +498,7 @@ function resolveQueryCacheSyncType(rxdb: RxDB, namespace: string, entity: string
489
498
  throw new RxDBError(`Entity not found for QueryCache outbox flush: ${namespace}/${entity}`);
490
499
  }
491
500
 
492
- const syncType = getSyncType(getEntityMetadata(EntityType), rxdb.config.sync);
501
+ const syncType = getSyncType(getEntityMetadata(EntityType), rxdb.entitySync);
493
502
  if (syncType !== 'querycache') {
494
503
  throw new RxDBError(
495
504
  `flushQueryCacheOutbox only handles syncType 'querycache'; ${namespace}/${entity} is '${syncType}'. ` +
@@ -522,6 +531,9 @@ async function queryOutboxChanges(
522
531
  { field: 'entity', operator: '=', value: entity },
523
532
  { field: 'branchId', operator: '=', value: branchId },
524
533
  { field: 'remoteId', operator: '=', value: null },
534
+ // 被拒变更(rejectedAt 非空)的 remoteId 也一直是 null,但它永远不会再被推送,
535
+ // 必须和 remoteId = null 一起排除,否则会被当成待推行重放到远端
536
+ { field: 'rejectedAt', operator: '=', value: null },
525
537
  { field: 'revertChangeId', operator: '=', value: null }
526
538
  ];
527
539
 
@@ -836,6 +848,47 @@ function remoteWrite(
836
848
  return remoteAdapter.update ? remoteAdapter.update(entity, entry.entityId, patch) : null;
837
849
  }
838
850
 
851
+ /** {@link repairLocalCache} 判定「这批 id 里谁被新写盖过」所需的快照上下文 */
852
+ interface RepairSnapshot {
853
+ changeRepo: IRepository<typeof RxDBChange>;
854
+ namespace: string;
855
+ branchId: string;
856
+ /** 本轮重放开始时 `pending` 的最大变更行 id —— 判定起点,见 {@link findEntityIdsWithNewerPendingChanges} */
857
+ maxChangeId: number;
858
+ }
859
+
860
+ /**
861
+ * 查一遍这批实体里谁在本轮快照之后又产生了新的待推变更。
862
+ *
863
+ * @returns 有新变更的实体 id 集合
864
+ *
865
+ * @remarks
866
+ * RV-055:`repairLocalCache` 要把 KEEP_REMOTE 判负的那些行覆盖回远端取到的投影,但判负
867
+ * 是对着 `runOutboxFlush` 开头那份 `pending` 快照做的 —— 决定做完、`findByIds` 响应还在
868
+ * 网络上滞留的这段时间里,用户完全可能在同一个实体上又写了一笔离线改动。那次新写有
869
+ * 它自己的 `RxDBChange` 行,而触发器写入的变更 id 是自增的,必然大于本轮快照的
870
+ * `maxChangeId`——不用比时间戳(铁律:不用计时式修复),只用这一条单调递增的序号就能
871
+ * 确定「是不是本轮判过负之后才出现的」。命中的那些 id 对应的修复必须跳过:旧判决覆盖
872
+ * 的是「本轮快照里的状态」,而本地此刻的真实状态已经是那笔新写,拿旧判决去盖,等于让
873
+ * 一次已经提交的新写无声消失,且发起它的人永远不会知道。
874
+ *
875
+ * 查询条件复用 {@link queryOutboxChanges}:同分支、未推送、未回滚、`id > maxChangeId`,
876
+ * 与「谁还排在出站队列里」同一个口径,不开第二份判据。
877
+ */
878
+ async function findEntityIdsWithNewerPendingChanges(
879
+ snapshot: RepairSnapshot,
880
+ entity: string
881
+ ): Promise<ReadonlySet<string>> {
882
+ const newer = await queryOutboxChanges(
883
+ snapshot.changeRepo,
884
+ snapshot.namespace,
885
+ entity,
886
+ snapshot.branchId,
887
+ snapshot.maxChangeId
888
+ );
889
+ return new Set(newer.map(change => String(change.entityId)));
890
+ }
891
+
839
892
  /**
840
893
  * 把 LWW 判负的那些行在本地缓存里对齐远端。
841
894
  *
@@ -846,19 +899,39 @@ function remoteWrite(
846
899
  * 回去 —— 一个自我供给的循环,而且每绕一圈都以远端的那次改动被丢弃告终。
847
900
  *
848
901
  * 修复失败不改判定:远端侧已经是权威状态了,这里只是本地投影没跟上,下一次读会同步回来。
902
+ *
903
+ * **落盘前重新核对一遍「谁被新写盖过」(RV-055)**:真正的竞争窗口就是上面
904
+ * `findByIds` 在网络上滞留的这段时间,核对必须放在它之后、紧贴着 `upsertMany` /
905
+ * `deleteByIds` 之前 —— 早一步核对看到的还是旧状态,等于没核对。命中的 id 从本次要
906
+ * 写的集合里摘掉,留给下一轮:那时它会带着「本地此刻的真实状态」重新跟远端的最新值
907
+ * 比一次,而不是被这一轮的旧判决悄悄盖掉。
849
908
  */
850
909
  async function repairLocalCache(
851
910
  localAdapter: OutboxLocalAdapter,
852
911
  remoteAdapter: QueryCacheRemoteAdapter,
853
912
  entity: string,
854
- run: RunState
913
+ run: RunState,
914
+ snapshot: RepairSnapshot
855
915
  ): Promise<void> {
856
- if (run.restoreIds.length > 0) {
857
- const rows = await firstValueFrom(remoteAdapter.findByIds(entity, run.restoreIds));
858
- await firstValueFrom(localAdapter.upsertMany(entity, rows));
916
+ if (run.restoreIds.length === 0 && run.dropIds.length === 0) {
917
+ return;
859
918
  }
860
- if (run.dropIds.length > 0) {
861
- await firstValueFrom(localAdapter.deleteByIds(entity, run.dropIds));
919
+
920
+ const rows =
921
+ run.restoreIds.length > 0 ?
922
+ await firstValueFrom(remoteAdapter.findByIds<QueryCacheEntity>(entity, run.restoreIds))
923
+ : [];
924
+
925
+ const superseded = await findEntityIdsWithNewerPendingChanges(snapshot, entity);
926
+
927
+ const freshRows = rows.filter(row => !superseded.has(row.id));
928
+ if (freshRows.length > 0) {
929
+ await firstValueFrom(localAdapter.upsertMany(entity, freshRows));
930
+ }
931
+
932
+ const freshDropIds = run.dropIds.filter(id => !superseded.has(id));
933
+ if (freshDropIds.length > 0) {
934
+ await firstValueFrom(localAdapter.deleteByIds(entity, freshDropIds));
862
935
  }
863
936
  }
864
937
 
@@ -1,29 +1,100 @@
1
- import { type IRepository, RxDBBranch, RxDBChange, RxDBError } from '@aiao/rxdb';
1
+ import {
2
+ assertUsableBranchId,
3
+ InvalidBranchIdError,
4
+ RxDBBranch,
5
+ RxDBChange,
6
+ RxDBError,
7
+ type IRepository
8
+ } from '@aiao/rxdb';
2
9
  import { toLocalFromChangeId } from './branch-change-id.js';
3
10
  import type { SyncManager } from './SyncManager.js';
4
11
 
12
+ /**
13
+ * {@link SyncManager.syncBranches} 一轮的结果。
14
+ *
15
+ * @remarks
16
+ * `created + updated + skipped.length` 不一定等于 `total`:本地已有、且早已标了 `remote` 的
17
+ * 分支既不计 `updated`,也不进 `skipped`。
18
+ */
5
19
  export interface SyncBranchesResult {
20
+ /** 本轮在本地新建的远端分支数。 */
6
21
  created: number;
22
+ /** 本地已有、本轮补上 `remote: true` 标记的分支数。 */
7
23
  updated: number;
24
+ /** 远端本轮交回的分支总数,含被跳过的。 */
8
25
  total: number;
9
26
 
10
27
  /**
11
- * 因分叉点变更尚未拉到本地而**本轮未创建**的分支 id。
28
+ * 本轮**未创建**的远端分支 id —— 具体原因见同一个 id 在 {@link skipReasons} 里的条目。
12
29
  *
13
- * 远端分支行上的 `fromChangeId` 是远端 change id,落到本地必须翻译成本地 id
14
- * (见 {@link toLocalFromChangeId})。翻译不出来时既不能写远端 id(本地会当自己的 id 消费,
15
- * 分叉点变成一个无关变更),也不能写 `null`(`find-switch-branch-step` 会当「分叉于根」,
16
- * 切换分支时从第一条变更起算)。唯一无损的选择是本轮跳过,等分叉点变更拉到本地后再建。
30
+ * 一条分支被跳过,可能是它自己的问题(id 不可用、或分叉点变更还没拉到本地翻译不出本地
31
+ * id),也可能单纯因为它的父本轮被跳过——后一种情形下这条分支自身完全合格,但父本轮
32
+ * 不会落库,若仍然照常创建,它的 `parentId` 就是一条指向「本轮不存在的父」的悬空外键
33
+ * (`rxdb_branch.parentId` 是 `PRAGMA defer_foreign_keys` 延迟到 COMMIT 才检查的外键,
34
+ * 届时会回滚整个事务,连同本轮所有本该成功的分支)。三种成因统一走同一条 `skipped`
35
+ * 通道,因为对调用方而言处理方式是一样的:这个 id 本轮没有对应的本地分支,仅此而已。
36
+ *
37
+ * **跳过不留持久标记**:每轮都全量重拉远端分支、从头再判一遍,`skipped` 与
38
+ * {@link skipReasons} 只报这一轮。`unresolved-from-change-id` 在分叉点变更拉到本地后自愈,
39
+ * 挂在它下面的 `ancestor-skipped` 随之自愈;`invalid-id` 则远端不改就每轮重拉、重跳,
40
+ * 一直占位——调用方要区分这两类,看 {@link skipReasons}。要不要记一个持久的「已知坏行」
41
+ * 标记,登记在 `requirements/roadmap.md`「epic-006 评审顺延的架构项」。
17
42
  */
18
43
  skipped: string[];
44
+
45
+ /**
46
+ * `skipped` 里每一个 id 对应的具体跳过原因;key 覆盖 `skipped` 的每一项,一一对应。
47
+ *
48
+ * 单开一个字段而不是把原因塞进 `skipped` 数组本身:`skipped: string[]` 这个形状已经被
49
+ * 外部当「id 列表」消费,改成对象数组是破坏性变更;`Record<id, reason>` 是纯增量,
50
+ * 只读 `skipped` 的既有调用方不受影响。
51
+ */
52
+ skipReasons: Record<string, SyncBranchSkipReason>;
19
53
  }
20
54
 
55
+ /**
56
+ * 一条远端分支本轮被跳过的具体原因。
57
+ *
58
+ * @remarks
59
+ * 三种成因互斥——处理顺序保证每条被跳过的分支只会落进其中一种:
60
+ * - `invalid-id`:分支自己的 id 落不进本地 `rxdb_branch.id`(见 {@link isUsableBranchId})。
61
+ * - `unresolved-from-change-id`:分叉点变更还没拉到本地,`fromChangeId` 翻译不出本地 id。
62
+ * - `ancestor-skipped`:分支自身没有问题,但它的**直接父**本轮已经被跳过(不管父是因为
63
+ * 哪一种成因被跳的)。`ancestorId` 记的是那个直接父的 id,不是链路最顶端的根因——
64
+ * 要追根因,沿 `skipReasons[ancestorId]` 递归上溯,直到查到的原因不再是
65
+ * `ancestor-skipped` 为止。
66
+ */
67
+ export type SyncBranchSkipReason =
68
+ | { readonly cause: 'invalid-id' }
69
+ | { readonly cause: 'unresolved-from-change-id' }
70
+ | { readonly cause: 'ancestor-skipped'; readonly ancestorId: string };
71
+
21
72
  /** `pullBranches()` 交回来的形状里,本函数只依赖这三个字段。 */
22
73
  interface RemoteBranchRow {
23
74
  id: string;
24
75
  parentId?: string | null;
25
76
  }
26
77
 
78
+ /**
79
+ * 远端分支 id 能否落进本地 `rxdb_branch.id`。
80
+ *
81
+ * @remarks
82
+ * 判定口径**只有**核心 {@link assertUsableBranchId} 一份——这里把它的抛错翻成布尔,
83
+ * 不是第二份规则。各写一遍的话,核心哪天多禁一个字符,同步这条导入路径会静默放行。
84
+ *
85
+ * 只吞 {@link InvalidBranchIdError}:别的异常说明校验自身坏了,那不是「这条远端行不合格」,
86
+ * 不该被记成一次 `skipped`。
87
+ */
88
+ function isUsableBranchId(branchId: string): boolean {
89
+ try {
90
+ assertUsableBranchId(branchId);
91
+ return true;
92
+ } catch (error) {
93
+ if (error instanceof InvalidBranchIdError) return false;
94
+ throw error;
95
+ }
96
+ }
97
+
27
98
  /**
28
99
  * 把远端分支按「父在前」重排。
29
100
  *
@@ -98,12 +169,12 @@ export async function syncBranches(sm: SyncManager): Promise<SyncBranchesResult>
98
169
  const { adapter: remoteAdapter } = await sm.getRemoteRepositories();
99
170
 
100
171
  if (!remoteAdapter.pullBranches) {
101
- return { created: 0, updated: 0, total: 0, skipped: [] };
172
+ return { created: 0, updated: 0, total: 0, skipped: [], skipReasons: {} };
102
173
  }
103
174
 
104
175
  const remoteBranches = await remoteAdapter.pullBranches();
105
176
  if (remoteBranches.length === 0) {
106
- return { created: 0, updated: 0, total: 0, skipped: [] };
177
+ return { created: 0, updated: 0, total: 0, skipped: [], skipReasons: {} };
107
178
  }
108
179
 
109
180
  const { adapter } = await sm.getLocalRepositories();
@@ -120,8 +191,48 @@ export async function syncBranches(sm: SyncManager): Promise<SyncBranchesResult>
120
191
  let created = 0;
121
192
  let updated = 0;
122
193
  const skipped: string[] = [];
194
+ // 本轮已跳过的分支 id → 跳过原因。级联判断(下面对 `remoteParentId` 的检查)只能靠它,
195
+ // 不能靠 `localMap`——`localMap` 是本轮开始前的快照,而分支已经按父优先的拓扑序处理
196
+ // (`sortBranchesParentFirst`),父的跳过决定必然先于子被记录进这个集合。
197
+ //
198
+ // 用 Map 记、返回时才经 `Object.fromEntries` 转成对象:键是远端给的分支 id,`__proto__`
199
+ // 过得了 id 校验。对象字面量逐键赋值会命中 `Object.prototype.__proto__` 的 setter,
200
+ // 改写返回对象的原型而不是添一个键;`Object.fromEntries` 建的是自有数据属性,不走 setter。
201
+ const skippedThisRound = new Map<string, SyncBranchSkipReason>();
202
+
203
+ /**
204
+ * 记一次跳过:本函数三处跳过点(id 不可用 / 分叉点翻译不出 / 祖先已跳过)共用同一套
205
+ * 记账。不分渠道是有意为之——不管一条分支这一轮为什么建不出来,它的后代都同样不能
206
+ * 创建,记账收敛成一个函数,下面对 `skippedThisRound` 的级联检查才能对三种成因
207
+ * 一次性生效,不用每加一种跳过原因就多写一遍级联判断。
208
+ */
209
+ const recordSkip = (branchId: string, reason: SyncBranchSkipReason): void => {
210
+ skipped.push(branchId);
211
+ skippedThisRound.set(branchId, reason);
212
+ };
123
213
 
124
214
  for (const remote of sortBranchesParentFirst(remoteBranches, new Set(localMap.keys()))) {
215
+ // 子分支连带跳过必须最先判断:父本轮不会落库,子无论自身是否「合格」都不能创建——
216
+ // 创建了就是一条指向「本轮不存在的父」的悬空外键,真实落库会在 COMMIT 时被
217
+ // `PRAGMA defer_foreign_keys` 卡住,回滚整个事务(连同本轮所有本该成功的分支)。
218
+ // 父是因为 id 不可用被跳的、还是分叉点翻译不出被跳的、还是它自己的父被跳的,
219
+ // 这里都不关心——`skippedThisRound` 不区分渠道,只认「本轮是否已经决定跳过」。
220
+ const remoteParentId = remote.parentId ?? null;
221
+ if (remoteParentId !== null && skippedThisRound.has(remoteParentId)) {
222
+ recordSkip(remote.id, { cause: 'ancestor-skipped', ancestorId: remoteParentId });
223
+ continue;
224
+ }
225
+
226
+ // 远端分支行是外来数据,它的 id 没走过本地那条创建路径。不校验就直接落库,
227
+ // 一条叫 `*active*` 的远端分支会与 active 哨兵同形(`system/active-branch-guard.ts`)。
228
+ //
229
+ // 跳过而不是整批放弃:`skipped` 这条通道本来就是为「这一行本轮落不了库,别的行照常」
230
+ // 准备的。整批抛错会让一条坏的远端行把整个同步卡死,而本地这边一点办法都没有。
231
+ if (!isUsableBranchId(remote.id)) {
232
+ recordSkip(remote.id, { cause: 'invalid-id' });
233
+ continue;
234
+ }
235
+
125
236
  const local = localMap.get(remote.id);
126
237
  if (local) {
127
238
  if (!local.remote) {
@@ -136,13 +247,16 @@ export async function syncBranches(sm: SyncManager): Promise<SyncBranchesResult>
136
247
  const fromChangeId =
137
248
  remoteFromChangeId === null ? null : await toLocalFromChangeId(changeRepository, remoteFromChangeId);
138
249
  if (remoteFromChangeId !== null && fromChangeId === null) {
139
- skipped.push(remote.id);
250
+ recordSkip(remote.id, { cause: 'unresolved-from-change-id' });
140
251
  continue;
141
252
  }
142
253
 
143
254
  await branchRepository.create({
144
255
  id: remote.id,
145
256
  activated: false,
257
+ // 显式写 NULL 而不是留空:两列必须同进同出(`system/branch.ts`)。
258
+ // 留空在两个后端上**眼下**也落成 NULL,但那是默认值的巧合,不是这一行的意图。
259
+ activeKey: null,
146
260
  local: false,
147
261
  remote: true,
148
262
  fromChangeId,
@@ -151,6 +265,12 @@ export async function syncBranches(sm: SyncManager): Promise<SyncBranchesResult>
151
265
  created++;
152
266
  }
153
267
 
154
- return { created, updated, total: remoteBranches.length, skipped };
268
+ return {
269
+ created,
270
+ updated,
271
+ total: remoteBranches.length,
272
+ skipped,
273
+ skipReasons: Object.fromEntries(skippedThisRound)
274
+ };
155
275
  });
156
276
  }
@@ -28,8 +28,8 @@ type RemoteEntityEvent = EntityRemoteCreatedEvent | EntityRemoteUpdatedEvent | E
28
28
  * 跑一步回推动作,把失败报给面板后吞掉
29
29
  *
30
30
  * @param syncState - 失败的去处
31
- * @param task - 这一步
32
- * @returns 这一步是否成功
31
+ * @param task - 这一步;返回值是这一步**自己**判定的成功与否(见 {@link flushRepository})
32
+ * @returns 这一步是否成功——异常通道与 `task` 自报的 `false` 都算失败
33
33
  *
34
34
  * @remarks
35
35
  * 一轮里的各个仓库互不背书:一个仓库的出站队列重放不上,不代表别的仓库那条 REST 路
@@ -39,11 +39,15 @@ type RemoteEntityEvent = EntityRemoteCreatedEvent | EntityRemoteUpdatedEvent | E
39
39
  * 多少次网络都不会再有回推。真正的重试节奏交给
40
40
  * {@link ReachabilityMonitor.wakeup$} 的退避。吞掉不等于藏起来:错误进
41
41
  * {@link SyncStateHub.reportError},用户在面板上看得见。
42
+ *
43
+ * **只兜异常通道**:`flushQueryCacheOutbox` 对结构化失败(REST 403、网络错误)从不
44
+ * `reject`,失败躺在返回值的 `failures` 数组里(RV-052)。这里只负责「`task` 真的
45
+ * 抛了」这一种失败;`task` 自己上报 `syncState.reportError` 并返回 `false` 的那种
46
+ * 失败,原样作为返回值传上去,不重复上报。
42
47
  */
43
- async function runQuietly(syncState: SyncStateHub, task: () => Promise<unknown>): Promise<boolean> {
48
+ async function runQuietly(syncState: SyncStateHub, task: () => Promise<boolean>): Promise<boolean> {
44
49
  try {
45
- await task();
46
- return true;
50
+ return await task();
47
51
  } catch (error) {
48
52
  syncState.reportError(error);
49
53
  return false;
@@ -80,7 +84,7 @@ function queryCacheRepositories(sm: SyncManager): RepositoryIdentifier[] {
80
84
 
81
85
  for (const EntityClass of consumerEntities(sm)) {
82
86
  const metadata = getEntityMetadata(EntityClass);
83
- const capability = getSyncCapability(getSyncType(metadata, sm.rxdb.config.sync));
87
+ const capability = getSyncCapability(getSyncType(metadata, sm.rxdb.entitySync));
84
88
  if (capability.offlineWrite && !capability.push) {
85
89
  repositories.push({ namespace: metadata.namespace, entity: metadata.name });
86
90
  }
@@ -90,19 +94,35 @@ function queryCacheRepositories(sm: SyncManager): RepositoryIdentifier[] {
90
94
  }
91
95
 
92
96
  /**
93
- * 重放一个 QueryCache 仓库,把判负的实体报给面板
97
+ * 重放一个 QueryCache 仓库,把判负的实体和结构化失败都报给面板
98
+ *
99
+ * @returns 本仓库这一轮是否**真的**成功——`failures` 非空也算失败,即便没有任何异常抛出
94
100
  *
95
101
  * @remarks
96
- * 只有 `conflicts`(解析器判 `KEEP_REMOTE`)进面板:那是用户离线时写的东西被远端盖掉了,
97
- * 是唯一需要他知道的一类。逐条上报而不是只报最后一条,`lastConflict` 自然停在最新的那条。
102
+ * `conflicts`(解析器判 `KEEP_REMOTE`)进面板是用户离线时写的东西被远端盖掉了,
103
+ * 是需要他知道的一类;逐条上报而不是只报最后一条,`lastConflict` 自然停在最新的那条。
104
+ *
105
+ * **`failures` 同样必须上报(RV-052)**:`flushQueryCacheOutbox` 对 REST 403、网络错误
106
+ * 等结构化失败从不 `reject`——Promise 照常 resolve,失败躺在返回值的 `failures` 数组里
107
+ * (见 `query-cache-outbox.ts` 的 `replayEntry`/`runOutboxFlush`)。调用方如果只认「有没有
108
+ * 抛异常」,这类失败会被判成「没出错」,面板上 `lastError` 被清空、`reportSuccess()`
109
+ * 被调用,而出站队列其实原地没动。这里逐条把失败的原因上报给 `syncState`——
110
+ * 和 `conflicts` 同样的理由,`lastError` 自然停在本轮最新的那条——并把「有没有
111
+ * 失败」原样交还给调用方,参与整轮 {@link flushRepositories} 的成功聚合。
98
112
  */
99
- async function flushRepository(sm: SyncManager, namespace: string, entity: string): Promise<void> {
113
+ async function flushRepository(sm: SyncManager, namespace: string, entity: string): Promise<boolean> {
100
114
  const { syncState } = sm.rxdb;
101
115
  const result = await flushQueryCacheOutbox(sm.rxdb, namespace, entity);
102
116
 
103
117
  for (const entityId of result.conflicts) {
104
118
  syncState.reportConflict({ namespace, entity, entityId, winner: 'remote' });
105
119
  }
120
+
121
+ for (const failure of result.failures) {
122
+ syncState.reportError(failure.error);
123
+ }
124
+
125
+ return result.failures.length === 0;
106
126
  }
107
127
 
108
128
  /**
@@ -7,7 +7,8 @@ import {
7
7
  RepositorySyncErrorEvent,
8
8
  RxDBError,
9
9
  RxDBPartialSyncError,
10
- type SyncProgress
10
+ type SyncProgress,
11
+ type SyncRejection
11
12
  } from '@aiao/rxdb';
12
13
  import type { SyncManager } from './SyncManager.js';
13
14
  import {
@@ -89,6 +90,7 @@ function emptyPushResult(namespace: string, entity: string): PushRepositoryResul
89
90
  return {
90
91
  repository: { namespace, entity },
91
92
  pushed: 0,
93
+ rejected: 0,
92
94
  failed: 0,
93
95
  compacted: 0,
94
96
  originalCount: 0,
@@ -167,6 +169,7 @@ function rewrapPushFailure(
167
169
  * @param namespace - 实体命名空间
168
170
  * @param entity - 实体名称
169
171
  * @param options - 同步选项
172
+ * @param rejectionSink - 推送被拒清单收集器,语义同 {@link pushRepository} 的同名参数
170
173
  * @returns 同步结果
171
174
  *
172
175
  * @example
@@ -185,7 +188,8 @@ export async function syncRepository(
185
188
  sm: SyncManager,
186
189
  namespace: string,
187
190
  entity: string,
188
- options?: SyncRepositoryOptions
191
+ options?: SyncRepositoryOptions,
192
+ rejectionSink?: SyncRejection[]
189
193
  ): Promise<SyncRepositoryResult> {
190
194
  const rxdb = sm.rxdb;
191
195
 
@@ -194,7 +198,7 @@ export async function syncRepository(
194
198
  rxdb.dispatchEvent(new RepositorySyncBeginEvent('sync', namespace, entity, includeRelated));
195
199
 
196
200
  try {
197
- const result = await _syncRepositoryImpl(sm, namespace, entity, options);
201
+ const result = await _syncRepositoryImpl(sm, namespace, entity, options, rejectionSink);
198
202
 
199
203
  // 触发完成事件
200
204
  // 两个子结果在 SyncRepositoryResult 上都是必填:跳过的方向由 emptyPullResult /
@@ -226,7 +230,8 @@ async function _syncRepositoryImpl(
226
230
  sm: SyncManager,
227
231
  namespace: string,
228
232
  entity: string,
229
- options?: SyncRepositoryOptions
233
+ options?: SyncRepositoryOptions,
234
+ rejectionSink?: SyncRejection[]
230
235
  ): Promise<SyncRepositoryResult> {
231
236
  // 验证仓库是否存在
232
237
  const EntityType = sm.rxdb.config.entities.find(e => {
@@ -241,7 +246,7 @@ async function _syncRepositoryImpl(
241
246
  const metadata = getEntityMetadata(EntityType);
242
247
 
243
248
  // 检查同步类型(支持全局配置回退)
244
- const syncType = getSyncType(metadata, sm.rxdb.config.sync);
249
+ const syncType = getSyncType(metadata, sm.rxdb.entitySync);
245
250
  if (syncType === 'none') {
246
251
  throw new RxDBError(
247
252
  `Cannot sync repository ${namespace}:${entity}: syncType is 'none'. ` +
@@ -283,7 +288,7 @@ async function _syncRepositoryImpl(
283
288
 
284
289
  if (shouldPush) {
285
290
  try {
286
- pushResult = await pushRepository(sm, namespace, entity, options?.push);
291
+ pushResult = await pushRepository(sm, namespace, entity, options?.push, rejectionSink);
287
292
  } catch (error) {
288
293
  // pull 已落库的进度、push 已发到远端的进度,都不能因为这次抛错就消失
289
294
  throw rewrapPushFailure(error, pullResult, namespace, entity);
@@ -1,3 +0,0 @@
1
- import { RemoteChange, RxDBChange } from '@aiao/rxdb';
2
- export declare const remote_change_to_local: (remoteChange: RemoteChange[], base: Partial<RxDBChange>) => Omit<RxDBChange, "id" | "branch$">[];
3
- //# sourceMappingURL=version.utils.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"version.utils.d.ts","sourceRoot":"","sources":["../src/version.utils.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,YAAY,EAAE,UAAU,EAAQ,MAAM,YAAY,CAAC;AAC5D,eAAO,MAAM,sBAAsB,GACjC,cAAc,YAAY,EAAE,EAC5B,MAAM,OAAO,CAAC,UAAU,CAAC,KACxB,IAAI,CAAC,UAAU,EAAE,IAAI,GAAG,SAAS,CAAC,EAUjC,CAAC"}
@@ -1,20 +0,0 @@
1
- /**
2
- * @packageDocumentation
3
- * 版本工具模块
4
- * 提供远程变更转换为本地变更等功能
5
- */
6
- import { RemoteChange, RxDBChange, UUID } from '@aiao/rxdb';
7
- export const remote_change_to_local = (
8
- remoteChange: RemoteChange[],
9
- base: Partial<RxDBChange>
10
- ): Omit<RxDBChange, 'id' | 'branch$'>[] =>
11
- remoteChange.map(rc => {
12
- const { id, ...rest } = rc;
13
- delete rest.clientId;
14
- return {
15
- ...rest,
16
- ...base,
17
- entityId: (base.entityId ?? rest.entityId) as UUID,
18
- remoteId: id
19
- };
20
- });