@aiao/rxdb-plugin-sync 0.0.0-stage → 0.0.26
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/LICENSE +21 -0
- package/README.md +81 -2
- package/dist/SyncManager.d.ts +412 -0
- package/dist/SyncManager.d.ts.map +1 -0
- package/dist/branch-change-id.d.ts +31 -0
- package/dist/branch-change-id.d.ts.map +1 -0
- package/dist/branch-utils.d.ts +8 -0
- package/dist/branch-utils.d.ts.map +1 -0
- package/dist/bulk-sync.d.ts +117 -0
- package/dist/bulk-sync.d.ts.map +1 -0
- package/dist/cascade-blocking.d.ts +38 -0
- package/dist/cascade-blocking.d.ts.map +1 -0
- package/dist/check-repository-updates.d.ts +47 -0
- package/dist/check-repository-updates.d.ts.map +1 -0
- package/dist/cleanup-expired.d.ts +56 -0
- package/dist/cleanup-expired.d.ts.map +1 -0
- package/dist/dependency-graph.d.ts +128 -0
- package/dist/dependency-graph.d.ts.map +1 -0
- package/dist/get-all-repository-sync-status.d.ts +58 -0
- package/dist/get-all-repository-sync-status.d.ts.map +1 -0
- package/dist/get-repository-sync-status.d.ts +68 -0
- package/dist/get-repository-sync-status.d.ts.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3273 -0
- package/dist/plugin.d.ts +70 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/pull-ancestor-changes.d.ts +41 -0
- package/dist/pull-ancestor-changes.d.ts.map +1 -0
- package/dist/pull-batch.d.ts +16 -0
- package/dist/pull-batch.d.ts.map +1 -0
- package/dist/pull-conflict-utils.d.ts +43 -0
- package/dist/pull-conflict-utils.d.ts.map +1 -0
- package/dist/pull-repository.d.ts +36 -0
- package/dist/pull-repository.d.ts.map +1 -0
- package/dist/pull.d.ts +16 -0
- package/dist/pull.d.ts.map +1 -0
- package/dist/push-branch.d.ts +22 -0
- package/dist/push-branch.d.ts.map +1 -0
- package/dist/push-repository.d.ts +63 -0
- package/dist/push-repository.d.ts.map +1 -0
- package/dist/push.d.ts +14 -0
- package/dist/push.d.ts.map +1 -0
- package/dist/query-cache-outbox.d.ts +120 -0
- package/dist/query-cache-outbox.d.ts.map +1 -0
- package/dist/sync-branches.d.ts +35 -0
- package/dist/sync-branches.d.ts.map +1 -0
- package/dist/sync-listeners.d.ts +20 -0
- package/dist/sync-listeners.d.ts.map +1 -0
- package/dist/sync-manager.utils.d.ts +36 -0
- package/dist/sync-manager.utils.d.ts.map +1 -0
- package/dist/sync-repository.d.ts +63 -0
- package/dist/sync-repository.d.ts.map +1 -0
- package/dist/topological-sort.d.ts +126 -0
- package/dist/topological-sort.d.ts.map +1 -0
- package/dist/version.utils.d.ts +3 -0
- package/dist/version.utils.d.ts.map +1 -0
- package/package.json +58 -3
- package/src/SyncManager.ts +662 -0
- package/src/branch-change-id.ts +70 -0
- package/src/branch-utils.ts +37 -0
- package/src/bulk-sync.ts +338 -0
- package/src/cascade-blocking.ts +61 -0
- package/src/check-repository-updates.ts +143 -0
- package/src/cleanup-expired.ts +271 -0
- package/src/dependency-graph.ts +324 -0
- package/src/get-all-repository-sync-status.ts +115 -0
- package/src/get-repository-sync-status.ts +237 -0
- package/src/index.ts +53 -0
- package/src/plugin.ts +124 -0
- package/src/pull-ancestor-changes.ts +61 -0
- package/src/pull-batch.ts +410 -0
- package/src/pull-conflict-utils.ts +280 -0
- package/src/pull-repository.ts +701 -0
- package/src/pull.ts +204 -0
- package/src/push-branch.ts +61 -0
- package/src/push-repository.ts +1002 -0
- package/src/push.ts +104 -0
- package/src/query-cache-outbox.ts +866 -0
- package/src/sync-branches.ts +156 -0
- package/src/sync-listeners.ts +289 -0
- package/src/sync-manager.utils.ts +48 -0
- package/src/sync-repository.ts +302 -0
- package/src/topological-sort.ts +246 -0
- package/src/version.utils.ts +20 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { type IRepository, RxDBBranch, RxDBChange, RxDBError } from '@aiao/rxdb';
|
|
2
|
+
import { toLocalFromChangeId } from './branch-change-id.js';
|
|
3
|
+
import type { SyncManager } from './SyncManager.js';
|
|
4
|
+
|
|
5
|
+
export interface SyncBranchesResult {
|
|
6
|
+
created: number;
|
|
7
|
+
updated: number;
|
|
8
|
+
total: number;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 因分叉点变更尚未拉到本地而**本轮未创建**的分支 id。
|
|
12
|
+
*
|
|
13
|
+
* 远端分支行上的 `fromChangeId` 是远端 change id,落到本地必须翻译成本地 id
|
|
14
|
+
* (见 {@link toLocalFromChangeId})。翻译不出来时既不能写远端 id(本地会当自己的 id 消费,
|
|
15
|
+
* 分叉点变成一个无关变更),也不能写 `null`(`find-switch-branch-step` 会当「分叉于根」,
|
|
16
|
+
* 切换分支时从第一条变更起算)。唯一无损的选择是本轮跳过,等分叉点变更拉到本地后再建。
|
|
17
|
+
*/
|
|
18
|
+
skipped: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `pullBranches()` 交回来的形状里,本函数只依赖这三个字段。 */
|
|
22
|
+
interface RemoteBranchRow {
|
|
23
|
+
id: string;
|
|
24
|
+
parentId?: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 把远端分支按「父在前」重排。
|
|
29
|
+
*
|
|
30
|
+
* `rxdb_branch.parentId` 是指向自己的外键,逐条落库时父必须先在库里。而 `pullBranches()`
|
|
31
|
+
* 返回的数组**没有顺序保证** —— 远端按 `updatedAt`、按主键、按它高兴的任何顺序返回都合法,
|
|
32
|
+
* 分页更是会把父子切到不同批次。所以顺序必须由本地重建,不能指望远端。
|
|
33
|
+
*
|
|
34
|
+
* 判定一个分支「可落库」的依据是它的父是否已经就绪:要么父本来就在本地,要么父在本批里
|
|
35
|
+
* 且已排到它前面。反复扫描直到某一轮一条都排不出去 —— 剩下的就是父确实找不到
|
|
36
|
+
* (远端删了父 / 父被分页切走)或互相成环,两者都无法安全落库。
|
|
37
|
+
*
|
|
38
|
+
* @param remoteBranches - 远端原样交回的分支数组,顺序不可信
|
|
39
|
+
* @param localIds - 已在本地的分支 id,它们可以充当父
|
|
40
|
+
* @returns 父一定排在子前面的新数组
|
|
41
|
+
* @throws RxDBError 存在父分支缺失或互相成环的记录时。整批放弃优于留下孤儿:
|
|
42
|
+
* 孤儿会被切分支时的「父节点缺失即当作到根」静默吞掉,损坏无声。
|
|
43
|
+
*/
|
|
44
|
+
function sortBranchesParentFirst<T extends RemoteBranchRow>(remoteBranches: T[], localIds: Set<string>): T[] {
|
|
45
|
+
const resolved = new Set(localIds);
|
|
46
|
+
// 本批自己带来的 id:用来把「父确实缺失」和「父只是还排在后面」区分开,好让报错说人话。
|
|
47
|
+
const incoming = new Set(remoteBranches.map(branch => branch.id));
|
|
48
|
+
const sorted: T[] = [];
|
|
49
|
+
let pending = remoteBranches;
|
|
50
|
+
|
|
51
|
+
while (pending.length > 0) {
|
|
52
|
+
const ready = pending.filter(branch => {
|
|
53
|
+
const parentId = branch.parentId ?? null;
|
|
54
|
+
return parentId === null || resolved.has(parentId);
|
|
55
|
+
});
|
|
56
|
+
if (ready.length === 0) break;
|
|
57
|
+
for (const branch of ready) {
|
|
58
|
+
sorted.push(branch);
|
|
59
|
+
resolved.add(branch.id);
|
|
60
|
+
}
|
|
61
|
+
pending = pending.filter(branch => !resolved.has(branch.id));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (pending.length > 0) {
|
|
65
|
+
const detail = pending
|
|
66
|
+
.map(branch => {
|
|
67
|
+
const parentId = branch.parentId ?? null;
|
|
68
|
+
// 父在本批里却始终排不出去 = 成环;否则就是父压根不存在。
|
|
69
|
+
const reason = parentId !== null && incoming.has(parentId) ? 'cycle' : 'missing parent';
|
|
70
|
+
return `'${branch.id}' -> '${String(parentId)}' (${reason})`;
|
|
71
|
+
})
|
|
72
|
+
.join(', ');
|
|
73
|
+
throw new RxDBError(`syncBranches: 远端分支拓扑无法解析,整批放弃:${detail}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return sorted;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 从远程拉取所有分支信息并同步到本地
|
|
81
|
+
*
|
|
82
|
+
* 规则:
|
|
83
|
+
* 1. 远程新分支 → 在本地创建(local: false, remote: true)
|
|
84
|
+
* 2. 本地已有的远程分支 → 更新 remote 标记为 true
|
|
85
|
+
* 3. 纯本地分支(remote=false)→ 不受影响
|
|
86
|
+
*
|
|
87
|
+
* @remarks
|
|
88
|
+
* 读本地、排序、落库全部在**同一个事务窗口**内完成:
|
|
89
|
+
*
|
|
90
|
+
* - 无事务时中途失败会留下半批已提交的分支,下次重试撞主键,同步就此永久卡死;
|
|
91
|
+
* - 读若在事务外做,快照与写之间存在窗口,并发建分支会让「本地已有」的判断过时,
|
|
92
|
+
* 重复 create 同样撞主键。
|
|
93
|
+
*
|
|
94
|
+
* 事务体内只能用 `executor` 作用域的仓库。绑在适配器上的那一份走并发度 1 的写队列,
|
|
95
|
+
* 在事务体内调用会排到自己这个事务后面 —— 直接死锁。
|
|
96
|
+
*/
|
|
97
|
+
export async function syncBranches(sm: SyncManager): Promise<SyncBranchesResult> {
|
|
98
|
+
const { adapter: remoteAdapter } = await sm.getRemoteRepositories();
|
|
99
|
+
|
|
100
|
+
if (!remoteAdapter.pullBranches) {
|
|
101
|
+
return { created: 0, updated: 0, total: 0, skipped: [] };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const remoteBranches = await remoteAdapter.pullBranches();
|
|
105
|
+
if (remoteBranches.length === 0) {
|
|
106
|
+
return { created: 0, updated: 0, total: 0, skipped: [] };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const { adapter } = await sm.getLocalRepositories();
|
|
110
|
+
|
|
111
|
+
return adapter.transaction(async executor => {
|
|
112
|
+
const branchRepository = executor.getRepository(RxDBBranch) as unknown as IRepository<typeof RxDBBranch>;
|
|
113
|
+
const changeRepository = executor.getRepository(RxDBChange) as unknown as IRepository<typeof RxDBChange>;
|
|
114
|
+
|
|
115
|
+
const localBranches = await branchRepository.find({
|
|
116
|
+
where: { combinator: 'and', rules: [] }
|
|
117
|
+
});
|
|
118
|
+
const localMap = new Map(localBranches.map(b => [b.id, b]));
|
|
119
|
+
|
|
120
|
+
let created = 0;
|
|
121
|
+
let updated = 0;
|
|
122
|
+
const skipped: string[] = [];
|
|
123
|
+
|
|
124
|
+
for (const remote of sortBranchesParentFirst(remoteBranches, new Set(localMap.keys()))) {
|
|
125
|
+
const local = localMap.get(remote.id);
|
|
126
|
+
if (local) {
|
|
127
|
+
if (!local.remote) {
|
|
128
|
+
await branchRepository.update(local, { remote: true, updatedAt: new Date() });
|
|
129
|
+
updated++;
|
|
130
|
+
}
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 远端 change id 必须翻译成本地 id 才能写进本地分支行
|
|
135
|
+
const remoteFromChangeId = remote.fromChangeId ?? null;
|
|
136
|
+
const fromChangeId =
|
|
137
|
+
remoteFromChangeId === null ? null : await toLocalFromChangeId(changeRepository, remoteFromChangeId);
|
|
138
|
+
if (remoteFromChangeId !== null && fromChangeId === null) {
|
|
139
|
+
skipped.push(remote.id);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
await branchRepository.create({
|
|
144
|
+
id: remote.id,
|
|
145
|
+
activated: false,
|
|
146
|
+
local: false,
|
|
147
|
+
remote: true,
|
|
148
|
+
fromChangeId,
|
|
149
|
+
parentId: remote.parentId ?? null
|
|
150
|
+
} as InstanceType<typeof RxDBBranch>);
|
|
151
|
+
created++;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { created, updated, total: remoteBranches.length, skipped };
|
|
155
|
+
});
|
|
156
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ENTITY_REMOTE_CREATE_EVENT,
|
|
3
|
+
ENTITY_REMOTE_REMOVE_EVENT,
|
|
4
|
+
ENTITY_REMOTE_UPDATE_EVENT,
|
|
5
|
+
EntityRemoteCreatedEvent,
|
|
6
|
+
EntityRemoteRemovedEvent,
|
|
7
|
+
EntityRemoteUpdatedEvent,
|
|
8
|
+
type EntityType,
|
|
9
|
+
getEntityMetadata,
|
|
10
|
+
getSyncCapability,
|
|
11
|
+
getSyncType,
|
|
12
|
+
isSystemEntity,
|
|
13
|
+
type RepositoryIdentifier,
|
|
14
|
+
type SyncStateHub
|
|
15
|
+
} from '@aiao/rxdb';
|
|
16
|
+
import { isIgnorableDetachedVersionEventError } from '@aiao/rxdb-plugin-history';
|
|
17
|
+
import { combineLatest, merge, type Observable, Subscription } from 'rxjs';
|
|
18
|
+
import { distinctUntilChanged, exhaustMap, filter, map, withLatestFrom } from 'rxjs/operators';
|
|
19
|
+
import { countQueryCacheOutbox, flushQueryCacheOutbox } from './query-cache-outbox.js';
|
|
20
|
+
import type { SyncManager } from './SyncManager.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 远程实体事件类型集合(CREATE/UPDATE/REMOVE)
|
|
24
|
+
*/
|
|
25
|
+
type RemoteEntityEvent = EntityRemoteCreatedEvent | EntityRemoteUpdatedEvent | EntityRemoteRemovedEvent;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 跑一步回推动作,把失败报给面板后吞掉
|
|
29
|
+
*
|
|
30
|
+
* @param syncState - 失败的去处
|
|
31
|
+
* @param task - 这一步
|
|
32
|
+
* @returns 这一步是否成功
|
|
33
|
+
*
|
|
34
|
+
* @remarks
|
|
35
|
+
* 一轮里的各个仓库互不背书:一个仓库的出站队列重放不上,不代表别的仓库那条 REST 路
|
|
36
|
+
* 也走不通。任何一步的失败都只影响它自己,剩下的照跑。
|
|
37
|
+
*
|
|
38
|
+
* 吞错也是为了不掐断订阅 —— rxjs 里一个逃逸到流上的异常会终结整条链,此后再恢复
|
|
39
|
+
* 多少次网络都不会再有回推。真正的重试节奏交给
|
|
40
|
+
* {@link ReachabilityMonitor.wakeup$} 的退避。吞掉不等于藏起来:错误进
|
|
41
|
+
* {@link SyncStateHub.reportError},用户在面板上看得见。
|
|
42
|
+
*/
|
|
43
|
+
async function runQuietly(syncState: SyncStateHub, task: () => Promise<unknown>): Promise<boolean> {
|
|
44
|
+
try {
|
|
45
|
+
await task();
|
|
46
|
+
return true;
|
|
47
|
+
} catch (error) {
|
|
48
|
+
syncState.reportError(error);
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 接入方声明的实体,即摘掉系统表之后的 `config.entities`
|
|
55
|
+
*
|
|
56
|
+
* @remarks
|
|
57
|
+
* 分派问的是「**用户的**数据该往哪走」,而 `config.entities` 里还混着
|
|
58
|
+
* {@link SchemaManager.init} 补进来的四张系统表。它们不带自己的 `sync`,于是跟随库级配置 ——
|
|
59
|
+
* 库级两端俱全时 {@link getSyncType} 会把它们判成 `full`,四张本地簿记表就这么被卷进
|
|
60
|
+
* 用户数据的分派里。系统表自己的同步由 {@link SyncManager} 直接安排,从不经过这里。
|
|
61
|
+
*/
|
|
62
|
+
function consumerEntities(sm: SyncManager): EntityType[] {
|
|
63
|
+
return sm.rxdb.config.entities.filter(EntityClass => !isSystemEntity(EntityClass));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 枚举需要走 REST 重放的仓库
|
|
68
|
+
*
|
|
69
|
+
* @remarks
|
|
70
|
+
* 判据是 `offlineWrite && !push`,也就是「能在离线时接受本地写,但没有 changelog 端点」——
|
|
71
|
+
* 现阶段只有 `querycache`。带 `push` 能力的仓库不在这里:它们由用户显式调
|
|
72
|
+
* {@link SyncManager.push},那条路自己按能力矩阵筛。
|
|
73
|
+
*
|
|
74
|
+
* 不在这里查 `RxDBSync.enabled`:那要为每个仓库读一次库,而
|
|
75
|
+
* {@link flushQueryCacheOutbox} 入口本来就会判并返回 `skipped`。判两遍等于把同一个口径
|
|
76
|
+
* 抄成两份。
|
|
77
|
+
*/
|
|
78
|
+
function queryCacheRepositories(sm: SyncManager): RepositoryIdentifier[] {
|
|
79
|
+
const repositories: RepositoryIdentifier[] = [];
|
|
80
|
+
|
|
81
|
+
for (const EntityClass of consumerEntities(sm)) {
|
|
82
|
+
const metadata = getEntityMetadata(EntityClass);
|
|
83
|
+
const capability = getSyncCapability(getSyncType(metadata, sm.rxdb.config.sync));
|
|
84
|
+
if (capability.offlineWrite && !capability.push) {
|
|
85
|
+
repositories.push({ namespace: metadata.namespace, entity: metadata.name });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return repositories;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 重放一个 QueryCache 仓库,把判负的实体报给面板
|
|
94
|
+
*
|
|
95
|
+
* @remarks
|
|
96
|
+
* 只有 `conflicts`(解析器判 `KEEP_REMOTE`)进面板:那是用户离线时写的东西被远端盖掉了,
|
|
97
|
+
* 是唯一需要他知道的一类。逐条上报而不是只报最后一条,`lastConflict` 自然停在最新的那条。
|
|
98
|
+
*/
|
|
99
|
+
async function flushRepository(sm: SyncManager, namespace: string, entity: string): Promise<void> {
|
|
100
|
+
const { syncState } = sm.rxdb;
|
|
101
|
+
const result = await flushQueryCacheOutbox(sm.rxdb, namespace, entity);
|
|
102
|
+
|
|
103
|
+
for (const entityId of result.conflicts) {
|
|
104
|
+
syncState.reportConflict({ namespace, entity, entityId, winner: 'remote' });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 重放本轮所有 QueryCache 仓库
|
|
110
|
+
*
|
|
111
|
+
* @param sm - 当前 SyncManager
|
|
112
|
+
* @param repositories - 本轮的仓库名单,由 {@link queryCacheRepositories} 枚举
|
|
113
|
+
* @returns 是否每个仓库都成功
|
|
114
|
+
*/
|
|
115
|
+
async function flushRepositories(sm: SyncManager, repositories: RepositoryIdentifier[]): Promise<boolean> {
|
|
116
|
+
const { syncState } = sm.rxdb;
|
|
117
|
+
let allSucceeded = true;
|
|
118
|
+
|
|
119
|
+
for (const { namespace, entity } of repositories) {
|
|
120
|
+
const succeeded = await runQuietly(syncState, () => flushRepository(sm, namespace, entity));
|
|
121
|
+
allSucceeded = succeeded && allSucceeded;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return allSucceeded;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 重新数一遍 QueryCache 的出站积压,刷新面板
|
|
129
|
+
*
|
|
130
|
+
* @remarks
|
|
131
|
+
* 失败**不**走 {@link runQuietly}:这是一次读,读不到只意味着面板停在上一个数字,
|
|
132
|
+
* 而 `lastError` 说的是「你的数据没推上去」。把一次 COUNT 失败写进去会让用户
|
|
133
|
+
* 以为刚推成功的一轮出了问题。
|
|
134
|
+
*
|
|
135
|
+
* 每轮都数而不是只在推过之后数:水位线推进不写 `rxdb_change`,实时查询看不见这个数
|
|
136
|
+
* (见 {@link SyncStateHub.reportOutboxCount}),一轮一次重算是它唯一的纠偏时机。
|
|
137
|
+
*/
|
|
138
|
+
async function refreshOutboxCount(sm: SyncManager): Promise<void> {
|
|
139
|
+
try {
|
|
140
|
+
sm.rxdb.syncState.reportOutboxCount(await countQueryCacheOutbox(sm.rxdb));
|
|
141
|
+
} catch {
|
|
142
|
+
// 面板保留上一个数字
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 恢复联网后的一轮回推:逐个重放 QueryCache 仓库的出站队列
|
|
148
|
+
*
|
|
149
|
+
* @remarks
|
|
150
|
+
* 这一轮**只**管 QueryCache 那条 REST 路([US-020 D5-R](../../../../requirements/stories/core/US-020-querycache-repository.md))。
|
|
151
|
+
* changelog 那半边 —— {@link SyncManager.syncBranches} 与 {@link SyncManager.push} ——
|
|
152
|
+
* 不在这里自动跑:`push` 问的是「把我这条分支上攒的提交送到远端去」,和 git 的 `push`
|
|
153
|
+
* 一样是用户的决定。自动替他按下去,`pushableCount` 与界面上的 Push 按钮就成了摆设,
|
|
154
|
+
* 「写在本地、还没推」这个状态从此不存在。QueryCache 没有这层分支语义,它的出站队列
|
|
155
|
+
* 就是「这次写本该直接进远端,只是当时网断了」,联网补上才是它的正确行为。
|
|
156
|
+
*
|
|
157
|
+
* 各仓库串行而非并行 —— 它们打的是同一个远端,恢复瞬间并发只会把刚恢复的连接再压垮一次。
|
|
158
|
+
*
|
|
159
|
+
* 一个 QueryCache 仓库都没有时整轮不进:既省掉必然为空的一次 COUNT,也不让面板的
|
|
160
|
+
* `syncing` 为了一轮什么都不做的回推闪一下。
|
|
161
|
+
*
|
|
162
|
+
* `endRound` 放 `finally`:每一步都被 {@link runQuietly} 兜住了,但重算积压那步之外
|
|
163
|
+
* 若将来再加一步没兜住的,`syncing` 会永久卡在真上,面板从此显示「正在同步」。
|
|
164
|
+
*/
|
|
165
|
+
async function resumeSync(sm: SyncManager): Promise<void> {
|
|
166
|
+
const repositories = queryCacheRepositories(sm);
|
|
167
|
+
if (repositories.length === 0) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const { syncState } = sm.rxdb;
|
|
172
|
+
syncState.beginRound();
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
// 清账放在重算积压之前:先宣布本轮没出错,再把「还剩多少」更新上去
|
|
176
|
+
if (await flushRepositories(sm, repositories)) {
|
|
177
|
+
syncState.reportSuccess();
|
|
178
|
+
}
|
|
179
|
+
await refreshOutboxCount(sm);
|
|
180
|
+
} finally {
|
|
181
|
+
syncState.endRound();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 构造回推触发流
|
|
187
|
+
*
|
|
188
|
+
* @remarks
|
|
189
|
+
* 两个触发源,语义不同:
|
|
190
|
+
*
|
|
191
|
+
* - **可回推了**:`connected$`(适配器生命周期)与 `online$`(网络可达)**同时**为真。
|
|
192
|
+
* 两者缺一不可,也不能互相替代 —— HTTP 适配器的 `connect()` 不发任何网络请求,
|
|
193
|
+
* 断网时它照样报 connected;反过来网通了但适配器没连上,回推没有可用的本地仓储。
|
|
194
|
+
* - **再试一次**:离线期间的退避节拍。它只按 `connected$` 过滤,**不**按 `online$` ——
|
|
195
|
+
* 节拍存在的意义正是在判定为离线时驱动重试,用离线状态把它挡掉会让重试链彻底断掉。
|
|
196
|
+
*/
|
|
197
|
+
function createResumeTrigger(sm: SyncManager): Observable<unknown> {
|
|
198
|
+
const connected$ = sm.rxdb.connected$;
|
|
199
|
+
const ready$ = combineLatest([connected$, sm.rxdb.reachability.online$]).pipe(
|
|
200
|
+
map(([connected, online]) => connected && online),
|
|
201
|
+
distinctUntilChanged()
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
return merge(
|
|
205
|
+
ready$.pipe(filter(ready => ready)),
|
|
206
|
+
sm.rxdb.reachability.wakeup$.pipe(
|
|
207
|
+
withLatestFrom(connected$),
|
|
208
|
+
filter(([, connected]) => connected)
|
|
209
|
+
)
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 设置 SyncManager 的同步监听器
|
|
215
|
+
*
|
|
216
|
+
* 包含两条 reactive 链路:
|
|
217
|
+
* 1. 「已连接且网络可达」→ 自动重放 QueryCache 出站队列,离线期间由退避节拍驱动重试,
|
|
218
|
+
* 每个仓库失败仅吞错。changelog 的 `push` 不在其中,见 {@link resumeSync}
|
|
219
|
+
* 2. Remote 实体事件 → 累计 `pullableCount`(仅统计当前激活分支)
|
|
220
|
+
*
|
|
221
|
+
* 调用方负责管理返回的 subscriptions / removers 生命周期(destroy 时清理)。
|
|
222
|
+
*
|
|
223
|
+
* @param sm - 当前同步管理器
|
|
224
|
+
* @returns 已注册的 rxjs subscriptions 和事件解绑闭包
|
|
225
|
+
*/
|
|
226
|
+
export function setupSyncListeners(sm: SyncManager): {
|
|
227
|
+
subscriptions: Subscription[];
|
|
228
|
+
removers: Array<() => void>;
|
|
229
|
+
} {
|
|
230
|
+
const subscriptions: Subscription[] = [];
|
|
231
|
+
const removers: Array<() => void> = [];
|
|
232
|
+
|
|
233
|
+
// 1. 有远程适配器时,恢复联网后自动回推
|
|
234
|
+
const remoteAdapterName = sm.rxdb.config.sync?.remote?.adapter;
|
|
235
|
+
if (remoteAdapterName) {
|
|
236
|
+
// `exhaustMap` 而不是 `mergeMap`:恢复瞬间往往连着来好几个信号(`online` 事件、
|
|
237
|
+
// 退避节拍、用户手动重试),并发起两轮回推会把同一批变更重放两次 ——
|
|
238
|
+
// 第二轮读到的还是第一轮尚未推进的水位线。
|
|
239
|
+
const sub = createResumeTrigger(sm)
|
|
240
|
+
.pipe(
|
|
241
|
+
exhaustMap(() =>
|
|
242
|
+
// 一轮回推失败只终结这一轮。`resumeSync` 里 `runQuietly` 兜住的只有逐仓库 flush,
|
|
243
|
+
// 枚举仓库与面板上报都在兜底之外;它们抛错顺着 `exhaustMap` 冒到订阅上就会终结整条
|
|
244
|
+
// 触发流 —— 此后 online 事件、退避节拍、重新连接一律无效,自动回推永久停摆,
|
|
245
|
+
// 而离线恢复本就是最容易出错的场景。吞掉不等于藏起来:错误进面板,用户看得见。
|
|
246
|
+
resumeSync(sm).catch((error: unknown) => {
|
|
247
|
+
sm.rxdb.syncState.reportError(error);
|
|
248
|
+
})
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
.subscribe();
|
|
252
|
+
subscriptions.push(sub);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 2. 只累计当前激活分支的远程变更数量
|
|
256
|
+
const filterByBranch = async (entities: { branchId?: string }[]): Promise<number> => {
|
|
257
|
+
const branch = await sm.getCurrentBranch();
|
|
258
|
+
return entities.filter(e => !e.branchId || e.branchId === branch.id).length;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const makeRemoteHandler = (label: string) => {
|
|
262
|
+
return (event: RemoteEntityEvent) => {
|
|
263
|
+
void (async () => {
|
|
264
|
+
try {
|
|
265
|
+
const count = await filterByBranch(event.entities);
|
|
266
|
+
if (count > 0) sm.history.incrementPullableCount(count);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (!isIgnorableDetachedVersionEventError(error)) {
|
|
269
|
+
console.error(`[SyncManager] ${label} failed:`, error);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
})();
|
|
273
|
+
};
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const remoteHandlers = [
|
|
277
|
+
{ type: ENTITY_REMOTE_CREATE_EVENT, handler: makeRemoteHandler('onRemoteCreate') },
|
|
278
|
+
{ type: ENTITY_REMOTE_UPDATE_EVENT, handler: makeRemoteHandler('onRemoteUpdate') },
|
|
279
|
+
{ type: ENTITY_REMOTE_REMOVE_EVENT, handler: makeRemoteHandler('onRemoteRemove') }
|
|
280
|
+
] as const;
|
|
281
|
+
|
|
282
|
+
for (const { type, handler } of remoteHandlers) {
|
|
283
|
+
sm.rxdb.addEventListener(type, handler);
|
|
284
|
+
// 在注册点闭包捕获已静态对齐的 type/handler,解绑时无需类型断言
|
|
285
|
+
removers.push(() => sm.rxdb.removeEventListener(type, handler));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { subscriptions, removers };
|
|
289
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview 同步管理器的纯函数工具集
|
|
3
|
+
*
|
|
4
|
+
* 从 {@link SyncManager} 抽出的无状态辅助函数。US-025 阶段 D 之前它们住在
|
|
5
|
+
* `@aiao/rxdb-plugin-history` 的 `version-manager.utils.ts`,随 `bulkSync` 一起搬过来 ——
|
|
6
|
+
* 两个函数的形参都是 {@link SyncRepositoryResult},这是同步侧的类型。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { RxDBPartialSyncError } from '@aiao/rxdb';
|
|
10
|
+
import type { SyncRepositoryResult } from './sync-repository.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 「这次同步已经改写了本地数据」的共同判据形状。
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* 四个粒度的结果都满足它,所以判据只需要写一份:整库 / 单仓库的 `PullResult` 只有
|
|
17
|
+
* `historyInvalidated`,`SyncRepositoryResult` 还多一段 `pushResult`。两项都取可选,
|
|
18
|
+
* 缺哪一段就当那一段没有进度。
|
|
19
|
+
*/
|
|
20
|
+
interface SyncedProgress {
|
|
21
|
+
readonly historyInvalidated?: boolean;
|
|
22
|
+
readonly pushResult?: { readonly pushed: number };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 一次仓库同步是否改写了本地实体数据(undo 历史边界因此失效) */
|
|
26
|
+
export const hasSyncedData = (result: SyncedProgress | undefined): boolean =>
|
|
27
|
+
result?.historyInvalidated === true || (result?.pushResult?.pushed ?? 0) > 0;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 中断的同步里是否带着「已经落库 / 已经上行」的进度。
|
|
31
|
+
*
|
|
32
|
+
* @remarks
|
|
33
|
+
* 部分成功以 {@link RxDBPartialSyncError} 的形式抛出,进度只挂在它的 `result` 上。
|
|
34
|
+
* `pull` / `pullRepository` / `sync` / `syncRepository` 四个入口共用这一条判据 ——
|
|
35
|
+
* 各写各的 `instanceof` + 强转,迟早会有一个入口漏掉 `pushResult` 那一半。
|
|
36
|
+
*/
|
|
37
|
+
export const partialSyncInvalidatesHistory = (error: unknown): boolean =>
|
|
38
|
+
error instanceof RxDBPartialSyncError && hasSyncedData(error.result as SyncedProgress);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 取出失败项里携带的部分进度。
|
|
42
|
+
*
|
|
43
|
+
* @remarks
|
|
44
|
+
* 仓库在失败前可能已经提交了部分结果,它只存在于 {@link RxDBPartialSyncError.result}。
|
|
45
|
+
* 忽略它会让「远端数据已落库但 undo 边界没推进」的状态逃过检查。
|
|
46
|
+
*/
|
|
47
|
+
export const partialResultOf = (error: Error | undefined): SyncRepositoryResult | undefined =>
|
|
48
|
+
error instanceof RxDBPartialSyncError ? (error.result as SyncRepositoryResult) : undefined;
|