@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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -2
  3. package/dist/SyncManager.d.ts +426 -0
  4. package/dist/SyncManager.d.ts.map +1 -0
  5. package/dist/branch-change-id.d.ts +31 -0
  6. package/dist/branch-change-id.d.ts.map +1 -0
  7. package/dist/branch-materialization-source.d.ts +22 -0
  8. package/dist/branch-materialization-source.d.ts.map +1 -0
  9. package/dist/branch-utils.d.ts +40 -0
  10. package/dist/branch-utils.d.ts.map +1 -0
  11. package/dist/bulk-sync.d.ts +117 -0
  12. package/dist/bulk-sync.d.ts.map +1 -0
  13. package/dist/cascade-blocking.d.ts +38 -0
  14. package/dist/cascade-blocking.d.ts.map +1 -0
  15. package/dist/check-repository-updates.d.ts +47 -0
  16. package/dist/check-repository-updates.d.ts.map +1 -0
  17. package/dist/cleanup-expired.d.ts +56 -0
  18. package/dist/cleanup-expired.d.ts.map +1 -0
  19. package/dist/dependency-graph.d.ts +128 -0
  20. package/dist/dependency-graph.d.ts.map +1 -0
  21. package/dist/get-all-repository-sync-status.d.ts +58 -0
  22. package/dist/get-all-repository-sync-status.d.ts.map +1 -0
  23. package/dist/get-repository-sync-status.d.ts +68 -0
  24. package/dist/get-repository-sync-status.d.ts.map +1 -0
  25. package/dist/index.d.ts +39 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +3773 -0
  28. package/dist/plugin.d.ts +74 -0
  29. package/dist/plugin.d.ts.map +1 -0
  30. package/dist/pull-ancestor-changes.d.ts +41 -0
  31. package/dist/pull-ancestor-changes.d.ts.map +1 -0
  32. package/dist/pull-batch.d.ts +16 -0
  33. package/dist/pull-batch.d.ts.map +1 -0
  34. package/dist/pull-conflict-utils.d.ts +43 -0
  35. package/dist/pull-conflict-utils.d.ts.map +1 -0
  36. package/dist/pull-repository.d.ts +55 -0
  37. package/dist/pull-repository.d.ts.map +1 -0
  38. package/dist/pull-round.d.ts +41 -0
  39. package/dist/pull-round.d.ts.map +1 -0
  40. package/dist/pull.d.ts +16 -0
  41. package/dist/pull.d.ts.map +1 -0
  42. package/dist/push-branch.d.ts +22 -0
  43. package/dist/push-branch.d.ts.map +1 -0
  44. package/dist/push-repository.d.ts +79 -0
  45. package/dist/push-repository.d.ts.map +1 -0
  46. package/dist/push.d.ts +14 -0
  47. package/dist/push.d.ts.map +1 -0
  48. package/dist/query-cache-outbox.d.ts +120 -0
  49. package/dist/query-cache-outbox.d.ts.map +1 -0
  50. package/dist/sync-branches.d.ts +81 -0
  51. package/dist/sync-branches.d.ts.map +1 -0
  52. package/dist/sync-listeners.d.ts +20 -0
  53. package/dist/sync-listeners.d.ts.map +1 -0
  54. package/dist/sync-manager.utils.d.ts +36 -0
  55. package/dist/sync-manager.utils.d.ts.map +1 -0
  56. package/dist/sync-repository.d.ts +64 -0
  57. package/dist/sync-repository.d.ts.map +1 -0
  58. package/dist/topological-sort.d.ts +126 -0
  59. package/dist/topological-sort.d.ts.map +1 -0
  60. package/package.json +60 -3
  61. package/src/SyncManager.ts +690 -0
  62. package/src/branch-change-id.ts +70 -0
  63. package/src/branch-materialization-source.ts +445 -0
  64. package/src/branch-utils.ts +74 -0
  65. package/src/bulk-sync.ts +349 -0
  66. package/src/cascade-blocking.ts +61 -0
  67. package/src/check-repository-updates.ts +143 -0
  68. package/src/cleanup-expired.ts +285 -0
  69. package/src/dependency-graph.ts +324 -0
  70. package/src/get-all-repository-sync-status.ts +115 -0
  71. package/src/get-repository-sync-status.ts +237 -0
  72. package/src/index.ts +58 -0
  73. package/src/plugin.ts +132 -0
  74. package/src/pull-ancestor-changes.ts +61 -0
  75. package/src/pull-batch.ts +386 -0
  76. package/src/pull-conflict-utils.ts +284 -0
  77. package/src/pull-repository.ts +700 -0
  78. package/src/pull-round.ts +103 -0
  79. package/src/pull.ts +204 -0
  80. package/src/push-branch.ts +62 -0
  81. package/src/push-repository.ts +1541 -0
  82. package/src/push.ts +106 -0
  83. package/src/query-cache-outbox.ts +939 -0
  84. package/src/sync-branches.ts +276 -0
  85. package/src/sync-listeners.ts +309 -0
  86. package/src/sync-manager.utils.ts +48 -0
  87. package/src/sync-repository.ts +307 -0
  88. package/src/topological-sort.ts +246 -0
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @packageDocumentation
3
+ * 一轮拉取里与「谁推的、映射回哪一行」有关的两步。
4
+ *
5
+ * @remarks
6
+ * `pullBatchOnce`(`pull-batch.ts`)与 `pullSingleRepository`(`pull-repository.ts`)走的是
7
+ * 同一段流程:**拆分自推与他人的变更 → 回填自推变更的 `remoteId` → 压缩他人变更 → 解冲突 →
8
+ * 合并进实体表 → 推水位线**。两边曾各抄一份,而抄件会分叉——next-11 评审复核时实测到的分叉
9
+ * 正在这里:一侧把「只回填还没映射过的行」写进 SQL(`remoteId = null` → `IS NULL`),
10
+ * 一侧写成 JS 守卫(`local.remoteId == null`)。两种写法当时判据相同,但**没有任何东西保证
11
+ * 下一次改动仍然相同**,而一侧漏改不会让任何用例变红。
12
+ *
13
+ * 本模块收的是那段流程里**不碰受信写原语**的前半截。后半截(`executor.mergeChanges` 与随之
14
+ * 而来的 supersession 标记)留在各自文件里,不是嫌麻烦:那一次写要按 `file`·`symbol`·`intent`
15
+ * 在 `TRUSTED_CALLSITE_REGISTRY` 里对号入座(`declareTrustedWrite`),搬进本模块等于把两个
16
+ * 登记入口合并成一个——那是核心包的适配器契约变更,不是同步插件自己能定的事。
17
+ */
18
+
19
+ import { type IRepository, type RemoteChange, type RxDBChange } from '@aiao/rxdb';
20
+
21
+ /** {@link splitRemoteChangesByOrigin} 的结果 */
22
+ export interface RemoteChangeOrigins {
23
+ /** 本客户端自己 push 上去、这轮又拉回来的变更 */
24
+ ownChanges: RemoteChange[];
25
+ /** 其他客户端产生的变更,只有这一批进 apply / conflict 链路 */
26
+ otherChanges: RemoteChange[];
27
+ }
28
+
29
+ /**
30
+ * 按 `clientId` 把一批远端变更拆成「自己推的」与「他人的」
31
+ *
32
+ * @param changes - 本轮拉到的远端变更
33
+ * @param clientId - 本客户端 id
34
+ * @returns 两个互斥的子集,顺序与入参一致
35
+ *
36
+ * @remarks
37
+ * **只认 `clientId`,不拿 `localId` 当替补**:push 走 actions-only 路径时远端记录不带 `localId`,
38
+ * 若用「有 localId 就是自己的」去判,这类记录会被判成他人的而重复应用一遍;反过来,若把
39
+ * 「没有 clientId」也算成自己的,那这批数据拉下来了却永远不进 apply 链路——静默丢数据。
40
+ * 所以缺 `clientId` 一律算他人的:多走一遍幂等的 apply,好过整批不落库。
41
+ */
42
+ export const splitRemoteChangesByOrigin = (
43
+ changes: RemoteChange[],
44
+ clientId: string | undefined
45
+ ): RemoteChangeOrigins => {
46
+ const ownChanges: RemoteChange[] = [];
47
+ const otherChanges: RemoteChange[] = [];
48
+
49
+ for (const change of changes) {
50
+ if (change.clientId != null && change.clientId === clientId) {
51
+ ownChanges.push(change);
52
+ } else {
53
+ otherChanges.push(change);
54
+ }
55
+ }
56
+
57
+ return { ownChanges, otherChanges };
58
+ };
59
+
60
+ /**
61
+ * 把自推变更拿到的远端 id 回填到对应的本地 `RxDBChange` 行上
62
+ *
63
+ * @param changeRepo - 变更表仓库;在事务里调用时必须是经 executor 取到的那一个
64
+ * @param ownChanges - {@link splitRemoteChangesByOrigin} 分出的自推变更
65
+ *
66
+ * @remarks
67
+ * 只回填 `remoteId` 还空着的行,判据写在 SQL 里(`= null` 由适配器编译成 `IS NULL`)而不是
68
+ * 取回来再用 JS 挑:少取一批行是次要的,主要是让「只写没映射过的」成为查询本身的一部分,
69
+ * 而不是一条可以被下一次改动顺手删掉的 `if`。
70
+ *
71
+ * 覆盖已有映射不是小事:`cleanupExpired` 按 `remoteId` 判「这条已经同步出去了」,
72
+ * 把旧映射改写成本轮的 id 会让保留窗口整段错位——旧变更被当成刚同步的留下来,
73
+ * 而真正该留的那批反而先到期。
74
+ *
75
+ * 没有 `localId` 的自推变更直接跳过:对不上本地行,硬回填只能靠猜。
76
+ */
77
+ export const backfillOwnChangeRemoteIds = async (
78
+ changeRepo: IRepository<typeof RxDBChange>,
79
+ ownChanges: RemoteChange[]
80
+ ): Promise<void> => {
81
+ const mappableChanges = ownChanges.filter(change => change.localId != null);
82
+ if (mappableChanges.length === 0) return;
83
+
84
+ const locals = await changeRepo.find({
85
+ where: {
86
+ combinator: 'and',
87
+ rules: [
88
+ { field: 'id', operator: 'in', value: mappableChanges.map(change => change.localId!) },
89
+ { field: 'remoteId', operator: '=', value: null },
90
+ // 被拒变更(rejectedAt 非空)的 remoteId 也一直是 null,但它永远不会再被推送,
91
+ // 不该被本轮回填覆盖掉——覆盖了反而让 cleanupExpired 的保留窗口误判
92
+ { field: 'rejectedAt', operator: '=', value: null }
93
+ ]
94
+ }
95
+ });
96
+ if (locals.length === 0) return;
97
+
98
+ const remoteIdByLocalId = new Map(mappableChanges.map(change => [change.localId!, change.id]));
99
+ for (const local of locals) {
100
+ const remoteId = remoteIdByLocalId.get(local.id);
101
+ if (remoteId != null) await changeRepo.update(local, { remoteId });
102
+ }
103
+ };
package/src/pull.ts ADDED
@@ -0,0 +1,204 @@
1
+ import {
2
+ findCurrentSyncRecord,
3
+ getEntityMetadata,
4
+ getSyncCapability,
5
+ getSyncType,
6
+ isRepositorySyncEnabled,
7
+ PullOptions,
8
+ PullRepositoryResult,
9
+ PullResult,
10
+ RxDBError,
11
+ RxDBPartialSyncError,
12
+ SyncBeginEvent,
13
+ SyncCompleteEvent,
14
+ SyncErrorEvent
15
+ } from '@aiao/rxdb';
16
+ import { BulkSyncOptions } from './bulk-sync.js';
17
+ import { pullBatch } from './pull-batch.js';
18
+ import { pullRepository } from './pull-repository.js';
19
+ import type { SyncManager } from './SyncManager.js';
20
+
21
+ /**
22
+ * Pull 功能实现
23
+ *
24
+ * 从远程拉取变更并应用到本地实体表
25
+ *
26
+ * 默认使用 pullBatch 单次 HTTP 请求拉取所有实体的变更。
27
+ * 当指定 repositoryFilter 时,降级为 bulkSync 逐实体拉取。
28
+ *
29
+ * @param sm - SyncManager 实例
30
+ * @param options - Pull 选项
31
+ * @returns Pull 结果
32
+ */
33
+ export async function pull(sm: SyncManager, options?: PullOptions): Promise<PullResult> {
34
+ const rxdb = sm.rxdb;
35
+
36
+ // 验证远程适配器配置
37
+ const remoteAdapterName = rxdb.config.sync?.remote?.adapter;
38
+ if (!remoteAdapterName) {
39
+ throw new RxDBError('Remote adapter not configured.');
40
+ }
41
+
42
+ // 触发同步开始事件
43
+ rxdb.dispatchEvent(new SyncBeginEvent('pull'));
44
+
45
+ try {
46
+ let pullResult: PullResult;
47
+
48
+ if (!options?.repositoryFilter?.length) {
49
+ // 无过滤时使用批量拉取(单次 HTTP 请求)
50
+ pullResult = await pullBatch(sm, {
51
+ limit: options?.limit,
52
+ fetchAll: options?.fetchAll,
53
+ conflictResolver: options?.conflictResolver
54
+ });
55
+ // pullBatch 对 syncType === 'filter' 的仓库直接 continue(过滤条件是逐仓库的,
56
+ // 拼不进一次批量请求)。不补这一趟,pull() 就会对 filter 仓库一条不拉却报告
57
+ // `hasMore:false, failures:[]` —— isCompletePull 判真、pullableCount 归零,
58
+ // 用户看到的是「已全部同步」。
59
+ await pullFilterRepositories(sm, options, pullResult);
60
+ } else {
61
+ // 指定仓库过滤时降级为逐仓库拉取
62
+ pullResult = await pullWithBulkSync(sm, options);
63
+ }
64
+
65
+ // 触发同步完成事件
66
+ rxdb.dispatchEvent(new SyncCompleteEvent('pull', pullResult));
67
+
68
+ return pullResult;
69
+ } catch (error) {
70
+ // 触发同步错误事件
71
+ rxdb.dispatchEvent(new SyncErrorEvent('pull', error as Error));
72
+ throw error;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * 补拉 `syncType === 'filter'` 的仓库,并把结果并进批量拉取的聚合里。
78
+ *
79
+ * @param sm - SyncManager 实例
80
+ * @param options - 调用方传给 `pull()` 的选项,`limit` / `fetchAll` 原样透传
81
+ * @param aggregated - `pullBatch` 的结果,**就地**累加
82
+ *
83
+ * @remarks
84
+ * `includeRelated: false`:批量那一趟已经覆盖了所有非 filter 仓库,再级联一次只会重复拉取。
85
+ * 单个仓库失败不中断其余仓库,失败结构化进 `failures` —— 与 `pullWithBulkSync` 同口径。
86
+ */
87
+ async function pullFilterRepositories(sm: SyncManager, options: PullOptions | undefined, aggregated: PullResult) {
88
+ const rxdb = sm.rxdb;
89
+
90
+ for (const EntityClass of rxdb.config.entities) {
91
+ const metadata = getEntityMetadata(EntityClass);
92
+ const syncType = getSyncType(metadata, rxdb.entitySync);
93
+ if (syncType !== 'filter' || !getSyncCapability(syncType).pull) continue;
94
+
95
+ const repoSync = await findCurrentSyncRecord(sm.rxdb, metadata.namespace, metadata.name);
96
+ if (!isRepositorySyncEnabled(repoSync)) continue;
97
+
98
+ let result: PullRepositoryResult;
99
+ try {
100
+ result = await pullRepository(sm, metadata.namespace, metadata.name, {
101
+ limit: options?.limit,
102
+ fetchAll: options?.fetchAll,
103
+ conflictResolver: options?.conflictResolver,
104
+ includeRelated: false
105
+ });
106
+ } catch (error) {
107
+ const normalized = error instanceof Error ? error : new Error(String(error));
108
+ // 失败前已提交的进度挂在 RxDBPartialSyncError.result 上,同样要并进聚合
109
+ if (normalized instanceof RxDBPartialSyncError) {
110
+ accumulatePullProgress(aggregated, normalized.result as PullRepositoryResult);
111
+ }
112
+ aggregated.failures.push({
113
+ repository: { namespace: metadata.namespace, entity: metadata.name },
114
+ error: normalized instanceof RxDBPartialSyncError ? normalized.cause : normalized
115
+ });
116
+ continue;
117
+ }
118
+
119
+ accumulatePullProgress(aggregated, result);
120
+ aggregated.failures.push(...result.failures);
121
+ }
122
+ }
123
+
124
+ /** 把一个仓库的拉取进度就地累加进聚合结果(不含 failures)。 */
125
+ function accumulatePullProgress(aggregated: PullResult, result: PullRepositoryResult): void {
126
+ aggregated.pulled += result.pulled;
127
+ aggregated.compacted += result.compacted;
128
+ aggregated.applied += result.applied;
129
+ aggregated.conflictsResolved += result.conflictsResolved;
130
+ aggregated.conflictsDeferred += result.conflictsDeferred;
131
+ aggregated.hasMore ||= result.hasMore;
132
+ aggregated.persistedProgress ||= result.persistedProgress;
133
+ aggregated.historyInvalidated ||= result.historyInvalidated;
134
+ }
135
+
136
+ /**
137
+ * 降级方案:使用 bulkSync 逐仓库拉取(用于指定 repositoryFilter 的场景)
138
+ */
139
+ async function pullWithBulkSync(sm: SyncManager, options: PullOptions): Promise<PullResult> {
140
+ const bulkOptions: BulkSyncOptions = {
141
+ operation: 'pull',
142
+ repositories: options.repositoryFilter?.map(item => {
143
+ if (typeof item === 'string') {
144
+ return { namespace: 'public', entity: item };
145
+ }
146
+ return item;
147
+ }),
148
+ // 此前这里漏传,逐仓库拉取时 limit/fetchAll 静默失效
149
+ pull: { limit: options.limit, fetchAll: options.fetchAll, conflictResolver: options.conflictResolver }
150
+ };
151
+
152
+ const bulkResult = await sm.bulkSync(bulkOptions);
153
+
154
+ const aggregated: PullResult = {
155
+ pulled: 0,
156
+ compacted: 0,
157
+ applied: 0,
158
+ hasMore: false,
159
+ conflictsResolved: 0,
160
+ conflictsDeferred: 0,
161
+ persistedProgress: false,
162
+ historyInvalidated: false,
163
+ failures: []
164
+ };
165
+
166
+ for (const result of bulkResult.results) {
167
+ // 失败仓库自己已提交的进度也要累加 —— 它藏在 `RxDBPartialSyncError.result`
168
+ // 里,被 bulkSync 原样挂在 `result.result` 上,只看 success 会把这段进度丢掉。
169
+ const pullResult = result.result?.pullResult;
170
+ if (pullResult) {
171
+ aggregated.pulled += pullResult.pulled;
172
+ aggregated.compacted += pullResult.compacted;
173
+ aggregated.applied += pullResult.applied;
174
+ aggregated.conflictsResolved += pullResult.conflictsResolved;
175
+ aggregated.conflictsDeferred += pullResult.conflictsDeferred;
176
+ aggregated.hasMore ||= pullResult.hasMore;
177
+ aggregated.persistedProgress ||= pullResult.persistedProgress;
178
+ aggregated.historyInvalidated ||= pullResult.historyInvalidated;
179
+ }
180
+
181
+ if (result.success) continue;
182
+
183
+ // 此前失败的仓库被静默跳过,调用方拿到「成功」的聚合结果
184
+ // 只留第一个失败会让后面几个仓库的失败无处可查,全部结构化保留
185
+ const failure =
186
+ result.error ??
187
+ new RxDBError(`Repository pull failed: ${result.repository.namespace}:${result.repository.entity}`);
188
+ aggregated.failures.push({
189
+ repository: result.repository,
190
+ // 嵌套的 partial error 解包成根因,避免调用方拿到「错误里套错误」
191
+ error: failure instanceof RxDBPartialSyncError ? failure.cause : failure
192
+ });
193
+ }
194
+
195
+ const [firstFailure] = aggregated.failures;
196
+ if (firstFailure) {
197
+ // 单个失败且零进度时包装只会多剥一层,原始错误更有用(对齐 pull-batch.ts 的约定);
198
+ // 但只要有进度、或有多于一个失败,就必须包装 —— 否则进度和其余失败都无处可查。
199
+ if (!aggregated.persistedProgress && aggregated.failures.length === 1) throw firstFailure.error;
200
+ throw new RxDBPartialSyncError<PullResult>(aggregated, firstFailure.error);
201
+ }
202
+
203
+ return aggregated;
204
+ }
@@ -0,0 +1,62 @@
1
+ import { MAIN_BRANCH_ID } from '@aiao/rxdb';
2
+ import { toRemoteFromChangeId } from './branch-change-id.js';
3
+ import type { SyncManager } from './SyncManager.js';
4
+
5
+ export interface PushBranchResult {
6
+ synced: number;
7
+ skipped: string[];
8
+ /**
9
+ * 分叉点变更还没有 `remoteId`,本次只能带 `fromChangeId: null` 上行。
10
+ *
11
+ * 调用方应在实体变更推送完成、分叉点拿到 `remoteId` 之后重推一次分支,
12
+ * 把远端那一行补全。
13
+ */
14
+ forkPointPending: boolean;
15
+ }
16
+
17
+ /**
18
+ * 将当前激活分支推送到远程
19
+ *
20
+ * 规则:
21
+ * 1. 不推送根分支({@link MAIN_BRANCH_ID})
22
+ * 2. 不修改远程分支的 activated 属性(由 SQL 函数保证)
23
+ * 3. `fromChangeId` 翻译成远端 id 后再上行(见 {@link toRemoteFromChangeId})
24
+ */
25
+ export async function pushBranch(sm: SyncManager): Promise<PushBranchResult> {
26
+ const branch = await sm.getCurrentBranch();
27
+ if (!branch || branch.id === MAIN_BRANCH_ID) {
28
+ return { synced: 0, skipped: branch ? [MAIN_BRANCH_ID] : [], forkPointPending: false };
29
+ }
30
+
31
+ const { adapter: remoteAdapter } = await sm.getRemoteRepositories();
32
+
33
+ if (!remoteAdapter.pushBranches) {
34
+ return { synced: 0, skipped: [], forkPointPending: false };
35
+ }
36
+
37
+ const { branchRepository, changeRepository } = await sm.getLocalRepositories();
38
+
39
+ // 本地 change id 对远端毫无意义,必须翻译成 remoteId 再上行。
40
+ // 分叉点还没推上去时只能发 null(远端此刻确实不知道这个分叉点),并把这件事报给调用方。
41
+ const localFromChangeId = branch.fromChangeId ?? null;
42
+ const remoteFromChangeId =
43
+ localFromChangeId === null ? null : await toRemoteFromChangeId(changeRepository, localFromChangeId);
44
+ const forkPointPending = localFromChangeId !== null && remoteFromChangeId === null;
45
+
46
+ const branchData: Record<string, unknown> = {
47
+ id: branch.id,
48
+ fromChangeId: remoteFromChangeId,
49
+ local: branch.local,
50
+ remote: true,
51
+ parentId: branch.parentId
52
+ };
53
+
54
+ const result = await remoteAdapter.pushBranches([branchData]);
55
+
56
+ // 推送成功后更新本地分支的 remote 标记
57
+ if (result.synced > 0 && !branch.remote) {
58
+ await branchRepository.update(branch, { remote: true, updatedAt: new Date() });
59
+ }
60
+
61
+ return { ...result, forkPointPending };
62
+ }