@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,866 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @packageDocumentation
|
|
3
|
+
* QueryCache 的出站重放 —— 把离线期间攒下的本地改动按 REST 动词推回远端。
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* 这是 local-first 闭环的后半段。前半段是 `@aiao/rxdb-plugin-querycache` 的
|
|
7
|
+
* `query-cache-primary.ts`:远端不可达时,写经**实体仓储**落本地,触发器随之在
|
|
8
|
+
* `rxdb_change` 里排出一条正常的出站行。本模块负责把那批行送回去。
|
|
9
|
+
*
|
|
10
|
+
* **为什么不复用 `pushRepository`**:那条路的提交出口是 `remoteAdapter.mergeChanges()`,
|
|
11
|
+
* 而 QueryCache 的远端契约是纯 REST(`create` / `update` / `delete` / `findByIds` /
|
|
12
|
+
* `fetchMetadata`),根本没有 changelog 端点 —— `RxDBAdapterHttp.mergeChanges()` 直接抛
|
|
13
|
+
* `HttpChangelogUnsupportedError`。能力矩阵里 `querycache` 的 `push` 保持 `false`、
|
|
14
|
+
* `offlineWrite` 取 `true`,正是为了让调度侧能把这两件事分开派发
|
|
15
|
+
* (见 `sync-type-utils.ts` 的 `SYNC_CAPABILITY_MATRIX`)。
|
|
16
|
+
*
|
|
17
|
+
* 共用的是**队列与水位线**:出站行就是触发器已经在写的 `rxdb_change`,水位线就是
|
|
18
|
+
* `RxDBSync.lastPushedChangeId`(`syncType` 的联合类型里本来就有 `'querycache'`)。
|
|
19
|
+
* 不同的只有提交动作。
|
|
20
|
+
*
|
|
21
|
+
* **为什么在 sync 插件里**(US-025 阶段 D):正因为共用那条水位线,它是 changelog 的
|
|
22
|
+
* 第二个消费者,与 push / pull 是同一类东西。按本次拆分的规则「搬走的是消费者,不是
|
|
23
|
+
* 原语」,`rxdb_change` / `RxDBSync` 两张表与它们的解析函数留在核心,读它们的这三个
|
|
24
|
+
* 入口随本包走。核心侧只剩 `QueryCacheOutboxProvider` 那一个接口 —— `Repository` 建
|
|
25
|
+
* QueryCache 会话时要问一次「谁还占着 id」,由 {@link pendingQueryCacheWriteIds} 应答。
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
buildOfflineWriteRepositoryRules,
|
|
30
|
+
compactChanges,
|
|
31
|
+
type ConflictResolver,
|
|
32
|
+
type EntityType,
|
|
33
|
+
findCurrentSyncRecord,
|
|
34
|
+
getCurrentBranch,
|
|
35
|
+
getEntityMetadata,
|
|
36
|
+
getLocalSystemRepositories,
|
|
37
|
+
getOrCreateSyncRecord,
|
|
38
|
+
getRxDBChangeKey,
|
|
39
|
+
getSyncType,
|
|
40
|
+
type IRepository,
|
|
41
|
+
isNetworkError,
|
|
42
|
+
isRepositorySyncEnabled,
|
|
43
|
+
LWWConflictResolver,
|
|
44
|
+
type QueryCacheRemoteAdapter,
|
|
45
|
+
type RxDB,
|
|
46
|
+
RxDBChange,
|
|
47
|
+
type RxDBChangeRuleGroup,
|
|
48
|
+
RxDBError,
|
|
49
|
+
RxDBSync,
|
|
50
|
+
type SwitchVersionActions,
|
|
51
|
+
type SwitchVersionChange,
|
|
52
|
+
SYNC_DISABLED_REASON
|
|
53
|
+
} from '@aiao/rxdb';
|
|
54
|
+
import { firstValueFrom, type Observable } from 'rxjs';
|
|
55
|
+
|
|
56
|
+
/** 压缩后每条净操作的类型 */
|
|
57
|
+
type OutboxKind = 'INSERT' | 'UPDATE' | 'DELETE';
|
|
58
|
+
|
|
59
|
+
/** 远端写动词,与 {@link QueryCacheRemoteAdapter} 上的可选方法名一一对应 */
|
|
60
|
+
type RemoteWriteVerb = 'create' | 'update' | 'delete';
|
|
61
|
+
|
|
62
|
+
/** 本模块用到的本地适配器切面:系统表仓储 + 两条不入队的裸 SQL 出口 */
|
|
63
|
+
interface OutboxLocalAdapter {
|
|
64
|
+
getRepository<T extends EntityType, RT extends IRepository<T> = IRepository<T>>(EntityType: T): RT;
|
|
65
|
+
upsertMany<T>(entityName: string, data: T[]): Observable<void>;
|
|
66
|
+
deleteByIds(entityName: string, ids: string[]): Observable<void>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 一次重放失败。
|
|
71
|
+
*
|
|
72
|
+
* @remarks
|
|
73
|
+
* 出现在 {@link QueryCacheOutboxResult.failures} 里:一轮重放**不因单行失败而中止**,
|
|
74
|
+
* 失败的行留在队列里等下一轮,成功的行照常推进水位线。
|
|
75
|
+
*/
|
|
76
|
+
export interface QueryCacheOutboxFailure {
|
|
77
|
+
/** 失败的实体 id;整批性失败(例如元数据探测失败)时为 `null` */
|
|
78
|
+
entityId: string | null;
|
|
79
|
+
/** 失败原因,原样透传,不包装 */
|
|
80
|
+
error: Error;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 一轮出站重放的结果,{@link flushQueryCacheOutbox} 的返回值。
|
|
85
|
+
*
|
|
86
|
+
* @remarks
|
|
87
|
+
* 四个净操作计数(`replayed` / `discarded` / `noop` 与 `failures.length`)互斥且穷尽:
|
|
88
|
+
* 压缩之后的每一个净操作恰好落进其中一类。`originalCount - compacted` 是净操作总数。
|
|
89
|
+
*/
|
|
90
|
+
export interface QueryCacheOutboxResult {
|
|
91
|
+
/** 本轮处理的仓库 */
|
|
92
|
+
repository: { namespace: string; entity: string };
|
|
93
|
+
/** 读到的原始变更行数 */
|
|
94
|
+
originalCount: number;
|
|
95
|
+
/** 被本地压缩抵消掉的原始变更行数 */
|
|
96
|
+
compacted: number;
|
|
97
|
+
/** 真的发出去并成功的净操作数 */
|
|
98
|
+
replayed: number;
|
|
99
|
+
/** LWW 判负、本地改动被丢弃的净操作数 */
|
|
100
|
+
discarded: number;
|
|
101
|
+
/** 远端状态已经满足意图、一个请求都没发的净操作数 */
|
|
102
|
+
noop: number;
|
|
103
|
+
/** 水位线推进到的 change id;本轮没有推进时为 `null` */
|
|
104
|
+
watermark: number | null;
|
|
105
|
+
/**
|
|
106
|
+
* 冲突判定中远端胜出的实体 id —— 也就是离线期间的本地改动被丢掉的那批。
|
|
107
|
+
*
|
|
108
|
+
* @remarks
|
|
109
|
+
* 只收解决器明确判 `KEEP_REMOTE` 的。「远端这行已经不在了」不算冲突:那种情况没有
|
|
110
|
+
* 竞争写可比,本地意图是被收敛掉而不是被判负(见 `MISSING_REMOTE`)。
|
|
111
|
+
*/
|
|
112
|
+
conflicts: string[];
|
|
113
|
+
/** 本轮的失败;非空即意味着水位线没有推进 */
|
|
114
|
+
failures: QueryCacheOutboxFailure[];
|
|
115
|
+
/** 整轮被跳过时的原因(例如同步开关被关掉) */
|
|
116
|
+
skipped?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 压缩后的一条净操作,连同它的原始变更行 */
|
|
120
|
+
interface OutboxEntry {
|
|
121
|
+
/** `${namespace}:${entity}:${entityId}`,冲突判定的键 */
|
|
122
|
+
key: string;
|
|
123
|
+
entityId: string;
|
|
124
|
+
kind: OutboxKind;
|
|
125
|
+
action: SwitchVersionChange;
|
|
126
|
+
/**
|
|
127
|
+
* 该实体最后一条原始变更行。
|
|
128
|
+
*
|
|
129
|
+
* @remarks
|
|
130
|
+
* 冲突的本地侧取**最后一条**而非压缩结果:LWW 比的是 `createdAt`,而压缩结果是一个
|
|
131
|
+
* 合成对象、没有时间戳。取最后一条与拉取路径的 `resolveConflictsAndBuildActions` 一致。
|
|
132
|
+
*/
|
|
133
|
+
lastChange: RxDBChange;
|
|
134
|
+
/**
|
|
135
|
+
* 该实体**最早**一条原始变更行的 id。
|
|
136
|
+
*
|
|
137
|
+
* @remarks
|
|
138
|
+
* 部分推进水位线时的下界依据:本条净操作没结算掉,它名下最早的那一行就一步都不能被
|
|
139
|
+
* 水位线越过(越过 = 永远查不出来 = 静默丢写)。
|
|
140
|
+
*/
|
|
141
|
+
minChangeId: number;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 一条净操作的处置方式 */
|
|
145
|
+
type Disposition =
|
|
146
|
+
| { kind: 'replay'; verb: RemoteWriteVerb }
|
|
147
|
+
| { kind: 'discard'; repair: 'restore' | 'drop' }
|
|
148
|
+
| { kind: 'noop' }
|
|
149
|
+
| { kind: 'unresolved'; resolution: string };
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 远端已经没有这一行时的处置。
|
|
153
|
+
*
|
|
154
|
+
* @remarks
|
|
155
|
+
* `fetchMetadata` 的契约里只有存活行的 `{ id, updatedAt }`,删除**不带时间戳**,
|
|
156
|
+
* 所以这一格 LWW 无从比较,只能按语义定死:
|
|
157
|
+
*
|
|
158
|
+
* - `INSERT`:远端没有正是预期,照常创建。
|
|
159
|
+
* - `UPDATE`:远端把这行删了。收敛到「两边都没有」,而不是把它重新创建出来 ——
|
|
160
|
+
* 后者会让一次已经生效的删除自己复活,且删它的人永远不会知道。
|
|
161
|
+
* - `DELETE`:意图已经达成,一个请求都不必发。
|
|
162
|
+
*/
|
|
163
|
+
const MISSING_REMOTE: Readonly<Record<OutboxKind, Disposition>> = {
|
|
164
|
+
INSERT: { kind: 'replay', verb: 'create' },
|
|
165
|
+
UPDATE: { kind: 'discard', repair: 'drop' },
|
|
166
|
+
DELETE: { kind: 'noop' }
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 远端有这一行、且 LWW 判本地胜出时该用的动词。
|
|
171
|
+
*
|
|
172
|
+
* @remarks
|
|
173
|
+
* `INSERT` 落在 `update` 上不是笔误:远端已经有同 id 的行了,再发一次 `create` 只会撞
|
|
174
|
+
* 唯一键。用 `update` 把远端收敛到本地这份状态,才是「本地胜出」的实际含义。
|
|
175
|
+
*/
|
|
176
|
+
const PRESENT_REMOTE_VERB: Readonly<Record<OutboxKind, RemoteWriteVerb>> = {
|
|
177
|
+
INSERT: 'update',
|
|
178
|
+
UPDATE: 'update',
|
|
179
|
+
DELETE: 'delete'
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 重放相位。
|
|
184
|
+
*
|
|
185
|
+
* @remarks
|
|
186
|
+
* 与 `push-repository.ts` 的 `PUSH_PHASES` 同一个理由:DELETE 必须整体先于
|
|
187
|
+
* INSERT/UPDATE,否则「删掉旧行 + 新建复用同一 id 的行」这种批次会在中途把刚建好的行删掉。
|
|
188
|
+
*/
|
|
189
|
+
const REPLAY_PHASES: readonly ReadonlySet<OutboxKind>[] = [
|
|
190
|
+
new Set<OutboxKind>(['DELETE']),
|
|
191
|
+
new Set<OutboxKind>(['INSERT', 'UPDATE'])
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 正在进行中的 flush,按 `RxDB` 实例 + 仓库分组。
|
|
196
|
+
*
|
|
197
|
+
* @remarks
|
|
198
|
+
* 恢复连接往往连着来好几个信号(`navigator` 的 `online` 事件、退避探测的第一次成功、
|
|
199
|
+
* 用户手动重试),每个都起一轮 flush 会让同一批变更被并发重放两次 —— 第二轮读到的还是
|
|
200
|
+
* 第一轮尚未推进的水位线。用 `WeakMap` 挂在 `RxDB` 实例上,多实例互不串门,
|
|
201
|
+
* 实例回收时这张表跟着走。
|
|
202
|
+
*/
|
|
203
|
+
const inflight = new WeakMap<RxDB, Map<string, Promise<QueryCacheOutboxResult>>>();
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 把某个 QueryCache 仓库积压的离线改动重放回远端。
|
|
207
|
+
*
|
|
208
|
+
* @param rxdb - RxDB 实例,用来取分支、本地/远端适配器与实体配置
|
|
209
|
+
* @param namespace - 实体命名空间
|
|
210
|
+
* @param entity - 实体名
|
|
211
|
+
* @param options - 可选的冲突解决器;默认 {@link LWWConflictResolver}
|
|
212
|
+
* @returns 本轮的重放结果
|
|
213
|
+
* @throws {@link RxDBError} 该仓库不存在,或它的 `syncType` 不是 `'querycache'`
|
|
214
|
+
*
|
|
215
|
+
* @remarks
|
|
216
|
+
* 同一仓库上的并发调用会复用同一个在飞的 Promise(见 {@link inflight})。
|
|
217
|
+
*
|
|
218
|
+
* **失败即整轮中止、水位线不推进**:REST 没有批级原子性,也没有 per-change 的确认回执。
|
|
219
|
+
* 已经成功发出去的那几条因此会在下一轮被重发 —— 这与 `pushRepository` 在批次中途失败时
|
|
220
|
+
* 的暴露面相同。用一个哨兵值去写 `RxDBChange.remoteId` 能消除重发,但那个字段的语义是
|
|
221
|
+
* 「本地变更对应的远端 changelog 行」,REST 路径根本没有这个 id,写进去会让
|
|
222
|
+
* `pull-conflict-utils` 与待推计数读到一个不存在的引用。
|
|
223
|
+
*
|
|
224
|
+
* @example
|
|
225
|
+
* ```ts
|
|
226
|
+
* const result = await flushQueryCacheOutbox(rxdb, 'public', 'Recipe');
|
|
227
|
+
* if (result.failures.length === 0) {
|
|
228
|
+
* console.log(`replayed ${result.replayed}, watermark → ${result.watermark}`);
|
|
229
|
+
* }
|
|
230
|
+
* ```
|
|
231
|
+
*/
|
|
232
|
+
export function flushQueryCacheOutbox(
|
|
233
|
+
rxdb: RxDB,
|
|
234
|
+
namespace: string,
|
|
235
|
+
entity: string,
|
|
236
|
+
options?: { conflictResolver?: ConflictResolver }
|
|
237
|
+
): Promise<QueryCacheOutboxResult> {
|
|
238
|
+
const byRepository = inflight.get(rxdb) ?? new Map<string, Promise<QueryCacheOutboxResult>>();
|
|
239
|
+
inflight.set(rxdb, byRepository);
|
|
240
|
+
|
|
241
|
+
const key = `${namespace}:${entity}`;
|
|
242
|
+
const running = byRepository.get(key);
|
|
243
|
+
if (running) {
|
|
244
|
+
return running;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const started = runOutboxFlush(
|
|
248
|
+
rxdb,
|
|
249
|
+
namespace,
|
|
250
|
+
entity,
|
|
251
|
+
options?.conflictResolver ?? new LWWConflictResolver()
|
|
252
|
+
).finally(() => {
|
|
253
|
+
byRepository.delete(key);
|
|
254
|
+
});
|
|
255
|
+
byRepository.set(key, started);
|
|
256
|
+
return started;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** 一轮的空结果基线 */
|
|
260
|
+
const emptyResult = (namespace: string, entity: string): QueryCacheOutboxResult => ({
|
|
261
|
+
repository: { namespace, entity },
|
|
262
|
+
originalCount: 0,
|
|
263
|
+
compacted: 0,
|
|
264
|
+
replayed: 0,
|
|
265
|
+
discarded: 0,
|
|
266
|
+
noop: 0,
|
|
267
|
+
watermark: null,
|
|
268
|
+
conflicts: [],
|
|
269
|
+
failures: []
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
async function runOutboxFlush(
|
|
273
|
+
rxdb: RxDB,
|
|
274
|
+
namespace: string,
|
|
275
|
+
entity: string,
|
|
276
|
+
conflictResolver: ConflictResolver
|
|
277
|
+
): Promise<QueryCacheOutboxResult> {
|
|
278
|
+
const syncType = resolveQueryCacheSyncType(rxdb, namespace, entity);
|
|
279
|
+
|
|
280
|
+
const branch = await getCurrentBranch(rxdb);
|
|
281
|
+
const { adapter } = await getLocalSystemRepositories(rxdb);
|
|
282
|
+
const localAdapter = adapter as unknown as OutboxLocalAdapter;
|
|
283
|
+
const repoSyncRepo = localAdapter.getRepository(RxDBSync);
|
|
284
|
+
|
|
285
|
+
const repoSync = await getOrCreateSyncRecord(repoSyncRepo, { namespace, entity, branchId: branch.id, syncType }, () =>
|
|
286
|
+
rxdb.entityManager.instantiate(RxDBSync)
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
if (!isRepositorySyncEnabled(repoSync)) {
|
|
290
|
+
return { ...emptyResult(namespace, entity), skipped: SYNC_DISABLED_REASON };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const changeRepo = localAdapter.getRepository(RxDBChange);
|
|
294
|
+
const pending = await queryOutboxChanges(changeRepo, namespace, entity, branch.id, repoSync.lastPushedChangeId);
|
|
295
|
+
if (pending.length === 0) {
|
|
296
|
+
return emptyResult(namespace, entity);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const maxChangeId = pending.reduce((max, change) => (change.id > max ? change.id : max), pending[0].id);
|
|
300
|
+
const entries = buildOutboxEntries(pending);
|
|
301
|
+
const base = { ...emptyResult(namespace, entity), originalCount: pending.length };
|
|
302
|
+
|
|
303
|
+
// 整批被本地压缩抵消(本地新建又删掉,远端从没见过)。一个请求都不发,
|
|
304
|
+
// 但水位线必须推进:这些行的 `remoteId` 永远是 null,不推进的话每一轮都会把它们
|
|
305
|
+
// 重新查出来重新压缩,还会被待推计数一直算作「待推」。
|
|
306
|
+
// 不写 `lastPushedAt` —— 没有任何数据真的发出去,那是纯展示字段,不该被伪造。
|
|
307
|
+
if (entries.length === 0) {
|
|
308
|
+
await repoSyncRepo.update(repoSync, { lastPushedChangeId: maxChangeId, updatedAt: new Date() });
|
|
309
|
+
return { ...base, compacted: pending.length, watermark: maxChangeId };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// 取适配器走 `remoteAdapter$` 而不是 `getRemoteSystemRepositories()`:后者除了给适配器,
|
|
313
|
+
// 还会**急切**地建一对 changelog 仓储(`RxDBBranch` / `RxDBChange`),而这正是 QueryCache
|
|
314
|
+
// 的远端明确不实现的东西 —— `RxDBAdapterHttp.getRepository()` 无条件抛。绕这一道会让
|
|
315
|
+
// 整轮回推在发出第一个请求之前就死掉,本模块开头写明的「不复用 changelog 那条路」
|
|
316
|
+
// 也就名存实亡。
|
|
317
|
+
const remoteAdapter = (await firstValueFrom(rxdb.remoteAdapter$)) as unknown as QueryCacheRemoteAdapter;
|
|
318
|
+
const run: RunState = {
|
|
319
|
+
...base,
|
|
320
|
+
compacted: pending.length - entries.length,
|
|
321
|
+
restoreIds: [],
|
|
322
|
+
dropIds: [],
|
|
323
|
+
settled: new Set<OutboxEntry>()
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const metadata = await probeRemoteMetadata(remoteAdapter, entity, entries, rxdb, run);
|
|
327
|
+
if (metadata) {
|
|
328
|
+
await replayPhases(entries, metadata, { rxdb, entity, remoteAdapter, conflictResolver, run });
|
|
329
|
+
}
|
|
330
|
+
await repairLocalCache(localAdapter, remoteAdapter, entity, run);
|
|
331
|
+
|
|
332
|
+
if (run.failures.length > 0) {
|
|
333
|
+
// 整批没推完,但已经结算掉的那一段可以让水位线跟上去。不跟的话,下一轮会把这些
|
|
334
|
+
// 已经推上去的行重新判定一次 —— 那时远端的 `updatedAt` 是我们自己写的,LWW 判本地输,
|
|
335
|
+
// 一次成功的推送就被报成冲突。
|
|
336
|
+
// 不写 `lastPushedAt`:本轮没推完,那是纯展示字段,不该记成一次完整的推送。
|
|
337
|
+
const partial = settledWatermark(entries, run.settled, maxChangeId);
|
|
338
|
+
if (partial <= (repoSync.lastPushedChangeId ?? 0)) return toResult(run);
|
|
339
|
+
await repoSyncRepo.update(repoSync, { lastPushedChangeId: partial, updatedAt: new Date() });
|
|
340
|
+
return { ...toResult(run), watermark: partial };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
await repoSyncRepo.update(repoSync, {
|
|
344
|
+
lastPushedChangeId: maxChangeId,
|
|
345
|
+
lastPushedAt: new Date(),
|
|
346
|
+
updatedAt: new Date()
|
|
347
|
+
});
|
|
348
|
+
return { ...toResult(run), watermark: maxChangeId };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* 累计中的一轮状态:计数 + 待修复的本地缓存 id
|
|
353
|
+
*
|
|
354
|
+
* @remarks
|
|
355
|
+
* 刻意 `Omit` 掉 `conflicts`:那个字段是 `restoreIds` 的对外投影(见 {@link toResult}),
|
|
356
|
+
* 留着会变成同一份事实的第二个可写副本,两边迟早对不上。
|
|
357
|
+
*/
|
|
358
|
+
interface RunState extends Omit<QueryCacheOutboxResult, 'conflicts'> {
|
|
359
|
+
/** LWW 判负、需要用远端行覆盖本地缓存的 id */
|
|
360
|
+
restoreIds: string[];
|
|
361
|
+
/** 远端已消失、需要从本地缓存清掉的 id */
|
|
362
|
+
dropIds: string[];
|
|
363
|
+
/**
|
|
364
|
+
* 本轮已经结算掉的净操作(重放成功 / 判负丢弃 / 无事可做)。
|
|
365
|
+
*
|
|
366
|
+
* @remarks
|
|
367
|
+
* 失败轮次里靠它算出水位线还能往前挪多少。挪不动的那些必须原样留着 —— 见
|
|
368
|
+
* {@link settledWatermark}。
|
|
369
|
+
*/
|
|
370
|
+
settled: Set<OutboxEntry>;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** 把累计状态收敛成对外结果,丢掉只在本轮内部用的修复清单 */
|
|
374
|
+
const toResult = (run: RunState): QueryCacheOutboxResult => ({
|
|
375
|
+
repository: run.repository,
|
|
376
|
+
originalCount: run.originalCount,
|
|
377
|
+
compacted: run.compacted,
|
|
378
|
+
replayed: run.replayed,
|
|
379
|
+
discarded: run.discarded,
|
|
380
|
+
noop: run.noop,
|
|
381
|
+
watermark: run.watermark,
|
|
382
|
+
// 判负的那批就是要用远端行覆盖回来的那批:解决器说 KEEP_REMOTE 才会进 restoreIds
|
|
383
|
+
conflicts: run.restoreIds,
|
|
384
|
+
failures: run.failures
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* 数一遍所有 QueryCache 仓库在当前分支上还没推回远端的变更行。
|
|
389
|
+
*
|
|
390
|
+
* @param rxdb - RxDB 实例
|
|
391
|
+
* @returns 待重放的变更行数;没有这类仓库时为 0
|
|
392
|
+
*
|
|
393
|
+
* @remarks
|
|
394
|
+
* 口径与 {@link flushQueryCacheOutbox} 的取行条件一致(同分支、未推送、未回滚、
|
|
395
|
+
* 水位线之后),只是一次把所有 `offlineWrite && !push` 的仓库并成一个 OR 组来数,
|
|
396
|
+
* 不必逐仓库往返。
|
|
397
|
+
*
|
|
398
|
+
* 与 `HistoryManager.pushableCount$` **不重叠**:那一侧按 `capability.push` 取仓库,
|
|
399
|
+
* 这一侧取它的补集,因此 {@link SyncStateHub} 把两个数直接相加是安全的。
|
|
400
|
+
*/
|
|
401
|
+
export async function countQueryCacheOutbox(rxdb: RxDB): Promise<number> {
|
|
402
|
+
const branch = await getCurrentBranch(rxdb);
|
|
403
|
+
const { adapter } = await getLocalSystemRepositories(rxdb);
|
|
404
|
+
const localAdapter = adapter as unknown as OutboxLocalAdapter;
|
|
405
|
+
|
|
406
|
+
const repoSyncs = await localAdapter.getRepository(RxDBSync).find({
|
|
407
|
+
where: { combinator: 'and', rules: [{ field: 'branchId', operator: '=', value: branch.id }] }
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
const repoRules = buildOfflineWriteRepositoryRules(rxdb.config.entities, rxdb.config.sync, repoSyncs);
|
|
411
|
+
if (repoRules.length === 0) {
|
|
412
|
+
return 0;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// 变更行走 `entityManager` 而不是本文件其他地方用的 `localAdapter.getRepository`:
|
|
416
|
+
// 这一条与 `updatePushableCount` 数的是同一批行、用的是同一套 repoRules 类型,
|
|
417
|
+
// 换一个入口就得给规则加一层断言 —— 那是把两个计数口径的同源关系藏进 cast 里。
|
|
418
|
+
return firstValueFrom(
|
|
419
|
+
rxdb.entityManager.getRepository(RxDBChange).count({
|
|
420
|
+
where: {
|
|
421
|
+
combinator: 'and',
|
|
422
|
+
rules: [
|
|
423
|
+
{ field: 'branchId', operator: '=', value: branch.id },
|
|
424
|
+
{ field: 'revertChangeId', operator: '=', value: null },
|
|
425
|
+
{ field: 'remoteId', operator: '=', value: null },
|
|
426
|
+
{ combinator: 'or', rules: repoRules }
|
|
427
|
+
]
|
|
428
|
+
}
|
|
429
|
+
})
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* 取出某个 QueryCache 仓库当前**被出站队列占着**的实体 id。
|
|
435
|
+
*
|
|
436
|
+
* @param rxdb - RxDB 实例
|
|
437
|
+
* @param namespace - 命名空间
|
|
438
|
+
* @param entity - 实体名
|
|
439
|
+
* @returns 队列里还没推回远端的那些实体 id;队列为空时是空集
|
|
440
|
+
*
|
|
441
|
+
* @remarks
|
|
442
|
+
* 取行条件与 {@link flushQueryCacheOutbox} 逐字同源(同分支、`remoteId = null`、
|
|
443
|
+
* `revertChangeId = null`、水位线之后),因为「谁归队列管」和「谁会被重放」必须是
|
|
444
|
+
* 同一个集合 —— 两边各写一份过滤条件,漂移的那一刻就是数据被误删的那一刻。
|
|
445
|
+
*
|
|
446
|
+
* **不**看 `RxDBSync.enabled`:仓库关掉同步时队列永远不会 flush,那些行于是永远留在本地。
|
|
447
|
+
* 它们仍然是只有本地有的用户数据,按「远端没返回 = 孤儿」删掉就再也找不回来了。
|
|
448
|
+
*
|
|
449
|
+
* 没有 `RxDBSync` 记录时按「没有水位线」读全量,不新建记录:这是一次纯读,
|
|
450
|
+
* 给一个还没同步过的仓库建记录会凭空多出一条水位线为 0 的同步状态。
|
|
451
|
+
*/
|
|
452
|
+
export async function pendingQueryCacheWriteIds(
|
|
453
|
+
rxdb: RxDB,
|
|
454
|
+
namespace: string,
|
|
455
|
+
entity: string
|
|
456
|
+
): Promise<ReadonlySet<string>> {
|
|
457
|
+
const branch = await getCurrentBranch(rxdb);
|
|
458
|
+
const { adapter } = await getLocalSystemRepositories(rxdb);
|
|
459
|
+
const localAdapter = adapter as unknown as OutboxLocalAdapter;
|
|
460
|
+
|
|
461
|
+
const repoSync = await findCurrentSyncRecord(rxdb, namespace, entity);
|
|
462
|
+
const pending = await queryOutboxChanges(
|
|
463
|
+
localAdapter.getRepository(RxDBChange),
|
|
464
|
+
namespace,
|
|
465
|
+
entity,
|
|
466
|
+
branch.id,
|
|
467
|
+
repoSync?.lastPushedChangeId ?? null
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
return new Set(pending.map(change => String(change.entityId)));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* 确认这个仓库确实走 QueryCache,并返回它的 syncType。
|
|
475
|
+
*
|
|
476
|
+
* @throws {@link RxDBError} 实体未注册,或它的 syncType 不是 `'querycache'`
|
|
477
|
+
*
|
|
478
|
+
* @remarks
|
|
479
|
+
* 拦在最前面而不是让它安静地返回空结果:喂错仓库时,这里的每一步(读 `rxdb_change`、
|
|
480
|
+
* 推进 `RxDBSync` 水位线)都会动到那个仓库**真正的**推送状态 —— 一次 Full 同步仓库的
|
|
481
|
+
* 水位线被 REST 路径推过去,等于把它没推的变更全部标成已推。
|
|
482
|
+
*/
|
|
483
|
+
function resolveQueryCacheSyncType(rxdb: RxDB, namespace: string, entity: string): 'querycache' {
|
|
484
|
+
const EntityType = rxdb.config.entities.find(candidate => {
|
|
485
|
+
const meta = getEntityMetadata(candidate);
|
|
486
|
+
return meta.namespace === namespace && meta.name === entity;
|
|
487
|
+
});
|
|
488
|
+
if (!EntityType) {
|
|
489
|
+
throw new RxDBError(`Entity not found for QueryCache outbox flush: ${namespace}/${entity}`);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const syncType = getSyncType(getEntityMetadata(EntityType), rxdb.config.sync);
|
|
493
|
+
if (syncType !== 'querycache') {
|
|
494
|
+
throw new RxDBError(
|
|
495
|
+
`flushQueryCacheOutbox only handles syncType 'querycache'; ${namespace}/${entity} is '${syncType}'. ` +
|
|
496
|
+
`Repositories with a changelog endpoint push through syncManager.push().`
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
return syncType;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* 查该仓库在当前分支上尚未推送的变更行。
|
|
504
|
+
*
|
|
505
|
+
* @remarks
|
|
506
|
+
* 规则形状与 `push-repository.ts` 的 `queryUnpushedChanges` 一致,只是不追祖先分支 ——
|
|
507
|
+
* QueryCache 没有分支语义(它的远端是 REST 资源,不认 branchId),跨分支收集只会把
|
|
508
|
+
* 别的分支的改动推到同一个远端资源上。
|
|
509
|
+
*
|
|
510
|
+
* 水位线为 `null` 时**不加** `id` 条件:`id > null` 在 SQL 里恒为 NULL,会把整批筛空,
|
|
511
|
+
* 于是「从未推送过」的仓库永远推不出第一批。
|
|
512
|
+
*/
|
|
513
|
+
async function queryOutboxChanges(
|
|
514
|
+
changeRepo: IRepository<typeof RxDBChange>,
|
|
515
|
+
namespace: string,
|
|
516
|
+
entity: string,
|
|
517
|
+
branchId: string,
|
|
518
|
+
lastPushedChangeId: number | null
|
|
519
|
+
): Promise<RxDBChange[]> {
|
|
520
|
+
const rules: RxDBChangeRuleGroup['rules'] = [
|
|
521
|
+
{ field: 'namespace', operator: '=', value: namespace },
|
|
522
|
+
{ field: 'entity', operator: '=', value: entity },
|
|
523
|
+
{ field: 'branchId', operator: '=', value: branchId },
|
|
524
|
+
{ field: 'remoteId', operator: '=', value: null },
|
|
525
|
+
{ field: 'revertChangeId', operator: '=', value: null }
|
|
526
|
+
];
|
|
527
|
+
|
|
528
|
+
if (lastPushedChangeId !== null) {
|
|
529
|
+
rules.push({ field: 'id', operator: '>', value: lastPushedChangeId });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return changeRepo.find({
|
|
533
|
+
where: { combinator: 'and', rules },
|
|
534
|
+
orderBy: [{ field: 'id', sort: 'asc' }]
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* 把原始变更行压成每个实体一条净操作。
|
|
540
|
+
*
|
|
541
|
+
* @remarks
|
|
542
|
+
* 同一行在离线期间可能被改了很多次,中间态既没人看见过,逐条重放还会把那串往返全部压在
|
|
543
|
+
* 恢复连接的那一瞬间。压缩规则(含「本地新建后删除完全抵消」)复用 `compactChanges`。
|
|
544
|
+
*/
|
|
545
|
+
function buildOutboxEntries(pending: RxDBChange[]): OutboxEntry[] {
|
|
546
|
+
const changesByKey = new Map<string, RxDBChange[]>();
|
|
547
|
+
for (const change of pending) {
|
|
548
|
+
const key = getRxDBChangeKey(change);
|
|
549
|
+
const group = changesByKey.get(key) ?? [];
|
|
550
|
+
group.push(change);
|
|
551
|
+
changesByKey.set(key, group);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const actions = compactChanges(pending);
|
|
555
|
+
const entries: OutboxEntry[] = [];
|
|
556
|
+
|
|
557
|
+
const collect = (map: SwitchVersionActions['inserts'], kind: OutboxKind): void => {
|
|
558
|
+
for (const [key, action] of map) {
|
|
559
|
+
const group = changesByKey.get(key);
|
|
560
|
+
if (!group?.length) {
|
|
561
|
+
throw new RxDBError(`Missing source changes for compacted QueryCache action: ${key}`);
|
|
562
|
+
}
|
|
563
|
+
const lastChange = group[group.length - 1];
|
|
564
|
+
const minChangeId = group.reduce((min, change) => (change.id < min ? change.id : min), group[0].id);
|
|
565
|
+
entries.push({ key, entityId: String(lastChange.entityId), kind, action, lastChange, minChangeId });
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
collect(actions.deletes, 'DELETE');
|
|
570
|
+
collect(actions.updates, 'UPDATE');
|
|
571
|
+
collect(actions.inserts, 'INSERT');
|
|
572
|
+
|
|
573
|
+
return entries;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* 元数据探测每次请求最多带多少个 id。
|
|
578
|
+
*
|
|
579
|
+
* @remarks
|
|
580
|
+
* 与 HTTP / supabase 适配器的 `idChunkSize` 同一个数。core 不认识任何具体适配器,
|
|
581
|
+
* 拿不到它们的配置,只能自己持一份同口径的常量。
|
|
582
|
+
*
|
|
583
|
+
* 为什么必须切:离线攒下的待推行没有上限,一次几千个 id 塞进 `id in (...)` 会把请求
|
|
584
|
+
* 撑到网关 413 / URL 长度上限之外。适配器侧只有 `findByIds` 分了块
|
|
585
|
+
* (`findByIdsInChunks`),`fetchMetadata` 分的是**响应**的页,请求里那串 id 原样透传。
|
|
586
|
+
*/
|
|
587
|
+
const METADATA_PROBE_CHUNK_SIZE = 100;
|
|
588
|
+
|
|
589
|
+
/** 按固定尺寸切分 id 列表 */
|
|
590
|
+
function chunkIds(ids: readonly string[], size: number): string[][] {
|
|
591
|
+
const chunks: string[][] = [];
|
|
592
|
+
for (let index = 0; index < ids.length; index += size) {
|
|
593
|
+
chunks.push(ids.slice(index, index + size));
|
|
594
|
+
}
|
|
595
|
+
return chunks;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* 拉一次涉及实体的远端元数据,作为冲突判定的远端侧。
|
|
600
|
+
*
|
|
601
|
+
* @returns `id → updatedAt` 映射;探测失败时返回 `null`,调用方据此跳过整轮重放
|
|
602
|
+
*
|
|
603
|
+
* @remarks
|
|
604
|
+
* 分块**串行**,首块出错即停 —— 与 `findByIdsInChunks` 同款理由:远端已经出问题时继续
|
|
605
|
+
* 打后面几块只是加压,而结果反正是整轮跳过。半张表也绝不能拿去判冲突:缺席的 id 会被
|
|
606
|
+
* {@link decide} 当成「远端没有这一行」,UPDATE 于是降级成 INSERT,DELETE 直接丢弃。
|
|
607
|
+
*/
|
|
608
|
+
async function probeRemoteMetadata(
|
|
609
|
+
remoteAdapter: QueryCacheRemoteAdapter,
|
|
610
|
+
entity: string,
|
|
611
|
+
entries: OutboxEntry[],
|
|
612
|
+
rxdb: RxDB,
|
|
613
|
+
run: RunState
|
|
614
|
+
): Promise<Map<string, string> | null> {
|
|
615
|
+
const ids = entries.map(entry => entry.entityId);
|
|
616
|
+
const found = new Map<string, string>();
|
|
617
|
+
for (const batch of chunkIds(ids, METADATA_PROBE_CHUNK_SIZE)) {
|
|
618
|
+
try {
|
|
619
|
+
const metadata = await firstValueFrom(
|
|
620
|
+
remoteAdapter.fetchMetadata(entity, {
|
|
621
|
+
combinator: 'and',
|
|
622
|
+
rules: [{ field: 'id', operator: 'in', value: batch }]
|
|
623
|
+
})
|
|
624
|
+
);
|
|
625
|
+
for (const row of metadata) found.set(row.id, row.updatedAt);
|
|
626
|
+
} catch (error) {
|
|
627
|
+
rxdb.reachability.report(error);
|
|
628
|
+
run.failures.push({ entityId: null, error: error instanceof Error ? error : new Error(String(error)) });
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
rxdb.reachability.report(null);
|
|
633
|
+
return found;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
interface ReplayContext {
|
|
637
|
+
rxdb: RxDB;
|
|
638
|
+
entity: string;
|
|
639
|
+
remoteAdapter: QueryCacheRemoteAdapter;
|
|
640
|
+
conflictResolver: ConflictResolver;
|
|
641
|
+
run: RunState;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** 按 DELETE → INSERT/UPDATE 的相位顺序判定并重放;任一失败即整轮中止 */
|
|
645
|
+
async function replayPhases(
|
|
646
|
+
entries: OutboxEntry[],
|
|
647
|
+
metadata: Map<string, string>,
|
|
648
|
+
context: ReplayContext
|
|
649
|
+
): Promise<void> {
|
|
650
|
+
for (const kinds of REPLAY_PHASES) {
|
|
651
|
+
for (const entry of entries.filter(candidate => kinds.has(candidate.kind))) {
|
|
652
|
+
if (context.run.failures.length > 0) return;
|
|
653
|
+
const before = context.run.failures.length;
|
|
654
|
+
await settleEntry(entry, metadata.get(entry.entityId), context);
|
|
655
|
+
// 本条自己没添失败才算结算掉。结算过的净操作绝不能在下一轮再被判定一次:
|
|
656
|
+
// 那时远端的 `updatedAt` 已经是**我们自己**这一次写进去的时刻,LWW 一比就判本地输,
|
|
657
|
+
// 于是一次成功的推送被报成冲突,本地缓存还要被「修复」回远端行。
|
|
658
|
+
if (context.run.failures.length === before) context.run.settled.add(entry);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* 失败轮次里水位线还能推到哪。
|
|
665
|
+
*
|
|
666
|
+
* @param entries - 本轮的全部净操作
|
|
667
|
+
* @param maxChangeId - 本批变更行的最大 id
|
|
668
|
+
* @returns 可安全写回的 `lastPushedChangeId`
|
|
669
|
+
*
|
|
670
|
+
* @remarks
|
|
671
|
+
* 取「最早那条**没结算**的变更行 id 减一」:这个位置之前的行全都已经有了归宿,再查出来
|
|
672
|
+
* 只会被重判一次;这个位置之后的行一条都不能被越过。全部结算完时直接给 `maxChangeId`。
|
|
673
|
+
*
|
|
674
|
+
* 残留的一段:同一批里 A、B 两个实体的变更行交错(A₁ B₁ A₂),B 没结算而 A 结算了,
|
|
675
|
+
* 下界被 B₁ 钉住,A₂ 下一轮照样要重判一次。彻底消掉它需要**逐行**的已推标记,而
|
|
676
|
+
* `RxDBChange.remoteId` 的语义是「这条变更是从远端同步过来的」,不是「这条已经推上去了」,
|
|
677
|
+
* 借用它会污染拉取侧的判据。真正的出路是加一个属于出站的持久标记 —— 那是一次 schema
|
|
678
|
+
* 变更,不在本次修复的范围里。
|
|
679
|
+
*/
|
|
680
|
+
function settledWatermark(entries: readonly OutboxEntry[], settled: ReadonlySet<OutboxEntry>, maxChangeId: number) {
|
|
681
|
+
let lowestPending: number | undefined;
|
|
682
|
+
for (const entry of entries) {
|
|
683
|
+
if (settled.has(entry)) continue;
|
|
684
|
+
if (lowestPending === undefined || entry.minChangeId < lowestPending) lowestPending = entry.minChangeId;
|
|
685
|
+
}
|
|
686
|
+
return lowestPending === undefined ? maxChangeId : lowestPending - 1;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** 判定单条净操作的去向并执行 */
|
|
690
|
+
async function settleEntry(
|
|
691
|
+
entry: OutboxEntry,
|
|
692
|
+
remoteUpdatedAt: string | undefined,
|
|
693
|
+
context: ReplayContext
|
|
694
|
+
): Promise<void> {
|
|
695
|
+
const disposition = await decide(entry, remoteUpdatedAt, context.conflictResolver);
|
|
696
|
+
const { run } = context;
|
|
697
|
+
|
|
698
|
+
if (disposition.kind === 'noop') {
|
|
699
|
+
run.noop += 1;
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (disposition.kind === 'discard') {
|
|
704
|
+
run.discarded += 1;
|
|
705
|
+
(disposition.repair === 'restore' ? run.restoreIds : run.dropIds).push(entry.entityId);
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (disposition.kind === 'unresolved') {
|
|
710
|
+
run.failures.push({
|
|
711
|
+
entityId: entry.entityId,
|
|
712
|
+
error: new RxDBError(
|
|
713
|
+
`Unsupported conflict resolution '${disposition.resolution}' during QueryCache outbox replay for ` +
|
|
714
|
+
`${entry.key}. Only KEEP_LOCAL and KEEP_REMOTE can be applied over REST verbs.`
|
|
715
|
+
)
|
|
716
|
+
});
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
await replayEntry(entry, disposition.verb, context);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* 决定一条净操作的去向。
|
|
725
|
+
*
|
|
726
|
+
* @param entry - 压缩后的净操作
|
|
727
|
+
* @param remoteUpdatedAt - 远端该行的 `updatedAt`;远端没有这一行时为 `undefined`
|
|
728
|
+
* @param conflictResolver - 冲突解决器
|
|
729
|
+
*
|
|
730
|
+
* @remarks
|
|
731
|
+
* 远端有这一行就一律过一遍解决器 —— 包括远端更旧的情况。这里不自己先比一次时间戳再决定
|
|
732
|
+
* 要不要问:那会让「谁赢」由两处代码共同决定,自定义解决器只能在本模块已经认定有冲突的
|
|
733
|
+
* 子集上生效。
|
|
734
|
+
*/
|
|
735
|
+
async function decide(
|
|
736
|
+
entry: OutboxEntry,
|
|
737
|
+
remoteUpdatedAt: string | undefined,
|
|
738
|
+
conflictResolver: ConflictResolver
|
|
739
|
+
): Promise<Disposition> {
|
|
740
|
+
if (remoteUpdatedAt === undefined) {
|
|
741
|
+
return MISSING_REMOTE[entry.kind];
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const resolution = await conflictResolver.resolve({
|
|
745
|
+
entityKey: entry.key,
|
|
746
|
+
local: entry.lastChange,
|
|
747
|
+
remote: synthesizeRemoteChange(entry, remoteUpdatedAt)
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
if (resolution.type === 'KEEP_LOCAL') {
|
|
751
|
+
return { kind: 'replay', verb: PRESENT_REMOTE_VERB[entry.kind] };
|
|
752
|
+
}
|
|
753
|
+
if (resolution.type === 'KEEP_REMOTE') {
|
|
754
|
+
return { kind: 'discard', repair: 'restore' };
|
|
755
|
+
}
|
|
756
|
+
return { kind: 'unresolved', resolution: resolution.type };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* 用远端元数据合成一条 `IRxDBChange`,充当冲突的远端侧。
|
|
761
|
+
*
|
|
762
|
+
* @remarks
|
|
763
|
+
* `Conflict` 两侧都要 `IRxDBChange`,而 REST 远端只给得出 `{ id, updatedAt }`。
|
|
764
|
+
* `createdAt` 取远端的 `updatedAt` —— 那是远端这行最后一次被写的时刻,正是 LWW 要比的量。
|
|
765
|
+
* `clientId` 留空:REST 不带写方身份,缺它意味着时间戳打平时 LWW 退回本地优先,
|
|
766
|
+
* 这是解决器已文档化的既有约定(`LWWConflictResolver.resolve`)。
|
|
767
|
+
* `id` 取 0:REST 没有远端变更 id,而本模块不走 `markLocalChangesSuperseded`,没人读它。
|
|
768
|
+
*/
|
|
769
|
+
function synthesizeRemoteChange(entry: OutboxEntry, remoteUpdatedAt: string): RxDBChange {
|
|
770
|
+
const timestamp = new Date(remoteUpdatedAt);
|
|
771
|
+
return {
|
|
772
|
+
id: 0,
|
|
773
|
+
namespace: entry.lastChange.namespace,
|
|
774
|
+
entity: entry.lastChange.entity,
|
|
775
|
+
entityId: entry.lastChange.entityId,
|
|
776
|
+
branchId: entry.lastChange.branchId,
|
|
777
|
+
type: entry.kind,
|
|
778
|
+
patch: null,
|
|
779
|
+
inversePatch: null,
|
|
780
|
+
createdAt: timestamp,
|
|
781
|
+
updatedAt: timestamp
|
|
782
|
+
} as RxDBChange;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** 发一次远端写,并把结果上报给可达性监视器 */
|
|
786
|
+
async function replayEntry(entry: OutboxEntry, verb: RemoteWriteVerb, context: ReplayContext): Promise<void> {
|
|
787
|
+
const { entity, remoteAdapter, run, rxdb } = context;
|
|
788
|
+
|
|
789
|
+
const write = remoteWrite(remoteAdapter, entity, entry, verb);
|
|
790
|
+
if (!write) {
|
|
791
|
+
run.failures.push({
|
|
792
|
+
entityId: entry.entityId,
|
|
793
|
+
error: new RxDBError(`Remote adapter does not support ${verb} operation for ${entity}`)
|
|
794
|
+
});
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
try {
|
|
799
|
+
await firstValueFrom(write);
|
|
800
|
+
rxdb.reachability.report(null);
|
|
801
|
+
run.replayed += 1;
|
|
802
|
+
} catch (error) {
|
|
803
|
+
rxdb.reachability.report(error);
|
|
804
|
+
run.failures.push({ entityId: entry.entityId, error: error instanceof Error ? error : new Error(String(error)) });
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* 取该动词对应的远端写流。
|
|
810
|
+
*
|
|
811
|
+
* @returns 远端适配器没实现这个动词时返回 `null`
|
|
812
|
+
*
|
|
813
|
+
* @remarks
|
|
814
|
+
* HTTP 适配器的写动词是按 handler 有无条件挂载的,漏配一个就少一个方法。缺动词不是网络
|
|
815
|
+
* 问题,不能按不可达处理 —— 那样这条改动会一直排在队里、每轮重试都注定失败。
|
|
816
|
+
*/
|
|
817
|
+
function remoteWrite(
|
|
818
|
+
remoteAdapter: QueryCacheRemoteAdapter,
|
|
819
|
+
entity: string,
|
|
820
|
+
entry: OutboxEntry,
|
|
821
|
+
verb: RemoteWriteVerb
|
|
822
|
+
): Observable<unknown> | null {
|
|
823
|
+
if (verb === 'delete') {
|
|
824
|
+
return remoteAdapter.delete ? remoteAdapter.delete(entity, [entry.entityId]) : null;
|
|
825
|
+
}
|
|
826
|
+
const patch = entry.action.patch ?? {};
|
|
827
|
+
if (verb === 'create') {
|
|
828
|
+
// `id` 必须现补:触发器建 patch 时明确跳过它(`trigger_sql.ts` 的
|
|
829
|
+
// `if (jsName === 'id') continue`),行的身份记在变更行自己的 `entityId` 列上。
|
|
830
|
+
// 不补就是把一条**没有身份的新行**发给远端,远端只能自己造一个 id ——
|
|
831
|
+
// 本地那份从此对不上远端,成了远端从不认识的孤儿行,下一轮元数据拉取把它当孤儿清掉。
|
|
832
|
+
// 水位线照常推进,用户看着推上去了,其实丢了。
|
|
833
|
+
const body = { id: entry.entityId, ...patch };
|
|
834
|
+
return remoteAdapter.create ? remoteAdapter.create(entity, body) : null;
|
|
835
|
+
}
|
|
836
|
+
return remoteAdapter.update ? remoteAdapter.update(entity, entry.entityId, patch) : null;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* 把 LWW 判负的那些行在本地缓存里对齐远端。
|
|
841
|
+
*
|
|
842
|
+
* @remarks
|
|
843
|
+
* **必须走 `localAdapter` 的裸 SQL 出口**(`upsertMany` / `deleteByIds`,触发器已被
|
|
844
|
+
* `withTriggersDisabled` 抑制),不能走实体仓储。接受远端是「把远端投影抄到本地」,
|
|
845
|
+
* 不是一次用户改动;走实体仓储会让触发器再排一条出站行,下一轮把远端刚赢下的值又覆盖
|
|
846
|
+
* 回去 —— 一个自我供给的循环,而且每绕一圈都以远端的那次改动被丢弃告终。
|
|
847
|
+
*
|
|
848
|
+
* 修复失败不改判定:远端侧已经是权威状态了,这里只是本地投影没跟上,下一次读会同步回来。
|
|
849
|
+
*/
|
|
850
|
+
async function repairLocalCache(
|
|
851
|
+
localAdapter: OutboxLocalAdapter,
|
|
852
|
+
remoteAdapter: QueryCacheRemoteAdapter,
|
|
853
|
+
entity: string,
|
|
854
|
+
run: RunState
|
|
855
|
+
): Promise<void> {
|
|
856
|
+
if (run.restoreIds.length > 0) {
|
|
857
|
+
const rows = await firstValueFrom(remoteAdapter.findByIds(entity, run.restoreIds));
|
|
858
|
+
await firstValueFrom(localAdapter.upsertMany(entity, rows));
|
|
859
|
+
}
|
|
860
|
+
if (run.dropIds.length > 0) {
|
|
861
|
+
await firstValueFrom(localAdapter.deleteByIds(entity, run.dropIds));
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/** 重导出,方便调用方按同一口径判断 flush 失败是不是网络原因 */
|
|
866
|
+
export { isNetworkError };
|