@aiao/rxdb-plugin-sync 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +81 -2
- package/dist/SyncManager.d.ts +426 -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-materialization-source.d.ts +22 -0
- package/dist/branch-materialization-source.d.ts.map +1 -0
- package/dist/branch-utils.d.ts +40 -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 +3773 -0
- package/dist/plugin.d.ts +74 -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 +55 -0
- package/dist/pull-repository.d.ts.map +1 -0
- package/dist/pull-round.d.ts +41 -0
- package/dist/pull-round.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 +79 -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 +81 -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 +64 -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/package.json +60 -3
- package/src/SyncManager.ts +690 -0
- package/src/branch-change-id.ts +70 -0
- package/src/branch-materialization-source.ts +445 -0
- package/src/branch-utils.ts +74 -0
- package/src/bulk-sync.ts +349 -0
- package/src/cascade-blocking.ts +61 -0
- package/src/check-repository-updates.ts +143 -0
- package/src/cleanup-expired.ts +285 -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 +58 -0
- package/src/plugin.ts +132 -0
- package/src/pull-ancestor-changes.ts +61 -0
- package/src/pull-batch.ts +386 -0
- package/src/pull-conflict-utils.ts +284 -0
- package/src/pull-repository.ts +700 -0
- package/src/pull-round.ts +103 -0
- package/src/pull.ts +204 -0
- package/src/push-branch.ts +62 -0
- package/src/push-repository.ts +1541 -0
- package/src/push.ts +106 -0
- package/src/query-cache-outbox.ts +939 -0
- package/src/sync-branches.ts +276 -0
- package/src/sync-listeners.ts +309 -0
- package/src/sync-manager.utils.ts +48 -0
- package/src/sync-repository.ts +307 -0
- package/src/topological-sort.ts +246 -0
|
@@ -0,0 +1,1541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview 仓库级别的推送操作
|
|
3
|
+
*
|
|
4
|
+
* 在仓库(实体类型)级别提供细粒度的推送控制,
|
|
5
|
+
* 支持通过依赖图分析的级联同步。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
assertSingleActiveBranch,
|
|
10
|
+
compactChanges,
|
|
11
|
+
declareTrustedWrite,
|
|
12
|
+
type EntityType,
|
|
13
|
+
getEntityMetadata,
|
|
14
|
+
getOrCreateSyncRecord,
|
|
15
|
+
getRxDBChangeEntityIdQueryValues,
|
|
16
|
+
getRxDBChangeKey,
|
|
17
|
+
getSyncType,
|
|
18
|
+
IRepository,
|
|
19
|
+
type PushRepositoryResult,
|
|
20
|
+
type RemoteChangeRejection,
|
|
21
|
+
type RemoteChangeResult,
|
|
22
|
+
repositoryKey,
|
|
23
|
+
RepositorySyncBeginEvent,
|
|
24
|
+
RepositorySyncCompleteEvent,
|
|
25
|
+
RepositorySyncErrorEvent,
|
|
26
|
+
resolvePushIneligibility,
|
|
27
|
+
type RxDBAdapterRemoteBase,
|
|
28
|
+
RxDBChange,
|
|
29
|
+
RxDBChangeRuleGroup,
|
|
30
|
+
RxDBDependencyFailedError,
|
|
31
|
+
RxDBError,
|
|
32
|
+
RxDBPartialSyncError,
|
|
33
|
+
RxDBSync,
|
|
34
|
+
type SwitchVersionActions,
|
|
35
|
+
type SwitchVersionChange,
|
|
36
|
+
type SyncFailure,
|
|
37
|
+
type SyncRejection,
|
|
38
|
+
type TransactionExecutor,
|
|
39
|
+
TrustedWriteIntent
|
|
40
|
+
} from '@aiao/rxdb';
|
|
41
|
+
import type { PushInFlightSession } from '@aiao/rxdb-plugin-history';
|
|
42
|
+
import { firstValueFrom } from 'rxjs';
|
|
43
|
+
import { getAncestorBranchIds } from './branch-utils.js';
|
|
44
|
+
import { findBlockingDependency } from './cascade-blocking.js';
|
|
45
|
+
import { buildDependencyGraph, type DependencyGraph, type RepositoryIdentifier } from './dependency-graph.js';
|
|
46
|
+
import type { SyncManager } from './SyncManager.js';
|
|
47
|
+
import { dependencyEdgeForAction, type SortActionKind, topologicalSortForAction } from './topological-sort.js';
|
|
48
|
+
/**
|
|
49
|
+
* 推送仓库选项
|
|
50
|
+
*/
|
|
51
|
+
export interface PushRepositoryOptions {
|
|
52
|
+
/**
|
|
53
|
+
* 每批次推送的最大变更数量
|
|
54
|
+
* @default 1000
|
|
55
|
+
*/
|
|
56
|
+
batchSize?: number;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 是否包含相关实体(级联同步)
|
|
60
|
+
*
|
|
61
|
+
* 当为 true 时:
|
|
62
|
+
* - 自动推送所有子实体(引用当前实体的实体)
|
|
63
|
+
* - 遵循反向拓扑顺序:子 -> 父
|
|
64
|
+
* - 示例:推送 User 时会自动推送 Post
|
|
65
|
+
*
|
|
66
|
+
* 当为 false 时:
|
|
67
|
+
* - 仅同步指定仓库
|
|
68
|
+
* - 可能会导致远端数据不完整
|
|
69
|
+
*
|
|
70
|
+
* @default true
|
|
71
|
+
*/
|
|
72
|
+
includeRelated?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// `PushRepositoryResult` 此前在本文件和 `VersionManager.interface.ts`
|
|
76
|
+
// 各有一份完全相同的定义,改一处漏一处。唯一定义收敛到对外导出的那一份,这里只做转发
|
|
77
|
+
// (与 `pull-repository.ts` 的 `PullRepositoryResult` 同一处理)。
|
|
78
|
+
export type { PushRepositoryResult };
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 默认推送仓库选项
|
|
82
|
+
*/
|
|
83
|
+
const DEFAULT_PUSH_REPOSITORY_OPTIONS: Required<PushRepositoryOptions> = {
|
|
84
|
+
batchSize: 1000,
|
|
85
|
+
includeRelated: true
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
function assertBatchSize(batchSize: number): void {
|
|
89
|
+
if (!Number.isSafeInteger(batchSize) || batchSize <= 0) {
|
|
90
|
+
throw new RangeError('batchSize must be a positive safe integer');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 从异常里取出推送侧的部分进度。
|
|
96
|
+
*
|
|
97
|
+
* 与 `partialRepositoryProgressOf`(pull 侧)对称:按 `PushRepositoryResult` 独有的
|
|
98
|
+
* `pushed` + `originalCount` 两个数值字段判形,`PullRepositoryResult` /
|
|
99
|
+
* `SyncRepositoryResult` 都没有它们,不会误判。
|
|
100
|
+
*
|
|
101
|
+
* @param error - 任意异常
|
|
102
|
+
* @returns 携带的推送结果;异常不是 `RxDBPartialSyncError` 或负载形状不符时返回 `undefined`
|
|
103
|
+
*/
|
|
104
|
+
export function partialPushProgressOf(error: Error): PushRepositoryResult | undefined {
|
|
105
|
+
if (!(error instanceof RxDBPartialSyncError)) return undefined;
|
|
106
|
+
const result: unknown = error.result;
|
|
107
|
+
if (!result || typeof result !== 'object') return undefined;
|
|
108
|
+
const candidate = result as Partial<PushRepositoryResult>;
|
|
109
|
+
return typeof candidate.pushed === 'number' && typeof candidate.originalCount === 'number' ?
|
|
110
|
+
(candidate as PushRepositoryResult)
|
|
111
|
+
: undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 推送失败的唯一出口:一律抛,不 resolve 出 `success: false`。
|
|
116
|
+
*
|
|
117
|
+
* @remarks
|
|
118
|
+
* 此前级联路径抛错、单仓路径 resolve 出 `success: false`,两种形状并存。
|
|
119
|
+
* `bulkSync` 只看「有没有抛」来判定成败,于是单仓路径的失败被记成成功,
|
|
120
|
+
* `BulkSyncResult.failed` 恒为 0 —— 推送失败在聚合层完全消失。
|
|
121
|
+
*
|
|
122
|
+
* 抛什么则按有没有真的发出去东西分:
|
|
123
|
+
*
|
|
124
|
+
* - `pushed === 0`:一条都没到远端,包一层只会让调用方多剥一层,直接抛原始错误
|
|
125
|
+
* (与 `pull.ts` / `pull-batch.ts` 的既有约定一致);
|
|
126
|
+
* - `pushed > 0`:这些条目已经落在远端且**不会**因为本次抛错而回滚,
|
|
127
|
+
* 正是 {@link RxDBPartialSyncError} 的语义,进度挂在 `result` 上交出去。
|
|
128
|
+
*
|
|
129
|
+
* @param result - 失败的推送结果,`success` 必须为 `false`
|
|
130
|
+
* @throws 恒抛
|
|
131
|
+
* @internal
|
|
132
|
+
*/
|
|
133
|
+
function throwPushFailure(result: PushRepositoryResult): never {
|
|
134
|
+
const { error } = result;
|
|
135
|
+
// `success: false` 必然带 error(提交失败、远端失败、依赖阻断三条路径都会写)。
|
|
136
|
+
// 真出现缺失只可能是内部状态坏了,不能静默当成功。
|
|
137
|
+
if (!error) {
|
|
138
|
+
throw new RxDBError(`Internal error: repository ${repositoryKey(result.repository)} failed without an error`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (result.pushed === 0) throw error;
|
|
142
|
+
throw new RxDBPartialSyncError<PushRepositoryResult>(result, error);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 为单个仓库推送变更
|
|
147
|
+
*
|
|
148
|
+
* @param sm - SyncManager 实例
|
|
149
|
+
* @param namespace - 实体命名空间
|
|
150
|
+
* @param entity - 实体名称
|
|
151
|
+
* @param options - 推送选项
|
|
152
|
+
* @param rejectionSink - 被拒清单收集器。传入时本次调用只往里追加、不上报,由调用方在整次操作结束时
|
|
153
|
+
* 统一上报(`push()` / `bulkSync` 一轮多仓);不传时本次调用结束即自行上报。
|
|
154
|
+
* @returns 推送结果
|
|
155
|
+
*
|
|
156
|
+
* @remarks
|
|
157
|
+
* `reportRejections` 是整体替换语义:一轮操作里多次 `pushRepository` 各自上报会互相覆盖,
|
|
158
|
+
* 只剩最后一个仓库的清单,所以上报边界必须由整次操作的发起者持有。
|
|
159
|
+
* 无论成功、部分失败还是抛错,已落库的被拒都会交出去(追加进收集器或自行上报)。
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* ```ts
|
|
163
|
+
* // 在不进行级联的情况下推送 Todo 仓库
|
|
164
|
+
* const result = await pushRepository(sm, 'public', 'Todo', {
|
|
165
|
+
* includeRelated: false
|
|
166
|
+
* });
|
|
167
|
+
*
|
|
168
|
+
* // 推送 Todo 以及所有依赖它的实体(如 Comment)
|
|
169
|
+
* const result = await pushRepository(sm, 'public', 'Todo', {
|
|
170
|
+
* includeRelated: true
|
|
171
|
+
* });
|
|
172
|
+
* ```
|
|
173
|
+
*/
|
|
174
|
+
export async function pushRepository(
|
|
175
|
+
sm: SyncManager,
|
|
176
|
+
namespace: string,
|
|
177
|
+
entity: string,
|
|
178
|
+
options?: PushRepositoryOptions,
|
|
179
|
+
rejectionSink?: SyncRejection[]
|
|
180
|
+
): Promise<PushRepositoryResult> {
|
|
181
|
+
const opts: Required<PushRepositoryOptions> = {
|
|
182
|
+
batchSize: options?.batchSize === undefined ? DEFAULT_PUSH_REPOSITORY_OPTIONS.batchSize : options.batchSize,
|
|
183
|
+
includeRelated:
|
|
184
|
+
options?.includeRelated === undefined ? DEFAULT_PUSH_REPOSITORY_OPTIONS.includeRelated : options.includeRelated
|
|
185
|
+
};
|
|
186
|
+
const rxdb = sm.rxdb;
|
|
187
|
+
|
|
188
|
+
// 触发开始事件
|
|
189
|
+
rxdb.dispatchEvent(new RepositorySyncBeginEvent('push', namespace, entity, opts.includeRelated));
|
|
190
|
+
|
|
191
|
+
// 本轮 push 认领的「在飞」区间;undo 据此把还在往返途中的变更当成已推。
|
|
192
|
+
// 从哪条路径提前返回都会经下面那个 finally,认领不会泄漏。
|
|
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
|
+
};
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
assertBatchSize(opts.batchSize);
|
|
203
|
+
let result: PushRepositoryResult;
|
|
204
|
+
try {
|
|
205
|
+
result = await _pushRepositoryImpl(sm, namespace, entity, opts, inFlight, rejections);
|
|
206
|
+
} finally {
|
|
207
|
+
settleRejections();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 触发完成事件
|
|
211
|
+
rxdb.dispatchEvent(
|
|
212
|
+
new RepositorySyncCompleteEvent('push', namespace, entity, {
|
|
213
|
+
pushed: result.pushed,
|
|
214
|
+
compacted: result.compacted,
|
|
215
|
+
failed: result.failed
|
|
216
|
+
})
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
return result;
|
|
220
|
+
} catch (error) {
|
|
221
|
+
// 触发错误事件
|
|
222
|
+
rxdb.dispatchEvent(new RepositorySyncErrorEvent('push', namespace, entity, error as Error));
|
|
223
|
+
throw error;
|
|
224
|
+
} finally {
|
|
225
|
+
inFlight.release();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* pushRepository 的内部实现
|
|
231
|
+
*/
|
|
232
|
+
async function _pushRepositoryImpl(
|
|
233
|
+
sm: SyncManager,
|
|
234
|
+
namespace: string,
|
|
235
|
+
entity: string,
|
|
236
|
+
opts: Required<PushRepositoryOptions>,
|
|
237
|
+
inFlight: PushInFlightSession,
|
|
238
|
+
rejections: SyncRejection[]
|
|
239
|
+
): Promise<PushRepositoryResult> {
|
|
240
|
+
// 验证仓库是否存在
|
|
241
|
+
const EntityType = sm.rxdb.config.entities.find(e => {
|
|
242
|
+
const meta = getEntityMetadata(e);
|
|
243
|
+
return meta.namespace === namespace && meta.name === entity;
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
if (!EntityType) {
|
|
247
|
+
throw new RxDBError(`Entity not found: ${namespace}:${entity}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const metadata = getEntityMetadata(EntityType);
|
|
251
|
+
|
|
252
|
+
// 检查同步类型(支持全局配置回退)。资格判定与级联路径共用 `resolvePushIneligibility`,
|
|
253
|
+
// 避免两条路径各写一份而漂移。
|
|
254
|
+
// 同一处叠加 `RxDBSync.enabled`(此前推送路径从不读它)
|
|
255
|
+
const ineligible = await resolvePushIneligibility(
|
|
256
|
+
sm.rxdb,
|
|
257
|
+
namespace,
|
|
258
|
+
entity,
|
|
259
|
+
getSyncType(metadata, sm.rxdb.entitySync)
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
if (ineligible) {
|
|
263
|
+
throw new RxDBError(`Cannot push repository ${namespace}:${entity}: ${ineligible}.`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// 处理级联推送
|
|
267
|
+
if (opts.includeRelated) {
|
|
268
|
+
return await pushWithCascade(sm, namespace, entity, opts, inFlight, rejections);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 单仓库推送
|
|
272
|
+
return await pushSingleRepository(sm, namespace, entity, opts, inFlight, rejections);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 级联推送(包含依赖)
|
|
277
|
+
*
|
|
278
|
+
* @remarks
|
|
279
|
+
* 这里**按相位**扫两遍仓库,而不是按单一拓扑序扫一遍。
|
|
280
|
+
* 理由见 {@link topologicalSortForAction} —— INSERT 要父先、DELETE 要子先,
|
|
281
|
+
* 一个顺序不可能同时满足。
|
|
282
|
+
*
|
|
283
|
+
* 每个仓库的「查变更 + 压缩」只做一次({@link planRepositoryPush}),两个相位共用同一份
|
|
284
|
+
* 计划;落库(写 `remoteId` + 推进水位线)也只做一次,在全部相位跑完后统一提交
|
|
285
|
+
* ({@link commitPushPlans})。否则 `originalCount` / `compacted` 会被算两遍,
|
|
286
|
+
* 且第一个相位就把水位线推到最大 change id,第二个相位的变更会被整批吞掉。
|
|
287
|
+
*
|
|
288
|
+
* @internal
|
|
289
|
+
*/
|
|
290
|
+
async function pushWithCascade(
|
|
291
|
+
sm: SyncManager,
|
|
292
|
+
namespace: string,
|
|
293
|
+
entity: string,
|
|
294
|
+
options: Required<PushRepositoryOptions>,
|
|
295
|
+
inFlight: PushInFlightSession,
|
|
296
|
+
rejections: SyncRejection[]
|
|
297
|
+
): Promise<PushRepositoryResult> {
|
|
298
|
+
// 构建依赖图
|
|
299
|
+
const entities = sm.rxdb.config.entities.map(e => getEntityMetadata(e));
|
|
300
|
+
const graph = buildDependencyGraph(entities);
|
|
301
|
+
|
|
302
|
+
// 查找目标仓库及其依赖项
|
|
303
|
+
const targetKey = `${namespace}:${entity}`;
|
|
304
|
+
const dep = graph.get(targetKey);
|
|
305
|
+
|
|
306
|
+
if (!dep) {
|
|
307
|
+
throw new RxDBError(`Repository ${namespace}:${entity} not found in dependency graph`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// 收集所有需要推送的仓库(目标 + 其依赖)
|
|
311
|
+
const reposToPush = new Set<string>([targetKey]);
|
|
312
|
+
const collectDependents = (key: string): void => {
|
|
313
|
+
const d = graph.get(key);
|
|
314
|
+
if (!d) return;
|
|
315
|
+
|
|
316
|
+
for (const child of d.requiredBy) {
|
|
317
|
+
const childKey = `${child.namespace}:${child.entity}`;
|
|
318
|
+
if (!reposToPush.has(childKey)) {
|
|
319
|
+
reposToPush.add(childKey);
|
|
320
|
+
collectDependents(childKey);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
collectDependents(targetKey);
|
|
326
|
+
|
|
327
|
+
const orderRepos = (action: SortActionKind): RepositoryIdentifier[] =>
|
|
328
|
+
topologicalSortForAction(graph, action).filter(repo => reposToPush.has(repositoryKey(repo)));
|
|
329
|
+
|
|
330
|
+
const nodes = new Map<string, CascadeNode>();
|
|
331
|
+
const failedRepos = new Map<string, Error>();
|
|
332
|
+
|
|
333
|
+
for (const phase of PUSH_PHASES) {
|
|
334
|
+
for (const repo of orderRepos(phase.action)) {
|
|
335
|
+
await runCascadePhase(sm, graph, repo, phase, options, nodes, failedRepos, inFlight);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// 相位全部跑完才落库:水位线必须一次推到位,中途推进会吞掉后一个相位的变更
|
|
340
|
+
const insertOrder = orderRepos('INSERT').map(repo => {
|
|
341
|
+
const node = nodes.get(repositoryKey(repo));
|
|
342
|
+
// 每个相位都会遍历全部仓库,节点必然已建好
|
|
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
|
+
);
|
|
352
|
+
|
|
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)}`);
|
|
359
|
+
results.push(result);
|
|
360
|
+
|
|
361
|
+
// 按策略跳过(`skipped` 且 `success`)不算失败,不进失败清单
|
|
362
|
+
if (result.error) {
|
|
363
|
+
failures.push({ repository: repo, error: result.error });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// 返回目标仓库结果,并附带相关仓库的结果
|
|
368
|
+
const targetResult = results.find(r => r.repository.namespace === namespace && r.repository.entity === entity);
|
|
369
|
+
if (!targetResult) {
|
|
370
|
+
throw new RxDBError(`Internal error: target repository result not found`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
targetResult.relatedResults = results.filter(r => r !== targetResult);
|
|
374
|
+
// 目标仓的 `failures` 是整次级联的聚合(含目标仓自身);
|
|
375
|
+
// `relatedResults` 里每一项的 `failures` 只覆盖它自己那一次子调用
|
|
376
|
+
targetResult.failures = failures;
|
|
377
|
+
|
|
378
|
+
// 如果目标失败,则抛出。关联仓失败但目标仓推送成功时不抛:
|
|
379
|
+
// 已经发到远端的变更不会因为抛错而回滚,调用方重试只会重复推送;
|
|
380
|
+
// 失败清单通过 `failures` 交出去。
|
|
381
|
+
if (!targetResult.success) throwPushFailure(targetResult);
|
|
382
|
+
|
|
383
|
+
return targetResult;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* 级联推送中一个仓库的跨相位状态
|
|
388
|
+
*
|
|
389
|
+
* `result` 一旦落定就不再进入后续相位:可能是被闸门挡下(跳过 / 依赖失败)、
|
|
390
|
+
* 无变更可推、或某个相位推失败了。否则 `plan` 一直留到所有相位跑完再统一提交。
|
|
391
|
+
*
|
|
392
|
+
* @internal
|
|
393
|
+
*/
|
|
394
|
+
interface CascadeNode {
|
|
395
|
+
readonly repo: RepositoryIdentifier;
|
|
396
|
+
plan?: RepositoryPushPlan;
|
|
397
|
+
result?: PushRepositoryResult;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* 让一个仓库走完某一个相位:先过依赖闸门,再过同步资格闸门,然后只推本相位那几类动作
|
|
402
|
+
*
|
|
403
|
+
* @internal
|
|
404
|
+
*/
|
|
405
|
+
async function runCascadePhase(
|
|
406
|
+
sm: SyncManager,
|
|
407
|
+
graph: DependencyGraph,
|
|
408
|
+
repo: RepositoryIdentifier,
|
|
409
|
+
phase: PushPhase,
|
|
410
|
+
options: Required<PushRepositoryOptions>,
|
|
411
|
+
nodes: Map<string, CascadeNode>,
|
|
412
|
+
failedRepos: Map<string, Error>,
|
|
413
|
+
inFlight: PushInFlightSession
|
|
414
|
+
): Promise<void> {
|
|
415
|
+
const repoKey = repositoryKey(repo);
|
|
416
|
+
let node = nodes.get(repoKey);
|
|
417
|
+
if (!node) {
|
|
418
|
+
node = { repo };
|
|
419
|
+
nodes.set(repoKey, node);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// 上一个相位已经定案(跳过 / 无变更 / 推失败),不再往下推
|
|
423
|
+
if (node.result) return;
|
|
424
|
+
|
|
425
|
+
// 阻断边随相位翻转:DELETE 相位子先父后(被 requiredBy 阻断),
|
|
426
|
+
// INSERT 相位父先子后(被 dependsOn 阻断)。理由见 findBlockingDependency 的 @remarks。
|
|
427
|
+
const blocked = findBlockingDependency(graph, repoKey, failedRepos, dependencyEdgeForAction(phase.action));
|
|
428
|
+
if (blocked) {
|
|
429
|
+
const error = new RxDBDependencyFailedError(repo, blocked.dependency, blocked.cause);
|
|
430
|
+
node.result = {
|
|
431
|
+
...blockedPushProgress(node.plan),
|
|
432
|
+
repository: repo,
|
|
433
|
+
success: false,
|
|
434
|
+
skipped: `dependency ${repositoryKey(blocked.dependency)} failed`,
|
|
435
|
+
error
|
|
436
|
+
};
|
|
437
|
+
failedRepos.set(repoKey, error);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (!node.plan) {
|
|
442
|
+
// 级联节点必须走和单仓路径同一份资格校验,否则 `remote` / `local` / `none`
|
|
443
|
+
// 的关联仓会被无差别推去远端
|
|
444
|
+
const ineligible = await cascadeNodeIneligibility(sm, repo);
|
|
445
|
+
if (ineligible) {
|
|
446
|
+
node.result = { ...emptyPushProgress(), repository: repo, success: true, skipped: ineligible };
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
try {
|
|
451
|
+
const planned = await planRepositoryPush(sm, repo.namespace, repo.entity, inFlight);
|
|
452
|
+
if ('emptyResult' in planned) {
|
|
453
|
+
node.result = { ...planned.emptyResult, success: planned.emptyResult.success ?? true };
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
node.plan = planned;
|
|
457
|
+
} catch (error) {
|
|
458
|
+
const normalized = error instanceof Error ? error : new Error(String(error));
|
|
459
|
+
node.result = {
|
|
460
|
+
...emptyPushProgress(),
|
|
461
|
+
repository: repo,
|
|
462
|
+
success: false,
|
|
463
|
+
error: normalized,
|
|
464
|
+
failures: [{ repository: repo, error: normalized }]
|
|
465
|
+
};
|
|
466
|
+
failedRepos.set(repoKey, normalized);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
await pushPlanEntries(node.plan, phase.kinds, options.batchSize);
|
|
472
|
+
|
|
473
|
+
// 本相位推失败:立刻定案,后续相位不再推,并把失败传导给依赖它的仓库
|
|
474
|
+
if (node.plan.error) {
|
|
475
|
+
// 有条目没拿到回执:什么都不落,被拒也不算,下一轮整批重推
|
|
476
|
+
node.result = pushRepositoryResult(node.plan, node.plan.pushed, 0, node.plan.error);
|
|
477
|
+
failedRepos.set(repoKey, node.plan.error);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* 判定级联节点是否没有推送资格
|
|
483
|
+
*
|
|
484
|
+
* @internal
|
|
485
|
+
*/
|
|
486
|
+
async function cascadeNodeIneligibility(sm: SyncManager, repo: RepositoryIdentifier): Promise<string | undefined> {
|
|
487
|
+
const EntityType = sm.rxdb.config.entities.find(e => {
|
|
488
|
+
const meta = getEntityMetadata(e);
|
|
489
|
+
return meta.namespace === repo.namespace && meta.name === repo.entity;
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// 依赖图由 `config.entities` 构建,节点必然能反查回实体类
|
|
493
|
+
if (!EntityType) {
|
|
494
|
+
throw new RxDBError(`Internal error: entity not found for cascade node ${repositoryKey(repo)}`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// 关联仓被单独关掉时同样跳过 —— 级联不是绕开开关的后门
|
|
498
|
+
return resolvePushIneligibility(
|
|
499
|
+
sm.rxdb,
|
|
500
|
+
repo.namespace,
|
|
501
|
+
repo.entity,
|
|
502
|
+
getSyncType(getEntityMetadata(EntityType), sm.rxdb.entitySync)
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* 仓库级「零进度」基线:跳过或失败前一条都没推送时用它填充计数字段
|
|
508
|
+
*
|
|
509
|
+
* @internal
|
|
510
|
+
*/
|
|
511
|
+
function emptyPushProgress(): Omit<PushRepositoryResult, 'repository'> {
|
|
512
|
+
return { pushed: 0, rejected: 0, failed: 0, compacted: 0, originalCount: 0, failures: [] };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* 依赖失败时,把这个节点**已经发给远端**的进度如实交出去。
|
|
517
|
+
*
|
|
518
|
+
* 阻断是逐相位判定的:DELETE 相位可能已经把删除动作推上去了,
|
|
519
|
+
* 到 INSERT 相位才撞上依赖失败。此前这里无条件铺 {@link emptyPushProgress},
|
|
520
|
+
* 于是「远端确实收到了几条,结果里记作 0」—— 而水位线又因为本轮失败不会推进,
|
|
521
|
+
* 调用方从计数上完全看不出发生过部分推送,回头对账「远端为什么多出几条」时无从查起。
|
|
522
|
+
*
|
|
523
|
+
* `failed` 与 {@link pushRepositoryResult} 同一个算式(`effectiveCount - pushed`),
|
|
524
|
+
* 保证「阻断」和「提交失败」两条路径交出的计数可以直接相加。
|
|
525
|
+
*
|
|
526
|
+
* 第一个相位就被阻断时没有 `plan`,各计数本就该是 0。
|
|
527
|
+
*
|
|
528
|
+
* @param plan - 该节点已完成的推送计划;尚未进入规划阶段时为 `undefined`
|
|
529
|
+
* @returns 除 `repository` 外的进度字段
|
|
530
|
+
*
|
|
531
|
+
* @internal
|
|
532
|
+
*/
|
|
533
|
+
function blockedPushProgress(plan: RepositoryPushPlan | undefined): Omit<PushRepositoryResult, 'repository'> {
|
|
534
|
+
if (!plan) return emptyPushProgress();
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
pushed: plan.pushed,
|
|
538
|
+
// 没落库的被拒不算被拒:标记没写、对齐没做,下一轮会原样重推
|
|
539
|
+
rejected: 0,
|
|
540
|
+
failed: plan.effectiveCount - plan.pushed,
|
|
541
|
+
compacted: plan.compacted,
|
|
542
|
+
originalCount: plan.originalCount,
|
|
543
|
+
failures: []
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
type CompactedActionKind = 'deletes' | 'updates' | 'inserts';
|
|
548
|
+
|
|
549
|
+
interface CompactedPushEntry {
|
|
550
|
+
/** 源变更所属分支,也是这一条推送时的目标分支,见 {@link compactPushEntriesByBranch} */
|
|
551
|
+
branchId: string;
|
|
552
|
+
actionKind: CompactedActionKind;
|
|
553
|
+
key: string;
|
|
554
|
+
action: SwitchVersionChange;
|
|
555
|
+
sourceChanges: RxDBChange[];
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
interface CompactedPushBatch {
|
|
559
|
+
actions: SwitchVersionActions;
|
|
560
|
+
sourceChanges: RxDBChange[];
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* 一个推送相位:本相位提交哪些动作类型,以及仓库该按什么顺序走。
|
|
565
|
+
*
|
|
566
|
+
* 分相位的理由见 {@link topologicalSortForAction}:INSERT 要父先、DELETE 要子先,
|
|
567
|
+
* 一遍扫描不可能同时满足,只能拆成两趟。DELETE 先走完再走 INSERT/UPDATE ——
|
|
568
|
+
* 反过来的话,「删掉旧父行 + 新建子行指向新父行」这种批次会在中途出现
|
|
569
|
+
* 子行指向一个即将被删的父行的瞬态。
|
|
570
|
+
*/
|
|
571
|
+
interface PushPhase {
|
|
572
|
+
readonly action: SortActionKind;
|
|
573
|
+
readonly kinds: ReadonlySet<CompactedActionKind>;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const PUSH_PHASES: readonly PushPhase[] = [
|
|
577
|
+
{ action: 'DELETE', kinds: new Set<CompactedActionKind>(['deletes']) },
|
|
578
|
+
{ action: 'INSERT', kinds: new Set<CompactedActionKind>(['updates', 'inserts']) }
|
|
579
|
+
];
|
|
580
|
+
|
|
581
|
+
/** 单仓路径不涉及跨仓顺序,三类一次推完。 */
|
|
582
|
+
const ALL_ACTION_KINDS: ReadonlySet<CompactedActionKind> = new Set<CompactedActionKind>([
|
|
583
|
+
'deletes',
|
|
584
|
+
'updates',
|
|
585
|
+
'inserts'
|
|
586
|
+
]);
|
|
587
|
+
|
|
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[] {
|
|
648
|
+
const changesByKey = new Map<string, RxDBChange[]>();
|
|
649
|
+
for (const change of localChanges) {
|
|
650
|
+
const key = getRxDBChangeKey(change);
|
|
651
|
+
const groupedChanges = changesByKey.get(key) ?? [];
|
|
652
|
+
groupedChanges.push(change);
|
|
653
|
+
changesByKey.set(key, groupedChanges);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const pushEntries: CompactedPushEntry[] = [];
|
|
657
|
+
const appendEntries = (actionKind: CompactedActionKind, entries: SwitchVersionActions['inserts']): void => {
|
|
658
|
+
for (const [key, action] of entries) {
|
|
659
|
+
const sourceChanges = changesByKey.get(key);
|
|
660
|
+
if (!sourceChanges?.length) {
|
|
661
|
+
throw new RxDBError(`Missing source changes for compacted action: ${key}`);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
pushEntries.push({
|
|
665
|
+
branchId,
|
|
666
|
+
actionKind,
|
|
667
|
+
key,
|
|
668
|
+
action,
|
|
669
|
+
sourceChanges
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
appendEntries('deletes', actions.deletes);
|
|
675
|
+
appendEntries('updates', actions.updates);
|
|
676
|
+
appendEntries('inserts', actions.inserts);
|
|
677
|
+
|
|
678
|
+
return pushEntries;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function createCompactedPushBatch(entries: CompactedPushEntry[]): CompactedPushBatch {
|
|
682
|
+
const actions: SwitchVersionActions = {
|
|
683
|
+
deletes: new Map(),
|
|
684
|
+
updates: new Map(),
|
|
685
|
+
inserts: new Map()
|
|
686
|
+
};
|
|
687
|
+
const sourceChanges: RxDBChange[] = [];
|
|
688
|
+
|
|
689
|
+
for (const entry of entries) {
|
|
690
|
+
actions[entry.actionKind].set(entry.key, entry.action);
|
|
691
|
+
for (const sourceChange of entry.sourceChanges) {
|
|
692
|
+
sourceChanges.push(sourceChange);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
sourceChanges.sort((left, right) => left.id - right.id);
|
|
697
|
+
return {
|
|
698
|
+
actions,
|
|
699
|
+
sourceChanges
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
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>();
|
|
714
|
+
|
|
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}`);
|
|
718
|
+
}
|
|
719
|
+
if (seenLocalIds.has(result.localId)) {
|
|
720
|
+
throw new RxDBError(`Remote merge result duplicates local change: ${result.localId}`);
|
|
721
|
+
}
|
|
722
|
+
seenLocalIds.add(result.localId);
|
|
723
|
+
}
|
|
724
|
+
|
|
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;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async function mergePushBatch(
|
|
782
|
+
remoteAdapter: RxDBAdapterRemoteBase,
|
|
783
|
+
branchId: string,
|
|
784
|
+
entries: CompactedPushEntry[]
|
|
785
|
+
): Promise<ClassifiedPushBatch> {
|
|
786
|
+
const batch = createCompactedPushBatch(entries);
|
|
787
|
+
const mergeResult = await remoteAdapter.mergeChanges(batch.actions, branchId, batch.sourceChanges);
|
|
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;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* 一个仓库这一次推送的全部状态:查一次、压缩一次,之后各相位只从这里取自己那部分。
|
|
810
|
+
*
|
|
811
|
+
* 拆出这层是分相位的前提。若让两个相位各自调一遍
|
|
812
|
+
* 「查变更 → 压缩 → 推 → 推进水位线」,`originalCount` / `compacted` 会被算两遍,
|
|
813
|
+
* 水位线也会在第一个相位就提前推进,把第二个相位的变更整批吞掉。
|
|
814
|
+
*
|
|
815
|
+
* @internal
|
|
816
|
+
*/
|
|
817
|
+
interface RepositoryPushPlan {
|
|
818
|
+
readonly repository: RepositoryIdentifier;
|
|
819
|
+
readonly entityType: EntityType;
|
|
820
|
+
readonly entries: CompactedPushEntry[];
|
|
821
|
+
readonly localChanges: RxDBChange[];
|
|
822
|
+
readonly repoSync: RxDBSync;
|
|
823
|
+
readonly localAdapter: Awaited<ReturnType<SyncManager['getLocalRepositories']>>['adapter'];
|
|
824
|
+
readonly remoteAdapter: RxDBAdapterRemoteBase;
|
|
825
|
+
readonly branchId: string;
|
|
826
|
+
/** 规划时的 {@link SyncManager.branchSwitchGeneration};提交时据此识别远端往返期间的分支切换。 */
|
|
827
|
+
readonly branchGeneration: number;
|
|
828
|
+
readonly originalCount: number;
|
|
829
|
+
readonly effectiveCount: number;
|
|
830
|
+
readonly compacted: number;
|
|
831
|
+
/** 各相位累积被远端接受(applied)的条目数;被拒的不算。 */
|
|
832
|
+
pushed: number;
|
|
833
|
+
/** 各相位累积拿到的远端 id,最终一并落库。 */
|
|
834
|
+
readonly remoteIdsByChange: Map<RxDBChange, number>;
|
|
835
|
+
/** 各相位累积被远端拒绝的条目,最终与远端 id 同一个事务落库。 */
|
|
836
|
+
readonly rejectedEntries: RejectedPushEntry[];
|
|
837
|
+
error?: Error;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* 阶段一:查出待推变更并压缩,但**一条都不推**。
|
|
842
|
+
*
|
|
843
|
+
* 返回 `{ emptyResult }` 表示这个仓库这一轮无事可做 —— 要么本就没有未推变更,
|
|
844
|
+
* 要么整批被本地压缩抵消;后者该推进的水位线已在此推进。
|
|
845
|
+
*
|
|
846
|
+
* @internal
|
|
847
|
+
*/
|
|
848
|
+
async function planRepositoryPush(
|
|
849
|
+
sm: SyncManager,
|
|
850
|
+
namespace: string,
|
|
851
|
+
entity: string,
|
|
852
|
+
inFlight: PushInFlightSession
|
|
853
|
+
): Promise<RepositoryPushPlan | { emptyResult: PushRepositoryResult }> {
|
|
854
|
+
const rxdb = sm.rxdb;
|
|
855
|
+
|
|
856
|
+
// 验证远端适配器
|
|
857
|
+
const remoteAdapterName = rxdb.config.sync?.remote?.adapter;
|
|
858
|
+
if (!remoteAdapterName) {
|
|
859
|
+
throw new RxDBError('Remote adapter not configured.');
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
await sm.getRemoteRepositories(); // 确保远端已配置
|
|
863
|
+
const { adapter: localAdapter } = await sm.getLocalRepositories();
|
|
864
|
+
|
|
865
|
+
// 代际先于分支读取:两者之间若插进一次切换,提交时代际对不上,宁可多拒一轮也不错配
|
|
866
|
+
const branchGeneration = sm.branchSwitchGeneration;
|
|
867
|
+
// 获取当前分支
|
|
868
|
+
const branch = await sm.getCurrentBranch();
|
|
869
|
+
|
|
870
|
+
// 获取或创建 RxDBSync 记录
|
|
871
|
+
const repoSyncRepo = localAdapter.getRepository(RxDBSync);
|
|
872
|
+
|
|
873
|
+
const EntityType = sm.rxdb.config.entities.find(e => {
|
|
874
|
+
const meta = getEntityMetadata(e);
|
|
875
|
+
return meta.namespace === namespace && meta.name === entity;
|
|
876
|
+
});
|
|
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);
|
|
881
|
+
|
|
882
|
+
const repoSync = await getOrCreateSyncRecord(
|
|
883
|
+
repoSyncRepo,
|
|
884
|
+
{
|
|
885
|
+
namespace,
|
|
886
|
+
entity,
|
|
887
|
+
branchId: branch.id,
|
|
888
|
+
syncType
|
|
889
|
+
},
|
|
890
|
+
() => rxdb.entityManager.instantiate(RxDBSync)
|
|
891
|
+
);
|
|
892
|
+
|
|
893
|
+
const lastPushedChangeId: number | null = repoSync.lastPushedChangeId;
|
|
894
|
+
|
|
895
|
+
// 获取祖先分支列表(包含自身),查询所有祖先分支的未推送变更
|
|
896
|
+
const branchIds = await getAncestorBranchIds(sm, branch.id);
|
|
897
|
+
|
|
898
|
+
const changeRepo = localAdapter.getRepository(RxDBChange);
|
|
899
|
+
|
|
900
|
+
let localChanges: RxDBChange[];
|
|
901
|
+
if (branchIds.length === 1) {
|
|
902
|
+
// 单分支(main 或无父分支),沿用原逻辑
|
|
903
|
+
localChanges = await queryUnpushedChanges(changeRepo, branchIds, lastPushedChangeId, namespace, [entity]);
|
|
904
|
+
} else {
|
|
905
|
+
// 多分支:收集每个祖先分支的水位线,用最小值查询
|
|
906
|
+
let minWatermark: number | null = lastPushedChangeId;
|
|
907
|
+
for (const ancestorBranchId of branchIds.slice(1)) {
|
|
908
|
+
const ancestorSyncId = `${namespace}:${entity}:${ancestorBranchId}`;
|
|
909
|
+
const ancestorSyncResults = await repoSyncRepo.find({
|
|
910
|
+
where: { combinator: 'and', rules: [{ field: 'id', operator: '=', value: ancestorSyncId }] },
|
|
911
|
+
limit: 1
|
|
912
|
+
});
|
|
913
|
+
const ancestorWatermark = ancestorSyncResults[0]?.lastPushedChangeId ?? null;
|
|
914
|
+
if (ancestorWatermark === null) {
|
|
915
|
+
minWatermark = null;
|
|
916
|
+
break;
|
|
917
|
+
}
|
|
918
|
+
if (minWatermark === null || ancestorWatermark < minWatermark) {
|
|
919
|
+
minWatermark = ancestorWatermark;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
localChanges = await queryUnpushedChanges(changeRepo, branchIds, minWatermark, namespace, [entity]);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const originalCount = localChanges.length;
|
|
926
|
+
|
|
927
|
+
if (originalCount === 0) {
|
|
928
|
+
return { emptyResult: { ...emptyPushProgress(), repository: { namespace, entity } } };
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// 压缩变更:按变更自己的分支分组,见 compactPushEntriesByBranch
|
|
932
|
+
const entries = compactPushEntriesByBranch(localChanges, branchIds);
|
|
933
|
+
const effectiveCount = entries.length;
|
|
934
|
+
const compacted = originalCount - effectiveCount;
|
|
935
|
+
|
|
936
|
+
// 无有效变更:整批被本地压缩抵消(如 INSERT → DELETE 且服务器从未见过这条数据)。
|
|
937
|
+
//
|
|
938
|
+
// 仍然必须推进水位线:这些变更的 `remoteId` / `revertChangeId` 永远保持 null,
|
|
939
|
+
// 后续任何一次 push 都不可能把它们发出去。不推进的话每次 push 都会重新查出、
|
|
940
|
+
// 重新压缩同一批,历史越长开销越大;`calculatePushableCount` 用的也是这条水位线,
|
|
941
|
+
// 会把它们永远算作「待推送」。成功路径同样是用**全部** localChanges 的最大 id
|
|
942
|
+
// (而非实际推送的那些)推进水位线,这里保持一致。
|
|
943
|
+
//
|
|
944
|
+
// 不写 `lastPushedAt`:没有任何数据真的发到远端,那是纯展示字段,不该被伪造。
|
|
945
|
+
if (effectiveCount === 0) {
|
|
946
|
+
const maxChangeId = localChanges.reduce((max, c) => (c.id > max ? c.id : max), localChanges[0].id);
|
|
947
|
+
await repoSyncRepo.update(repoSync, { lastPushedChangeId: maxChangeId, updatedAt: new Date() });
|
|
948
|
+
|
|
949
|
+
return {
|
|
950
|
+
emptyResult: {
|
|
951
|
+
...emptyPushProgress(),
|
|
952
|
+
repository: { namespace, entity },
|
|
953
|
+
compacted: originalCount,
|
|
954
|
+
originalCount
|
|
955
|
+
}
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// 获取远端适配器
|
|
960
|
+
const { adapter: remoteAdapter } = await sm.getRemoteRepositories();
|
|
961
|
+
|
|
962
|
+
// 认领必须在**返回计划之前**:调用方拿到计划的下一步就是往远端发,
|
|
963
|
+
// 认领晚一拍就等于把那一拍重新暴露给 undo。
|
|
964
|
+
inFlight.claim(
|
|
965
|
+
`${namespace}:${entity}`,
|
|
966
|
+
localChanges.reduce((max, c) => (c.id > max ? c.id : max), localChanges[0].id)
|
|
967
|
+
);
|
|
968
|
+
|
|
969
|
+
return {
|
|
970
|
+
repository: { namespace, entity },
|
|
971
|
+
entityType: EntityType,
|
|
972
|
+
entries,
|
|
973
|
+
localChanges,
|
|
974
|
+
repoSync,
|
|
975
|
+
localAdapter,
|
|
976
|
+
remoteAdapter,
|
|
977
|
+
branchId: branch.id,
|
|
978
|
+
branchGeneration,
|
|
979
|
+
originalCount,
|
|
980
|
+
effectiveCount,
|
|
981
|
+
compacted,
|
|
982
|
+
pushed: 0,
|
|
983
|
+
remoteIdsByChange: new Map<RxDBChange, number>(),
|
|
984
|
+
rejectedEntries: []
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* 阶段二:把计划里属于本相位的条目发给远端,**不落库**。
|
|
990
|
+
*
|
|
991
|
+
* applied 的条目累加进 `plan.pushed`,拿到的远端 id 累加进 `plan.remoteIdsByChange`,
|
|
992
|
+
* 被拒的条目累加进 `plan.rejectedEntries`,都留给 {@link commitPushPlans} 一次性提交。
|
|
993
|
+
*
|
|
994
|
+
* 前一个相位已失败时直接返回:继续推只会在一个已知不会落库的批次上白跑一趟远端。
|
|
995
|
+
*
|
|
996
|
+
* @internal
|
|
997
|
+
*/
|
|
998
|
+
async function pushPlanEntries(
|
|
999
|
+
plan: RepositoryPushPlan,
|
|
1000
|
+
kinds: ReadonlySet<CompactedActionKind>,
|
|
1001
|
+
batchSize: number
|
|
1002
|
+
): Promise<void> {
|
|
1003
|
+
if (plan.error) return;
|
|
1004
|
+
|
|
1005
|
+
const entries = plan.entries.filter(entry => kinds.has(entry.actionKind));
|
|
1006
|
+
if (entries.length === 0) return;
|
|
1007
|
+
|
|
1008
|
+
const { namespace, entity } = plan.repository;
|
|
1009
|
+
|
|
1010
|
+
for (const { branchId, entries: batchEntries } of splitPushBatches(entries, batchSize)) {
|
|
1011
|
+
try {
|
|
1012
|
+
const classified = await mergePushBatch(plan.remoteAdapter, branchId, batchEntries);
|
|
1013
|
+
for (const { change, remoteId } of classified.applied) {
|
|
1014
|
+
plan.remoteIdsByChange.set(change, remoteId);
|
|
1015
|
+
}
|
|
1016
|
+
for (const rejected of classified.rejected) plan.rejectedEntries.push(rejected);
|
|
1017
|
+
plan.pushed += batchEntries.length - classified.rejected.length;
|
|
1018
|
+
} catch (error) {
|
|
1019
|
+
console.error(`Error pushing changes to remote for repository [${namespace}/${entity}]:`, error);
|
|
1020
|
+
plan.error = error instanceof Error ? error : new Error(String(error));
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* 一次 `mergeChanges` 的条目:同属一个目标分支。
|
|
1028
|
+
*
|
|
1029
|
+
* @internal
|
|
1030
|
+
*/
|
|
1031
|
+
interface PushBatchSlice {
|
|
1032
|
+
readonly branchId: string;
|
|
1033
|
+
readonly entries: CompactedPushEntry[];
|
|
1034
|
+
}
|
|
1035
|
+
|
|
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
|
+
}
|
|
1057
|
+
|
|
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;
|
|
1095
|
+
|
|
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));
|
|
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));
|
|
1107
|
+
}
|
|
1108
|
+
return results;
|
|
1109
|
+
}
|
|
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;
|
|
1124
|
+
return {
|
|
1125
|
+
repository,
|
|
1126
|
+
success: failed === 0,
|
|
1127
|
+
error,
|
|
1128
|
+
pushed,
|
|
1129
|
+
rejected,
|
|
1130
|
+
failed,
|
|
1131
|
+
compacted: plan.compacted,
|
|
1132
|
+
originalCount: plan.originalCount,
|
|
1133
|
+
failures: error ? [{ repository, error }] : []
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
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
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* 单仓库推送变更(内部实现)
|
|
1470
|
+
*
|
|
1471
|
+
* 不涉及跨仓顺序,三类动作一趟推完。
|
|
1472
|
+
*
|
|
1473
|
+
* @internal
|
|
1474
|
+
*/
|
|
1475
|
+
async function pushSingleRepository(
|
|
1476
|
+
sm: SyncManager,
|
|
1477
|
+
namespace: string,
|
|
1478
|
+
entity: string,
|
|
1479
|
+
options: Required<PushRepositoryOptions>,
|
|
1480
|
+
inFlight: PushInFlightSession,
|
|
1481
|
+
rejections: SyncRejection[]
|
|
1482
|
+
): Promise<PushRepositoryResult> {
|
|
1483
|
+
const planned = await planRepositoryPush(sm, namespace, entity, inFlight);
|
|
1484
|
+
if ('emptyResult' in planned) return planned.emptyResult;
|
|
1485
|
+
|
|
1486
|
+
await pushPlanEntries(planned, ALL_ACTION_KINDS, options.batchSize);
|
|
1487
|
+
const result = (await commitPushPlans(sm, [planned], rejections)).get(planned);
|
|
1488
|
+
if (!result) throw new RxDBError(`Internal error: push result missing for ${namespace}:${entity}`);
|
|
1489
|
+
// 与级联路径同一个失败出口,见 throwPushFailure 的 @remarks
|
|
1490
|
+
if (!result.success) throwPushFailure(result);
|
|
1491
|
+
return result;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
/**
|
|
1495
|
+
* 查询指定仓库的未推送变更
|
|
1496
|
+
*
|
|
1497
|
+
* @internal
|
|
1498
|
+
*/
|
|
1499
|
+
async function queryUnpushedChanges(
|
|
1500
|
+
changeRepo: IRepository<typeof RxDBChange>,
|
|
1501
|
+
branchIds: string[],
|
|
1502
|
+
lastPushedChangeId: number | null,
|
|
1503
|
+
namespace: string,
|
|
1504
|
+
entityFilter: string[]
|
|
1505
|
+
): Promise<RxDBChange[]> {
|
|
1506
|
+
const baseRules: RxDBChangeRuleGroup['rules'] = [
|
|
1507
|
+
{ field: 'revertChangeId', operator: '=', value: null },
|
|
1508
|
+
{ field: 'remoteId', operator: '=', value: null },
|
|
1509
|
+
// 被拒变更(rejectedAt 非空)的 remoteId 也一直是 null,但它永远不会再被推送,
|
|
1510
|
+
// 必须和 remoteId = null 一起排除,否则会被当成待推变更反复捞出来
|
|
1511
|
+
{ field: 'rejectedAt', operator: '=', value: null },
|
|
1512
|
+
// 实体名在不同 namespace 下可以重名,只按 entity 过滤会把别的 namespace 的
|
|
1513
|
+
// 变更一起捞出来错推,同时把对方的水位线推进导致后续漏推
|
|
1514
|
+
{ field: 'namespace', operator: '=', value: namespace }
|
|
1515
|
+
];
|
|
1516
|
+
|
|
1517
|
+
// 分支过滤(支持多分支)
|
|
1518
|
+
if (branchIds.length === 1) {
|
|
1519
|
+
baseRules.push({ field: 'branchId', operator: '=', value: branchIds[0] });
|
|
1520
|
+
} else {
|
|
1521
|
+
baseRules.push({ field: 'branchId', operator: 'in', value: branchIds });
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// 添加仓库过滤(实体名)
|
|
1525
|
+
if (entityFilter && entityFilter.length > 0) {
|
|
1526
|
+
baseRules.push({ field: 'entity', operator: 'in', value: entityFilter });
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// 添加变更 ID 过滤
|
|
1530
|
+
if (lastPushedChangeId !== null) {
|
|
1531
|
+
baseRules.push({ field: 'id', operator: '>', value: lastPushedChangeId });
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
return await changeRepo.find({
|
|
1535
|
+
where: {
|
|
1536
|
+
combinator: 'and',
|
|
1537
|
+
rules: baseRules
|
|
1538
|
+
},
|
|
1539
|
+
orderBy: [{ field: 'id', sort: 'asc' }]
|
|
1540
|
+
});
|
|
1541
|
+
}
|