@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.ts
CHANGED
|
@@ -54,6 +54,7 @@ export async function push(sm: SyncManager, options?: PushOptions): Promise<Push
|
|
|
54
54
|
// 汇总结果
|
|
55
55
|
const pushResult: PushResult = {
|
|
56
56
|
pushed: 0,
|
|
57
|
+
rejected: 0,
|
|
57
58
|
failed: 0,
|
|
58
59
|
compacted: 0,
|
|
59
60
|
originalCount: 0,
|
|
@@ -67,6 +68,7 @@ export async function push(sm: SyncManager, options?: PushOptions): Promise<Push
|
|
|
67
68
|
const repoResult = result.result?.pushResult;
|
|
68
69
|
if (repoResult) {
|
|
69
70
|
pushResult.pushed += repoResult.pushed;
|
|
71
|
+
pushResult.rejected += repoResult.rejected;
|
|
70
72
|
pushResult.failed += repoResult.failed;
|
|
71
73
|
pushResult.compacted += repoResult.compacted;
|
|
72
74
|
pushResult.originalCount += repoResult.originalCount;
|
|
@@ -75,7 +77,7 @@ export async function push(sm: SyncManager, options?: PushOptions): Promise<Push
|
|
|
75
77
|
if (result.success) continue;
|
|
76
78
|
|
|
77
79
|
// 失败**不折算成 failed += 1**:failed 的单位是变更条数,混进仓库计数会破坏
|
|
78
|
-
// `originalCount = pushed + failed + compacted`。失败结构化保留在 failures 里。
|
|
80
|
+
// `originalCount = pushed + failed + rejected + compacted`。失败结构化保留在 failures 里。
|
|
79
81
|
const failure =
|
|
80
82
|
result.error ??
|
|
81
83
|
new RxDBError(`Repository push failed: ${result.repository.namespace}:${result.repository.entity}`);
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
isNetworkError,
|
|
42
42
|
isRepositorySyncEnabled,
|
|
43
43
|
LWWConflictResolver,
|
|
44
|
+
type QueryCacheEntity,
|
|
44
45
|
type QueryCacheRemoteAdapter,
|
|
45
46
|
type RxDB,
|
|
46
47
|
RxDBChange,
|
|
@@ -327,7 +328,12 @@ async function runOutboxFlush(
|
|
|
327
328
|
if (metadata) {
|
|
328
329
|
await replayPhases(entries, metadata, { rxdb, entity, remoteAdapter, conflictResolver, run });
|
|
329
330
|
}
|
|
330
|
-
await repairLocalCache(localAdapter, remoteAdapter, entity, run
|
|
331
|
+
await repairLocalCache(localAdapter, remoteAdapter, entity, run, {
|
|
332
|
+
changeRepo,
|
|
333
|
+
namespace,
|
|
334
|
+
branchId: branch.id,
|
|
335
|
+
maxChangeId
|
|
336
|
+
});
|
|
331
337
|
|
|
332
338
|
if (run.failures.length > 0) {
|
|
333
339
|
// 整批没推完,但已经结算掉的那一段可以让水位线跟上去。不跟的话,下一轮会把这些
|
|
@@ -407,7 +413,7 @@ export async function countQueryCacheOutbox(rxdb: RxDB): Promise<number> {
|
|
|
407
413
|
where: { combinator: 'and', rules: [{ field: 'branchId', operator: '=', value: branch.id }] }
|
|
408
414
|
});
|
|
409
415
|
|
|
410
|
-
const repoRules = buildOfflineWriteRepositoryRules(rxdb.config.entities, rxdb.
|
|
416
|
+
const repoRules = buildOfflineWriteRepositoryRules(rxdb.config.entities, rxdb.entitySync, repoSyncs);
|
|
411
417
|
if (repoRules.length === 0) {
|
|
412
418
|
return 0;
|
|
413
419
|
}
|
|
@@ -423,6 +429,9 @@ export async function countQueryCacheOutbox(rxdb: RxDB): Promise<number> {
|
|
|
423
429
|
{ field: 'branchId', operator: '=', value: branch.id },
|
|
424
430
|
{ field: 'revertChangeId', operator: '=', value: null },
|
|
425
431
|
{ field: 'remoteId', operator: '=', value: null },
|
|
432
|
+
// 被拒变更(rejectedAt 非空)的 remoteId 也一直是 null,但它永远不会再被推送,
|
|
433
|
+
// 必须和 remoteId = null 一起排除,否则会被当成积压计进出站队列
|
|
434
|
+
{ field: 'rejectedAt', operator: '=', value: null },
|
|
426
435
|
{ combinator: 'or', rules: repoRules }
|
|
427
436
|
]
|
|
428
437
|
}
|
|
@@ -489,7 +498,7 @@ function resolveQueryCacheSyncType(rxdb: RxDB, namespace: string, entity: string
|
|
|
489
498
|
throw new RxDBError(`Entity not found for QueryCache outbox flush: ${namespace}/${entity}`);
|
|
490
499
|
}
|
|
491
500
|
|
|
492
|
-
const syncType = getSyncType(getEntityMetadata(EntityType), rxdb.
|
|
501
|
+
const syncType = getSyncType(getEntityMetadata(EntityType), rxdb.entitySync);
|
|
493
502
|
if (syncType !== 'querycache') {
|
|
494
503
|
throw new RxDBError(
|
|
495
504
|
`flushQueryCacheOutbox only handles syncType 'querycache'; ${namespace}/${entity} is '${syncType}'. ` +
|
|
@@ -522,6 +531,9 @@ async function queryOutboxChanges(
|
|
|
522
531
|
{ field: 'entity', operator: '=', value: entity },
|
|
523
532
|
{ field: 'branchId', operator: '=', value: branchId },
|
|
524
533
|
{ field: 'remoteId', operator: '=', value: null },
|
|
534
|
+
// 被拒变更(rejectedAt 非空)的 remoteId 也一直是 null,但它永远不会再被推送,
|
|
535
|
+
// 必须和 remoteId = null 一起排除,否则会被当成待推行重放到远端
|
|
536
|
+
{ field: 'rejectedAt', operator: '=', value: null },
|
|
525
537
|
{ field: 'revertChangeId', operator: '=', value: null }
|
|
526
538
|
];
|
|
527
539
|
|
|
@@ -836,6 +848,47 @@ function remoteWrite(
|
|
|
836
848
|
return remoteAdapter.update ? remoteAdapter.update(entity, entry.entityId, patch) : null;
|
|
837
849
|
}
|
|
838
850
|
|
|
851
|
+
/** {@link repairLocalCache} 判定「这批 id 里谁被新写盖过」所需的快照上下文 */
|
|
852
|
+
interface RepairSnapshot {
|
|
853
|
+
changeRepo: IRepository<typeof RxDBChange>;
|
|
854
|
+
namespace: string;
|
|
855
|
+
branchId: string;
|
|
856
|
+
/** 本轮重放开始时 `pending` 的最大变更行 id —— 判定起点,见 {@link findEntityIdsWithNewerPendingChanges} */
|
|
857
|
+
maxChangeId: number;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* 查一遍这批实体里谁在本轮快照之后又产生了新的待推变更。
|
|
862
|
+
*
|
|
863
|
+
* @returns 有新变更的实体 id 集合
|
|
864
|
+
*
|
|
865
|
+
* @remarks
|
|
866
|
+
* RV-055:`repairLocalCache` 要把 KEEP_REMOTE 判负的那些行覆盖回远端取到的投影,但判负
|
|
867
|
+
* 是对着 `runOutboxFlush` 开头那份 `pending` 快照做的 —— 决定做完、`findByIds` 响应还在
|
|
868
|
+
* 网络上滞留的这段时间里,用户完全可能在同一个实体上又写了一笔离线改动。那次新写有
|
|
869
|
+
* 它自己的 `RxDBChange` 行,而触发器写入的变更 id 是自增的,必然大于本轮快照的
|
|
870
|
+
* `maxChangeId`——不用比时间戳(铁律:不用计时式修复),只用这一条单调递增的序号就能
|
|
871
|
+
* 确定「是不是本轮判过负之后才出现的」。命中的那些 id 对应的修复必须跳过:旧判决覆盖
|
|
872
|
+
* 的是「本轮快照里的状态」,而本地此刻的真实状态已经是那笔新写,拿旧判决去盖,等于让
|
|
873
|
+
* 一次已经提交的新写无声消失,且发起它的人永远不会知道。
|
|
874
|
+
*
|
|
875
|
+
* 查询条件复用 {@link queryOutboxChanges}:同分支、未推送、未回滚、`id > maxChangeId`,
|
|
876
|
+
* 与「谁还排在出站队列里」同一个口径,不开第二份判据。
|
|
877
|
+
*/
|
|
878
|
+
async function findEntityIdsWithNewerPendingChanges(
|
|
879
|
+
snapshot: RepairSnapshot,
|
|
880
|
+
entity: string
|
|
881
|
+
): Promise<ReadonlySet<string>> {
|
|
882
|
+
const newer = await queryOutboxChanges(
|
|
883
|
+
snapshot.changeRepo,
|
|
884
|
+
snapshot.namespace,
|
|
885
|
+
entity,
|
|
886
|
+
snapshot.branchId,
|
|
887
|
+
snapshot.maxChangeId
|
|
888
|
+
);
|
|
889
|
+
return new Set(newer.map(change => String(change.entityId)));
|
|
890
|
+
}
|
|
891
|
+
|
|
839
892
|
/**
|
|
840
893
|
* 把 LWW 判负的那些行在本地缓存里对齐远端。
|
|
841
894
|
*
|
|
@@ -846,19 +899,39 @@ function remoteWrite(
|
|
|
846
899
|
* 回去 —— 一个自我供给的循环,而且每绕一圈都以远端的那次改动被丢弃告终。
|
|
847
900
|
*
|
|
848
901
|
* 修复失败不改判定:远端侧已经是权威状态了,这里只是本地投影没跟上,下一次读会同步回来。
|
|
902
|
+
*
|
|
903
|
+
* **落盘前重新核对一遍「谁被新写盖过」(RV-055)**:真正的竞争窗口就是上面
|
|
904
|
+
* `findByIds` 在网络上滞留的这段时间,核对必须放在它之后、紧贴着 `upsertMany` /
|
|
905
|
+
* `deleteByIds` 之前 —— 早一步核对看到的还是旧状态,等于没核对。命中的 id 从本次要
|
|
906
|
+
* 写的集合里摘掉,留给下一轮:那时它会带着「本地此刻的真实状态」重新跟远端的最新值
|
|
907
|
+
* 比一次,而不是被这一轮的旧判决悄悄盖掉。
|
|
849
908
|
*/
|
|
850
909
|
async function repairLocalCache(
|
|
851
910
|
localAdapter: OutboxLocalAdapter,
|
|
852
911
|
remoteAdapter: QueryCacheRemoteAdapter,
|
|
853
912
|
entity: string,
|
|
854
|
-
run: RunState
|
|
913
|
+
run: RunState,
|
|
914
|
+
snapshot: RepairSnapshot
|
|
855
915
|
): Promise<void> {
|
|
856
|
-
if (run.restoreIds.length
|
|
857
|
-
|
|
858
|
-
await firstValueFrom(localAdapter.upsertMany(entity, rows));
|
|
916
|
+
if (run.restoreIds.length === 0 && run.dropIds.length === 0) {
|
|
917
|
+
return;
|
|
859
918
|
}
|
|
860
|
-
|
|
861
|
-
|
|
919
|
+
|
|
920
|
+
const rows =
|
|
921
|
+
run.restoreIds.length > 0 ?
|
|
922
|
+
await firstValueFrom(remoteAdapter.findByIds<QueryCacheEntity>(entity, run.restoreIds))
|
|
923
|
+
: [];
|
|
924
|
+
|
|
925
|
+
const superseded = await findEntityIdsWithNewerPendingChanges(snapshot, entity);
|
|
926
|
+
|
|
927
|
+
const freshRows = rows.filter(row => !superseded.has(row.id));
|
|
928
|
+
if (freshRows.length > 0) {
|
|
929
|
+
await firstValueFrom(localAdapter.upsertMany(entity, freshRows));
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const freshDropIds = run.dropIds.filter(id => !superseded.has(id));
|
|
933
|
+
if (freshDropIds.length > 0) {
|
|
934
|
+
await firstValueFrom(localAdapter.deleteByIds(entity, freshDropIds));
|
|
862
935
|
}
|
|
863
936
|
}
|
|
864
937
|
|
package/src/sync-branches.ts
CHANGED
|
@@ -1,29 +1,100 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
assertUsableBranchId,
|
|
3
|
+
InvalidBranchIdError,
|
|
4
|
+
RxDBBranch,
|
|
5
|
+
RxDBChange,
|
|
6
|
+
RxDBError,
|
|
7
|
+
type IRepository
|
|
8
|
+
} from '@aiao/rxdb';
|
|
2
9
|
import { toLocalFromChangeId } from './branch-change-id.js';
|
|
3
10
|
import type { SyncManager } from './SyncManager.js';
|
|
4
11
|
|
|
12
|
+
/**
|
|
13
|
+
* {@link SyncManager.syncBranches} 一轮的结果。
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* `created + updated + skipped.length` 不一定等于 `total`:本地已有、且早已标了 `remote` 的
|
|
17
|
+
* 分支既不计 `updated`,也不进 `skipped`。
|
|
18
|
+
*/
|
|
5
19
|
export interface SyncBranchesResult {
|
|
20
|
+
/** 本轮在本地新建的远端分支数。 */
|
|
6
21
|
created: number;
|
|
22
|
+
/** 本地已有、本轮补上 `remote: true` 标记的分支数。 */
|
|
7
23
|
updated: number;
|
|
24
|
+
/** 远端本轮交回的分支总数,含被跳过的。 */
|
|
8
25
|
total: number;
|
|
9
26
|
|
|
10
27
|
/**
|
|
11
|
-
*
|
|
28
|
+
* 本轮**未创建**的远端分支 id —— 具体原因见同一个 id 在 {@link skipReasons} 里的条目。
|
|
12
29
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
30
|
+
* 一条分支被跳过,可能是它自己的问题(id 不可用、或分叉点变更还没拉到本地翻译不出本地
|
|
31
|
+
* id),也可能单纯因为它的父本轮被跳过——后一种情形下这条分支自身完全合格,但父本轮
|
|
32
|
+
* 不会落库,若仍然照常创建,它的 `parentId` 就是一条指向「本轮不存在的父」的悬空外键
|
|
33
|
+
* (`rxdb_branch.parentId` 是 `PRAGMA defer_foreign_keys` 延迟到 COMMIT 才检查的外键,
|
|
34
|
+
* 届时会回滚整个事务,连同本轮所有本该成功的分支)。三种成因统一走同一条 `skipped`
|
|
35
|
+
* 通道,因为对调用方而言处理方式是一样的:这个 id 本轮没有对应的本地分支,仅此而已。
|
|
36
|
+
*
|
|
37
|
+
* **跳过不留持久标记**:每轮都全量重拉远端分支、从头再判一遍,`skipped` 与
|
|
38
|
+
* {@link skipReasons} 只报这一轮。`unresolved-from-change-id` 在分叉点变更拉到本地后自愈,
|
|
39
|
+
* 挂在它下面的 `ancestor-skipped` 随之自愈;`invalid-id` 则远端不改就每轮重拉、重跳,
|
|
40
|
+
* 一直占位——调用方要区分这两类,看 {@link skipReasons}。要不要记一个持久的「已知坏行」
|
|
41
|
+
* 标记,登记在 `requirements/roadmap.md`「epic-006 评审顺延的架构项」。
|
|
17
42
|
*/
|
|
18
43
|
skipped: string[];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `skipped` 里每一个 id 对应的具体跳过原因;key 覆盖 `skipped` 的每一项,一一对应。
|
|
47
|
+
*
|
|
48
|
+
* 单开一个字段而不是把原因塞进 `skipped` 数组本身:`skipped: string[]` 这个形状已经被
|
|
49
|
+
* 外部当「id 列表」消费,改成对象数组是破坏性变更;`Record<id, reason>` 是纯增量,
|
|
50
|
+
* 只读 `skipped` 的既有调用方不受影响。
|
|
51
|
+
*/
|
|
52
|
+
skipReasons: Record<string, SyncBranchSkipReason>;
|
|
19
53
|
}
|
|
20
54
|
|
|
55
|
+
/**
|
|
56
|
+
* 一条远端分支本轮被跳过的具体原因。
|
|
57
|
+
*
|
|
58
|
+
* @remarks
|
|
59
|
+
* 三种成因互斥——处理顺序保证每条被跳过的分支只会落进其中一种:
|
|
60
|
+
* - `invalid-id`:分支自己的 id 落不进本地 `rxdb_branch.id`(见 {@link isUsableBranchId})。
|
|
61
|
+
* - `unresolved-from-change-id`:分叉点变更还没拉到本地,`fromChangeId` 翻译不出本地 id。
|
|
62
|
+
* - `ancestor-skipped`:分支自身没有问题,但它的**直接父**本轮已经被跳过(不管父是因为
|
|
63
|
+
* 哪一种成因被跳的)。`ancestorId` 记的是那个直接父的 id,不是链路最顶端的根因——
|
|
64
|
+
* 要追根因,沿 `skipReasons[ancestorId]` 递归上溯,直到查到的原因不再是
|
|
65
|
+
* `ancestor-skipped` 为止。
|
|
66
|
+
*/
|
|
67
|
+
export type SyncBranchSkipReason =
|
|
68
|
+
| { readonly cause: 'invalid-id' }
|
|
69
|
+
| { readonly cause: 'unresolved-from-change-id' }
|
|
70
|
+
| { readonly cause: 'ancestor-skipped'; readonly ancestorId: string };
|
|
71
|
+
|
|
21
72
|
/** `pullBranches()` 交回来的形状里,本函数只依赖这三个字段。 */
|
|
22
73
|
interface RemoteBranchRow {
|
|
23
74
|
id: string;
|
|
24
75
|
parentId?: string | null;
|
|
25
76
|
}
|
|
26
77
|
|
|
78
|
+
/**
|
|
79
|
+
* 远端分支 id 能否落进本地 `rxdb_branch.id`。
|
|
80
|
+
*
|
|
81
|
+
* @remarks
|
|
82
|
+
* 判定口径**只有**核心 {@link assertUsableBranchId} 一份——这里把它的抛错翻成布尔,
|
|
83
|
+
* 不是第二份规则。各写一遍的话,核心哪天多禁一个字符,同步这条导入路径会静默放行。
|
|
84
|
+
*
|
|
85
|
+
* 只吞 {@link InvalidBranchIdError}:别的异常说明校验自身坏了,那不是「这条远端行不合格」,
|
|
86
|
+
* 不该被记成一次 `skipped`。
|
|
87
|
+
*/
|
|
88
|
+
function isUsableBranchId(branchId: string): boolean {
|
|
89
|
+
try {
|
|
90
|
+
assertUsableBranchId(branchId);
|
|
91
|
+
return true;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error instanceof InvalidBranchIdError) return false;
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
27
98
|
/**
|
|
28
99
|
* 把远端分支按「父在前」重排。
|
|
29
100
|
*
|
|
@@ -98,12 +169,12 @@ export async function syncBranches(sm: SyncManager): Promise<SyncBranchesResult>
|
|
|
98
169
|
const { adapter: remoteAdapter } = await sm.getRemoteRepositories();
|
|
99
170
|
|
|
100
171
|
if (!remoteAdapter.pullBranches) {
|
|
101
|
-
return { created: 0, updated: 0, total: 0, skipped: [] };
|
|
172
|
+
return { created: 0, updated: 0, total: 0, skipped: [], skipReasons: {} };
|
|
102
173
|
}
|
|
103
174
|
|
|
104
175
|
const remoteBranches = await remoteAdapter.pullBranches();
|
|
105
176
|
if (remoteBranches.length === 0) {
|
|
106
|
-
return { created: 0, updated: 0, total: 0, skipped: [] };
|
|
177
|
+
return { created: 0, updated: 0, total: 0, skipped: [], skipReasons: {} };
|
|
107
178
|
}
|
|
108
179
|
|
|
109
180
|
const { adapter } = await sm.getLocalRepositories();
|
|
@@ -120,8 +191,48 @@ export async function syncBranches(sm: SyncManager): Promise<SyncBranchesResult>
|
|
|
120
191
|
let created = 0;
|
|
121
192
|
let updated = 0;
|
|
122
193
|
const skipped: string[] = [];
|
|
194
|
+
// 本轮已跳过的分支 id → 跳过原因。级联判断(下面对 `remoteParentId` 的检查)只能靠它,
|
|
195
|
+
// 不能靠 `localMap`——`localMap` 是本轮开始前的快照,而分支已经按父优先的拓扑序处理
|
|
196
|
+
// (`sortBranchesParentFirst`),父的跳过决定必然先于子被记录进这个集合。
|
|
197
|
+
//
|
|
198
|
+
// 用 Map 记、返回时才经 `Object.fromEntries` 转成对象:键是远端给的分支 id,`__proto__`
|
|
199
|
+
// 过得了 id 校验。对象字面量逐键赋值会命中 `Object.prototype.__proto__` 的 setter,
|
|
200
|
+
// 改写返回对象的原型而不是添一个键;`Object.fromEntries` 建的是自有数据属性,不走 setter。
|
|
201
|
+
const skippedThisRound = new Map<string, SyncBranchSkipReason>();
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 记一次跳过:本函数三处跳过点(id 不可用 / 分叉点翻译不出 / 祖先已跳过)共用同一套
|
|
205
|
+
* 记账。不分渠道是有意为之——不管一条分支这一轮为什么建不出来,它的后代都同样不能
|
|
206
|
+
* 创建,记账收敛成一个函数,下面对 `skippedThisRound` 的级联检查才能对三种成因
|
|
207
|
+
* 一次性生效,不用每加一种跳过原因就多写一遍级联判断。
|
|
208
|
+
*/
|
|
209
|
+
const recordSkip = (branchId: string, reason: SyncBranchSkipReason): void => {
|
|
210
|
+
skipped.push(branchId);
|
|
211
|
+
skippedThisRound.set(branchId, reason);
|
|
212
|
+
};
|
|
123
213
|
|
|
124
214
|
for (const remote of sortBranchesParentFirst(remoteBranches, new Set(localMap.keys()))) {
|
|
215
|
+
// 子分支连带跳过必须最先判断:父本轮不会落库,子无论自身是否「合格」都不能创建——
|
|
216
|
+
// 创建了就是一条指向「本轮不存在的父」的悬空外键,真实落库会在 COMMIT 时被
|
|
217
|
+
// `PRAGMA defer_foreign_keys` 卡住,回滚整个事务(连同本轮所有本该成功的分支)。
|
|
218
|
+
// 父是因为 id 不可用被跳的、还是分叉点翻译不出被跳的、还是它自己的父被跳的,
|
|
219
|
+
// 这里都不关心——`skippedThisRound` 不区分渠道,只认「本轮是否已经决定跳过」。
|
|
220
|
+
const remoteParentId = remote.parentId ?? null;
|
|
221
|
+
if (remoteParentId !== null && skippedThisRound.has(remoteParentId)) {
|
|
222
|
+
recordSkip(remote.id, { cause: 'ancestor-skipped', ancestorId: remoteParentId });
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 远端分支行是外来数据,它的 id 没走过本地那条创建路径。不校验就直接落库,
|
|
227
|
+
// 一条叫 `*active*` 的远端分支会与 active 哨兵同形(`system/active-branch-guard.ts`)。
|
|
228
|
+
//
|
|
229
|
+
// 跳过而不是整批放弃:`skipped` 这条通道本来就是为「这一行本轮落不了库,别的行照常」
|
|
230
|
+
// 准备的。整批抛错会让一条坏的远端行把整个同步卡死,而本地这边一点办法都没有。
|
|
231
|
+
if (!isUsableBranchId(remote.id)) {
|
|
232
|
+
recordSkip(remote.id, { cause: 'invalid-id' });
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
125
236
|
const local = localMap.get(remote.id);
|
|
126
237
|
if (local) {
|
|
127
238
|
if (!local.remote) {
|
|
@@ -136,13 +247,16 @@ export async function syncBranches(sm: SyncManager): Promise<SyncBranchesResult>
|
|
|
136
247
|
const fromChangeId =
|
|
137
248
|
remoteFromChangeId === null ? null : await toLocalFromChangeId(changeRepository, remoteFromChangeId);
|
|
138
249
|
if (remoteFromChangeId !== null && fromChangeId === null) {
|
|
139
|
-
|
|
250
|
+
recordSkip(remote.id, { cause: 'unresolved-from-change-id' });
|
|
140
251
|
continue;
|
|
141
252
|
}
|
|
142
253
|
|
|
143
254
|
await branchRepository.create({
|
|
144
255
|
id: remote.id,
|
|
145
256
|
activated: false,
|
|
257
|
+
// 显式写 NULL 而不是留空:两列必须同进同出(`system/branch.ts`)。
|
|
258
|
+
// 留空在两个后端上**眼下**也落成 NULL,但那是默认值的巧合,不是这一行的意图。
|
|
259
|
+
activeKey: null,
|
|
146
260
|
local: false,
|
|
147
261
|
remote: true,
|
|
148
262
|
fromChangeId,
|
|
@@ -151,6 +265,12 @@ export async function syncBranches(sm: SyncManager): Promise<SyncBranchesResult>
|
|
|
151
265
|
created++;
|
|
152
266
|
}
|
|
153
267
|
|
|
154
|
-
return {
|
|
268
|
+
return {
|
|
269
|
+
created,
|
|
270
|
+
updated,
|
|
271
|
+
total: remoteBranches.length,
|
|
272
|
+
skipped,
|
|
273
|
+
skipReasons: Object.fromEntries(skippedThisRound)
|
|
274
|
+
};
|
|
155
275
|
});
|
|
156
276
|
}
|
package/src/sync-listeners.ts
CHANGED
|
@@ -28,8 +28,8 @@ type RemoteEntityEvent = EntityRemoteCreatedEvent | EntityRemoteUpdatedEvent | E
|
|
|
28
28
|
* 跑一步回推动作,把失败报给面板后吞掉
|
|
29
29
|
*
|
|
30
30
|
* @param syncState - 失败的去处
|
|
31
|
-
* @param task -
|
|
32
|
-
* @returns
|
|
31
|
+
* @param task - 这一步;返回值是这一步**自己**判定的成功与否(见 {@link flushRepository})
|
|
32
|
+
* @returns 这一步是否成功——异常通道与 `task` 自报的 `false` 都算失败
|
|
33
33
|
*
|
|
34
34
|
* @remarks
|
|
35
35
|
* 一轮里的各个仓库互不背书:一个仓库的出站队列重放不上,不代表别的仓库那条 REST 路
|
|
@@ -39,11 +39,15 @@ type RemoteEntityEvent = EntityRemoteCreatedEvent | EntityRemoteUpdatedEvent | E
|
|
|
39
39
|
* 多少次网络都不会再有回推。真正的重试节奏交给
|
|
40
40
|
* {@link ReachabilityMonitor.wakeup$} 的退避。吞掉不等于藏起来:错误进
|
|
41
41
|
* {@link SyncStateHub.reportError},用户在面板上看得见。
|
|
42
|
+
*
|
|
43
|
+
* **只兜异常通道**:`flushQueryCacheOutbox` 对结构化失败(REST 403、网络错误)从不
|
|
44
|
+
* `reject`,失败躺在返回值的 `failures` 数组里(RV-052)。这里只负责「`task` 真的
|
|
45
|
+
* 抛了」这一种失败;`task` 自己上报 `syncState.reportError` 并返回 `false` 的那种
|
|
46
|
+
* 失败,原样作为返回值传上去,不重复上报。
|
|
42
47
|
*/
|
|
43
|
-
async function runQuietly(syncState: SyncStateHub, task: () => Promise<
|
|
48
|
+
async function runQuietly(syncState: SyncStateHub, task: () => Promise<boolean>): Promise<boolean> {
|
|
44
49
|
try {
|
|
45
|
-
await task();
|
|
46
|
-
return true;
|
|
50
|
+
return await task();
|
|
47
51
|
} catch (error) {
|
|
48
52
|
syncState.reportError(error);
|
|
49
53
|
return false;
|
|
@@ -80,7 +84,7 @@ function queryCacheRepositories(sm: SyncManager): RepositoryIdentifier[] {
|
|
|
80
84
|
|
|
81
85
|
for (const EntityClass of consumerEntities(sm)) {
|
|
82
86
|
const metadata = getEntityMetadata(EntityClass);
|
|
83
|
-
const capability = getSyncCapability(getSyncType(metadata, sm.rxdb.
|
|
87
|
+
const capability = getSyncCapability(getSyncType(metadata, sm.rxdb.entitySync));
|
|
84
88
|
if (capability.offlineWrite && !capability.push) {
|
|
85
89
|
repositories.push({ namespace: metadata.namespace, entity: metadata.name });
|
|
86
90
|
}
|
|
@@ -90,19 +94,35 @@ function queryCacheRepositories(sm: SyncManager): RepositoryIdentifier[] {
|
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
/**
|
|
93
|
-
* 重放一个 QueryCache
|
|
97
|
+
* 重放一个 QueryCache 仓库,把判负的实体和结构化失败都报给面板
|
|
98
|
+
*
|
|
99
|
+
* @returns 本仓库这一轮是否**真的**成功——`failures` 非空也算失败,即便没有任何异常抛出
|
|
94
100
|
*
|
|
95
101
|
* @remarks
|
|
96
|
-
*
|
|
97
|
-
*
|
|
102
|
+
* `conflicts`(解析器判 `KEEP_REMOTE`)进面板是用户离线时写的东西被远端盖掉了,
|
|
103
|
+
* 是需要他知道的一类;逐条上报而不是只报最后一条,`lastConflict` 自然停在最新的那条。
|
|
104
|
+
*
|
|
105
|
+
* **`failures` 同样必须上报(RV-052)**:`flushQueryCacheOutbox` 对 REST 403、网络错误
|
|
106
|
+
* 等结构化失败从不 `reject`——Promise 照常 resolve,失败躺在返回值的 `failures` 数组里
|
|
107
|
+
* (见 `query-cache-outbox.ts` 的 `replayEntry`/`runOutboxFlush`)。调用方如果只认「有没有
|
|
108
|
+
* 抛异常」,这类失败会被判成「没出错」,面板上 `lastError` 被清空、`reportSuccess()`
|
|
109
|
+
* 被调用,而出站队列其实原地没动。这里逐条把失败的原因上报给 `syncState`——
|
|
110
|
+
* 和 `conflicts` 同样的理由,`lastError` 自然停在本轮最新的那条——并把「有没有
|
|
111
|
+
* 失败」原样交还给调用方,参与整轮 {@link flushRepositories} 的成功聚合。
|
|
98
112
|
*/
|
|
99
|
-
async function flushRepository(sm: SyncManager, namespace: string, entity: string): Promise<
|
|
113
|
+
async function flushRepository(sm: SyncManager, namespace: string, entity: string): Promise<boolean> {
|
|
100
114
|
const { syncState } = sm.rxdb;
|
|
101
115
|
const result = await flushQueryCacheOutbox(sm.rxdb, namespace, entity);
|
|
102
116
|
|
|
103
117
|
for (const entityId of result.conflicts) {
|
|
104
118
|
syncState.reportConflict({ namespace, entity, entityId, winner: 'remote' });
|
|
105
119
|
}
|
|
120
|
+
|
|
121
|
+
for (const failure of result.failures) {
|
|
122
|
+
syncState.reportError(failure.error);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return result.failures.length === 0;
|
|
106
126
|
}
|
|
107
127
|
|
|
108
128
|
/**
|
package/src/sync-repository.ts
CHANGED
|
@@ -7,7 +7,8 @@ import {
|
|
|
7
7
|
RepositorySyncErrorEvent,
|
|
8
8
|
RxDBError,
|
|
9
9
|
RxDBPartialSyncError,
|
|
10
|
-
type SyncProgress
|
|
10
|
+
type SyncProgress,
|
|
11
|
+
type SyncRejection
|
|
11
12
|
} from '@aiao/rxdb';
|
|
12
13
|
import type { SyncManager } from './SyncManager.js';
|
|
13
14
|
import {
|
|
@@ -89,6 +90,7 @@ function emptyPushResult(namespace: string, entity: string): PushRepositoryResul
|
|
|
89
90
|
return {
|
|
90
91
|
repository: { namespace, entity },
|
|
91
92
|
pushed: 0,
|
|
93
|
+
rejected: 0,
|
|
92
94
|
failed: 0,
|
|
93
95
|
compacted: 0,
|
|
94
96
|
originalCount: 0,
|
|
@@ -167,6 +169,7 @@ function rewrapPushFailure(
|
|
|
167
169
|
* @param namespace - 实体命名空间
|
|
168
170
|
* @param entity - 实体名称
|
|
169
171
|
* @param options - 同步选项
|
|
172
|
+
* @param rejectionSink - 推送被拒清单收集器,语义同 {@link pushRepository} 的同名参数
|
|
170
173
|
* @returns 同步结果
|
|
171
174
|
*
|
|
172
175
|
* @example
|
|
@@ -185,7 +188,8 @@ export async function syncRepository(
|
|
|
185
188
|
sm: SyncManager,
|
|
186
189
|
namespace: string,
|
|
187
190
|
entity: string,
|
|
188
|
-
options?: SyncRepositoryOptions
|
|
191
|
+
options?: SyncRepositoryOptions,
|
|
192
|
+
rejectionSink?: SyncRejection[]
|
|
189
193
|
): Promise<SyncRepositoryResult> {
|
|
190
194
|
const rxdb = sm.rxdb;
|
|
191
195
|
|
|
@@ -194,7 +198,7 @@ export async function syncRepository(
|
|
|
194
198
|
rxdb.dispatchEvent(new RepositorySyncBeginEvent('sync', namespace, entity, includeRelated));
|
|
195
199
|
|
|
196
200
|
try {
|
|
197
|
-
const result = await _syncRepositoryImpl(sm, namespace, entity, options);
|
|
201
|
+
const result = await _syncRepositoryImpl(sm, namespace, entity, options, rejectionSink);
|
|
198
202
|
|
|
199
203
|
// 触发完成事件
|
|
200
204
|
// 两个子结果在 SyncRepositoryResult 上都是必填:跳过的方向由 emptyPullResult /
|
|
@@ -226,7 +230,8 @@ async function _syncRepositoryImpl(
|
|
|
226
230
|
sm: SyncManager,
|
|
227
231
|
namespace: string,
|
|
228
232
|
entity: string,
|
|
229
|
-
options?: SyncRepositoryOptions
|
|
233
|
+
options?: SyncRepositoryOptions,
|
|
234
|
+
rejectionSink?: SyncRejection[]
|
|
230
235
|
): Promise<SyncRepositoryResult> {
|
|
231
236
|
// 验证仓库是否存在
|
|
232
237
|
const EntityType = sm.rxdb.config.entities.find(e => {
|
|
@@ -241,7 +246,7 @@ async function _syncRepositoryImpl(
|
|
|
241
246
|
const metadata = getEntityMetadata(EntityType);
|
|
242
247
|
|
|
243
248
|
// 检查同步类型(支持全局配置回退)
|
|
244
|
-
const syncType = getSyncType(metadata, sm.rxdb.
|
|
249
|
+
const syncType = getSyncType(metadata, sm.rxdb.entitySync);
|
|
245
250
|
if (syncType === 'none') {
|
|
246
251
|
throw new RxDBError(
|
|
247
252
|
`Cannot sync repository ${namespace}:${entity}: syncType is 'none'. ` +
|
|
@@ -283,7 +288,7 @@ async function _syncRepositoryImpl(
|
|
|
283
288
|
|
|
284
289
|
if (shouldPush) {
|
|
285
290
|
try {
|
|
286
|
-
pushResult = await pushRepository(sm, namespace, entity, options?.push);
|
|
291
|
+
pushResult = await pushRepository(sm, namespace, entity, options?.push, rejectionSink);
|
|
287
292
|
} catch (error) {
|
|
288
293
|
// pull 已落库的进度、push 已发到远端的进度,都不能因为这次抛错就消失
|
|
289
294
|
throw rewrapPushFailure(error, pullResult, namespace, entity);
|
package/dist/version.utils.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"version.utils.d.ts","sourceRoot":"","sources":["../src/version.utils.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,YAAY,EAAE,UAAU,EAAQ,MAAM,YAAY,CAAC;AAC5D,eAAO,MAAM,sBAAsB,GACjC,cAAc,YAAY,EAAE,EAC5B,MAAM,OAAO,CAAC,UAAU,CAAC,KACxB,IAAI,CAAC,UAAU,EAAE,IAAI,GAAG,SAAS,CAAC,EAUjC,CAAC"}
|
package/src/version.utils.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @packageDocumentation
|
|
3
|
-
* 版本工具模块
|
|
4
|
-
* 提供远程变更转换为本地变更等功能
|
|
5
|
-
*/
|
|
6
|
-
import { RemoteChange, RxDBChange, UUID } from '@aiao/rxdb';
|
|
7
|
-
export const remote_change_to_local = (
|
|
8
|
-
remoteChange: RemoteChange[],
|
|
9
|
-
base: Partial<RxDBChange>
|
|
10
|
-
): Omit<RxDBChange, 'id' | 'branch$'>[] =>
|
|
11
|
-
remoteChange.map(rc => {
|
|
12
|
-
const { id, ...rest } = rc;
|
|
13
|
-
delete rest.clientId;
|
|
14
|
-
return {
|
|
15
|
-
...rest,
|
|
16
|
-
...base,
|
|
17
|
-
entityId: (base.entityId ?? rest.entityId) as UUID,
|
|
18
|
-
remoteId: id
|
|
19
|
-
};
|
|
20
|
-
});
|