@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
@@ -0,0 +1,445 @@
1
+ /**
2
+ * @fileoverview 同步插件对核心 {@link BranchMaterializationSource} 契约的实现
3
+ *
4
+ * `syncBranches()` 只把远端分支的**元数据**拉到本地(`local: false, remote: true`),一条变更都不带。
5
+ * 工作树插件把这样的分支判成 metadata-only,第一次切过去之前要先按本来源交出的分页快照物化。
6
+ * 本文件回答四件事:远端「到哪为止」(冻结水位)、「哪些表」(冻结 scope)、「怎么分页续拉」
7
+ * (游标写在 payload 里)、「屏障里怎么落」(投影 + 结算水位)。
8
+ *
9
+ * 口径与 {@link pullSingleRepository} 对齐——同一套仓库枚举、同一条祖先链、同一份行级 filter。
10
+ * 物化出来的数据与「先切过去再 pull」必须是同一份,否则切换之后第一次 pull 会把差异当成新变更。
11
+ */
12
+
13
+ import {
14
+ type BranchMaterializationBarrierContext,
15
+ type BranchMaterializationIntent,
16
+ branchMaterializationPageFingerprint,
17
+ type BranchMaterializationPagePayload,
18
+ type BranchMaterializationPageRequest,
19
+ type BranchMaterializationProjectionContext,
20
+ type BranchMaterializationSource,
21
+ canonicalMaterializationJson,
22
+ compactChanges,
23
+ type EntityMetadata,
24
+ type EntityStaticType,
25
+ getEntityMetadata,
26
+ getOrCreateSyncRecord,
27
+ getSyncCapability,
28
+ getSyncConfig,
29
+ getSyncType,
30
+ isRepositorySyncEnabled,
31
+ type RemoteChange,
32
+ repositoryKey,
33
+ type RuleGroup,
34
+ RxDB,
35
+ type RxDBAdapterRemoteBase,
36
+ RxDBBranch,
37
+ RxDBError,
38
+ RxDBSync,
39
+ type SwitchVersionActions,
40
+ type TransactionExecutor
41
+ } from '@aiao/rxdb';
42
+ import type { SyncManager } from './SyncManager.js';
43
+ import { ancestorBranchIdsFrom, type BranchLineageReader } from './branch-utils.js';
44
+ import { buildDependencyGraph } from './dependency-graph.js';
45
+ import { pullAncestorBranchChanges } from './pull-ancestor-changes.js';
46
+ import { resolveCascadeFilter } from './pull-repository.js';
47
+ import { topologicalSortForPull } from './topological-sort.js';
48
+
49
+ /** 每页最多向远端要多少条;与 `pullRepository` 的默认批量同值 */
50
+ const MATERIALIZATION_PAGE_LIMIT = 1000;
51
+
52
+ /** 读同步水位记录所需的最小仓库能力;本地仓库与事务执行器给出的仓库都接得住 */
53
+ interface SyncRecordReader {
54
+ /**
55
+ * 按条件查同步记录
56
+ *
57
+ * @param options - 查询选项
58
+ * @returns 命中的记录
59
+ */
60
+ find(options: EntityStaticType<typeof RxDBSync, 'findOptions'>): Promise<RxDBSync[]>;
61
+ }
62
+
63
+ /** 算本地 scope 要读的两张系统表 */
64
+ interface ScopeReaders {
65
+ /** 分支表:算血缘 */
66
+ readonly branchRepository: BranchLineageReader;
67
+
68
+ /** 同步水位表:判 `enabled` */
69
+ readonly syncRepository: SyncRecordReader;
70
+ }
71
+
72
+ /** 本地此刻认定的物化范围:拉哪些表、沿哪条祖先链、每张表带什么行级条件 */
73
+ interface LocalScope {
74
+ /** 目标分支及其全部祖先,与 {@link getAncestorBranchIds} 同一口径 */
75
+ readonly lineage: string[];
76
+
77
+ /** 按拉取拓扑序排好的仓库键 */
78
+ readonly syncScope: string[];
79
+
80
+ /** 每张表的行级条件;`null` 表示不带条件 */
81
+ readonly filters: Record<string, RuleGroup | null>;
82
+ }
83
+
84
+ /** 冻结在 staging 行上的水位;`frozenRemoteWatermark` 的实际形状 */
85
+ interface FrozenWatermark {
86
+ /** 冻结时的血缘 */
87
+ readonly lineage: string[];
88
+
89
+ /** 每张表的远端截止 id:只物化 `id <= cutoff` 的变更 */
90
+ readonly cutoffs: Record<string, number>;
91
+
92
+ /** 冻结时每张表的行级条件 */
93
+ readonly filters: Record<string, RuleGroup | null>;
94
+ }
95
+
96
+ /** 冻结意图解出来的完整形状 */
97
+ interface FrozenIntent extends FrozenWatermark {
98
+ /** 冻结时的仓库键,顺序即拉取顺序 */
99
+ readonly syncScope: readonly string[];
100
+ }
101
+
102
+ /**
103
+ * 一页的 payload:既是要投影的变更,也是续拉游标。
104
+ *
105
+ * @remarks
106
+ * 游标写在 payload 里而不是按页号推算:页号说不出「拉到了哪」,而同一张表可能占好几页、
107
+ * 被 cutoff 截掉的那一页又比满页短。续拉时工作树把最后落库的那一页原样交还。
108
+ */
109
+ interface MaterializationPage {
110
+ /** 这一页属于哪张表 */
111
+ readonly repository: string;
112
+
113
+ /** 这一页请求时用的水位 */
114
+ readonly sinceId: number;
115
+
116
+ /** 这一页之后该表的水位;空页时等于 `sinceId` */
117
+ readonly lastId: number;
118
+
119
+ /** 这张表拉完了吗;`true` 时下一页换下一张表 */
120
+ readonly done: boolean;
121
+
122
+ /** 这一页的远端变更,id 升序,全部 `<= cutoff` */
123
+ readonly changes: RemoteChange[];
124
+ }
125
+
126
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
127
+ typeof value === 'object' && value !== null && !Array.isArray(value);
128
+
129
+ const isStringArray = (value: unknown): value is string[] =>
130
+ Array.isArray(value) && value.every(item => typeof item === 'string');
131
+
132
+ /**
133
+ * 从意图里解出冻结值。
134
+ *
135
+ * @throws {@link RxDBError} 形状不是本来源冻结的那一种时
136
+ *
137
+ * @remarks
138
+ * 意图落库之后是 JSON 往返回来的,类型信息早就丢了;在这里验一次形状,下游才敢按字段取。
139
+ * 不对就抛——一份别人冻结的意图没有「差不多能用」的读法。
140
+ */
141
+ const readFrozenIntent = (intent: BranchMaterializationIntent): FrozenIntent => {
142
+ const watermark = intent.frozenRemoteWatermark;
143
+ const { lineage, cutoffs, filters } = watermark;
144
+ const wellFormed =
145
+ isStringArray(lineage) &&
146
+ isRecord(cutoffs) &&
147
+ isRecord(filters) &&
148
+ intent.syncScope.every(key => typeof cutoffs[key] === 'number' && key in filters);
149
+ if (!wellFormed) {
150
+ throw new RxDBError(`分支物化意图不是同步插件冻结的形状:${canonicalMaterializationJson(watermark)}`);
151
+ }
152
+ return {
153
+ syncScope: intent.syncScope,
154
+ lineage,
155
+ cutoffs: cutoffs as Record<string, number>,
156
+ filters: filters as Record<string, RuleGroup | null>
157
+ };
158
+ };
159
+
160
+ /**
161
+ * 从一页 payload 里解出游标与变更。
162
+ *
163
+ * @throws {@link RxDBError} 形状不对时
164
+ */
165
+ const readPage = (payload: Record<string, unknown>): MaterializationPage => {
166
+ const { repository, sinceId, lastId, done, changes } = payload;
167
+ const wellFormed =
168
+ typeof repository === 'string' &&
169
+ typeof sinceId === 'number' &&
170
+ typeof lastId === 'number' &&
171
+ typeof done === 'boolean' &&
172
+ Array.isArray(changes);
173
+ if (!wellFormed) {
174
+ throw new RxDBError(`分支物化分页不是同步插件交出的形状:${canonicalMaterializationJson(payload)}`);
175
+ }
176
+ // `createdAt` / `updatedAt` 落库往返后是字符串;投影只读 id / 类型 / 补丁,不碰它们。
177
+ return { repository, sinceId, lastId, done, changes: changes as RemoteChange[] };
178
+ };
179
+
180
+ /** 按仓库键找实体元数据;配置里没有这张表时抛 */
181
+ const metadataOf = (rxdb: RxDB, key: string): EntityMetadata => {
182
+ for (const EntityClass of rxdb.config.entities) {
183
+ const metadata = getEntityMetadata(EntityClass);
184
+ if (repositoryKey({ namespace: metadata.namespace, entity: metadata.name }) === key) return metadata;
185
+ }
186
+ throw new RxDBError(`分支物化范围里的仓库 ${key} 不在当前实体配置里`);
187
+ };
188
+
189
+ /** 按主键读一条同步记录,只读不创建 */
190
+ const findSyncRecord = async (reader: SyncRecordReader, syncId: string): Promise<RxDBSync | undefined> =>
191
+ (
192
+ await reader.find({
193
+ where: { combinator: 'and', rules: [{ field: 'id', operator: '=', value: syncId }] },
194
+ limit: 1
195
+ })
196
+ )[0];
197
+
198
+ /**
199
+ * 算本地此刻认定的物化范围。
200
+ *
201
+ * @param rxdb - 宿主实例
202
+ * @param readers - 事务外传适配器仓库,屏障里传执行器仓库
203
+ * @param targetBranchId - 目标分支
204
+ * @returns 见 {@link LocalScope}
205
+ *
206
+ * @remarks
207
+ * 仓库枚举与 `pullBatch` 同一口径:能力矩阵判 pull、`RxDBSync.enabled` 判开关,顺序取拉取拓扑序。
208
+ * 开关读的是**目标分支**那条记录——物化之后的水位也结算在它上面。
209
+ *
210
+ * 不走 `resolvePullIneligibility`:它读的是绑在适配器上的仓库,屏障里用会死锁。
211
+ */
212
+ const readLocalScope = async (rxdb: RxDB, readers: ScopeReaders, targetBranchId: string): Promise<LocalScope> => {
213
+ const lineage = await ancestorBranchIdsFrom(readers.branchRepository, targetBranchId);
214
+ const ordered = topologicalSortForPull(buildDependencyGraph(rxdb.config.entities.map(e => getEntityMetadata(e))));
215
+ const syncScope: string[] = [];
216
+ const filters: Record<string, RuleGroup | null> = {};
217
+ for (const repository of ordered) {
218
+ const key = repositoryKey(repository);
219
+ const metadata = metadataOf(rxdb, key);
220
+ const syncType = getSyncType(metadata, rxdb.entitySync);
221
+ if (!getSyncCapability(syncType).pull) continue;
222
+ const record = await findSyncRecord(readers.syncRepository, `${key}:${targetBranchId}`);
223
+ if (!isRepositorySyncEnabled(record)) continue;
224
+ syncScope.push(key);
225
+ filters[key] = resolveCascadeFilter(key, getSyncConfig(metadata, rxdb.entitySync), syncType, undefined) ?? null;
226
+ }
227
+ return { lineage, syncScope, filters };
228
+ };
229
+
230
+ /** 执行器上的两张系统表 */
231
+ const executorReaders = (executor: TransactionExecutor): ScopeReaders => ({
232
+ branchRepository: executor.getRepository(RxDBBranch),
233
+ syncRepository: executor.getRepository(RxDBSync)
234
+ });
235
+
236
+ /**
237
+ * 取远端适配器。
238
+ *
239
+ * @throws {@link RxDBError} 没配远端时——`remoteAdapter$` 在那种库上永远不发值,等下去就是挂死
240
+ */
241
+ const remoteAdapterOf = async (sm: SyncManager): Promise<RxDBAdapterRemoteBase> => {
242
+ if (!sm.rxdb.config.sync?.remote?.adapter) throw new RxDBError('Remote adapter not configured.');
243
+ const { adapter } = await sm.getRemoteRepositories();
244
+ return adapter;
245
+ };
246
+
247
+ /**
248
+ * 冻结一张表的远端截止 id:血缘上每条分支各问一次,取最大。
249
+ *
250
+ * @remarks
251
+ * `getChangeCount` 不带行级条件,截止 id 可能落在一条被 filter 掉的变更上——
252
+ * 截止 id 只用来划「到哪为止」,落在哪条上都不影响取到的集合。
253
+ */
254
+ const freezeCutoff = async (
255
+ remote: RxDBAdapterRemoteBase,
256
+ key: string,
257
+ lineage: readonly string[]
258
+ ): Promise<number> => {
259
+ const counts = await Promise.all(lineage.map(branchId => remote.getChangeCount(0, [key], branchId)));
260
+ return Math.max(0, ...counts.map(count => count.latestChangeId));
261
+ };
262
+
263
+ /** 续拉起点:第几张表、从哪个水位接 */
264
+ interface ResumePoint {
265
+ readonly index: number;
266
+ readonly sinceId: number;
267
+ }
268
+
269
+ /**
270
+ * 从最后落库的那一页推出续拉起点。
271
+ *
272
+ * @throws {@link RxDBError} 那一页的表不在冻结范围里时——staging 与意图对不上,接不下去
273
+ */
274
+ const resumePointOf = (
275
+ intent: FrozenIntent,
276
+ previous: BranchMaterializationPageRequest['previousPage']
277
+ ): ResumePoint => {
278
+ if (!previous) return { index: 0, sinceId: 0 };
279
+ const page = readPage(previous.payload);
280
+ const index = intent.syncScope.indexOf(page.repository);
281
+ if (index < 0) throw new RxDBError(`续拉游标指向的仓库 ${page.repository} 不在冻结范围里`);
282
+ return page.done ? { index: index + 1, sinceId: 0 } : { index, sinceId: page.lastId };
283
+ };
284
+
285
+ /**
286
+ * 向远端要一页:同一个水位对整条血缘各拉一次,合并排序后截到 cutoff。
287
+ *
288
+ * @remarks
289
+ * 空页也照交:它把「这张表拉完了」落进 staging,续拉时不必再为同一张表白跑一趟。
290
+ */
291
+ const pullPage = async (
292
+ rxdb: RxDB,
293
+ remote: RxDBAdapterRemoteBase,
294
+ intent: FrozenIntent,
295
+ key: string,
296
+ sinceId: number
297
+ ): Promise<BranchMaterializationPagePayload> => {
298
+ const metadata = metadataOf(rxdb, key);
299
+ const cutoff = intent.cutoffs[key];
300
+ const pulled = await pullAncestorBranchChanges(
301
+ remote,
302
+ {
303
+ namespace: metadata.namespace,
304
+ entity: metadata.name,
305
+ sinceId,
306
+ limit: MATERIALIZATION_PAGE_LIMIT,
307
+ filter: intent.filters[key] ?? undefined
308
+ },
309
+ intent.lineage
310
+ );
311
+ const changes = pulled.filter(change => change.id <= cutoff);
312
+ const lastId = changes.at(-1)?.id ?? sinceId;
313
+ const done = pulled.length < MATERIALIZATION_PAGE_LIMIT || changes.length < pulled.length || lastId >= cutoff;
314
+ const payload = { repository: key, sinceId, lastId, done, changes: changes.map(toPageChange) };
315
+ return { payload, fingerprint: branchMaterializationPageFingerprint(payload) };
316
+ };
317
+
318
+ /** 只留投影与诊断要用的字段;`clientId` / `transactionId` 之类不进 staging */
319
+ const toPageChange = (change: RemoteChange): RemoteChange => ({
320
+ id: change.id,
321
+ namespace: change.namespace,
322
+ entity: change.entity,
323
+ entityId: change.entityId,
324
+ branchId: change.branchId,
325
+ type: change.type,
326
+ patch: change.patch ?? null,
327
+ inversePatch: change.inversePatch ?? null,
328
+ createdAt: change.createdAt,
329
+ updatedAt: change.updatedAt
330
+ });
331
+
332
+ /**
333
+ * 比对冻结值与此刻本地的范围;不一致时给出原因。
334
+ *
335
+ * @remarks
336
+ * 三项各比各的,而不是整体比一次:原因要说得出是哪一项漂了,诊断才有抓手。
337
+ */
338
+ const describeDrift = (frozen: FrozenIntent, current: LocalScope): string | undefined => {
339
+ if (canonicalMaterializationJson(frozen.syncScope) !== canonicalMaterializationJson(current.syncScope)) {
340
+ return `同步范围变了:冻结时 ${frozen.syncScope.join(', ')},现在 ${current.syncScope.join(', ')}`;
341
+ }
342
+ if (canonicalMaterializationJson(frozen.lineage) !== canonicalMaterializationJson(current.lineage)) {
343
+ return `分支血缘变了:冻结时 ${frozen.lineage.join(' → ')},现在 ${current.lineage.join(' → ')}`;
344
+ }
345
+ const changed = frozen.syncScope.filter(
346
+ key => canonicalMaterializationJson(frozen.filters[key]) !== canonicalMaterializationJson(current.filters[key])
347
+ );
348
+ if (changed.length > 0) return `行级过滤条件变了:${changed.join(', ')}`;
349
+ return undefined;
350
+ };
351
+
352
+ /**
353
+ * 造同步插件的分支物化来源。
354
+ *
355
+ * @param sm - 本纪元的同步管理器
356
+ * @returns 交给 `rxdb.branchMaterializationSource()` 登记的来源
357
+ *
358
+ * @remarks
359
+ * 各成员的调用时机由工作树插件决定,这里只守契约:
360
+ *
361
+ * - `freezeIntent` / `pages` 在任何事务之外跑,可以碰远端;
362
+ * - `resolveIntentDrift` / `projectPage` / `settle` 跑在切换事务里,只用传进来的执行器,
363
+ * 一次远端都不碰——绑在适配器上的仓库在那里会排在那笔事务自己后面,死锁。
364
+ *
365
+ * 投影**不区分**自己推上去的变更与别人的:metadata-only 分支在本地一行数据都没有,
366
+ * `pull` 跳过自己那份的前提(「本地已经有了」)在这里不成立。
367
+ *
368
+ * @internal
369
+ */
370
+ export const createSyncBranchMaterializationSource = (sm: SyncManager): BranchMaterializationSource => {
371
+ const rxdb = sm.rxdb;
372
+ return {
373
+ async freezeIntent(targetBranchId: string): Promise<BranchMaterializationIntent> {
374
+ const { adapter } = await sm.getLocalRepositories();
375
+ const scope = await readLocalScope(
376
+ rxdb,
377
+ { branchRepository: adapter.getRepository(RxDBBranch), syncRepository: adapter.getRepository(RxDBSync) },
378
+ targetBranchId
379
+ );
380
+ const remote = await remoteAdapterOf(sm);
381
+ const cutoffs: Record<string, number> = {};
382
+ for (const key of scope.syncScope) cutoffs[key] = await freezeCutoff(remote, key, scope.lineage);
383
+ const frozenRemoteWatermark: FrozenWatermark = { lineage: scope.lineage, cutoffs, filters: scope.filters };
384
+ return { syncScope: scope.syncScope, frozenRemoteWatermark: { ...frozenRemoteWatermark } };
385
+ },
386
+
387
+ async *pages(request: BranchMaterializationPageRequest): AsyncIterable<BranchMaterializationPagePayload> {
388
+ const intent = readFrozenIntent(request.intent);
389
+ const start = resumePointOf(intent, request.previousPage);
390
+ const remote = await remoteAdapterOf(sm);
391
+ let sinceId = start.sinceId;
392
+ for (let index = start.index; index < intent.syncScope.length; index++, sinceId = 0) {
393
+ const key = intent.syncScope[index];
394
+ let done = sinceId >= intent.cutoffs[key];
395
+ while (!done) {
396
+ const page = await pullPage(rxdb, remote, intent, key, sinceId);
397
+ yield page;
398
+ done = page.payload['done'] === true;
399
+ sinceId = page.payload['lastId'] as number;
400
+ }
401
+ }
402
+ },
403
+
404
+ async resolveIntentDrift(context: BranchMaterializationBarrierContext): Promise<string | undefined> {
405
+ let frozen: FrozenIntent;
406
+ try {
407
+ frozen = readFrozenIntent(context.intent);
408
+ } catch (error) {
409
+ // 形状不对的意图接不下去,唯一的出路是作废重冻;报成漂移正是让工作树这么做。
410
+ return error instanceof Error ? error.message : String(error);
411
+ }
412
+ const current = await readLocalScope(rxdb, executorReaders(context.executor), context.targetBranchId);
413
+ return describeDrift(frozen, current);
414
+ },
415
+
416
+ async projectPage(context: BranchMaterializationProjectionContext): Promise<SwitchVersionActions> {
417
+ return compactChanges(readPage(context.page.payload).changes);
418
+ },
419
+
420
+ async settle(context: BranchMaterializationBarrierContext): Promise<void> {
421
+ const intent = readFrozenIntent(context.intent);
422
+ const syncRepository = context.executor.getRepository(RxDBSync);
423
+ const now = new Date();
424
+ for (const key of intent.syncScope) {
425
+ const metadata = metadataOf(rxdb, key);
426
+ const record = await getOrCreateSyncRecord(
427
+ syncRepository,
428
+ {
429
+ namespace: metadata.namespace,
430
+ entity: metadata.name,
431
+ branchId: context.targetBranchId,
432
+ syncType: getSyncType(metadata, rxdb.entitySync)
433
+ },
434
+ () => rxdb.entityManager.instantiate(RxDBSync)
435
+ );
436
+ // 水位结算到冻结的截止 id:物化交出的正是 `<= cutoff` 的全部变更,之后的交给普通 pull。
437
+ await syncRepository.update(record, {
438
+ lastPullRemoteChangeId: intent.cutoffs[key],
439
+ lastPulledAt: now,
440
+ updatedAt: now
441
+ });
442
+ }
443
+ }
444
+ };
445
+ };
@@ -1,17 +1,54 @@
1
- import { RxDBBranch } from '@aiao/rxdb';
1
+ import { EntityStaticType, MAIN_BRANCH_ID, RxDBBranch } from '@aiao/rxdb';
2
2
  import type { SyncManager } from './SyncManager.js';
3
3
 
4
+ /**
5
+ * 沿 `parentId` 向上走分支链所需的最小仓库能力。
6
+ *
7
+ * @remarks
8
+ * 写成结构化接口,是为了让绑在适配器上的本地仓库与事务执行器给出的仓库都接得住——
9
+ * 分支物化来源要在切换事务里按同一口径重算血缘,而绑在适配器上的仓库在那里会排在
10
+ * 那笔事务自己后面,死锁。
11
+ */
12
+ export interface BranchLineageReader {
13
+ /**
14
+ * 按条件查分支
15
+ *
16
+ * @param options - 查询选项
17
+ * @returns 命中的分支行
18
+ */
19
+ find(options: EntityStaticType<typeof RxDBBranch, 'findOptions'>): Promise<RxDBBranch[]>;
20
+ }
21
+
4
22
  /**
5
23
  * 获取当前分支及其所有祖先分支的 ID 列表
6
24
  *
7
25
  * @returns [currentBranch, parent, grandparent, ...] 例如 ['dev', 'main']
8
26
  */
9
27
  export async function getAncestorBranchIds(sm: SyncManager, branchId: string): Promise<string[]> {
10
- const branchIds = [branchId];
11
- if (branchId === 'main') return branchIds;
12
-
28
+ if (branchId === MAIN_BRANCH_ID) return [branchId];
13
29
  const { adapter } = await sm.getLocalRepositories();
14
- const branchRepository = adapter.getRepository(RxDBBranch);
30
+ return ancestorBranchIdsFrom(adapter.getRepository(RxDBBranch), branchId);
31
+ }
32
+
33
+ /**
34
+ * 用给定的分支仓库算 `branchId` 及其全部祖先——{@link getAncestorBranchIds} 的本体。
35
+ *
36
+ * @param branchRepository - 见 {@link BranchLineageReader}
37
+ * @param branchId - 起点分支
38
+ * @returns [branchId, parent, grandparent, ...]
39
+ *
40
+ * @remarks
41
+ * 拆出来给分支物化来源用:冻结意图时在事务外算一次,屏障里再用执行器的仓库算一次比对,
42
+ * 两处必须与 pull 同一口径,否则「范围变没变」的判定本身就在漂。
43
+ *
44
+ * @internal
45
+ */
46
+ export async function ancestorBranchIdsFrom(
47
+ branchRepository: BranchLineageReader,
48
+ branchId: string
49
+ ): Promise<string[]> {
50
+ const branchIds = [branchId];
51
+ if (branchId === MAIN_BRANCH_ID) return branchIds;
15
52
 
16
53
  let currentId = branchId;
17
54
  const visited = new Set<string>([currentId]);
package/src/bulk-sync.ts CHANGED
@@ -12,11 +12,12 @@ import {
12
12
  isRepositorySyncEnabled,
13
13
  type RxDB,
14
14
  RxDBError,
15
- RxDBPartialSyncError
15
+ RxDBPartialSyncError,
16
+ type SyncRejection
16
17
  } from '@aiao/rxdb';
17
18
  import type { RepositoryIdentifier } from './dependency-graph.js';
18
19
  import type { PullRepositoryOptions } from './pull-repository.js';
19
- import type { PushRepositoryOptions } from './push-repository.js';
20
+ import { type PushRepositoryOptions, reportPushRejections } from './push-repository.js';
20
21
  import { syncRepository, type SyncRepositoryOptions, type SyncRepositoryResult } from './sync-repository.js';
21
22
  import type { SyncManager } from './SyncManager.js';
22
23
  /**
@@ -120,7 +121,7 @@ export async function getRepositoriesToSync(rxdb: RxDB, options: BulkSyncOptions
120
121
  const metadata = getEntityMetadata(EntityClass);
121
122
 
122
123
  // 检查 syncType(支持全局配置回退)
123
- const capability = getSyncCapability(getSyncType(metadata, rxdb.config.sync));
124
+ const capability = getSyncCapability(getSyncType(metadata, rxdb.entitySync));
124
125
  if (!capability.pull && !capability.push) {
125
126
  continue;
126
127
  }
@@ -145,12 +146,14 @@ export async function getRepositoriesToSync(rxdb: RxDB, options: BulkSyncOptions
145
146
  * @param sm - 调用方持有的同步管理器实例
146
147
  * @param repo - 要同步的仓库
147
148
  * @param syncOptions - 同步选项
149
+ * @param rejections - 本轮被拒清单收集器,由 {@link bulkSync} 统一上报
148
150
  * @returns 带有成功标志的结果对象
149
151
  */
150
152
  async function syncSingleRepository(
151
153
  sm: SyncManager,
152
154
  repo: RepositoryIdentifier,
153
- syncOptions: SyncRepositoryOptions
155
+ syncOptions: SyncRepositoryOptions,
156
+ rejections: SyncRejection[]
154
157
  ): Promise<{
155
158
  repository: RepositoryIdentifier;
156
159
  success: boolean;
@@ -158,7 +161,7 @@ async function syncSingleRepository(
158
161
  error?: Error;
159
162
  }> {
160
163
  try {
161
- const result = await syncRepository(sm, repo.namespace, repo.entity, syncOptions);
164
+ const result = await syncRepository(sm, repo.namespace, repo.entity, syncOptions, rejections);
162
165
 
163
166
  return {
164
167
  repository: repo,
@@ -184,12 +187,14 @@ async function syncSingleRepository(
184
187
  * @param sm - 调用方持有的同步管理器实例
185
188
  * @param repositories - 仓库列表
186
189
  * @param syncOptions - 同步选项
190
+ * @param rejections - 本轮被拒清单收集器
187
191
  * @returns 结果数组
188
192
  */
189
193
  async function executeSyncSequentially(
190
194
  sm: SyncManager,
191
195
  repositories: RepositoryIdentifier[],
192
- syncOptions: SyncRepositoryOptions
196
+ syncOptions: SyncRepositoryOptions,
197
+ rejections: SyncRejection[]
193
198
  ): Promise<
194
199
  Array<{
195
200
  repository: RepositoryIdentifier;
@@ -206,7 +211,7 @@ async function executeSyncSequentially(
206
211
  }> = [];
207
212
 
208
213
  for (const repo of repositories) {
209
- const result = await syncSingleRepository(sm, repo, syncOptions);
214
+ const result = await syncSingleRepository(sm, repo, syncOptions, rejections);
210
215
  results.push(result);
211
216
  }
212
217
 
@@ -220,13 +225,15 @@ async function executeSyncSequentially(
220
225
  * @param repositories - 仓库列表
221
226
  * @param syncOptions - 同步选项
222
227
  * @param concurrency - 最大并发操作数
228
+ * @param rejections - 本轮被拒清单收集器
223
229
  * @returns 结果数组
224
230
  */
225
231
  async function executeSyncConcurrently(
226
232
  sm: SyncManager,
227
233
  repositories: RepositoryIdentifier[],
228
234
  syncOptions: SyncRepositoryOptions,
229
- concurrency: number
235
+ concurrency: number,
236
+ rejections: SyncRejection[]
230
237
  ): Promise<
231
238
  Array<{
232
239
  repository: RepositoryIdentifier;
@@ -245,7 +252,7 @@ async function executeSyncConcurrently(
245
252
  // 按并发限制分批处理
246
253
  for (let i = 0; i < repositories.length; i += concurrency) {
247
254
  const batch = repositories.slice(i, i + concurrency);
248
- const batchPromises = batch.map(repo => syncSingleRepository(sm, repo, syncOptions));
255
+ const batchPromises = batch.map(repo => syncSingleRepository(sm, repo, syncOptions, rejections));
249
256
  const batchResults = await Promise.all(batchPromises);
250
257
  results.push(...batchResults);
251
258
  }
@@ -318,11 +325,15 @@ export async function bulkSync(rxdb: RxDB, options: BulkSyncOptions = {}): Promi
318
325
  error?: Error;
319
326
  }>;
320
327
 
328
+ // 本轮各仓已落库的被拒汇总后只报一次:`reportRejections` 整体替换,逐仓报会互相覆盖。
329
+ // 逐仓的失败都被 syncSingleRepository 收成结果,不会越过这里,前仓已落库的被拒照报
330
+ const rejections: SyncRejection[] = [];
321
331
  if (concurrent) {
322
- results = await executeSyncConcurrently(sm, repositories, syncOptions, concurrency);
332
+ results = await executeSyncConcurrently(sm, repositories, syncOptions, concurrency, rejections);
323
333
  } else {
324
- results = await executeSyncSequentially(sm, repositories, syncOptions);
334
+ results = await executeSyncSequentially(sm, repositories, syncOptions, rejections);
325
335
  }
336
+ reportPushRejections(sm, rejections);
326
337
 
327
338
  // 4. 计算统计信息
328
339
  const succeeded = results.filter(r => r.success).length;
@@ -80,7 +80,7 @@ export async function checkRepositoryUpdates(
80
80
  const metadata = getEntityMetadata(EntityType);
81
81
 
82
82
  // 如果不需要 pull(Local 或 None),直接返回无更新
83
- if (!needsPull(metadata, rxdb.config.sync)) {
83
+ if (!needsPull(metadata, rxdb.entitySync)) {
84
84
  return {
85
85
  repository,
86
86
  remoteLatestChangeId: 0,
@@ -6,9 +6,11 @@
6
6
  */
7
7
 
8
8
  import {
9
+ declareTrustedWrite,
9
10
  getEntityMetadata,
10
11
  getRxDBChangeEntityIdQueryValues,
11
12
  getRxDBEntityIdentityKey,
13
+ getSyncConfig,
12
14
  getSyncType,
13
15
  type OperatorName,
14
16
  type Rule,
@@ -16,7 +18,8 @@ import {
16
18
  RxDBChange,
17
19
  type RxDBEntityId,
18
20
  RxDBError,
19
- type SwitchVersionActions
21
+ type SwitchVersionActions,
22
+ TrustedWriteIntent
20
23
  } from '@aiao/rxdb';
21
24
  /**
22
25
  * 仅要求「能拿到 RxDBChange 仓库并 find」的最小结构 —— 适配器与
@@ -104,12 +107,13 @@ export async function cleanupExpired(
104
107
  }
105
108
 
106
109
  const metadata = getEntityMetadata(EntityType);
107
- const syncType = getSyncType(metadata, rxdb.config.sync);
110
+ const syncType = getSyncType(metadata, rxdb.entitySync);
108
111
 
109
112
  // 获取 filter 条件
110
113
  let filter = options?.filter;
111
114
  if (!filter && syncType === 'filter') {
112
- const syncConfig = metadata.sync as { remote?: { filter?: () => RuleGroup } };
115
+ // 取生效配置而不是装饰器原值:实例覆盖可能换掉了 filter
116
+ const syncConfig = getSyncConfig(metadata, rxdb.entitySync) as { remote?: { filter?: () => RuleGroup } };
113
117
  if (syncConfig?.remote?.filter) {
114
118
  try {
115
119
  filter = syncConfig.remote.filter();
@@ -154,6 +158,10 @@ export async function cleanupExpired(
154
158
  { field: 'namespace', operator: '=', value: namespace },
155
159
  { field: 'entity', operator: '=', value: entityName },
156
160
  { field: 'remoteId', operator: '=', value: null },
161
+ // 被拒变更(rejectedAt 非空)的 remoteId 也一直是 null,但它永远不会再被推送,
162
+ // 必须和 remoteId = null 一起排除,否则会被误判成「还占着没推完」而继续
163
+ // 保护对应实体不被清理
164
+ { field: 'rejectedAt', operator: '=', value: null },
157
165
  { field: 'revertChangeId', operator: '=', value: null },
158
166
  {
159
167
  field: 'entityId',
@@ -202,6 +210,12 @@ export async function cleanupExpired(
202
210
  }
203
211
  const actions: SwitchVersionActions = { deletes, updates: new Map(), inserts: new Map() };
204
212
  // 使用 mergeChanges + disableTriggers 删除,避免生成 RxDBChange 记录
213
+ // 过期清理走 cleanup_expired 入口:删的是本地缓存副本,不是用户删的数据。
214
+ declareTrustedWrite(executor, {
215
+ file: 'cleanup-expired.ts',
216
+ symbol: 'cleanupExpired',
217
+ intent: TrustedWriteIntent.remote_sync
218
+ });
205
219
  await executor.mergeChanges(actions, undefined, true);
206
220
  }
207
221