@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
package/src/push-repository.ts
CHANGED
|
@@ -6,14 +6,19 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
+
assertSingleActiveBranch,
|
|
9
10
|
compactChanges,
|
|
11
|
+
declareTrustedWrite,
|
|
12
|
+
type EntityType,
|
|
10
13
|
getEntityMetadata,
|
|
11
14
|
getOrCreateSyncRecord,
|
|
15
|
+
getRxDBChangeEntityIdQueryValues,
|
|
12
16
|
getRxDBChangeKey,
|
|
13
17
|
getSyncType,
|
|
14
18
|
IRepository,
|
|
15
19
|
type PushRepositoryResult,
|
|
16
|
-
type
|
|
20
|
+
type RemoteChangeRejection,
|
|
21
|
+
type RemoteChangeResult,
|
|
17
22
|
repositoryKey,
|
|
18
23
|
RepositorySyncBeginEvent,
|
|
19
24
|
RepositorySyncCompleteEvent,
|
|
@@ -28,9 +33,13 @@ import {
|
|
|
28
33
|
RxDBSync,
|
|
29
34
|
type SwitchVersionActions,
|
|
30
35
|
type SwitchVersionChange,
|
|
31
|
-
type SyncFailure
|
|
36
|
+
type SyncFailure,
|
|
37
|
+
type SyncRejection,
|
|
38
|
+
type TransactionExecutor,
|
|
39
|
+
TrustedWriteIntent
|
|
32
40
|
} from '@aiao/rxdb';
|
|
33
41
|
import type { PushInFlightSession } from '@aiao/rxdb-plugin-history';
|
|
42
|
+
import { firstValueFrom } from 'rxjs';
|
|
34
43
|
import { getAncestorBranchIds } from './branch-utils.js';
|
|
35
44
|
import { findBlockingDependency } from './cascade-blocking.js';
|
|
36
45
|
import { buildDependencyGraph, type DependencyGraph, type RepositoryIdentifier } from './dependency-graph.js';
|
|
@@ -140,8 +149,15 @@ function throwPushFailure(result: PushRepositoryResult): never {
|
|
|
140
149
|
* @param namespace - 实体命名空间
|
|
141
150
|
* @param entity - 实体名称
|
|
142
151
|
* @param options - 推送选项
|
|
152
|
+
* @param rejectionSink - 被拒清单收集器。传入时本次调用只往里追加、不上报,由调用方在整次操作结束时
|
|
153
|
+
* 统一上报(`push()` / `bulkSync` 一轮多仓);不传时本次调用结束即自行上报。
|
|
143
154
|
* @returns 推送结果
|
|
144
155
|
*
|
|
156
|
+
* @remarks
|
|
157
|
+
* `reportRejections` 是整体替换语义:一轮操作里多次 `pushRepository` 各自上报会互相覆盖,
|
|
158
|
+
* 只剩最后一个仓库的清单,所以上报边界必须由整次操作的发起者持有。
|
|
159
|
+
* 无论成功、部分失败还是抛错,已落库的被拒都会交出去(追加进收集器或自行上报)。
|
|
160
|
+
*
|
|
145
161
|
* @example
|
|
146
162
|
* ```ts
|
|
147
163
|
* // 在不进行级联的情况下推送 Todo 仓库
|
|
@@ -159,7 +175,8 @@ export async function pushRepository(
|
|
|
159
175
|
sm: SyncManager,
|
|
160
176
|
namespace: string,
|
|
161
177
|
entity: string,
|
|
162
|
-
options?: PushRepositoryOptions
|
|
178
|
+
options?: PushRepositoryOptions,
|
|
179
|
+
rejectionSink?: SyncRejection[]
|
|
163
180
|
): Promise<PushRepositoryResult> {
|
|
164
181
|
const opts: Required<PushRepositoryOptions> = {
|
|
165
182
|
batchSize: options?.batchSize === undefined ? DEFAULT_PUSH_REPOSITORY_OPTIONS.batchSize : options.batchSize,
|
|
@@ -174,10 +191,21 @@ export async function pushRepository(
|
|
|
174
191
|
// 本轮 push 认领的「在飞」区间;undo 据此把还在往返途中的变更当成已推。
|
|
175
192
|
// 从哪条路径提前返回都会经下面那个 finally,认领不会泄漏。
|
|
176
193
|
const inFlight = sm.pushInFlight.session();
|
|
194
|
+
// 本次调用已落库的被拒;先于完成/错误事件交出去,监听者看到事件时清单已是最新
|
|
195
|
+
const rejections: SyncRejection[] = [];
|
|
196
|
+
const settleRejections = (): void => {
|
|
197
|
+
if (rejectionSink) rejectionSink.push(...rejections);
|
|
198
|
+
else reportPushRejections(sm, rejections);
|
|
199
|
+
};
|
|
177
200
|
|
|
178
201
|
try {
|
|
179
202
|
assertBatchSize(opts.batchSize);
|
|
180
|
-
|
|
203
|
+
let result: PushRepositoryResult;
|
|
204
|
+
try {
|
|
205
|
+
result = await _pushRepositoryImpl(sm, namespace, entity, opts, inFlight, rejections);
|
|
206
|
+
} finally {
|
|
207
|
+
settleRejections();
|
|
208
|
+
}
|
|
181
209
|
|
|
182
210
|
// 触发完成事件
|
|
183
211
|
rxdb.dispatchEvent(
|
|
@@ -206,7 +234,8 @@ async function _pushRepositoryImpl(
|
|
|
206
234
|
namespace: string,
|
|
207
235
|
entity: string,
|
|
208
236
|
opts: Required<PushRepositoryOptions>,
|
|
209
|
-
inFlight: PushInFlightSession
|
|
237
|
+
inFlight: PushInFlightSession,
|
|
238
|
+
rejections: SyncRejection[]
|
|
210
239
|
): Promise<PushRepositoryResult> {
|
|
211
240
|
// 验证仓库是否存在
|
|
212
241
|
const EntityType = sm.rxdb.config.entities.find(e => {
|
|
@@ -227,7 +256,7 @@ async function _pushRepositoryImpl(
|
|
|
227
256
|
sm.rxdb,
|
|
228
257
|
namespace,
|
|
229
258
|
entity,
|
|
230
|
-
getSyncType(metadata, sm.rxdb.
|
|
259
|
+
getSyncType(metadata, sm.rxdb.entitySync)
|
|
231
260
|
);
|
|
232
261
|
|
|
233
262
|
if (ineligible) {
|
|
@@ -236,11 +265,11 @@ async function _pushRepositoryImpl(
|
|
|
236
265
|
|
|
237
266
|
// 处理级联推送
|
|
238
267
|
if (opts.includeRelated) {
|
|
239
|
-
return await pushWithCascade(sm, namespace, entity, opts, inFlight);
|
|
268
|
+
return await pushWithCascade(sm, namespace, entity, opts, inFlight, rejections);
|
|
240
269
|
}
|
|
241
270
|
|
|
242
271
|
// 单仓库推送
|
|
243
|
-
return await pushSingleRepository(sm, namespace, entity, opts, inFlight);
|
|
272
|
+
return await pushSingleRepository(sm, namespace, entity, opts, inFlight, rejections);
|
|
244
273
|
}
|
|
245
274
|
|
|
246
275
|
/**
|
|
@@ -253,7 +282,7 @@ async function _pushRepositoryImpl(
|
|
|
253
282
|
*
|
|
254
283
|
* 每个仓库的「查变更 + 压缩」只做一次({@link planRepositoryPush}),两个相位共用同一份
|
|
255
284
|
* 计划;落库(写 `remoteId` + 推进水位线)也只做一次,在全部相位跑完后统一提交
|
|
256
|
-
* ({@link
|
|
285
|
+
* ({@link commitPushPlans})。否则 `originalCount` / `compacted` 会被算两遍,
|
|
257
286
|
* 且第一个相位就把水位线推到最大 change id,第二个相位的变更会被整批吞掉。
|
|
258
287
|
*
|
|
259
288
|
* @internal
|
|
@@ -263,7 +292,8 @@ async function pushWithCascade(
|
|
|
263
292
|
namespace: string,
|
|
264
293
|
entity: string,
|
|
265
294
|
options: Required<PushRepositoryOptions>,
|
|
266
|
-
inFlight: PushInFlightSession
|
|
295
|
+
inFlight: PushInFlightSession,
|
|
296
|
+
rejections: SyncRejection[]
|
|
267
297
|
): Promise<PushRepositoryResult> {
|
|
268
298
|
// 构建依赖图
|
|
269
299
|
const entities = sm.rxdb.config.entities.map(e => getEntityMetadata(e));
|
|
@@ -307,15 +337,25 @@ async function pushWithCascade(
|
|
|
307
337
|
}
|
|
308
338
|
|
|
309
339
|
// 相位全部跑完才落库:水位线必须一次推到位,中途推进会吞掉后一个相位的变更
|
|
310
|
-
const
|
|
311
|
-
const failures: SyncFailure[] = [];
|
|
312
|
-
|
|
313
|
-
for (const repo of orderRepos('INSERT')) {
|
|
340
|
+
const insertOrder = orderRepos('INSERT').map(repo => {
|
|
314
341
|
const node = nodes.get(repositoryKey(repo));
|
|
315
342
|
// 每个相位都会遍历全部仓库,节点必然已建好
|
|
316
343
|
if (!node) throw new RxDBError(`Internal error: cascade node missing for ${repositoryKey(repo)}`);
|
|
344
|
+
return node;
|
|
345
|
+
});
|
|
346
|
+
// 未定案的仓库一起提交:被拒对齐的删除要子先父后、恢复要父先子后,逐仓各开一个事务做不到
|
|
347
|
+
const committed = await commitPushPlans(
|
|
348
|
+
sm,
|
|
349
|
+
insertOrder.flatMap(node => (node.result ? [] : [node.plan!])),
|
|
350
|
+
rejections
|
|
351
|
+
);
|
|
317
352
|
|
|
318
|
-
|
|
353
|
+
const results: PushRepositoryResult[] = [];
|
|
354
|
+
const failures: SyncFailure[] = [];
|
|
355
|
+
for (const node of insertOrder) {
|
|
356
|
+
const repo = node.repo;
|
|
357
|
+
const result = node.result ?? committed.get(node.plan!);
|
|
358
|
+
if (!result) throw new RxDBError(`Internal error: push result missing for ${repositoryKey(repo)}`);
|
|
319
359
|
results.push(result);
|
|
320
360
|
|
|
321
361
|
// 按策略跳过(`skipped` 且 `success`)不算失败,不进失败清单
|
|
@@ -432,7 +472,8 @@ async function runCascadePhase(
|
|
|
432
472
|
|
|
433
473
|
// 本相位推失败:立刻定案,后续相位不再推,并把失败传导给依赖它的仓库
|
|
434
474
|
if (node.plan.error) {
|
|
435
|
-
|
|
475
|
+
// 有条目没拿到回执:什么都不落,被拒也不算,下一轮整批重推
|
|
476
|
+
node.result = pushRepositoryResult(node.plan, node.plan.pushed, 0, node.plan.error);
|
|
436
477
|
failedRepos.set(repoKey, node.plan.error);
|
|
437
478
|
}
|
|
438
479
|
}
|
|
@@ -458,7 +499,7 @@ async function cascadeNodeIneligibility(sm: SyncManager, repo: RepositoryIdentif
|
|
|
458
499
|
sm.rxdb,
|
|
459
500
|
repo.namespace,
|
|
460
501
|
repo.entity,
|
|
461
|
-
getSyncType(getEntityMetadata(EntityType), sm.rxdb.
|
|
502
|
+
getSyncType(getEntityMetadata(EntityType), sm.rxdb.entitySync)
|
|
462
503
|
);
|
|
463
504
|
}
|
|
464
505
|
|
|
@@ -468,7 +509,7 @@ async function cascadeNodeIneligibility(sm: SyncManager, repo: RepositoryIdentif
|
|
|
468
509
|
* @internal
|
|
469
510
|
*/
|
|
470
511
|
function emptyPushProgress(): Omit<PushRepositoryResult, 'repository'> {
|
|
471
|
-
return { pushed: 0, failed: 0, compacted: 0, originalCount: 0, failures: [] };
|
|
512
|
+
return { pushed: 0, rejected: 0, failed: 0, compacted: 0, originalCount: 0, failures: [] };
|
|
472
513
|
}
|
|
473
514
|
|
|
474
515
|
/**
|
|
@@ -479,7 +520,7 @@ function emptyPushProgress(): Omit<PushRepositoryResult, 'repository'> {
|
|
|
479
520
|
* 于是「远端确实收到了几条,结果里记作 0」—— 而水位线又因为本轮失败不会推进,
|
|
480
521
|
* 调用方从计数上完全看不出发生过部分推送,回头对账「远端为什么多出几条」时无从查起。
|
|
481
522
|
*
|
|
482
|
-
* `failed` 与 {@link
|
|
523
|
+
* `failed` 与 {@link pushRepositoryResult} 同一个算式(`effectiveCount - pushed`),
|
|
483
524
|
* 保证「阻断」和「提交失败」两条路径交出的计数可以直接相加。
|
|
484
525
|
*
|
|
485
526
|
* 第一个相位就被阻断时没有 `plan`,各计数本就该是 0。
|
|
@@ -494,6 +535,8 @@ function blockedPushProgress(plan: RepositoryPushPlan | undefined): Omit<PushRep
|
|
|
494
535
|
|
|
495
536
|
return {
|
|
496
537
|
pushed: plan.pushed,
|
|
538
|
+
// 没落库的被拒不算被拒:标记没写、对齐没做,下一轮会原样重推
|
|
539
|
+
rejected: 0,
|
|
497
540
|
failed: plan.effectiveCount - plan.pushed,
|
|
498
541
|
compacted: plan.compacted,
|
|
499
542
|
originalCount: plan.originalCount,
|
|
@@ -504,6 +547,8 @@ function blockedPushProgress(plan: RepositoryPushPlan | undefined): Omit<PushRep
|
|
|
504
547
|
type CompactedActionKind = 'deletes' | 'updates' | 'inserts';
|
|
505
548
|
|
|
506
549
|
interface CompactedPushEntry {
|
|
550
|
+
/** 源变更所属分支,也是这一条推送时的目标分支,见 {@link compactPushEntriesByBranch} */
|
|
551
|
+
branchId: string;
|
|
507
552
|
actionKind: CompactedActionKind;
|
|
508
553
|
key: string;
|
|
509
554
|
action: SwitchVersionChange;
|
|
@@ -513,7 +558,6 @@ interface CompactedPushEntry {
|
|
|
513
558
|
interface CompactedPushBatch {
|
|
514
559
|
actions: SwitchVersionActions;
|
|
515
560
|
sourceChanges: RxDBChange[];
|
|
516
|
-
sourceChangesByLocalId: Map<number, RxDBChange[]>;
|
|
517
561
|
}
|
|
518
562
|
|
|
519
563
|
/**
|
|
@@ -541,7 +585,66 @@ const ALL_ACTION_KINDS: ReadonlySet<CompactedActionKind> = new Set<CompactedActi
|
|
|
541
585
|
'inserts'
|
|
542
586
|
]);
|
|
543
587
|
|
|
544
|
-
|
|
588
|
+
/**
|
|
589
|
+
* 按源变更**自己的**分支分组压缩,祖先在前(main 最先)。
|
|
590
|
+
*
|
|
591
|
+
* @remarks
|
|
592
|
+
* 不能把祖先分支上未推送的变更与当前分支的并在一起压缩、再以当前分支为目标推送:
|
|
593
|
+
* 远端只在目标为 main 时写业务表,其余分支只写日志;而 main 的配对校验要求每条 main 变更
|
|
594
|
+
* 都有对应的业务写入(RX002 `unpaired_change`)。以 feature 为目标会丢掉 main 变更的业务写入,
|
|
595
|
+
* 以 main 为目标又会把 feature 的改动写进 main,还要改写变更的 branchId——两条路都不对。
|
|
596
|
+
* 各自成组、按各自分支推送,两边才都成立。
|
|
597
|
+
*
|
|
598
|
+
* 祖先在前:后代分支的变更建立在祖先状态之上,远端也应先见到祖先那一段。
|
|
599
|
+
* 同一实体在不同分支上的变更因此各成一条,`effectiveCount` 按条累计。
|
|
600
|
+
*
|
|
601
|
+
* @param localChanges - 待推变更,`branchId` 都在 `branchIds` 内(查询时已按它过滤)
|
|
602
|
+
* @param branchIds - 当前分支及其祖先,自身在前({@link getAncestorBranchIds} 的顺序)
|
|
603
|
+
*
|
|
604
|
+
* @internal
|
|
605
|
+
*/
|
|
606
|
+
function compactPushEntriesByBranch(localChanges: RxDBChange[], branchIds: readonly string[]): CompactedPushEntry[] {
|
|
607
|
+
const changesByBranch = new Map<string, RxDBChange[]>();
|
|
608
|
+
for (const change of localChanges) {
|
|
609
|
+
const { branchId } = change;
|
|
610
|
+
// 查询按 branchId =/IN 过滤,NULL 分支的行进不来;走到这里说明查询与本函数的约定被打破
|
|
611
|
+
if (branchId === undefined) throw new RxDBError(`Internal error: unpushed change ${change.id} has no branchId`);
|
|
612
|
+
const group = changesByBranch.get(branchId) ?? [];
|
|
613
|
+
group.push(change);
|
|
614
|
+
changesByBranch.set(branchId, group);
|
|
615
|
+
}
|
|
616
|
+
return [...branchIds].reverse().flatMap(branchId => {
|
|
617
|
+
const changes = changesByBranch.get(branchId);
|
|
618
|
+
return changes ? compactBranchChanges(branchId, changes) : [];
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** 单个分支内的压缩:与分组前的逻辑相同,只是范围限定在这一个分支的变更上。 */
|
|
623
|
+
function compactBranchChanges(branchId: string, changes: RxDBChange[]): CompactedPushEntry[] {
|
|
624
|
+
const actions: SwitchVersionActions = { deletes: new Map(), updates: new Map(), inserts: new Map() };
|
|
625
|
+
compactChanges(
|
|
626
|
+
changes.map(c => ({
|
|
627
|
+
id: c.id,
|
|
628
|
+
namespace: c.namespace,
|
|
629
|
+
entity: c.entity,
|
|
630
|
+
entityId: c.entityId,
|
|
631
|
+
type: c.type as 'INSERT' | 'UPDATE' | 'DELETE',
|
|
632
|
+
branchId: c.branchId,
|
|
633
|
+
patch: c.patch,
|
|
634
|
+
inversePatch: c.inversePatch,
|
|
635
|
+
createdAt: c.createdAt,
|
|
636
|
+
updatedAt: c.updatedAt
|
|
637
|
+
})),
|
|
638
|
+
actions
|
|
639
|
+
);
|
|
640
|
+
return buildCompactedPushEntries(branchId, changes, actions);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function buildCompactedPushEntries(
|
|
644
|
+
branchId: string,
|
|
645
|
+
localChanges: RxDBChange[],
|
|
646
|
+
actions: SwitchVersionActions
|
|
647
|
+
): CompactedPushEntry[] {
|
|
545
648
|
const changesByKey = new Map<string, RxDBChange[]>();
|
|
546
649
|
for (const change of localChanges) {
|
|
547
650
|
const key = getRxDBChangeKey(change);
|
|
@@ -559,6 +662,7 @@ function buildCompactedPushEntries(localChanges: RxDBChange[], actions: SwitchVe
|
|
|
559
662
|
}
|
|
560
663
|
|
|
561
664
|
pushEntries.push({
|
|
665
|
+
branchId,
|
|
562
666
|
actionKind,
|
|
563
667
|
key,
|
|
564
668
|
action,
|
|
@@ -581,60 +685,124 @@ function createCompactedPushBatch(entries: CompactedPushEntry[]): CompactedPushB
|
|
|
581
685
|
inserts: new Map()
|
|
582
686
|
};
|
|
583
687
|
const sourceChanges: RxDBChange[] = [];
|
|
584
|
-
const sourceChangesByLocalId = new Map<number, RxDBChange[]>();
|
|
585
688
|
|
|
586
689
|
for (const entry of entries) {
|
|
587
690
|
actions[entry.actionKind].set(entry.key, entry.action);
|
|
588
691
|
for (const sourceChange of entry.sourceChanges) {
|
|
589
692
|
sourceChanges.push(sourceChange);
|
|
590
|
-
sourceChangesByLocalId.set(sourceChange.id, [sourceChange]);
|
|
591
693
|
}
|
|
592
694
|
}
|
|
593
695
|
|
|
594
696
|
sourceChanges.sort((left, right) => left.id - right.id);
|
|
595
697
|
return {
|
|
596
698
|
actions,
|
|
597
|
-
sourceChanges
|
|
598
|
-
sourceChangesByLocalId
|
|
699
|
+
sourceChanges
|
|
599
700
|
};
|
|
600
701
|
}
|
|
601
702
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
703
|
+
/**
|
|
704
|
+
* 覆盖检查:远端结果必须与本批源变更的 localId 集合一一对应。
|
|
705
|
+
*
|
|
706
|
+
* @remarks
|
|
707
|
+
* 缺项、重复、或多出未知 localId 都视为契约违反——远端没有按约定覆盖本批全部源变更,
|
|
708
|
+
* 整轮必须失败、水位线不能推进(AC#14、FR-017)。检查先于任何本地写入,
|
|
709
|
+
* 因此失败时本地不会留下任何 `remoteId`。
|
|
710
|
+
*/
|
|
711
|
+
function assertMergeResultCoversBatch(results: RemoteChangeResult[], sourceChanges: RxDBChange[]): void {
|
|
712
|
+
const expectedLocalIds = new Set(sourceChanges.map(change => change.id));
|
|
713
|
+
const seenLocalIds = new Set<number>();
|
|
608
714
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
): Array<{ change: RxDBChange; remoteId: number }> {
|
|
613
|
-
const resolvedMappings = mapping.map(({ localId, remoteId }) => {
|
|
614
|
-
const sourceChanges = sourceChangesByLocalId.get(localId);
|
|
615
|
-
if (!sourceChanges) {
|
|
616
|
-
throw new RxDBError(`Remote change mapping references unknown local change: ${localId}`);
|
|
715
|
+
for (const result of results) {
|
|
716
|
+
if (!expectedLocalIds.has(result.localId)) {
|
|
717
|
+
throw new RxDBError(`Remote merge result references unknown local change: ${result.localId}`);
|
|
617
718
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
const remoteIdsByChange = new Map<RxDBChange, number>();
|
|
621
|
-
|
|
622
|
-
for (const { remoteId, sourceChanges } of resolvedMappings) {
|
|
623
|
-
for (const change of sourceChanges) {
|
|
624
|
-
remoteIdsByChange.set(change, remoteId);
|
|
719
|
+
if (seenLocalIds.has(result.localId)) {
|
|
720
|
+
throw new RxDBError(`Remote merge result duplicates local change: ${result.localId}`);
|
|
625
721
|
}
|
|
722
|
+
seenLocalIds.add(result.localId);
|
|
626
723
|
}
|
|
627
724
|
|
|
628
|
-
|
|
725
|
+
if (seenLocalIds.size !== expectedLocalIds.size) {
|
|
726
|
+
const missing = [...expectedLocalIds].filter(localId => !seenLocalIds.has(localId));
|
|
727
|
+
throw new RxDBError(`Remote merge result missing local changes: ${missing.join(', ')}`);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* 被远端拒绝的一个压缩后条目:实体的全部源变更一起标记,共用同一份拒绝原因。
|
|
733
|
+
*
|
|
734
|
+
* @internal
|
|
735
|
+
*/
|
|
736
|
+
interface RejectedPushEntry {
|
|
737
|
+
readonly entry: CompactedPushEntry;
|
|
738
|
+
readonly rejection: RemoteChangeRejection;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* 一批回执按条目归类后的结果
|
|
743
|
+
*
|
|
744
|
+
* @internal
|
|
745
|
+
*/
|
|
746
|
+
interface ClassifiedPushBatch {
|
|
747
|
+
readonly applied: Array<{ change: RxDBChange; remoteId: number }>;
|
|
748
|
+
readonly rejected: RejectedPushEntry[];
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* 判定一个条目是否被远端拒绝。
|
|
753
|
+
*
|
|
754
|
+
* @remarks
|
|
755
|
+
* 被拒以实体为单位(data-model §7):同一条目的源变更必须同进同退。一部分 applied、一部分被拒
|
|
756
|
+
* 说明远端把一次合并后的写拆开处理了——本地无论按哪一半落库都会和远端对不上,只能整轮失败。
|
|
757
|
+
* 这一步与覆盖检查一样先于任何本地写入。
|
|
758
|
+
*
|
|
759
|
+
* @returns 被拒时返回拒绝原因,全部 applied 时返回 `undefined`
|
|
760
|
+
*/
|
|
761
|
+
function entryRejection(
|
|
762
|
+
entry: CompactedPushEntry,
|
|
763
|
+
resultByLocalId: ReadonlyMap<number, RemoteChangeResult>
|
|
764
|
+
): RemoteChangeRejection | undefined {
|
|
765
|
+
const statuses = entry.sourceChanges.map(change => {
|
|
766
|
+
const result = resultByLocalId.get(change.id);
|
|
767
|
+
// 覆盖检查已确保本批每条源变更都恰有一项回执
|
|
768
|
+
if (!result) throw new RxDBError(`Remote merge result missing local changes: ${change.id}`);
|
|
769
|
+
return result;
|
|
770
|
+
});
|
|
771
|
+
const rejected = statuses.find(
|
|
772
|
+
(result): result is Extract<RemoteChangeResult, { status: 'rejected' }> => result.status === 'rejected'
|
|
773
|
+
);
|
|
774
|
+
if (!rejected) return undefined;
|
|
775
|
+
if (statuses.some(result => result.status === 'applied')) {
|
|
776
|
+
throw new RxDBError(`Remote merge result splits entity ${entry.key}: some source changes applied, some rejected`);
|
|
777
|
+
}
|
|
778
|
+
return rejected.rejection;
|
|
629
779
|
}
|
|
630
780
|
|
|
631
781
|
async function mergePushBatch(
|
|
632
782
|
remoteAdapter: RxDBAdapterRemoteBase,
|
|
633
783
|
branchId: string,
|
|
634
|
-
|
|
635
|
-
): Promise<
|
|
784
|
+
entries: CompactedPushEntry[]
|
|
785
|
+
): Promise<ClassifiedPushBatch> {
|
|
786
|
+
const batch = createCompactedPushBatch(entries);
|
|
636
787
|
const mergeResult = await remoteAdapter.mergeChanges(batch.actions, branchId, batch.sourceChanges);
|
|
637
|
-
|
|
788
|
+
assertMergeResultCoversBatch(mergeResult.results, batch.sourceChanges);
|
|
789
|
+
|
|
790
|
+
const resultByLocalId = new Map(mergeResult.results.map(result => [result.localId, result]));
|
|
791
|
+
const changeByLocalId = new Map(batch.sourceChanges.map(change => [change.id, change]));
|
|
792
|
+
const classified: ClassifiedPushBatch = { applied: [], rejected: [] };
|
|
793
|
+
for (const entry of entries) {
|
|
794
|
+
const rejection = entryRejection(entry, resultByLocalId);
|
|
795
|
+
if (rejection) classified.rejected.push({ entry, rejection });
|
|
796
|
+
}
|
|
797
|
+
// applied 按回执顺序收集:被拒条目整体不会出现 applied(上面已拒绝拆分),这里只需按状态过滤
|
|
798
|
+
for (const result of mergeResult.results) {
|
|
799
|
+
if (result.status !== 'applied') continue;
|
|
800
|
+
const change = changeByLocalId.get(result.localId);
|
|
801
|
+
// 覆盖检查已确保 result.localId ∈ 本批源变更
|
|
802
|
+
if (!change) throw new RxDBError(`Remote merge result references unknown local change: ${result.localId}`);
|
|
803
|
+
classified.applied.push({ change, remoteId: result.remoteId });
|
|
804
|
+
}
|
|
805
|
+
return classified;
|
|
638
806
|
}
|
|
639
807
|
|
|
640
808
|
/**
|
|
@@ -648,19 +816,24 @@ async function mergePushBatch(
|
|
|
648
816
|
*/
|
|
649
817
|
interface RepositoryPushPlan {
|
|
650
818
|
readonly repository: RepositoryIdentifier;
|
|
819
|
+
readonly entityType: EntityType;
|
|
651
820
|
readonly entries: CompactedPushEntry[];
|
|
652
821
|
readonly localChanges: RxDBChange[];
|
|
653
822
|
readonly repoSync: RxDBSync;
|
|
654
823
|
readonly localAdapter: Awaited<ReturnType<SyncManager['getLocalRepositories']>>['adapter'];
|
|
655
824
|
readonly remoteAdapter: RxDBAdapterRemoteBase;
|
|
656
825
|
readonly branchId: string;
|
|
826
|
+
/** 规划时的 {@link SyncManager.branchSwitchGeneration};提交时据此识别远端往返期间的分支切换。 */
|
|
827
|
+
readonly branchGeneration: number;
|
|
657
828
|
readonly originalCount: number;
|
|
658
829
|
readonly effectiveCount: number;
|
|
659
830
|
readonly compacted: number;
|
|
660
|
-
/**
|
|
831
|
+
/** 各相位累积被远端接受(applied)的条目数;被拒的不算。 */
|
|
661
832
|
pushed: number;
|
|
662
833
|
/** 各相位累积拿到的远端 id,最终一并落库。 */
|
|
663
834
|
readonly remoteIdsByChange: Map<RxDBChange, number>;
|
|
835
|
+
/** 各相位累积被远端拒绝的条目,最终与远端 id 同一个事务落库。 */
|
|
836
|
+
readonly rejectedEntries: RejectedPushEntry[];
|
|
664
837
|
error?: Error;
|
|
665
838
|
}
|
|
666
839
|
|
|
@@ -689,6 +862,8 @@ async function planRepositoryPush(
|
|
|
689
862
|
await sm.getRemoteRepositories(); // 确保远端已配置
|
|
690
863
|
const { adapter: localAdapter } = await sm.getLocalRepositories();
|
|
691
864
|
|
|
865
|
+
// 代际先于分支读取:两者之间若插进一次切换,提交时代际对不上,宁可多拒一轮也不错配
|
|
866
|
+
const branchGeneration = sm.branchSwitchGeneration;
|
|
692
867
|
// 获取当前分支
|
|
693
868
|
const branch = await sm.getCurrentBranch();
|
|
694
869
|
|
|
@@ -699,8 +874,10 @@ async function planRepositoryPush(
|
|
|
699
874
|
const meta = getEntityMetadata(e);
|
|
700
875
|
return meta.namespace === namespace && meta.name === entity;
|
|
701
876
|
});
|
|
702
|
-
|
|
703
|
-
|
|
877
|
+
// 调用方(单仓入口 / 级联资格闸门)已按同一条件确认过实体存在
|
|
878
|
+
if (!EntityType) throw new RxDBError(`Entity not found: ${namespace}:${entity}`);
|
|
879
|
+
const metadata = getEntityMetadata(EntityType);
|
|
880
|
+
const syncType = getSyncType(metadata, sm.rxdb.entitySync);
|
|
704
881
|
|
|
705
882
|
const repoSync = await getOrCreateSyncRecord(
|
|
706
883
|
repoSyncRepo,
|
|
@@ -751,30 +928,9 @@ async function planRepositoryPush(
|
|
|
751
928
|
return { emptyResult: { ...emptyPushProgress(), repository: { namespace, entity } } };
|
|
752
929
|
}
|
|
753
930
|
|
|
754
|
-
//
|
|
755
|
-
const
|
|
756
|
-
|
|
757
|
-
updates: new Map(),
|
|
758
|
-
inserts: new Map()
|
|
759
|
-
};
|
|
760
|
-
|
|
761
|
-
compactChanges(
|
|
762
|
-
localChanges.map(c => ({
|
|
763
|
-
id: c.id,
|
|
764
|
-
namespace: c.namespace,
|
|
765
|
-
entity: c.entity,
|
|
766
|
-
entityId: c.entityId,
|
|
767
|
-
type: c.type as 'INSERT' | 'UPDATE' | 'DELETE',
|
|
768
|
-
branchId: c.branchId,
|
|
769
|
-
patch: c.patch,
|
|
770
|
-
inversePatch: c.inversePatch,
|
|
771
|
-
createdAt: c.createdAt,
|
|
772
|
-
updatedAt: c.updatedAt
|
|
773
|
-
})),
|
|
774
|
-
localActions
|
|
775
|
-
);
|
|
776
|
-
|
|
777
|
-
const effectiveCount = localActions.inserts.size + localActions.updates.size + localActions.deletes.size;
|
|
931
|
+
// 压缩变更:按变更自己的分支分组,见 compactPushEntriesByBranch
|
|
932
|
+
const entries = compactPushEntriesByBranch(localChanges, branchIds);
|
|
933
|
+
const effectiveCount = entries.length;
|
|
778
934
|
const compacted = originalCount - effectiveCount;
|
|
779
935
|
|
|
780
936
|
// 无有效变更:整批被本地压缩抵消(如 INSERT → DELETE 且服务器从未见过这条数据)。
|
|
@@ -812,25 +968,28 @@ async function planRepositoryPush(
|
|
|
812
968
|
|
|
813
969
|
return {
|
|
814
970
|
repository: { namespace, entity },
|
|
815
|
-
|
|
971
|
+
entityType: EntityType,
|
|
972
|
+
entries,
|
|
816
973
|
localChanges,
|
|
817
974
|
repoSync,
|
|
818
975
|
localAdapter,
|
|
819
976
|
remoteAdapter,
|
|
820
977
|
branchId: branch.id,
|
|
978
|
+
branchGeneration,
|
|
821
979
|
originalCount,
|
|
822
980
|
effectiveCount,
|
|
823
981
|
compacted,
|
|
824
982
|
pushed: 0,
|
|
825
|
-
remoteIdsByChange: new Map<RxDBChange, number>()
|
|
983
|
+
remoteIdsByChange: new Map<RxDBChange, number>(),
|
|
984
|
+
rejectedEntries: []
|
|
826
985
|
};
|
|
827
986
|
}
|
|
828
987
|
|
|
829
988
|
/**
|
|
830
989
|
* 阶段二:把计划里属于本相位的条目发给远端,**不落库**。
|
|
831
990
|
*
|
|
832
|
-
*
|
|
833
|
-
*
|
|
991
|
+
* applied 的条目累加进 `plan.pushed`,拿到的远端 id 累加进 `plan.remoteIdsByChange`,
|
|
992
|
+
* 被拒的条目累加进 `plan.rejectedEntries`,都留给 {@link commitPushPlans} 一次性提交。
|
|
834
993
|
*
|
|
835
994
|
* 前一个相位已失败时直接返回:继续推只会在一个已知不会落库的批次上白跑一趟远端。
|
|
836
995
|
*
|
|
@@ -848,16 +1007,14 @@ async function pushPlanEntries(
|
|
|
848
1007
|
|
|
849
1008
|
const { namespace, entity } = plan.repository;
|
|
850
1009
|
|
|
851
|
-
for (
|
|
852
|
-
const batchEntries = entries.slice(offset, offset + batchSize);
|
|
853
|
-
const batch = createCompactedPushBatch(batchEntries);
|
|
854
|
-
|
|
1010
|
+
for (const { branchId, entries: batchEntries } of splitPushBatches(entries, batchSize)) {
|
|
855
1011
|
try {
|
|
856
|
-
const
|
|
857
|
-
for (const { change, remoteId } of
|
|
1012
|
+
const classified = await mergePushBatch(plan.remoteAdapter, branchId, batchEntries);
|
|
1013
|
+
for (const { change, remoteId } of classified.applied) {
|
|
858
1014
|
plan.remoteIdsByChange.set(change, remoteId);
|
|
859
1015
|
}
|
|
860
|
-
|
|
1016
|
+
for (const rejected of classified.rejected) plan.rejectedEntries.push(rejected);
|
|
1017
|
+
plan.pushed += batchEntries.length - classified.rejected.length;
|
|
861
1018
|
} catch (error) {
|
|
862
1019
|
console.error(`Error pushing changes to remote for repository [${namespace}/${entity}]:`, error);
|
|
863
1020
|
plan.error = error instanceof Error ? error : new Error(String(error));
|
|
@@ -867,70 +1024,447 @@ async function pushPlanEntries(
|
|
|
867
1024
|
}
|
|
868
1025
|
|
|
869
1026
|
/**
|
|
870
|
-
*
|
|
871
|
-
*
|
|
872
|
-
* 只有一条变更都没失败才落库:部分成功就推进水位线会让没推上去的那些永远丢失。
|
|
1027
|
+
* 一次 `mergeChanges` 的条目:同属一个目标分支。
|
|
873
1028
|
*
|
|
874
1029
|
* @internal
|
|
875
1030
|
*/
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
let pushError = plan.error;
|
|
881
|
-
let failed = effectiveCount - pushed;
|
|
1031
|
+
interface PushBatchSlice {
|
|
1032
|
+
readonly branchId: string;
|
|
1033
|
+
readonly entries: CompactedPushEntry[];
|
|
1034
|
+
}
|
|
882
1035
|
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
1036
|
+
/**
|
|
1037
|
+
* 切批:一批只含一个分支的条目(一次 `mergeChanges` 只有一个目标分支),且不超过 `batchSize`。
|
|
1038
|
+
*
|
|
1039
|
+
* @remarks
|
|
1040
|
+
* 依赖条目已按分支成段排列({@link compactPushEntriesByBranch} 的输出,按动作类型过滤后仍成段),
|
|
1041
|
+
* 因此只需在分支变化处断开,批次顺序即祖先在前。
|
|
1042
|
+
*
|
|
1043
|
+
* @internal
|
|
1044
|
+
*/
|
|
1045
|
+
function splitPushBatches(entries: readonly CompactedPushEntry[], batchSize: number): PushBatchSlice[] {
|
|
1046
|
+
const batches: PushBatchSlice[] = [];
|
|
1047
|
+
for (const entry of entries) {
|
|
1048
|
+
const last = batches.at(-1);
|
|
1049
|
+
if (last?.branchId === entry.branchId && last.entries.length < batchSize) {
|
|
1050
|
+
last.entries.push(entry);
|
|
1051
|
+
} else {
|
|
1052
|
+
batches.push({ branchId: entry.branchId, entries: [entry] });
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
return batches;
|
|
1056
|
+
}
|
|
886
1057
|
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
1058
|
+
/**
|
|
1059
|
+
* 阶段三:全部相位跑完后,把一组仓库的回执(远端 id + 被拒标记)、被拒实体的本地对齐和水位线一次性落库。
|
|
1060
|
+
*
|
|
1061
|
+
* @remarks
|
|
1062
|
+
* 只有每个条目都拿到了回执的仓库才落库:部分成功就推进水位线会让没推上去的那些永远丢失。
|
|
1063
|
+
* 被拒是回执的一种,不是失败——被拒条目照样越过水位线,标记为终态,不再重发(data-model §7)。
|
|
1064
|
+
*
|
|
1065
|
+
* 可落库的仓库**共用一个本地事务**,全有或全无:被拒新建要在本地删除(子先父后),被拒删除要在本地
|
|
1066
|
+
* 恢复(父先子后),两个方向相反,逐仓各开一个事务必然有一个方向先撞上本地外键。代价是同批任一仓库
|
|
1067
|
+
* 落库失败,其他仓库也一起不落库、下一轮重推。
|
|
1068
|
+
*
|
|
1069
|
+
* 落库成功后把被拒清单追加进 `rejections`,由调用方在整次推送结束时统一上报:
|
|
1070
|
+
* `reportRejections` 是整体替换语义,逐仓上报只会留下最后一个仓库的清单。
|
|
1071
|
+
*
|
|
1072
|
+
* @param sm - SyncManager 实例
|
|
1073
|
+
* @param plans - 已跑完全部相位的推送计划,按 INSERT 拓扑序(父先子后)
|
|
1074
|
+
* @param rejections - 落库成功后追加被拒清单的收集器
|
|
1075
|
+
* @returns 每个计划的推送结果
|
|
1076
|
+
*
|
|
1077
|
+
* @internal
|
|
1078
|
+
*/
|
|
1079
|
+
async function commitPushPlans(
|
|
1080
|
+
sm: SyncManager,
|
|
1081
|
+
plans: readonly RepositoryPushPlan[],
|
|
1082
|
+
rejections: SyncRejection[]
|
|
1083
|
+
): Promise<Map<RepositoryPushPlan, PushRepositoryResult>> {
|
|
1084
|
+
const results = new Map<RepositoryPushPlan, PushRepositoryResult>();
|
|
1085
|
+
const ready: RepositoryPushPlan[] = [];
|
|
1086
|
+
for (const plan of plans) {
|
|
1087
|
+
// 还有条目没拿到回执(某批失败):什么都不落,被拒也不算,下一轮整批重推
|
|
1088
|
+
if (plan.effectiveCount - plan.pushed - plan.rejectedEntries.length > 0) {
|
|
1089
|
+
results.set(plan, pushRepositoryResult(plan, plan.pushed, 0, plan.error));
|
|
1090
|
+
} else {
|
|
1091
|
+
ready.push(plan);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
if (ready.length === 0) return results;
|
|
894
1095
|
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
});
|
|
901
|
-
if (changesToSave.length > 0) {
|
|
902
|
-
await executor.saveMany(changesToSave);
|
|
903
|
-
}
|
|
904
|
-
await executor.getRepository(RxDBSync).update(repoSync, {
|
|
905
|
-
lastPushedChangeId: maxChangeId,
|
|
906
|
-
lastPushedAt: pushedAt,
|
|
907
|
-
updatedAt: pushedAt
|
|
908
|
-
});
|
|
909
|
-
});
|
|
910
|
-
} catch (error) {
|
|
911
|
-
for (const [change, remoteId] of previousRemoteIds) {
|
|
912
|
-
change.remoteId = remoteId;
|
|
913
|
-
}
|
|
914
|
-
Object.assign(repoSync, previousSyncState);
|
|
915
|
-
console.error(`Error saving push state for repository [${namespace}/${entity}]:`, error);
|
|
916
|
-
pushError = error instanceof Error ? error : new Error(String(error));
|
|
917
|
-
pushed = 0;
|
|
918
|
-
failed = effectiveCount;
|
|
1096
|
+
try {
|
|
1097
|
+
const committed = await persistPushReceipts(sm, ready);
|
|
1098
|
+
for (const rejection of committed) rejections.push(rejection);
|
|
1099
|
+
for (const plan of ready) {
|
|
1100
|
+
results.set(plan, pushRepositoryResult(plan, plan.pushed, plan.rejectedEntries.length, undefined));
|
|
919
1101
|
}
|
|
1102
|
+
} catch (error) {
|
|
1103
|
+
const repositories = ready.map(plan => repositoryKey(plan.repository)).join(', ');
|
|
1104
|
+
console.error(`Error saving push state for repositories [${repositories}]:`, error);
|
|
1105
|
+
const normalized = error instanceof Error ? error : new Error(String(error));
|
|
1106
|
+
for (const plan of ready) results.set(plan, pushRepositoryResult(plan, 0, 0, normalized));
|
|
920
1107
|
}
|
|
1108
|
+
return results;
|
|
1109
|
+
}
|
|
921
1110
|
|
|
1111
|
+
/**
|
|
1112
|
+
* 按计数组装仓库结果;`failed` 由不变式 `effectiveCount = pushed + failed + rejected` 反推。
|
|
1113
|
+
*
|
|
1114
|
+
* @internal
|
|
1115
|
+
*/
|
|
1116
|
+
function pushRepositoryResult(
|
|
1117
|
+
plan: RepositoryPushPlan,
|
|
1118
|
+
pushed: number,
|
|
1119
|
+
rejected: number,
|
|
1120
|
+
error: Error | undefined
|
|
1121
|
+
): PushRepositoryResult {
|
|
1122
|
+
const { repository } = plan;
|
|
1123
|
+
const failed = plan.effectiveCount - pushed - rejected;
|
|
922
1124
|
return {
|
|
923
1125
|
repository,
|
|
924
1126
|
success: failed === 0,
|
|
925
|
-
error
|
|
1127
|
+
error,
|
|
926
1128
|
pushed,
|
|
1129
|
+
rejected,
|
|
927
1130
|
failed,
|
|
928
1131
|
compacted: plan.compacted,
|
|
929
1132
|
originalCount: plan.originalCount,
|
|
930
|
-
failures:
|
|
1133
|
+
failures: error ? [{ repository, error }] : []
|
|
931
1134
|
};
|
|
932
1135
|
}
|
|
933
1136
|
|
|
1137
|
+
/**
|
|
1138
|
+
* 回执落库:applied 写 `remoteId`,被拒写 `rejectedAt` / `rejection`,对齐被拒实体,推进水位线——同一个本地事务。
|
|
1139
|
+
*
|
|
1140
|
+
* @remarks
|
|
1141
|
+
* 被拒实体的远端当前行在**事务外**先取齐(远端往返不能夹在本地事务里);取不到就整轮不提交,
|
|
1142
|
+
* 与提交失败同一个出口:内存里的标记回滚,水位线不动,下一轮重推会再拿一次回执。
|
|
1143
|
+
*
|
|
1144
|
+
* 对齐分两段:本地删除按计划逆序(子先父后),本地恢复按计划顺序(父先子后)。父行先删会撞上
|
|
1145
|
+
* RESTRICT,或经 CASCADE 带走子行、让子仓的删除拿不到前像(删除事件的前像取自删除时返回的本地行)。
|
|
1146
|
+
* 同一仓库内的自引用外键不在此列,仍按单次 `mergeChanges` 的顺序执行。
|
|
1147
|
+
*
|
|
1148
|
+
* @param sm - SyncManager 实例
|
|
1149
|
+
* @param plans - 回执齐全的推送计划,按 INSERT 拓扑序(父先子后)
|
|
1150
|
+
* @returns 落库成功后的被拒清单
|
|
1151
|
+
*
|
|
1152
|
+
* @internal
|
|
1153
|
+
*/
|
|
1154
|
+
async function persistPushReceipts(sm: SyncManager, plans: readonly RepositoryPushPlan[]): Promise<SyncRejection[]> {
|
|
1155
|
+
const prepared: { plan: RepositoryPushPlan; remoteRows: Map<string, Record<string, unknown>> }[] = [];
|
|
1156
|
+
for (const plan of plans) prepared.push({ plan, remoteRows: await fetchRejectedRemoteRows(plan) });
|
|
1157
|
+
|
|
1158
|
+
const pushedAt = new Date();
|
|
1159
|
+
const restorers = plans.map(snapshotPushState);
|
|
1160
|
+
|
|
1161
|
+
try {
|
|
1162
|
+
// 计划里的本地适配器都来自同一个 `sm.getLocalRepositories()`
|
|
1163
|
+
await plans[0].localAdapter.transaction(async executor => {
|
|
1164
|
+
await assertPlansOnActiveBranch(sm, executor, plans);
|
|
1165
|
+
const alignments: RejectionAlignment[] = [];
|
|
1166
|
+
for (const { plan, remoteRows } of prepared) {
|
|
1167
|
+
await writePushReceipts(executor, plan, pushedAt);
|
|
1168
|
+
alignments.push(await collectRejectionAlignment(executor, plan, remoteRows));
|
|
1169
|
+
}
|
|
1170
|
+
for (const { removals } of [...alignments].reverse()) await alignRejectedEntities(executor, removals);
|
|
1171
|
+
for (const { restores } of alignments) await alignRejectedEntities(executor, restores);
|
|
1172
|
+
for (const plan of plans) {
|
|
1173
|
+
await executor.getRepository(RxDBSync).update(plan.repoSync, {
|
|
1174
|
+
lastPushedChangeId: maxPlanChangeId(plan),
|
|
1175
|
+
lastPushedAt: pushedAt,
|
|
1176
|
+
updatedAt: pushedAt
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
});
|
|
1180
|
+
} catch (error) {
|
|
1181
|
+
for (const restore of restorers) restore();
|
|
1182
|
+
throw error;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
return plans.flatMap(plan => toSyncRejections(plan.rejectedEntries, pushedAt));
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* 本轮落库要动的变更:拿到远端 id 的,以及被拒条目的全部源变更。
|
|
1190
|
+
*
|
|
1191
|
+
* @internal
|
|
1192
|
+
*/
|
|
1193
|
+
function pushTouchedChanges(plan: RepositoryPushPlan): RxDBChange[] {
|
|
1194
|
+
return [...plan.remoteIdsByChange.keys(), ...plan.rejectedEntries.flatMap(({ entry }) => entry.sourceChanges)];
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/**
|
|
1198
|
+
* 本计划全部源变更里最大的 change id,即落库后的水位线。
|
|
1199
|
+
*
|
|
1200
|
+
* @internal
|
|
1201
|
+
*/
|
|
1202
|
+
function maxPlanChangeId(plan: RepositoryPushPlan): number {
|
|
1203
|
+
// 使用 reduce 求最大值,避免 Math.max(...arr) 在超大数组上触发调用栈溢出
|
|
1204
|
+
return plan.localChanges.reduce((max, c) => (c.id > max ? c.id : max), plan.localChanges[0].id);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* 记下落库会改动的内存状态(变更的回执字段、同步记录的水位线),返回把它们还原的函数。
|
|
1209
|
+
*
|
|
1210
|
+
* @internal
|
|
1211
|
+
*/
|
|
1212
|
+
function snapshotPushState(plan: RepositoryPushPlan): () => void {
|
|
1213
|
+
const { repoSync } = plan;
|
|
1214
|
+
const previousChanges = pushTouchedChanges(plan).map(change => ({
|
|
1215
|
+
change,
|
|
1216
|
+
remoteId: change.remoteId,
|
|
1217
|
+
rejectedAt: change.rejectedAt,
|
|
1218
|
+
rejection: change.rejection
|
|
1219
|
+
}));
|
|
1220
|
+
const previousSyncState = {
|
|
1221
|
+
lastPushedChangeId: repoSync.lastPushedChangeId,
|
|
1222
|
+
lastPushedAt: repoSync.lastPushedAt,
|
|
1223
|
+
updatedAt: repoSync.updatedAt
|
|
1224
|
+
};
|
|
1225
|
+
return () => {
|
|
1226
|
+
for (const previous of previousChanges) {
|
|
1227
|
+
previous.change.remoteId = previous.remoteId;
|
|
1228
|
+
previous.change.rejectedAt = previous.rejectedAt;
|
|
1229
|
+
previous.change.rejection = previous.rejection;
|
|
1230
|
+
}
|
|
1231
|
+
Object.assign(repoSync, previousSyncState);
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* 在事务内写回执:applied 写 `remoteId`,被拒的源变更写 `rejectedAt` / `rejection`。
|
|
1237
|
+
*
|
|
1238
|
+
* @internal
|
|
1239
|
+
*/
|
|
1240
|
+
async function writePushReceipts(executor: TransactionExecutor, plan: RepositoryPushPlan, at: Date): Promise<void> {
|
|
1241
|
+
for (const [change, remoteId] of plan.remoteIdsByChange) change.remoteId = remoteId;
|
|
1242
|
+
for (const { entry, rejection } of plan.rejectedEntries) {
|
|
1243
|
+
for (const change of entry.sourceChanges) {
|
|
1244
|
+
change.rejectedAt = at;
|
|
1245
|
+
change.rejection = rejection;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
const touched = pushTouchedChanges(plan);
|
|
1249
|
+
if (touched.length > 0) await executor.saveMany(touched);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* 被拒对齐改写的是**当前 active 分支**的业务投影,提交前必须确认它仍是规划时的那个分支。
|
|
1254
|
+
*
|
|
1255
|
+
* @remarks
|
|
1256
|
+
* 远端往返期间切过分支时,把旧分支的被拒对齐写下去会覆盖另一个分支的投影(切到 feature),
|
|
1257
|
+
* 或改写一份被切走又重建过的投影(main→feature→main:分支 id 对得上,激活代际对不上)。
|
|
1258
|
+
* 此时在任何本地写入之前让整轮提交失败:内存标记回滚、水位线不动,回到该分支后的下一轮推送会再拿一次回执。
|
|
1259
|
+
*
|
|
1260
|
+
* active 分支经**本事务**读:走 `sm.getCurrentBranch()` 会排在已持有的事务后面。
|
|
1261
|
+
* 激活代际只覆盖本实例内的切换;跨标签页的切换只由持久化的 active 分支兜住,A→B→A 不可见。
|
|
1262
|
+
*
|
|
1263
|
+
* 没有被拒时不校验:只写回执与水位线、不碰业务投影,不值得为一次分支切换让正常推送整轮重推。
|
|
1264
|
+
*
|
|
1265
|
+
* @internal
|
|
1266
|
+
*/
|
|
1267
|
+
async function assertPlansOnActiveBranch(
|
|
1268
|
+
sm: SyncManager,
|
|
1269
|
+
executor: TransactionExecutor,
|
|
1270
|
+
plans: readonly RepositoryPushPlan[]
|
|
1271
|
+
): Promise<void> {
|
|
1272
|
+
if (!plans.some(plan => plan.rejectedEntries.length > 0)) return;
|
|
1273
|
+
|
|
1274
|
+
const active = await assertSingleActiveBranch(executor);
|
|
1275
|
+
const generation = sm.branchSwitchGeneration;
|
|
1276
|
+
const stale = plans.find(plan => plan.branchId !== active.id || plan.branchGeneration !== generation);
|
|
1277
|
+
if (stale) {
|
|
1278
|
+
throw new RxDBError(
|
|
1279
|
+
`Branch switched while pushing ${repositoryKey(stale.repository)} from branch ${stale.branchId}; ` +
|
|
1280
|
+
`rejected receipts are not applied to active branch ${active.id}.`
|
|
1281
|
+
);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* 向远端取被拒实体的当前行,按 {@link rejectedRowKey} 索引;远端没有的行不出现在结果里。
|
|
1287
|
+
*
|
|
1288
|
+
* @remarks
|
|
1289
|
+
* 按回执里的实体引用分组查(`namespace:entity` 作为 `findByIds` 的实体名),不假设被拒实体都属于本仓。
|
|
1290
|
+
* 本轮无被拒时不查远端。
|
|
1291
|
+
*
|
|
1292
|
+
* @internal
|
|
1293
|
+
*/
|
|
1294
|
+
async function fetchRejectedRemoteRows(plan: RepositoryPushPlan): Promise<Map<string, Record<string, unknown>>> {
|
|
1295
|
+
const rows = new Map<string, Record<string, unknown>>();
|
|
1296
|
+
const idsByScope = new Map<string, string[]>();
|
|
1297
|
+
for (const { rejection } of plan.rejectedEntries) {
|
|
1298
|
+
const scope = `${rejection.entity.namespace}:${rejection.entity.entity}`;
|
|
1299
|
+
const ids = idsByScope.get(scope) ?? [];
|
|
1300
|
+
ids.push(rejection.entity.entityId);
|
|
1301
|
+
idsByScope.set(scope, ids);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
for (const [scope, ids] of idsByScope) {
|
|
1305
|
+
const found = await firstValueFrom(plan.remoteAdapter.findByIds<Record<string, unknown>>(scope, ids));
|
|
1306
|
+
for (const row of found) rows.set(`${scope}:${String(row['id'])}`, row);
|
|
1307
|
+
}
|
|
1308
|
+
return rows;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/**
|
|
1312
|
+
* 被拒实体在 {@link fetchRejectedRemoteRows} 结果里的键:回执的实体引用,`entityId` 已是字符串形式。
|
|
1313
|
+
*
|
|
1314
|
+
* @internal
|
|
1315
|
+
*/
|
|
1316
|
+
function rejectedRowKey(rejection: RemoteChangeRejection): string {
|
|
1317
|
+
const { namespace, entity, entityId } = rejection.entity;
|
|
1318
|
+
return `${namespace}:${entity}:${entityId}`;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* 一个仓库被拒实体的本地对齐动作,按方向拆开:移除与恢复要分处两段执行,见 {@link persistPushReceipts}。
|
|
1323
|
+
*
|
|
1324
|
+
* @internal
|
|
1325
|
+
*/
|
|
1326
|
+
interface RejectionAlignment {
|
|
1327
|
+
/** 远端无行:本地行移除(被拒的新建 / 更新) */
|
|
1328
|
+
readonly removals: SwitchVersionActions;
|
|
1329
|
+
/** 远端有行:本地行覆盖成远端值,被拒的删除即重建 */
|
|
1330
|
+
readonly restores: SwitchVersionActions;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
const emptyActions = (): SwitchVersionActions => ({ deletes: new Map(), updates: new Map(), inserts: new Map() });
|
|
1334
|
+
|
|
1335
|
+
/**
|
|
1336
|
+
* 算出被拒实体的本地对齐动作:远端有行就覆盖(被拒的删除即恢复),远端无行就移除。
|
|
1337
|
+
*
|
|
1338
|
+
* @remarks
|
|
1339
|
+
* 被拒实体在本批之外还有更新的待推变更(远端往返期间用户又改过)时**不对齐它**:
|
|
1340
|
+
* 覆盖掉会吞掉用户那次还没推的改动;它的下一轮推送会拿到自己的回执。这里不按分支过滤,
|
|
1341
|
+
* 任一分支上有更新的待推变更都算——宁可少对齐一次,也不覆盖用户改动。
|
|
1342
|
+
*
|
|
1343
|
+
* @internal
|
|
1344
|
+
*/
|
|
1345
|
+
async function collectRejectionAlignment(
|
|
1346
|
+
executor: TransactionExecutor,
|
|
1347
|
+
plan: RepositoryPushPlan,
|
|
1348
|
+
remoteRows: ReadonlyMap<string, Record<string, unknown>>
|
|
1349
|
+
): Promise<RejectionAlignment> {
|
|
1350
|
+
const alignment: RejectionAlignment = { removals: emptyActions(), restores: emptyActions() };
|
|
1351
|
+
if (plan.rejectedEntries.length === 0) return alignment;
|
|
1352
|
+
|
|
1353
|
+
const { namespace, entity } = plan.repository;
|
|
1354
|
+
const newerPending = await executor.getRepository(RxDBChange).find({
|
|
1355
|
+
where: {
|
|
1356
|
+
combinator: 'and',
|
|
1357
|
+
rules: [
|
|
1358
|
+
{ field: 'namespace', operator: '=', value: namespace },
|
|
1359
|
+
{ field: 'entity', operator: '=', value: entity },
|
|
1360
|
+
{
|
|
1361
|
+
field: 'entityId',
|
|
1362
|
+
operator: 'in',
|
|
1363
|
+
value: getRxDBChangeEntityIdQueryValues(
|
|
1364
|
+
plan.rejectedEntries.map(({ rejection }) => rejection.entity.entityId)
|
|
1365
|
+
)
|
|
1366
|
+
},
|
|
1367
|
+
{ field: 'id', operator: '>', value: maxPlanChangeId(plan) },
|
|
1368
|
+
{ field: 'revertChangeId', operator: '=', value: null },
|
|
1369
|
+
{ field: 'remoteId', operator: '=', value: null },
|
|
1370
|
+
{ field: 'rejectedAt', operator: '=', value: null }
|
|
1371
|
+
]
|
|
1372
|
+
} as never
|
|
1373
|
+
});
|
|
1374
|
+
const skipped = new Set(newerPending.map(change => getRxDBChangeKey(change)));
|
|
1375
|
+
|
|
1376
|
+
for (const { entry, rejection } of plan.rejectedEntries) {
|
|
1377
|
+
if (!skipped.has(entry.key)) addAlignmentAction(alignment, entry, remoteRows.get(rejectedRowKey(rejection)));
|
|
1378
|
+
}
|
|
1379
|
+
return alignment;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* 把一段对齐动作写进本地业务表。
|
|
1384
|
+
*
|
|
1385
|
+
* @remarks
|
|
1386
|
+
* 走 `executor.mergeChanges(…, true)` 关触发器写入:对齐是「把远端投影抄回本地」,不是一次用户改动,
|
|
1387
|
+
* 不能产生 `RxDBChange`,否则下一轮会把远端拒绝过的值又推一次。
|
|
1388
|
+
* 受信写入声明是一次性的,每次 `mergeChanges` 前都要重新声明。
|
|
1389
|
+
*
|
|
1390
|
+
* @internal
|
|
1391
|
+
*/
|
|
1392
|
+
async function alignRejectedEntities(executor: TransactionExecutor, actions: SwitchVersionActions): Promise<void> {
|
|
1393
|
+
if (actions.inserts.size + actions.updates.size + actions.deletes.size === 0) return;
|
|
1394
|
+
|
|
1395
|
+
declareTrustedWrite(executor, {
|
|
1396
|
+
file: 'push-repository.ts',
|
|
1397
|
+
symbol: 'alignRejectedEntities',
|
|
1398
|
+
intent: TrustedWriteIntent.remote_sync
|
|
1399
|
+
});
|
|
1400
|
+
await executor.mergeChanges(actions, undefined, true);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/**
|
|
1404
|
+
* 一个被拒条目对应的对齐动作:本地行要变成远端行(或在远端无行时消失)。
|
|
1405
|
+
*
|
|
1406
|
+
* @remarks
|
|
1407
|
+
* 移除动作不带前像:本地行此刻还在(删除子先父后保证它不会先被父行的 CASCADE 带走),
|
|
1408
|
+
* 前像由适配器在删除时从本地行取得,不在这里伪造。
|
|
1409
|
+
*
|
|
1410
|
+
* @internal
|
|
1411
|
+
*/
|
|
1412
|
+
function addAlignmentAction(
|
|
1413
|
+
alignment: RejectionAlignment,
|
|
1414
|
+
entry: CompactedPushEntry,
|
|
1415
|
+
remoteRow: Record<string, unknown> | undefined
|
|
1416
|
+
): void {
|
|
1417
|
+
if (remoteRow) {
|
|
1418
|
+
// 被拒的删除:本地行已经没了,按远端值重建;其余:本地行还在,覆盖成远端值
|
|
1419
|
+
const { restores } = alignment;
|
|
1420
|
+
const target = entry.actionKind === 'deletes' ? restores.inserts : restores.updates;
|
|
1421
|
+
target.set(entry.key, { patch: remoteRow, inversePatch: null });
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
// 远端无行且被拒的就是删除:两边都没有,无事可做
|
|
1425
|
+
if (entry.actionKind !== 'deletes') {
|
|
1426
|
+
alignment.removals.deletes.set(entry.key, { patch: null, inversePatch: null });
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
const ACTION_KIND_OP = {
|
|
1431
|
+
deletes: 'DELETE',
|
|
1432
|
+
updates: 'UPDATE',
|
|
1433
|
+
inserts: 'INSERT'
|
|
1434
|
+
} as const satisfies Record<CompactedActionKind, SyncRejection['op']>;
|
|
1435
|
+
|
|
1436
|
+
/**
|
|
1437
|
+
* 被拒条目 → 上报给 `SyncStateHub` 的清单项;`op` 取压缩后真正发给远端的那个操作。
|
|
1438
|
+
*
|
|
1439
|
+
* @internal
|
|
1440
|
+
*/
|
|
1441
|
+
function toSyncRejections(rejectedEntries: readonly RejectedPushEntry[], at: Date): SyncRejection[] {
|
|
1442
|
+
return rejectedEntries.map(({ entry, rejection }) => ({
|
|
1443
|
+
namespace: rejection.entity.namespace,
|
|
1444
|
+
entity: rejection.entity.entity,
|
|
1445
|
+
entityId: rejection.entity.entityId,
|
|
1446
|
+
op: ACTION_KIND_OP[entry.actionKind],
|
|
1447
|
+
code: rejection.code,
|
|
1448
|
+
reason: rejection.reason,
|
|
1449
|
+
message: rejection.message,
|
|
1450
|
+
...(rejection.dependsOn ? { dependsOn: rejection.dependsOn } : {}),
|
|
1451
|
+
at,
|
|
1452
|
+
changeIds: entry.sourceChanges.map(change => change.id)
|
|
1453
|
+
}));
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
/**
|
|
1457
|
+
* 整次推送结束后把收集到的被拒清单上报一次;没有被拒就不报(不清掉上一轮的清单)。
|
|
1458
|
+
*
|
|
1459
|
+
* @param sm - SyncManager 实例
|
|
1460
|
+
* @param rejections - 整次操作已落库的被拒清单
|
|
1461
|
+
*
|
|
1462
|
+
* @internal
|
|
1463
|
+
*/
|
|
1464
|
+
export function reportPushRejections(sm: SyncManager, rejections: readonly SyncRejection[]): void {
|
|
1465
|
+
if (rejections.length > 0) sm.rxdb.syncState.reportRejections(rejections);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
934
1468
|
/**
|
|
935
1469
|
* 单仓库推送变更(内部实现)
|
|
936
1470
|
*
|
|
@@ -943,13 +1477,15 @@ async function pushSingleRepository(
|
|
|
943
1477
|
namespace: string,
|
|
944
1478
|
entity: string,
|
|
945
1479
|
options: Required<PushRepositoryOptions>,
|
|
946
|
-
inFlight: PushInFlightSession
|
|
1480
|
+
inFlight: PushInFlightSession,
|
|
1481
|
+
rejections: SyncRejection[]
|
|
947
1482
|
): Promise<PushRepositoryResult> {
|
|
948
1483
|
const planned = await planRepositoryPush(sm, namespace, entity, inFlight);
|
|
949
1484
|
if ('emptyResult' in planned) return planned.emptyResult;
|
|
950
1485
|
|
|
951
1486
|
await pushPlanEntries(planned, ALL_ACTION_KINDS, options.batchSize);
|
|
952
|
-
const result = await
|
|
1487
|
+
const result = (await commitPushPlans(sm, [planned], rejections)).get(planned);
|
|
1488
|
+
if (!result) throw new RxDBError(`Internal error: push result missing for ${namespace}:${entity}`);
|
|
953
1489
|
// 与级联路径同一个失败出口,见 throwPushFailure 的 @remarks
|
|
954
1490
|
if (!result.success) throwPushFailure(result);
|
|
955
1491
|
return result;
|
|
@@ -970,6 +1506,9 @@ async function queryUnpushedChanges(
|
|
|
970
1506
|
const baseRules: RxDBChangeRuleGroup['rules'] = [
|
|
971
1507
|
{ field: 'revertChangeId', operator: '=', value: null },
|
|
972
1508
|
{ field: 'remoteId', operator: '=', value: null },
|
|
1509
|
+
// 被拒变更(rejectedAt 非空)的 remoteId 也一直是 null,但它永远不会再被推送,
|
|
1510
|
+
// 必须和 remoteId = null 一起排除,否则会被当成待推变更反复捞出来
|
|
1511
|
+
{ field: 'rejectedAt', operator: '=', value: null },
|
|
973
1512
|
// 实体名在不同 namespace 下可以重名,只按 entity 过滤会把别的 namespace 的
|
|
974
1513
|
// 变更一起捞出来错推,同时把对方的水位线推进导致后续漏推
|
|
975
1514
|
{ field: 'namespace', operator: '=', value: namespace }
|