@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
@@ -9,8 +9,6 @@ import {
9
9
  getSyncCapability,
10
10
  getSyncType,
11
11
  isRepositorySyncEnabled,
12
- needsPull,
13
- needsPush,
14
12
  type RepositoryIdentifier,
15
13
  type RxDB,
16
14
  RxDBChange,
@@ -117,6 +115,9 @@ async function calculatePushableCount(
117
115
  { field: 'entity', operator: '=', value: entity },
118
116
  { field: 'branchId', operator: '=', value: branchId },
119
117
  { field: 'remoteId', operator: '=', value: null },
118
+ // 被拒变更(rejectedAt 非空)的 remoteId 也一直是 null,但它永远不会再被推送,
119
+ // 必须和 remoteId = null 一起排除,否则会被当成待推变更计进 pushableCount
120
+ { field: 'rejectedAt', operator: '=', value: null },
120
121
  { field: 'revertChangeId', operator: '=', value: null }
121
122
  ];
122
123
 
@@ -181,7 +182,7 @@ export async function getRepositorySyncStatus(
181
182
  }
182
183
 
183
184
  const metadata = getEntityMetadata(EntityClass);
184
- const syncType = getSyncType(metadata, rxdb.config.sync);
185
+ const syncType = getSyncType(metadata, rxdb.entitySync);
185
186
  const syncCapability = getSyncCapability(syncType);
186
187
 
187
188
  // 2. 获取当前分支
@@ -209,13 +210,12 @@ export async function getRepositorySyncStatus(
209
210
  let pushableCount = 0;
210
211
  let pullableCount = 0;
211
212
 
212
- // 如果该实体需要推送,则计算 pushableCount(与 syncType 一致地传入全局 sync 回退)
213
- if (needsPush(metadata, rxdb.config.sync)) {
213
+ // 推拉能力取开头那一次解析的结果:中间隔着几个 await,重新解析可能与返回的 syncType 不一致
214
+ if (syncCapability.push) {
214
215
  pushableCount = await calculatePushableCount(sm, namespace, entity, branchId, repoSync?.lastPushedChangeId ?? null);
215
216
  }
216
217
 
217
- // 如果该实体需要拉取,则计算 pullableCount(与 syncType 一致地传入全局 sync 回退)
218
- if (needsPull(metadata, rxdb.config.sync)) {
218
+ if (syncCapability.pull) {
219
219
  pullableCount = await calculatePullableCount(rxdb, namespace, entity);
220
220
  }
221
221
 
package/src/index.ts CHANGED
@@ -31,7 +31,10 @@ export * from './plugin.js';
31
31
  export type { BulkSyncOptions, BulkSyncResult } from './bulk-sync.js';
32
32
  // `SyncManager.checkRepositoryUpdates()` 的返回值。函数本身是内部实现。
33
33
  export type { CheckRepositoryUpdatesResult } from './check-repository-updates.js';
34
- export * from './cleanup-expired.js';
34
+ // `SyncManager.cleanupExpired()` 的形参与返回值。同名的自由函数是该方法的实现,
35
+ // 全仓只有 `SyncManager.ts:427` 与本包测试相对 import 它——转出去等于多一个能绕开
36
+ // `SyncManager` 自身前置校验的入口。
37
+ export type { CleanupExpiredOptions, CleanupExpiredResult } from './cleanup-expired.js';
35
38
  // `SyncManager.getRepositoryDependencyGraph()` 的返回值。图的构建函数是内部实现。
36
39
  export type { DependencyGraph } from './dependency-graph.js';
37
40
  // `SyncManager.getAllRepositorySyncStatus()` 的可选过滤器。
@@ -48,6 +51,8 @@ export {
48
51
  type QueryCacheOutboxFailure,
49
52
  type QueryCacheOutboxResult
50
53
  } from './query-cache-outbox.js';
51
- export * from './sync-branches.js';
54
+ // 同 `cleanup-expired`:只转 `SyncManager.syncBranches()` 的返回值(连同 `skipReasons` 的
55
+ // 元素类型),自由函数收回。
56
+ export type { SyncBranchSkipReason, SyncBranchesResult } from './sync-branches.js';
52
57
  // 实例由插件装配,用户不自己 new,因此只转类型不转类。
53
58
  export type { SyncManager } from './SyncManager.js';
package/src/plugin.ts CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { IRxDBPlugin, Plugin, RxDB, RxDBPluginBase } from '@aiao/rxdb';
9
9
  import type { LifecycleScope } from '@aiao/utils';
10
+ import { createSyncBranchMaterializationSource } from './branch-materialization-source.js';
10
11
  import { pendingQueryCacheWriteIds } from './query-cache-outbox.js';
11
12
  import { SyncManager } from './SyncManager.js';
12
13
 
@@ -27,7 +28,7 @@ export type RxDBPluginSyncOptions = object;
27
28
  * 而 {@link SyncManager.init} 挂的 `connected$` 自动回推订阅必须在第一次连接就位之前
28
29
  * 就已经订阅上,声明适配器依赖会把安装推到引导链之后,第一次连接的那一跳回推就丢了。
29
30
  *
30
- * 五处宿主改动都登记在 `scope` 上,断开连接时由宿主逆序释放:
31
+ * 六处宿主改动都登记在 `scope` 上,断开连接时由宿主逆序释放:
31
32
  *
32
33
  * 1. `rxdb.syncManager` 这个实例槽位 —— 释放时连同 `destroy()` 一起撤掉,不给下一个纪元
33
34
  * 留一个指向已拆事件总线的管理器;
@@ -41,6 +42,10 @@ export type RxDBPluginSyncOptions = object;
41
42
  * 4. 宿主的 `online` / `offline` 监听 —— `ReachabilityMonitor` 只在有人 `watch()` 时才往
42
43
  * `globalThis` 上挂,而这两个事件唯一的消费者是本包的同步监听器(`wakeup$` 驱动回推
43
44
  * 重试)。不同步的库不该因为 `new RxDB()` 就永久多一对活过实例的监听器(US-025 D2)。
45
+ * 5. {@link RxDB.branchMaterializationSource} 这个分支物化来源槽 —— `syncBranches()` 拉下来的
46
+ * 分支只有元数据,工作树插件第一次切过去之前要按来源交出的分页快照物化。远端是本插件的,
47
+ * 来源于是也只能由本插件交;槽空着时那次切换稳定抛 `branch_not_materialized`,
48
+ * 不存在「没装同步也能物化」的形态。槽位至多一个来源,本插件登记即占住。
44
49
  */
45
50
  export class RxDBPluginSync extends RxDBPluginBase implements IRxDBPlugin {
46
51
  readonly lifecycle = 'scoped' as const;
@@ -70,6 +75,9 @@ export class RxDBPluginSync extends RxDBPluginBase implements IRxDBPlugin {
70
75
  scope
71
76
  );
72
77
 
78
+ // 传 scope:断开连接时撤销,下一个纪元登记的是指向新 syncManager 的那一个,不撞「至多一个」。
79
+ this.rxdb.branchMaterializationSource(createSyncBranchMaterializationSource(syncManager), scope);
80
+
73
81
  // 可达性监听:`watch()` 自己返回撤销函数,正好是 `acquire` 要的 setup 形状。
74
82
  // 引用计数在监视器一侧,所以同一个宿主上多装几个消费者也不会互相摘掉对方的监听。
75
83
  scope.acquire(() => this.rxdb.reachability.watch(), 'sync:reachability');
package/src/pull-batch.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  import {
14
14
  compactChanges,
15
15
  type ConflictResolver,
16
+ declareTrustedWrite,
16
17
  getEntityMetadata,
17
18
  getOrCreateSyncRecord,
18
19
  getSyncCapability,
@@ -26,7 +27,8 @@ import {
26
27
  RxDBError,
27
28
  type RxDBEvent,
28
29
  RxDBPartialSyncError,
29
- RxDBSync
30
+ RxDBSync,
31
+ TrustedWriteIntent
30
32
  } from '@aiao/rxdb';
31
33
  import type { SyncManager } from './SyncManager.js';
32
34
  import { getAncestorBranchIds } from './branch-utils.js';
@@ -39,6 +41,7 @@ import {
39
41
  queryPendingLocalChanges,
40
42
  resolveConflictsAndBuildActions
41
43
  } from './pull-conflict-utils.js';
44
+ import { backfillOwnChangeRemoteIds, splitRemoteChangesByOrigin } from './pull-round.js';
42
45
  import { topologicalSortForPull } from './topological-sort.js';
43
46
 
44
47
  interface RepoSyncInfo {
@@ -170,7 +173,7 @@ async function pullBatchOnce(
170
173
 
171
174
  for (const EntityClass of rxdb.config.entities) {
172
175
  const metadata = getEntityMetadata(EntityClass);
173
- const syncType = getSyncType(metadata, rxdb.config.sync);
176
+ const syncType = getSyncType(metadata, rxdb.entitySync);
174
177
 
175
178
  // 跳过口径从内联的 `none | local` 换成能力矩阵,与单仓/级联路径同源
176
179
  if (!getSyncCapability(syncType).pull) continue;
@@ -302,44 +305,10 @@ async function pullBatchOnce(
302
305
 
303
306
  totalPulled += filteredChanges.length;
304
307
 
305
- // 分离自己推送的变更和他人的变更
306
- // 只按 clientId 识别:远端记录缺少 localId(如 push 走 actions-only 路径)时,
307
- // 自己的变更也不能进入他人变更的 apply/conflict 链路
308
- const ownChanges: RemoteChange[] = [];
309
- const otherChanges: RemoteChange[] = [];
310
-
311
- for (const rc of filteredChanges) {
312
- if (rc.clientId != null && rc.clientId === clientId) {
313
- ownChanges.push(rc);
314
- } else {
315
- otherChanges.push(rc);
316
- }
317
- }
318
-
319
- // 批量更新自己推送变更的 remoteId(只有带 localId 的记录才能回填映射)
320
- const mappableChanges = ownChanges.filter(c => c.localId != null);
321
- if (mappableChanges.length > 0) {
322
- const localIds = mappableChanges.map(c => c.localId!);
323
- const locals = await changeRepo.find({
324
- where: {
325
- combinator: 'and',
326
- rules: [
327
- { field: 'id', operator: 'in', value: localIds },
328
- { field: 'remoteId', operator: '=', value: null }
329
- ]
330
- }
331
- });
332
-
333
- if (locals.length > 0) {
334
- const ownChangeMap = new Map(mappableChanges.map(c => [c.localId!, c.id]));
335
- for (const local of locals) {
336
- const remoteId = ownChangeMap.get(local.id);
337
- if (remoteId != null) {
338
- await changeRepo.update(local, { remoteId });
339
- }
340
- }
341
- }
342
- }
308
+ // 分离自己推送的变更和他人的变更,再把自推变更的 remoteId 回填到本地行上。
309
+ // 两步与 pull-repository.ts 共用同一份实现(见 pull-round.ts 的 @packageDocumentation)。
310
+ const { ownChanges, otherChanges } = splitRemoteChangesByOrigin(filteredChanges, clientId);
311
+ await backfillOwnChangeRemoteIds(changeRepo, ownChanges);
343
312
 
344
313
  // 压缩并应用他人的变更
345
314
  if (otherChanges.length > 0) {
@@ -375,6 +344,13 @@ async function pullBatchOnce(
375
344
  const applyCount = countActions(applyActions);
376
345
  if (applyCount > 0) {
377
346
  // disableTriggers=true 确保不生成本地 RxDBChange 记录
347
+ // disableTriggers=true 压掉的是变更日志行,不是工作树单元:FR-046 要求拉取
348
+ // 照样落一个 origin='remote_sync' 的单元,所以挂载点 2 从 actions 派生,不看日志。
349
+ declareTrustedWrite(executor, {
350
+ file: 'pull-batch.ts',
351
+ symbol: 'pullBatchOnce',
352
+ intent: TrustedWriteIntent.remote_sync
353
+ });
378
354
  await executor.mergeChanges(applyActions, undefined, true);
379
355
  totalApplied += applyCount;
380
356
  }
@@ -113,6 +113,10 @@ export async function queryPendingLocalChanges(
113
113
  { field: 'entity', operator: '=', value: entity },
114
114
  { field: 'branchId', operator: '=', value: branchId },
115
115
  { field: 'remoteId', operator: '=', value: null },
116
+ // 被拒变更(rejectedAt 非空)的 remoteId 也一直是 null,但它永远不会再被推送,
117
+ // 必须和 remoteId = null 一起排除,否则会被当成待推的本地变更,和远端变更
118
+ // 撞上同一个 entityId 时误判出一次本不存在的冲突
119
+ { field: 'rejectedAt', operator: '=', value: null },
116
120
  { field: 'revertChangeId', operator: '=', value: null },
117
121
  { field: 'entityId', operator: 'in', value: getRxDBChangeEntityIdQueryValues(entityIds) }
118
122
  ];
@@ -7,10 +7,11 @@
7
7
 
8
8
  import {
9
9
  compactChanges,
10
- type EntityMetadata,
10
+ declareTrustedWrite,
11
11
  type EntityType,
12
12
  getEntityMetadata,
13
13
  getOrCreateSyncRecord,
14
+ getSyncConfig,
14
15
  getSyncType,
15
16
  LWWConflictResolver,
16
17
  type PullRepositoryOptions,
@@ -30,7 +31,9 @@ import {
30
31
  type RxDBEvent,
31
32
  RxDBPartialSyncError,
32
33
  RxDBSync,
33
- type SyncFailure
34
+ type SyncFailure,
35
+ type SyncOptions,
36
+ TrustedWriteIntent
34
37
  } from '@aiao/rxdb';
35
38
  import type { SyncManager } from './SyncManager.js';
36
39
  import { getAncestorBranchIds } from './branch-utils.js';
@@ -45,6 +48,7 @@ import {
45
48
  queryPendingLocalChanges,
46
49
  resolveConflictsAndBuildActions
47
50
  } from './pull-conflict-utils.js';
51
+ import { backfillOwnChangeRemoteIds, splitRemoteChangesByOrigin } from './pull-round.js';
48
52
  import { topologicalSortForPull } from './topological-sort.js';
49
53
 
50
54
  /**
@@ -190,7 +194,7 @@ async function _pullRepositoryImpl(
190
194
  // 避免两条路径各写一份而漂移。
191
195
  // 同一处叠加 `RxDBSync.enabled` —— 显式点名单个仓库时抛错而非静默跳过,
192
196
  // 与 syncType 不合格时的行为一致;批量枚举路径(pullBatch)才是跳过。
193
- const syncType = getSyncType(metadata, sm.rxdb.config.sync);
197
+ const syncType = getSyncType(metadata, sm.rxdb.entitySync);
194
198
  const ineligible = await resolvePullIneligibility(sm.rxdb, namespace, entity, syncType);
195
199
 
196
200
  if (ineligible) {
@@ -200,7 +204,11 @@ async function _pullRepositoryImpl(
200
204
  // 对于 Filter 同步类型,从配置中提取 filter 函数并执行
201
205
  let effectiveFilter = opts.filter;
202
206
  if (syncType === 'filter' && !effectiveFilter) {
203
- const syncConfig = metadata.sync as { type: string; remote?: { filter?: () => RuleGroup } };
207
+ // 取生效配置而不是装饰器原值:实例覆盖可能换掉了 filter
208
+ const syncConfig = getSyncConfig(metadata, sm.rxdb.entitySync) as {
209
+ type: string;
210
+ remote?: { filter?: () => RuleGroup };
211
+ };
204
212
  if (syncConfig?.remote?.filter) {
205
213
  // T022: filter 函数执行错误处理
206
214
  try {
@@ -378,7 +386,7 @@ async function pullCascadeNode(
378
386
  }
379
387
 
380
388
  const repoMetadata = getEntityMetadata(EntityType);
381
- const repoSyncType = getSyncType(repoMetadata, sm.rxdb.config.sync);
389
+ const repoSyncType = getSyncType(repoMetadata, sm.rxdb.entitySync);
382
390
 
383
391
  // 级联节点必须走和单仓路径同一份资格校验,否则 `local` / `none`
384
392
  // 的依赖仓会被拿去问远端要数据,绕过它自己声明的同步策略
@@ -392,7 +400,12 @@ async function pullCascadeNode(
392
400
  const result = await pullSingleRepository(sm, repo.namespace, repo.entity, {
393
401
  ...options,
394
402
  // T039: 为每个实体独立提取 filter
395
- filter: resolveCascadeFilter(repoKey, repoMetadata, repoSyncType, options.filter),
403
+ filter: resolveCascadeFilter(
404
+ repoKey,
405
+ getSyncConfig(repoMetadata, sm.rxdb.entitySync),
406
+ repoSyncType,
407
+ options.filter
408
+ ),
396
409
  includeRelated: false // 防止递归级联
397
410
  });
398
411
  result.success = true;
@@ -416,7 +429,8 @@ async function pullCascadeNode(
416
429
  * 解析级联节点自己的 filter
417
430
  *
418
431
  * @param repoKey - 仓库键(`namespace:entity`),用于错误消息
419
- * @param repoMetadata - 该仓库的实体元数据
432
+ * @param repoSync - 该仓库的**生效**同步配置(`getSyncConfig(metadata, rxdb.entitySync)`);
433
+ * 不能传装饰器原值 `metadata.sync`:实例覆盖可能换掉了 filter,也可能把 Full 覆盖成 Filter
420
434
  * @param repoSyncType - 该仓库的有效同步类型
421
435
  * @param inheritedFilter - 调用方显式传入的 filter
422
436
  * @returns 该仓库实际生效的 filter
@@ -429,9 +443,9 @@ async function pullCascadeNode(
429
443
  *
430
444
  * @internal
431
445
  */
432
- function resolveCascadeFilter(
446
+ export function resolveCascadeFilter(
433
447
  repoKey: string,
434
- repoMetadata: EntityMetadata,
448
+ repoSync: SyncOptions | undefined,
435
449
  repoSyncType: RepositorySyncType,
436
450
  inheritedFilter: RuleGroup | undefined
437
451
  ): RuleGroup | undefined {
@@ -439,8 +453,7 @@ function resolveCascadeFilter(
439
453
  if (repoSyncType === 'full') return undefined;
440
454
  if (repoSyncType !== 'filter') return inheritedFilter;
441
455
 
442
- const syncConfig = repoMetadata.sync as { type: string; remote?: { filter?: () => RuleGroup } };
443
- const filterFn = syncConfig?.remote?.filter;
456
+ const filterFn = (repoSync as { remote?: { filter?: () => RuleGroup } } | undefined)?.remote?.filter;
444
457
  if (!filterFn) return inheritedFilter;
445
458
 
446
459
  let extractedFilter: RuleGroup;
@@ -497,7 +510,7 @@ async function pullSingleRepository(
497
510
  return meta.namespace === namespace && meta.name === entity;
498
511
  });
499
512
  const metadata = getEntityMetadata(EntityType!);
500
- const syncType = getSyncType(metadata, sm.rxdb.config.sync);
513
+ const syncType = getSyncType(metadata, sm.rxdb.entitySync);
501
514
 
502
515
  let repoSync = await getOrCreateSyncRecord(
503
516
  repoSyncRepo,
@@ -558,20 +571,8 @@ async function pullSingleRepository(
558
571
  lastRemoteChange = remoteChanges[remoteChanges.length - 1];
559
572
  sinceId = lastRemoteChange.id;
560
573
 
561
- // 分离自己 push 上去的变更和他人的变更
562
- const clientId = rxdb.context.clientId;
563
- const ownChanges: RemoteChange[] = [];
564
- const otherChanges: RemoteChange[] = [];
565
-
566
- // 只按 clientId 识别:远端记录缺少 localId(如 push 走 actions-only 路径)时,
567
- // 自己的变更也不能进入他人变更的 apply/conflict 链路
568
- for (const rc of remoteChanges) {
569
- if (rc.clientId != null && rc.clientId === clientId) {
570
- ownChanges.push(rc);
571
- } else {
572
- otherChanges.push(rc);
573
- }
574
- }
574
+ // 分离自己 push 上去的变更和他人的变更(与 pull-batch.ts 共用同一份实现)
575
+ const { ownChanges, otherChanges } = splitRemoteChangesByOrigin(remoteChanges, rxdb.context.clientId);
575
576
 
576
577
  // remoteId 回填、实体应用、supersession 标记与水位线推进必须在**一个**事务里。
577
578
  //
@@ -586,24 +587,9 @@ async function pullSingleRepository(
586
587
  // 绑定在适配器上,其读写会重新排队并排在自己这个事务后面(队列并发度 1)。
587
588
  const txChangeRepo = executor.getRepository(RxDBChange);
588
589
  const txRepoSyncRepo = executor.getRepository(RxDBSync);
589
- // 为自己 push 的变更更新本地 remoteId(只有带 localId 的记录才能回填映射)
590
- const mappableChanges = ownChanges.filter(c => c.localId != null);
591
- if (mappableChanges.length > 0) {
592
- const localIds = mappableChanges.map(c => c.localId!);
593
- const locals = await txChangeRepo.find({
594
- where: {
595
- combinator: 'and',
596
- rules: [{ field: 'id', operator: 'in', value: localIds }]
597
- }
598
- });
599
- const localMap = new Map(locals.map(l => [l.id, l]));
600
- for (const ownChange of mappableChanges) {
601
- const local = localMap.get(ownChange.localId!);
602
- if (local && local.remoteId == null) {
603
- await txChangeRepo.update(local, { remoteId: ownChange.id });
604
- }
605
- }
606
- }
590
+ // 为自己 push 的变更更新本地 remoteId。此前这里把「只写没映射过的行」写成 JS 守卫,
591
+ // 而 pull-batch 写在 SQL 里——判据相同、写法不同,改一处漏一处不会红。现已共用。
592
+ await backfillOwnChangeRemoteIds(txChangeRepo, ownChanges);
607
593
 
608
594
  // 只对他人的变更执行压缩和应用
609
595
  if (otherChanges.length > 0) {
@@ -630,6 +616,13 @@ async function pullSingleRepository(
630
616
  conflictResolver,
631
617
  dispatchEvent: (event: RxDBEvent) => rxdb.dispatchEvent(event),
632
618
  repoLabel: `${namespace}:${entity}`,
619
+ // 推迟 supersession 标记,改由下面 `mergeChanges` 之后自己写。pull-batch 不传这一项、
620
+ // 走的是解冲突时就地标记——两条路径由此分叉,但分叉是**有意的**:本文件的写全在
621
+ // 一个事务里,把「这条本地变更已被远端取代」写在实体合并之后,失败回滚时二者同进同退。
622
+ //
623
+ // 这个位置不会漏标:`localChangeSupersessions` 只在 KEEP_REMOTE 分支 push,而那一支
624
+ // 必定同时 `setAction`,于是非空的 supersession 蕴含 `applyCount > 0`,
625
+ // 下面那道 `if` 永远不会把它挡在外面。
633
626
  deferLocalChangeSupersession: true,
634
627
  localClientId: rxdb.context.clientId
635
628
  }
@@ -640,6 +633,12 @@ async function pullSingleRepository(
640
633
  if (applyCount > 0) {
641
634
  // 先将实体变更应用到本地数据库(在更新 RxDBSync 之前)
642
635
  // disableTriggers=true 确保不生成本地 RxDBChange 记录
636
+ // 同 pull-batch:远端来的行进工作树,但不回流成一次可 push 的本地变更。
637
+ declareTrustedWrite(executor, {
638
+ file: 'pull-repository.ts',
639
+ symbol: 'pullSingleRepository',
640
+ intent: TrustedWriteIntent.remote_sync
641
+ });
643
642
  await executor.mergeChanges(applyActions, undefined, true);
644
643
  for (const supersession of localChangeSupersessions) {
645
644
  await markLocalChangesSuperseded(txChangeRepo, supersession.localChanges, supersession.remoteId);
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @packageDocumentation
3
+ * 一轮拉取里与「谁推的、映射回哪一行」有关的两步。
4
+ *
5
+ * @remarks
6
+ * `pullBatchOnce`(`pull-batch.ts`)与 `pullSingleRepository`(`pull-repository.ts`)走的是
7
+ * 同一段流程:**拆分自推与他人的变更 → 回填自推变更的 `remoteId` → 压缩他人变更 → 解冲突 →
8
+ * 合并进实体表 → 推水位线**。两边曾各抄一份,而抄件会分叉——next-11 评审复核时实测到的分叉
9
+ * 正在这里:一侧把「只回填还没映射过的行」写进 SQL(`remoteId = null` → `IS NULL`),
10
+ * 一侧写成 JS 守卫(`local.remoteId == null`)。两种写法当时判据相同,但**没有任何东西保证
11
+ * 下一次改动仍然相同**,而一侧漏改不会让任何用例变红。
12
+ *
13
+ * 本模块收的是那段流程里**不碰受信写原语**的前半截。后半截(`executor.mergeChanges` 与随之
14
+ * 而来的 supersession 标记)留在各自文件里,不是嫌麻烦:那一次写要按 `file`·`symbol`·`intent`
15
+ * 在 `TRUSTED_CALLSITE_REGISTRY` 里对号入座(`declareTrustedWrite`),搬进本模块等于把两个
16
+ * 登记入口合并成一个——那是核心包的适配器契约变更,不是同步插件自己能定的事。
17
+ */
18
+
19
+ import { type IRepository, type RemoteChange, type RxDBChange } from '@aiao/rxdb';
20
+
21
+ /** {@link splitRemoteChangesByOrigin} 的结果 */
22
+ export interface RemoteChangeOrigins {
23
+ /** 本客户端自己 push 上去、这轮又拉回来的变更 */
24
+ ownChanges: RemoteChange[];
25
+ /** 其他客户端产生的变更,只有这一批进 apply / conflict 链路 */
26
+ otherChanges: RemoteChange[];
27
+ }
28
+
29
+ /**
30
+ * 按 `clientId` 把一批远端变更拆成「自己推的」与「他人的」
31
+ *
32
+ * @param changes - 本轮拉到的远端变更
33
+ * @param clientId - 本客户端 id
34
+ * @returns 两个互斥的子集,顺序与入参一致
35
+ *
36
+ * @remarks
37
+ * **只认 `clientId`,不拿 `localId` 当替补**:push 走 actions-only 路径时远端记录不带 `localId`,
38
+ * 若用「有 localId 就是自己的」去判,这类记录会被判成他人的而重复应用一遍;反过来,若把
39
+ * 「没有 clientId」也算成自己的,那这批数据拉下来了却永远不进 apply 链路——静默丢数据。
40
+ * 所以缺 `clientId` 一律算他人的:多走一遍幂等的 apply,好过整批不落库。
41
+ */
42
+ export const splitRemoteChangesByOrigin = (
43
+ changes: RemoteChange[],
44
+ clientId: string | undefined
45
+ ): RemoteChangeOrigins => {
46
+ const ownChanges: RemoteChange[] = [];
47
+ const otherChanges: RemoteChange[] = [];
48
+
49
+ for (const change of changes) {
50
+ if (change.clientId != null && change.clientId === clientId) {
51
+ ownChanges.push(change);
52
+ } else {
53
+ otherChanges.push(change);
54
+ }
55
+ }
56
+
57
+ return { ownChanges, otherChanges };
58
+ };
59
+
60
+ /**
61
+ * 把自推变更拿到的远端 id 回填到对应的本地 `RxDBChange` 行上
62
+ *
63
+ * @param changeRepo - 变更表仓库;在事务里调用时必须是经 executor 取到的那一个
64
+ * @param ownChanges - {@link splitRemoteChangesByOrigin} 分出的自推变更
65
+ *
66
+ * @remarks
67
+ * 只回填 `remoteId` 还空着的行,判据写在 SQL 里(`= null` 由适配器编译成 `IS NULL`)而不是
68
+ * 取回来再用 JS 挑:少取一批行是次要的,主要是让「只写没映射过的」成为查询本身的一部分,
69
+ * 而不是一条可以被下一次改动顺手删掉的 `if`。
70
+ *
71
+ * 覆盖已有映射不是小事:`cleanupExpired` 按 `remoteId` 判「这条已经同步出去了」,
72
+ * 把旧映射改写成本轮的 id 会让保留窗口整段错位——旧变更被当成刚同步的留下来,
73
+ * 而真正该留的那批反而先到期。
74
+ *
75
+ * 没有 `localId` 的自推变更直接跳过:对不上本地行,硬回填只能靠猜。
76
+ */
77
+ export const backfillOwnChangeRemoteIds = async (
78
+ changeRepo: IRepository<typeof RxDBChange>,
79
+ ownChanges: RemoteChange[]
80
+ ): Promise<void> => {
81
+ const mappableChanges = ownChanges.filter(change => change.localId != null);
82
+ if (mappableChanges.length === 0) return;
83
+
84
+ const locals = await changeRepo.find({
85
+ where: {
86
+ combinator: 'and',
87
+ rules: [
88
+ { field: 'id', operator: 'in', value: mappableChanges.map(change => change.localId!) },
89
+ { field: 'remoteId', operator: '=', value: null },
90
+ // 被拒变更(rejectedAt 非空)的 remoteId 也一直是 null,但它永远不会再被推送,
91
+ // 不该被本轮回填覆盖掉——覆盖了反而让 cleanupExpired 的保留窗口误判
92
+ { field: 'rejectedAt', operator: '=', value: null }
93
+ ]
94
+ }
95
+ });
96
+ if (locals.length === 0) return;
97
+
98
+ const remoteIdByLocalId = new Map(mappableChanges.map(change => [change.localId!, change.id]));
99
+ for (const local of locals) {
100
+ const remoteId = remoteIdByLocalId.get(local.id);
101
+ if (remoteId != null) await changeRepo.update(local, { remoteId });
102
+ }
103
+ };
package/src/pull.ts CHANGED
@@ -89,7 +89,7 @@ async function pullFilterRepositories(sm: SyncManager, options: PullOptions | un
89
89
 
90
90
  for (const EntityClass of rxdb.config.entities) {
91
91
  const metadata = getEntityMetadata(EntityClass);
92
- const syncType = getSyncType(metadata, rxdb.config.sync);
92
+ const syncType = getSyncType(metadata, rxdb.entitySync);
93
93
  if (syncType !== 'filter' || !getSyncCapability(syncType).pull) continue;
94
94
 
95
95
  const repoSync = await findCurrentSyncRecord(sm.rxdb, metadata.namespace, metadata.name);
@@ -1,3 +1,4 @@
1
+ import { MAIN_BRANCH_ID } from '@aiao/rxdb';
1
2
  import { toRemoteFromChangeId } from './branch-change-id.js';
2
3
  import type { SyncManager } from './SyncManager.js';
3
4
 
@@ -17,14 +18,14 @@ export interface PushBranchResult {
17
18
  * 将当前激活分支推送到远程
18
19
  *
19
20
  * 规则:
20
- * 1. 不推送 id='main' 的分支
21
+ * 1. 不推送根分支({@link MAIN_BRANCH_ID})
21
22
  * 2. 不修改远程分支的 activated 属性(由 SQL 函数保证)
22
23
  * 3. `fromChangeId` 翻译成远端 id 后再上行(见 {@link toRemoteFromChangeId})
23
24
  */
24
25
  export async function pushBranch(sm: SyncManager): Promise<PushBranchResult> {
25
26
  const branch = await sm.getCurrentBranch();
26
- if (!branch || branch.id === 'main') {
27
- return { synced: 0, skipped: branch ? ['main'] : [], forkPointPending: false };
27
+ if (!branch || branch.id === MAIN_BRANCH_ID) {
28
+ return { synced: 0, skipped: branch ? [MAIN_BRANCH_ID] : [], forkPointPending: false };
28
29
  }
29
30
 
30
31
  const { adapter: remoteAdapter } = await sm.getRemoteRepositories();