@aiao/rxdb-plugin-sync 0.0.0-stage → 0.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -2
  3. package/dist/SyncManager.d.ts +426 -0
  4. package/dist/SyncManager.d.ts.map +1 -0
  5. package/dist/branch-change-id.d.ts +31 -0
  6. package/dist/branch-change-id.d.ts.map +1 -0
  7. package/dist/branch-materialization-source.d.ts +22 -0
  8. package/dist/branch-materialization-source.d.ts.map +1 -0
  9. package/dist/branch-utils.d.ts +40 -0
  10. package/dist/branch-utils.d.ts.map +1 -0
  11. package/dist/bulk-sync.d.ts +117 -0
  12. package/dist/bulk-sync.d.ts.map +1 -0
  13. package/dist/cascade-blocking.d.ts +38 -0
  14. package/dist/cascade-blocking.d.ts.map +1 -0
  15. package/dist/check-repository-updates.d.ts +47 -0
  16. package/dist/check-repository-updates.d.ts.map +1 -0
  17. package/dist/cleanup-expired.d.ts +56 -0
  18. package/dist/cleanup-expired.d.ts.map +1 -0
  19. package/dist/dependency-graph.d.ts +128 -0
  20. package/dist/dependency-graph.d.ts.map +1 -0
  21. package/dist/get-all-repository-sync-status.d.ts +58 -0
  22. package/dist/get-all-repository-sync-status.d.ts.map +1 -0
  23. package/dist/get-repository-sync-status.d.ts +68 -0
  24. package/dist/get-repository-sync-status.d.ts.map +1 -0
  25. package/dist/index.d.ts +39 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +3773 -0
  28. package/dist/plugin.d.ts +74 -0
  29. package/dist/plugin.d.ts.map +1 -0
  30. package/dist/pull-ancestor-changes.d.ts +41 -0
  31. package/dist/pull-ancestor-changes.d.ts.map +1 -0
  32. package/dist/pull-batch.d.ts +16 -0
  33. package/dist/pull-batch.d.ts.map +1 -0
  34. package/dist/pull-conflict-utils.d.ts +43 -0
  35. package/dist/pull-conflict-utils.d.ts.map +1 -0
  36. package/dist/pull-repository.d.ts +55 -0
  37. package/dist/pull-repository.d.ts.map +1 -0
  38. package/dist/pull-round.d.ts +41 -0
  39. package/dist/pull-round.d.ts.map +1 -0
  40. package/dist/pull.d.ts +16 -0
  41. package/dist/pull.d.ts.map +1 -0
  42. package/dist/push-branch.d.ts +22 -0
  43. package/dist/push-branch.d.ts.map +1 -0
  44. package/dist/push-repository.d.ts +79 -0
  45. package/dist/push-repository.d.ts.map +1 -0
  46. package/dist/push.d.ts +14 -0
  47. package/dist/push.d.ts.map +1 -0
  48. package/dist/query-cache-outbox.d.ts +120 -0
  49. package/dist/query-cache-outbox.d.ts.map +1 -0
  50. package/dist/sync-branches.d.ts +81 -0
  51. package/dist/sync-branches.d.ts.map +1 -0
  52. package/dist/sync-listeners.d.ts +20 -0
  53. package/dist/sync-listeners.d.ts.map +1 -0
  54. package/dist/sync-manager.utils.d.ts +36 -0
  55. package/dist/sync-manager.utils.d.ts.map +1 -0
  56. package/dist/sync-repository.d.ts +64 -0
  57. package/dist/sync-repository.d.ts.map +1 -0
  58. package/dist/topological-sort.d.ts +126 -0
  59. package/dist/topological-sort.d.ts.map +1 -0
  60. package/package.json +60 -3
  61. package/src/SyncManager.ts +690 -0
  62. package/src/branch-change-id.ts +70 -0
  63. package/src/branch-materialization-source.ts +445 -0
  64. package/src/branch-utils.ts +74 -0
  65. package/src/bulk-sync.ts +349 -0
  66. package/src/cascade-blocking.ts +61 -0
  67. package/src/check-repository-updates.ts +143 -0
  68. package/src/cleanup-expired.ts +285 -0
  69. package/src/dependency-graph.ts +324 -0
  70. package/src/get-all-repository-sync-status.ts +115 -0
  71. package/src/get-repository-sync-status.ts +237 -0
  72. package/src/index.ts +58 -0
  73. package/src/plugin.ts +132 -0
  74. package/src/pull-ancestor-changes.ts +61 -0
  75. package/src/pull-batch.ts +386 -0
  76. package/src/pull-conflict-utils.ts +284 -0
  77. package/src/pull-repository.ts +700 -0
  78. package/src/pull-round.ts +103 -0
  79. package/src/pull.ts +204 -0
  80. package/src/push-branch.ts +62 -0
  81. package/src/push-repository.ts +1541 -0
  82. package/src/push.ts +106 -0
  83. package/src/query-cache-outbox.ts +939 -0
  84. package/src/sync-branches.ts +276 -0
  85. package/src/sync-listeners.ts +309 -0
  86. package/src/sync-manager.utils.ts +48 -0
  87. package/src/sync-repository.ts +307 -0
  88. package/src/topological-sort.ts +246 -0
@@ -0,0 +1,349 @@
1
+ /**
2
+ * @fileoverview 批量同步实现
3
+ *
4
+ * 提供在单次操作中同步多个仓库的功能,支持顺序或并发执行模式。
5
+ */
6
+
7
+ import {
8
+ findCurrentSyncRecord,
9
+ getEntityMetadata,
10
+ getSyncCapability,
11
+ getSyncType,
12
+ isRepositorySyncEnabled,
13
+ type RxDB,
14
+ RxDBError,
15
+ RxDBPartialSyncError,
16
+ type SyncRejection
17
+ } from '@aiao/rxdb';
18
+ import type { RepositoryIdentifier } from './dependency-graph.js';
19
+ import type { PullRepositoryOptions } from './pull-repository.js';
20
+ import { type PushRepositoryOptions, reportPushRejections } from './push-repository.js';
21
+ import { syncRepository, type SyncRepositoryOptions, type SyncRepositoryResult } from './sync-repository.js';
22
+ import type { SyncManager } from './SyncManager.js';
23
+ /**
24
+ * 批量同步选项
25
+ */
26
+ export interface BulkSyncOptions {
27
+ /**
28
+ * 同步操作类型
29
+ * @default 'sync'
30
+ */
31
+ operation?: 'pull' | 'push' | 'sync';
32
+
33
+ /**
34
+ * 目标仓库列表
35
+ * 如果为空,则同步所有启用的仓库
36
+ */
37
+ repositories?: RepositoryIdentifier[];
38
+
39
+ /**
40
+ * 拉取选项(应用于所有仓库)
41
+ */
42
+ pull?: PullRepositoryOptions;
43
+
44
+ /**
45
+ * 推送选项(应用于所有仓库)
46
+ */
47
+ push?: PushRepositoryOptions;
48
+
49
+ /**
50
+ * 是否并发执行
51
+ * @default false
52
+ */
53
+ concurrent?: boolean;
54
+
55
+ /**
56
+ * 并发限制(只有 concurrent=true 时生效)
57
+ * @default 3
58
+ */
59
+ concurrency?: number;
60
+ }
61
+
62
+ /**
63
+ * 批量同步结果
64
+ */
65
+ export interface BulkSyncResult {
66
+ /**
67
+ * 成功的仓库数量
68
+ */
69
+ succeeded: number;
70
+
71
+ /**
72
+ * 失败的仓库数量
73
+ */
74
+ failed: number;
75
+
76
+ /**
77
+ * 每个仓库的详细结果
78
+ */
79
+ results: Array<{
80
+ repository: RepositoryIdentifier;
81
+ success: boolean;
82
+ result?: SyncRepositoryResult;
83
+ error?: Error;
84
+ }>;
85
+
86
+ /**
87
+ * 总耗时(毫秒)
88
+ */
89
+ durationMs: number;
90
+ }
91
+
92
+ /**
93
+ * 获取需要同步的仓库列表
94
+ *
95
+ * 如果 options.repositories 有值则使用该值;否则从 rxdb.config.entities 中挑出**真正能同步**的仓库。
96
+ *
97
+ * @param rxdb - RxDB 实例
98
+ * @param options - 批量同步选项
99
+ * @returns 需要同步的仓库列表
100
+ *
101
+ * @remarks
102
+ * 这里是枚举路径,资格不足一律**跳过**(单仓入口才抛错)。两条过滤:
103
+ * - 能力矩阵:两个方向都不可同步的(`none` / `local`)没有理由进队列。此前只跳
104
+ * `none`,`local` 会一路走到 `syncRepository` 才被 `shouldPull` / `shouldPush`
105
+ * 双双判负,凭空多出一条「同步了 0 条」的结果。
106
+ * - `RxDBSync.enabled`:被用户关掉的仓库跳过。留着它只会在 `pullRepository` /
107
+ * `pushRepository` 里抛错,把「我关的」变成一条同步失败。
108
+ *
109
+ * @internal
110
+ */
111
+ export async function getRepositoriesToSync(rxdb: RxDB, options: BulkSyncOptions): Promise<RepositoryIdentifier[]> {
112
+ // 如果明确指定了 repositories,则直接使用:调用方点名要同步谁,
113
+ // 资格判定交给 syncRepository 去抛错,这里不替他做主
114
+ if (options.repositories && options.repositories.length > 0) {
115
+ return options.repositories;
116
+ }
117
+
118
+ const repositories: RepositoryIdentifier[] = [];
119
+
120
+ for (const EntityClass of rxdb.config.entities) {
121
+ const metadata = getEntityMetadata(EntityClass);
122
+
123
+ // 检查 syncType(支持全局配置回退)
124
+ const capability = getSyncCapability(getSyncType(metadata, rxdb.entitySync));
125
+ if (!capability.pull && !capability.push) {
126
+ continue;
127
+ }
128
+
129
+ const repoSync = await findCurrentSyncRecord(rxdb, metadata.namespace, metadata.name);
130
+ if (!isRepositorySyncEnabled(repoSync)) {
131
+ continue;
132
+ }
133
+
134
+ repositories.push({
135
+ namespace: metadata.namespace,
136
+ entity: metadata.name
137
+ });
138
+ }
139
+
140
+ return repositories;
141
+ }
142
+
143
+ /**
144
+ * 同步单个仓库并处理错误
145
+ *
146
+ * @param sm - 调用方持有的同步管理器实例
147
+ * @param repo - 要同步的仓库
148
+ * @param syncOptions - 同步选项
149
+ * @param rejections - 本轮被拒清单收集器,由 {@link bulkSync} 统一上报
150
+ * @returns 带有成功标志的结果对象
151
+ */
152
+ async function syncSingleRepository(
153
+ sm: SyncManager,
154
+ repo: RepositoryIdentifier,
155
+ syncOptions: SyncRepositoryOptions,
156
+ rejections: SyncRejection[]
157
+ ): Promise<{
158
+ repository: RepositoryIdentifier;
159
+ success: boolean;
160
+ result?: SyncRepositoryResult;
161
+ error?: Error;
162
+ }> {
163
+ try {
164
+ const result = await syncRepository(sm, repo.namespace, repo.entity, syncOptions, rejections);
165
+
166
+ return {
167
+ repository: repo,
168
+ success: true,
169
+ result
170
+ };
171
+ } catch (error) {
172
+ const normalized = error instanceof Error ? error : new Error(String(error));
173
+ return {
174
+ repository: repo,
175
+ success: false,
176
+ // 失败前已提交的部分进度同时挂到 `result` 上:只留 `error` 的话,调用方要想知道
177
+ // 「这个仓库其实已经落了多少数据」就得自己去拆 RxDBPartialSyncError
178
+ result: normalized instanceof RxDBPartialSyncError ? (normalized.result as SyncRepositoryResult) : undefined,
179
+ error: normalized
180
+ };
181
+ }
182
+ }
183
+
184
+ /**
185
+ * 顺序执行批量同步
186
+ *
187
+ * @param sm - 调用方持有的同步管理器实例
188
+ * @param repositories - 仓库列表
189
+ * @param syncOptions - 同步选项
190
+ * @param rejections - 本轮被拒清单收集器
191
+ * @returns 结果数组
192
+ */
193
+ async function executeSyncSequentially(
194
+ sm: SyncManager,
195
+ repositories: RepositoryIdentifier[],
196
+ syncOptions: SyncRepositoryOptions,
197
+ rejections: SyncRejection[]
198
+ ): Promise<
199
+ Array<{
200
+ repository: RepositoryIdentifier;
201
+ success: boolean;
202
+ result?: SyncRepositoryResult;
203
+ error?: Error;
204
+ }>
205
+ > {
206
+ const results: Array<{
207
+ repository: RepositoryIdentifier;
208
+ success: boolean;
209
+ result?: SyncRepositoryResult;
210
+ error?: Error;
211
+ }> = [];
212
+
213
+ for (const repo of repositories) {
214
+ const result = await syncSingleRepository(sm, repo, syncOptions, rejections);
215
+ results.push(result);
216
+ }
217
+
218
+ return results;
219
+ }
220
+
221
+ /**
222
+ * 并发执行批量同步(带并发限制)
223
+ *
224
+ * @param sm - 调用方持有的同步管理器实例
225
+ * @param repositories - 仓库列表
226
+ * @param syncOptions - 同步选项
227
+ * @param concurrency - 最大并发操作数
228
+ * @param rejections - 本轮被拒清单收集器
229
+ * @returns 结果数组
230
+ */
231
+ async function executeSyncConcurrently(
232
+ sm: SyncManager,
233
+ repositories: RepositoryIdentifier[],
234
+ syncOptions: SyncRepositoryOptions,
235
+ concurrency: number,
236
+ rejections: SyncRejection[]
237
+ ): Promise<
238
+ Array<{
239
+ repository: RepositoryIdentifier;
240
+ success: boolean;
241
+ result?: SyncRepositoryResult;
242
+ error?: Error;
243
+ }>
244
+ > {
245
+ const results: Array<{
246
+ repository: RepositoryIdentifier;
247
+ success: boolean;
248
+ result?: SyncRepositoryResult;
249
+ error?: Error;
250
+ }> = [];
251
+
252
+ // 按并发限制分批处理
253
+ for (let i = 0; i < repositories.length; i += concurrency) {
254
+ const batch = repositories.slice(i, i + concurrency);
255
+ const batchPromises = batch.map(repo => syncSingleRepository(sm, repo, syncOptions, rejections));
256
+ const batchResults = await Promise.all(batchPromises);
257
+ results.push(...batchResults);
258
+ }
259
+
260
+ return results;
261
+ }
262
+
263
+ /**
264
+ * 对多个仓库执行批量同步
265
+ *
266
+ * 支持顺序和并发两种执行模式。
267
+ * 在顺序模式下,仓库将被逐个同步。
268
+ * 在并发模式下,可以同时并发同步多个仓库。
269
+ *
270
+ * @param rxdb - RxDB 实例
271
+ * @param options - 批量同步选项
272
+ * @returns 带有成功/失败计数的批量同步结果
273
+ *
274
+ * @example
275
+ * ```typescript
276
+ * // 顺序同步所有启用的仓库
277
+ * const result = await bulkSync(rxdb);
278
+ *
279
+ * // 同步指定仓库
280
+ * const result = await bulkSync(rxdb, {
281
+ * repositories: [
282
+ * { namespace: 'public', entity: 'Todo' },
283
+ * { namespace: 'public', entity: 'User' }
284
+ * ]
285
+ * });
286
+ *
287
+ * // 并发同步并限制并发数
288
+ * const result = await bulkSync(rxdb, {
289
+ * concurrent: true,
290
+ * concurrency: 3
291
+ * });
292
+ * ```
293
+ */
294
+ export async function bulkSync(rxdb: RxDB, options: BulkSyncOptions = {}): Promise<BulkSyncResult> {
295
+ const startTime = Date.now();
296
+
297
+ // 槽位只在入口解析这一次。`rxdb.syncManager` 由插件作用域挂着,批处理跑到一半时
298
+ // 最后一个适配器断开就会把它删掉;逐轮重解析会让剩余仓库全部解引用 `undefined`,
299
+ // 被逐仓库的 try/catch 收成一串 TypeError,崩溃于是伪装成「同步失败」。
300
+ const sm = rxdb.syncManager;
301
+
302
+ // 1. 获取需要同步的仓库
303
+ const repositories = await getRepositoriesToSync(rxdb, options);
304
+
305
+ // 2. 构建同步选项
306
+ const syncOptions: SyncRepositoryOptions = {
307
+ direction: options.operation,
308
+ pull: options.pull,
309
+ push: options.push
310
+ };
311
+
312
+ // 3. 执行同步(顺序或并发)
313
+ const concurrent = options.concurrent ?? false;
314
+ const concurrency = options.concurrency ?? 3;
315
+ // 非正或非整的并发度会让 `executeSyncConcurrently` 的 `i += concurrency` 永不前进,
316
+ // 公开 Promise 永久 pending。挂死比报错难排查得多,入口就地 fail-fast
317
+ if (concurrent && (!Number.isSafeInteger(concurrency) || concurrency < 1)) {
318
+ throw new RxDBError(`bulkSync: concurrency 必须是不小于 1 的安全整数,收到 ${String(concurrency)}`);
319
+ }
320
+
321
+ let results: Array<{
322
+ repository: RepositoryIdentifier;
323
+ success: boolean;
324
+ result?: SyncRepositoryResult;
325
+ error?: Error;
326
+ }>;
327
+
328
+ // 本轮各仓已落库的被拒汇总后只报一次:`reportRejections` 整体替换,逐仓报会互相覆盖。
329
+ // 逐仓的失败都被 syncSingleRepository 收成结果,不会越过这里,前仓已落库的被拒照报
330
+ const rejections: SyncRejection[] = [];
331
+ if (concurrent) {
332
+ results = await executeSyncConcurrently(sm, repositories, syncOptions, concurrency, rejections);
333
+ } else {
334
+ results = await executeSyncSequentially(sm, repositories, syncOptions, rejections);
335
+ }
336
+ reportPushRejections(sm, rejections);
337
+
338
+ // 4. 计算统计信息
339
+ const succeeded = results.filter(r => r.success).length;
340
+ const failed = results.filter(r => !r.success).length;
341
+ const durationMs = Date.now() - startTime;
342
+
343
+ return {
344
+ succeeded,
345
+ failed,
346
+ results,
347
+ durationMs
348
+ };
349
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @fileoverview 级联阻断判定:在依赖图上找出挡住当前仓库的那个失败依赖。
3
+ *
4
+ * 与 {@link ../sync-contract/cascade-contract.js | cascade-contract} 分家的理由见后者的文件头 ——
5
+ * 这一半读依赖图,跟着级联调度器走;那一半只做资格判定,核心的 QueryCache 出站队列也要用。
6
+ */
7
+
8
+ import { type RepositoryIdentifier, repositoryKey } from '@aiao/rxdb';
9
+ import type { DependencyGraph } from './dependency-graph.js';
10
+ import type { DependencyEdge } from './topological-sort.js';
11
+ /**
12
+ * 被依赖失败阻断的仓库,及其阻断来源
13
+ */
14
+ export interface CascadeBlock {
15
+ /** 直接导致阻断的依赖仓库 */
16
+ dependency: RepositoryIdentifier;
17
+
18
+ /** 该依赖仓库失败的错误 */
19
+ cause: Error;
20
+ }
21
+
22
+ /**
23
+ * 在依赖图上查找阻断当前仓库的第一个失败依赖
24
+ *
25
+ * @param graph - 依赖图
26
+ * @param repoKey - 当前仓库的 `namespace:entity` 键
27
+ * @param failed - 已失败仓库到其错误的映射(键同为 `namespace:entity`)
28
+ * @param edge - 本相位沿哪条依赖边判定阻断,取自 `dependencyEdgeForAction`。
29
+ * **没有默认值是刻意的**:这个值必须和本相位的
30
+ * 拓扑序同源,随手给一个默认就等于允许两者各说各话。
31
+ * @returns 存在失败依赖时返回该依赖与其错误;否则返回 `undefined`
32
+ *
33
+ * @remarks
34
+ * 只看直接邻居:级联按拓扑序执行,间接依赖的失败会先把中间那一层标成失败,
35
+ * 再经由这一层传导下来。
36
+ *
37
+ * 边必须跟着相位走,不能一律取 `dependsOn`:
38
+ *
39
+ * - **INSERT/UPDATE** 父先子后,父建不出来子就挂不上外键 → 子被 `dependsOn` 阻断;
40
+ * - **DELETE** 子先父后,子删不掉父就删不得(外键还指着它)→ 父被 `requiredBy` 阻断。
41
+ *
42
+ * 此前两个相位都取 `dependsOn`,等于把 DELETE 的方向反过来看:子仓删除失败时
43
+ * 父仓照删不误,远端要么直接违反外键约束、要么留下指向已删父行的孤儿;反过来
44
+ * 父仓删除失败又会白白挡住子仓——删子从来不会破坏父。
45
+ */
46
+ export function findBlockingDependency(
47
+ graph: DependencyGraph,
48
+ repoKey: string,
49
+ failed: ReadonlyMap<string, Error>,
50
+ edge: DependencyEdge
51
+ ): CascadeBlock | undefined {
52
+ const dep = graph.get(repoKey);
53
+ if (!dep) return undefined;
54
+
55
+ for (const dependency of dep[edge]) {
56
+ const cause = failed.get(repositoryKey(dependency));
57
+ if (cause) return { dependency, cause };
58
+ }
59
+
60
+ return undefined;
61
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * 预检远程更新(不下载数据)
3
+ * 此函数只查询远程有多少新变更,不会实际拉取数据
4
+ * 用于"有 N 条更新"的场景,节省流量和时间
5
+ *
6
+ * User Story 2: 按需同步
7
+ */
8
+
9
+ import { getEntityMetadata, needsPull, type RepositoryIdentifier, type RxDB, RxDBSync } from '@aiao/rxdb';
10
+ import { getAncestorBranchIds } from './branch-utils.js';
11
+ // `RepositoryIdentifier` 此前在 4 个文件里各写了一份同形状定义,
12
+ // 统一收敛到 `VersionManager.interface.ts`(那份才是 `index.ts` 真正导出的)。
13
+ export type { RepositoryIdentifier };
14
+
15
+ /**
16
+ * 仓库更新检查结果。
17
+ */
18
+ export interface CheckRepositoryUpdatesResult {
19
+ /**
20
+ * Repository 标识
21
+ */
22
+ repository: RepositoryIdentifier;
23
+
24
+ /**
25
+ * 远程最新的 changeId
26
+ */
27
+ remoteLatestChangeId: number;
28
+
29
+ /**
30
+ * 本地最后拉取的远程 changeId
31
+ */
32
+ localLastPullRemoteChangeId: number | null;
33
+
34
+ /**
35
+ * 待拉取的变更数量
36
+ */
37
+ pendingCount: number;
38
+
39
+ /**
40
+ * 是否有更新
41
+ */
42
+ hasUpdates: boolean;
43
+ }
44
+
45
+ /**
46
+ * 预检远程更新(不下载数据)
47
+ *
48
+ * @param rxdb - RxDB 实例
49
+ * @param namespace - 命名空间
50
+ * @param entity - 实体名称
51
+ * @returns 更新检查结果
52
+ *
53
+ * @example
54
+ * ```typescript
55
+ * // 检查 Todo 是否有远程更新
56
+ * const result = await checkRepositoryUpdates(rxdb, 'public', 'Todo');
57
+ * if (result.hasUpdates) {
58
+ * console.log(`有 ${result.pendingCount} 条更新待拉取`);
59
+ * // 用户点击"更新"按钮后再调用 pullRepository()
60
+ * }
61
+ * ```
62
+ */
63
+ export async function checkRepositoryUpdates(
64
+ rxdb: RxDB,
65
+ namespace: string,
66
+ entity: string
67
+ ): Promise<CheckRepositoryUpdatesResult> {
68
+ const repository: RepositoryIdentifier = { namespace, entity };
69
+
70
+ // 1. 检查同步配置
71
+ const EntityType = rxdb.config.entities.find(e => {
72
+ const meta = getEntityMetadata(e);
73
+ return meta.namespace === namespace && meta.name === entity;
74
+ });
75
+
76
+ if (!EntityType) {
77
+ throw new Error(`Entity not found: ${namespace}.${entity}`);
78
+ }
79
+
80
+ const metadata = getEntityMetadata(EntityType);
81
+
82
+ // 如果不需要 pull(Local 或 None),直接返回无更新
83
+ if (!needsPull(metadata, rxdb.entitySync)) {
84
+ return {
85
+ repository,
86
+ remoteLatestChangeId: 0,
87
+ localLastPullRemoteChangeId: null,
88
+ pendingCount: 0,
89
+ hasUpdates: false
90
+ };
91
+ }
92
+
93
+ // 2. 获取 RxDBSync 记录
94
+ // 槽位只解析这一次:断连会把 `rxdb.syncManager` 删掉,一次查询里重解析四回
95
+ // 等于给自己留四个 `undefined` 的窗口。
96
+ const sm = rxdb.syncManager;
97
+ const branch = await sm.getCurrentBranch();
98
+ const branchId = branch.id;
99
+
100
+ const { adapter: localAdapter } = await sm.getLocalRepositories();
101
+ const repoSyncRepo = localAdapter.getRepository(RxDBSync);
102
+ const repoSyncId = `${namespace}:${entity}:${branchId}`;
103
+
104
+ const repoSyncResults = await repoSyncRepo.find({
105
+ where: {
106
+ combinator: 'and',
107
+ rules: [{ field: 'id', operator: '=', value: repoSyncId }]
108
+ },
109
+ limit: 1
110
+ });
111
+
112
+ const repoSync = repoSyncResults[0];
113
+ const localLastPullRemoteChangeId = repoSync?.lastPullRemoteChangeId ?? null;
114
+
115
+ // 3. 调用适配器的 getChangeCount() 查询远程变更数量
116
+ const { adapter: remoteAdapter } = await sm.getRemoteRepositories();
117
+ if (!remoteAdapter) {
118
+ throw new Error('Remote adapter not configured');
119
+ }
120
+
121
+ const sinceId = localLastPullRemoteChangeId ?? 0;
122
+ // 计数范围必须和 pull 的拉取范围一致,否则这个函数就是在回答另一个问题:
123
+ // `getChangeCount` 的 branchId 是精确匹配,只传当前分支时父分支上的新变更一条都不计入,
124
+ // 于是 `hasUpdates` 报 false、界面显示「已全部同步」,而 pull 其实还有东西要拉。
125
+ // 此前只传裸实体名,同名实体跨 namespace 存在时会解析歧义
126
+ const branchIds = await getAncestorBranchIds(sm, branchId);
127
+ const perBranch = await Promise.all(
128
+ branchIds.map(ancestorBranchId =>
129
+ remoteAdapter.getChangeCount(sinceId, [`${namespace}:${metadata.name}`], ancestorBranchId)
130
+ )
131
+ );
132
+ // 条数跨分支相加;`latestChangeId` 是同一个远端序列上的最大值,取 max 而非相加
133
+ const count = perBranch.reduce((sum, item) => sum + item.count, 0);
134
+ const latestChangeId = perBranch.reduce((max, item) => (item.latestChangeId > max ? item.latestChangeId : max), 0);
135
+ // 4. 构造返回结果
136
+ return {
137
+ repository,
138
+ remoteLatestChangeId: latestChangeId,
139
+ localLastPullRemoteChangeId,
140
+ pendingCount: count,
141
+ hasUpdates: count > 0
142
+ };
143
+ }