@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,70 @@
1
+ /**
2
+ * @fileoverview 分支分叉点(`rxdb_branch.fromChangeId`)的本地 / 远端 id 空间翻译
3
+ *
4
+ * `RxDBChange.id` 是**每端各自**的自增主键:同一条变更在本地是 17、在远端可能是 9042。
5
+ * 两者的对应关系只由 `RxDBChange.remoteId` 记录(push 成功后回填,pull 落库时写入)——
6
+ * `lastPushedChangeId` / `lastPullRemoteChangeId` 分列两个字段也正是因为这一点。
7
+ *
8
+ * `fromChangeId` 是一个 change id,因此跨端传输时**必须翻译**。原样透传的后果是静默的:
9
+ * 落到对端后被 `switch-branch-actions` / `find-switch-branch-step` / `merge-branch`
10
+ * 当本端 id 消费,分叉点变成一个碰巧存在的无关变更,切换与合并应用错误的变更区间。
11
+ */
12
+
13
+ import { type IRepository, RxDBChange, RxDBError } from '@aiao/rxdb';
14
+ /** 本模块只依赖 change 仓库的 `find`,用最小接口收窄,便于在事务 executor 上复用。 */
15
+ type ChangeFinder = Pick<IRepository<typeof RxDBChange>, 'find'>;
16
+
17
+ const findOneChange = async (
18
+ repository: ChangeFinder,
19
+ field: 'id' | 'remoteId',
20
+ value: number
21
+ ): Promise<InstanceType<typeof RxDBChange> | undefined> => {
22
+ const rows = await repository.find({
23
+ where: { combinator: 'and', rules: [{ field, operator: '=', value }] },
24
+ limit: 1
25
+ });
26
+ return rows[0];
27
+ };
28
+
29
+ /**
30
+ * 本地分叉点 → 远端 change id。
31
+ *
32
+ * @param repository - 本地 `RxDBChange` 仓库
33
+ * @param localFromChangeId - 本地分支的 `fromChangeId`
34
+ * @returns 远端 id;分叉点尚未推送(`remoteId` 为空)时返回 `null`
35
+ * @throws RxDBError 本地压根不存在这条变更 —— 这是分支表与变更表已经不一致,不能猜
36
+ *
37
+ * @remarks
38
+ * 返回 `null` 表示「远端此刻确实不知道这个分叉点」,而不是「分叉点在根」。调用方要据此
39
+ * 决定是否在实体变更推送完成后重推一次分支,见 {@link pushBranch}。
40
+ */
41
+ export const toRemoteFromChangeId = async (
42
+ repository: ChangeFinder,
43
+ localFromChangeId: number
44
+ ): Promise<number | null> => {
45
+ const change = await findOneChange(repository, 'id', localFromChangeId);
46
+ if (!change) {
47
+ throw new RxDBError(`分支分叉点翻译失败:本地不存在 RxDBChange id=${localFromChangeId},分支表与变更表已不一致`);
48
+ }
49
+ return change.remoteId ?? null;
50
+ };
51
+
52
+ /**
53
+ * 远端分叉点 → 本地 change id。
54
+ *
55
+ * @param repository - 本地 `RxDBChange` 仓库(事务内请传 executor 作用域的那一份)
56
+ * @param remoteFromChangeId - 远端分支行上的 `fromChangeId`
57
+ * @returns 本地 id;对应的变更尚未拉到本地时返回 `null`
58
+ *
59
+ * @remarks
60
+ * 返回 `null` 时**不可**退化成 `null`/`0` 写进本地分支行:`find-switch-branch-step` 会把它
61
+ * 当「分叉于根」处理,切换分支时应用的区间从第一条变更起算。调用方应跳过该分支,
62
+ * 等分叉点变更拉到本地后的下一轮再建。
63
+ */
64
+ export const toLocalFromChangeId = async (
65
+ repository: ChangeFinder,
66
+ remoteFromChangeId: number
67
+ ): Promise<number | null> => {
68
+ const change = await findOneChange(repository, 'remoteId', remoteFromChangeId);
69
+ return change?.id ?? null;
70
+ };
@@ -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
+ };
@@ -0,0 +1,74 @@
1
+ import { EntityStaticType, MAIN_BRANCH_ID, RxDBBranch } from '@aiao/rxdb';
2
+ import type { SyncManager } from './SyncManager.js';
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
+
22
+ /**
23
+ * 获取当前分支及其所有祖先分支的 ID 列表
24
+ *
25
+ * @returns [currentBranch, parent, grandparent, ...] 例如 ['dev', 'main']
26
+ */
27
+ export async function getAncestorBranchIds(sm: SyncManager, branchId: string): Promise<string[]> {
28
+ if (branchId === MAIN_BRANCH_ID) return [branchId];
29
+ const { adapter } = await sm.getLocalRepositories();
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;
52
+
53
+ let currentId = branchId;
54
+ const visited = new Set<string>([currentId]);
55
+
56
+ while (currentId) {
57
+ const branches = await branchRepository.find({
58
+ where: {
59
+ combinator: 'and',
60
+ rules: [{ field: 'id', operator: '=', value: currentId }]
61
+ },
62
+ limit: 1
63
+ });
64
+
65
+ const branch = branches[0];
66
+ if (!branch?.parentId || visited.has(branch.parentId)) break;
67
+
68
+ branchIds.push(branch.parentId);
69
+ visited.add(branch.parentId);
70
+ currentId = branch.parentId;
71
+ }
72
+
73
+ return branchIds;
74
+ }