@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.
- package/dist/SyncManager.d.ts +16 -2
- package/dist/SyncManager.d.ts.map +1 -1
- package/dist/branch-materialization-source.d.ts +22 -0
- package/dist/branch-materialization-source.d.ts.map +1 -0
- package/dist/branch-utils.d.ts +32 -0
- package/dist/branch-utils.d.ts.map +1 -1
- package/dist/bulk-sync.d.ts.map +1 -1
- package/dist/cleanup-expired.d.ts.map +1 -1
- package/dist/get-repository-sync-status.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1972 -1472
- package/dist/plugin.d.ts +5 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/pull-batch.d.ts.map +1 -1
- package/dist/pull-conflict-utils.d.ts.map +1 -1
- package/dist/pull-repository.d.ts +20 -1
- package/dist/pull-repository.d.ts.map +1 -1
- package/dist/pull-round.d.ts +41 -0
- package/dist/pull-round.d.ts.map +1 -0
- package/dist/push-branch.d.ts +1 -1
- package/dist/push-branch.d.ts.map +1 -1
- package/dist/push-repository.d.ts +18 -2
- package/dist/push-repository.d.ts.map +1 -1
- package/dist/push.d.ts.map +1 -1
- package/dist/query-cache-outbox.d.ts.map +1 -1
- package/dist/sync-branches.d.ts +51 -5
- package/dist/sync-branches.d.ts.map +1 -1
- package/dist/sync-listeners.d.ts.map +1 -1
- package/dist/sync-repository.d.ts +3 -2
- package/dist/sync-repository.d.ts.map +1 -1
- package/package.json +7 -5
- package/src/SyncManager.ts +28 -0
- package/src/branch-materialization-source.ts +445 -0
- package/src/branch-utils.ts +42 -5
- package/src/bulk-sync.ts +22 -11
- package/src/check-repository-updates.ts +1 -1
- package/src/cleanup-expired.ts +17 -3
- package/src/get-repository-sync-status.ts +7 -7
- package/src/index.ts +7 -2
- package/src/plugin.ts +9 -1
- package/src/pull-batch.ts +16 -40
- package/src/pull-conflict-utils.ts +4 -0
- package/src/pull-repository.ts +43 -44
- package/src/pull-round.ts +103 -0
- package/src/pull.ts +1 -1
- package/src/push-branch.ts +4 -3
- package/src/push-repository.ts +674 -135
- package/src/push.ts +3 -1
- package/src/query-cache-outbox.ts +82 -9
- package/src/sync-branches.ts +130 -10
- package/src/sync-listeners.ts +30 -10
- package/src/sync-repository.ts +11 -6
- package/dist/version.utils.d.ts +0 -3
- package/dist/version.utils.d.ts.map +0 -1
- 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
|
+
};
|
package/src/branch-utils.ts
CHANGED
|
@@ -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
|
-
|
|
11
|
-
if (branchId === 'main') return branchIds;
|
|
12
|
-
|
|
28
|
+
if (branchId === MAIN_BRANCH_ID) return [branchId];
|
|
13
29
|
const { adapter } = await sm.getLocalRepositories();
|
|
14
|
-
|
|
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
|
|
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.
|
|
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.
|
|
83
|
+
if (!needsPull(metadata, rxdb.entitySync)) {
|
|
84
84
|
return {
|
|
85
85
|
repository,
|
|
86
86
|
remoteLatestChangeId: 0,
|
package/src/cleanup-expired.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|