@powersync/service-module-mongodb 0.0.0-dev-20260909133214 → 0.0.0-dev-20260929095716

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/CHANGELOG.md +38 -6
  2. package/ci/test-connection.yaml +12 -0
  3. package/dist/api/MongoRouteAPIAdapter.d.ts +2 -2
  4. package/dist/api/MongoRouteAPIAdapter.js +73 -143
  5. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  6. package/dist/api/infer-collection-schema.d.ts +12 -0
  7. package/dist/api/infer-collection-schema.js +142 -0
  8. package/dist/api/infer-collection-schema.js.map +1 -0
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.js +1 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/module/MongoModule.d.ts +1 -2
  13. package/dist/module/MongoModule.js +0 -1
  14. package/dist/module/MongoModule.js.map +1 -1
  15. package/dist/replication/ChangeStream.d.ts +10 -2
  16. package/dist/replication/ChangeStream.js +228 -317
  17. package/dist/replication/ChangeStream.js.map +1 -1
  18. package/dist/replication/ChangeStreamReplicationJob.d.ts +4 -1
  19. package/dist/replication/ChangeStreamReplicationJob.js +4 -1
  20. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  21. package/dist/replication/ChangeStreamReplicator.d.ts +3 -0
  22. package/dist/replication/ChangeStreamReplicator.js +3 -0
  23. package/dist/replication/ChangeStreamReplicator.js.map +1 -1
  24. package/dist/replication/MongoRelation.js +5 -2
  25. package/dist/replication/MongoRelation.js.map +1 -1
  26. package/dist/replication/MongoReplicationQueryProvider.d.ts +87 -0
  27. package/dist/replication/MongoReplicationQueryProvider.js +23 -0
  28. package/dist/replication/MongoReplicationQueryProvider.js.map +1 -0
  29. package/dist/replication/MongoReplicationStream.d.ts +85 -0
  30. package/dist/replication/MongoReplicationStream.js +101 -0
  31. package/dist/replication/MongoReplicationStream.js.map +1 -0
  32. package/dist/replication/MongoSnapshotQuery.d.ts +2 -0
  33. package/dist/replication/MongoSnapshotQuery.js +12 -2
  34. package/dist/replication/MongoSnapshotQuery.js.map +1 -1
  35. package/dist/replication/MongoSnapshotter.d.ts +7 -2
  36. package/dist/replication/MongoSnapshotter.js +58 -85
  37. package/dist/replication/MongoSnapshotter.js.map +1 -1
  38. package/dist/replication/RawChangeStream.d.ts +1 -1
  39. package/dist/replication/RawChangeStream.js +1 -1
  40. package/dist/replication/RawChangeStream.js.map +1 -1
  41. package/dist/replication/SourceRowConverter.d.ts +1 -0
  42. package/dist/replication/SourceRowConverter.js +4 -2
  43. package/dist/replication/SourceRowConverter.js.map +1 -1
  44. package/dist/replication/bufferToSqlite.d.ts +3 -1
  45. package/dist/replication/bufferToSqlite.js +35 -23
  46. package/dist/replication/bufferToSqlite.js.map +1 -1
  47. package/dist/replication/replication-index.d.ts +6 -0
  48. package/dist/replication/replication-index.js +6 -0
  49. package/dist/replication/replication-index.js.map +1 -1
  50. package/dist/test-utils/ChangeStreamTestContext.d.ts +131 -0
  51. package/dist/test-utils/ChangeStreamTestContext.js +391 -0
  52. package/dist/test-utils/ChangeStreamTestContext.js.map +1 -0
  53. package/dist/test-utils/test-utils-index.d.ts +1 -0
  54. package/dist/test-utils/test-utils-index.js +2 -0
  55. package/dist/test-utils/test-utils-index.js.map +1 -0
  56. package/package.json +8 -8
  57. package/src/api/MongoRouteAPIAdapter.ts +79 -127
  58. package/src/api/infer-collection-schema.ts +153 -0
  59. package/src/index.ts +1 -0
  60. package/src/module/MongoModule.ts +1 -3
  61. package/src/replication/ChangeStream.ts +253 -359
  62. package/src/replication/ChangeStreamReplicationJob.ts +7 -2
  63. package/src/replication/ChangeStreamReplicator.ts +5 -0
  64. package/src/replication/MongoRelation.ts +8 -1
  65. package/src/replication/MongoReplicationQueryProvider.ts +108 -0
  66. package/src/replication/MongoReplicationStream.ts +201 -0
  67. package/src/replication/MongoSnapshotQuery.ts +19 -3
  68. package/src/replication/MongoSnapshotter.ts +70 -92
  69. package/src/replication/RawChangeStream.ts +2 -2
  70. package/src/replication/SourceRowConverter.ts +4 -2
  71. package/src/replication/bufferToSqlite.ts +43 -23
  72. package/src/replication/replication-index.ts +6 -0
  73. package/src/test-utils/ChangeStreamTestContext.ts +520 -0
  74. package/src/test-utils/test-utils-index.ts +1 -0
  75. package/test/src/buffer_to_sqlite.test.ts +9 -0
  76. package/test/src/change_stream.test.ts +3 -3
  77. package/test/src/change_stream_test_setup.ts +38 -0
  78. package/test/src/chunked_snapshot.test.ts +3 -3
  79. package/test/src/documentdb_mode.test.ts +3 -3
  80. package/test/src/replication_stream.test.ts +311 -0
  81. package/test/src/resume.test.ts +10 -4
  82. package/test/src/resuming_snapshots.test.ts +3 -3
  83. package/test/src/schema.test.ts +223 -0
  84. package/test/src/slow_tests.test.ts +3 -3
  85. package/test/src/snapshot_query_provider.test.ts +95 -0
  86. package/test/src/stream_progress.test.ts +312 -0
  87. package/tsconfig.tsbuildinfo +1 -1
  88. package/test/src/change_stream_utils.ts +0 -375
@@ -1,375 +0,0 @@
1
- import { mongo } from '@powersync/lib-service-mongodb';
2
- import { ReplicationAbortedError } from '@powersync/lib-services-framework';
3
- import {
4
- BucketStorageFactory,
5
- createCoreReplicationMetrics,
6
- initializeCoreReplicationMetrics,
7
- InternalOpId,
8
- isBatchEnd,
9
- LEGACY_STORAGE_VERSION,
10
- OplogEntry,
11
- ProtocolOpId,
12
- ReplicationCheckpoint,
13
- settledPromise,
14
- storage,
15
- SyncRulesBucketStorage,
16
- TestStorageOptions,
17
- unsettledPromise,
18
- updateSyncRulesFromYaml,
19
- utils
20
- } from '@powersync/service-core';
21
- import { bucketRequest, METRICS_HELPER, test_utils } from '@powersync/service-core-tests';
22
-
23
- import { SentinelLSN } from '@module/common/SentinelLSN.js';
24
- import { ChangeStream, ChangeStreamOptions } from '@module/replication/ChangeStream.js';
25
- import { MongoManager } from '@module/replication/MongoManager.js';
26
- import {
27
- createCheckpoint,
28
- createSentinelCheckpointLsn,
29
- STANDALONE_CHECKPOINT_ID
30
- } from '@module/replication/MongoRelation.js';
31
- import { detectDocumentDb } from '@module/replication/replication-utils.js';
32
- import { NormalizedMongoConnectionConfig } from '@module/types/types.js';
33
-
34
- import { clearTestDb, TEST_CONNECTION_OPTIONS } from './util.js';
35
-
36
- export class ChangeStreamTestContext {
37
- private _walStream?: ChangeStream;
38
- private abortController = new AbortController();
39
- private settledReplicationPromise?: Promise<PromiseSettledResult<void>>;
40
- private syncRulesContent?: storage.PersistedSyncConfigContent;
41
- public storage?: SyncRulesBucketStorage;
42
-
43
- /**
44
- * Tests operating on the mongo change stream need to configure the stream and manage asynchronous
45
- * replication, which gets a little tricky.
46
- *
47
- * This configures all the context, and tears it down afterwards.
48
- */
49
- static async open(
50
- factory: (options: TestStorageOptions) => Promise<BucketStorageFactory>,
51
- options?: {
52
- doNotClear?: boolean;
53
- storageVersion?: number;
54
- mongoOptions?: Partial<NormalizedMongoConnectionConfig>;
55
- streamOptions?: Partial<ChangeStreamOptions>;
56
- /**
57
- * Optional override for tests that need to force a mode. By default the
58
- * test context detects DocumentDB from the hello response.
59
- */
60
- documentDbMode?: boolean;
61
- }
62
- ) {
63
- const f = await factory({ doNotClear: options?.doNotClear });
64
- const connectionManager = new MongoManager({ ...TEST_CONNECTION_OPTIONS, ...options?.mongoOptions });
65
-
66
- if (!options?.doNotClear) {
67
- await clearTestDb(connectionManager.db);
68
- }
69
-
70
- const storageVersion = options?.storageVersion ?? LEGACY_STORAGE_VERSION;
71
- const documentDbMode = options?.documentDbMode ?? (await detectDocumentDb(connectionManager.db));
72
-
73
- return new ChangeStreamTestContext(f, connectionManager, options?.streamOptions, storageVersion, documentDbMode);
74
- }
75
-
76
- constructor(
77
- public factory: BucketStorageFactory,
78
- public connectionManager: MongoManager,
79
- private streamOptions: Partial<ChangeStreamOptions> = {},
80
- private storageVersion: number = LEGACY_STORAGE_VERSION,
81
- private documentDbMode: boolean = false
82
- ) {
83
- createCoreReplicationMetrics(METRICS_HELPER.metricsEngine);
84
- initializeCoreReplicationMetrics(METRICS_HELPER.metricsEngine);
85
- }
86
-
87
- /**
88
- * Abort snapshot and/or replication, without actively closing connections.
89
- */
90
- abort(cause?: Error) {
91
- this.abortController.abort(cause);
92
- }
93
-
94
- async dispose() {
95
- this.abort(new Error('Disposing test context'));
96
- await this.settledReplicationPromise;
97
- await this.factory[Symbol.asyncDispose]();
98
- await this.connectionManager.end();
99
- }
100
-
101
- async [Symbol.asyncDispose]() {
102
- await this.dispose();
103
- }
104
-
105
- get client() {
106
- return this.connectionManager.client;
107
- }
108
-
109
- get db() {
110
- return this.connectionManager.db;
111
- }
112
-
113
- get connectionTag() {
114
- return this.connectionManager.connectionTag;
115
- }
116
-
117
- async updateSyncRules(content: string) {
118
- const replicationStream = await this.factory.updateSyncRules(
119
- updateSyncRulesFromYaml(content, { validate: true, storageVersion: this.storageVersion })
120
- );
121
- this.syncRulesContent = replicationStream.syncConfigContent[0];
122
- this.storage = this.factory.getInstance(replicationStream);
123
- return this.storage!;
124
- }
125
-
126
- async loadNextSyncRules() {
127
- const syncConfig = await this.factory.getDeployingSyncConfig();
128
- if (syncConfig == null) {
129
- throw new Error(`Next sync config not available`);
130
- }
131
-
132
- this.syncRulesContent = syncConfig.content;
133
- this.storage = syncConfig.storage;
134
- return this.storage!;
135
- }
136
-
137
- async loadActiveSyncRules() {
138
- const syncConfig = await this.factory.getActiveSyncConfig();
139
- if (syncConfig == null) {
140
- throw new Error(`Active sync config not found`);
141
- }
142
-
143
- this.syncRulesContent = syncConfig.content;
144
- this.storage = syncConfig.storage;
145
- return this.storage!;
146
- }
147
-
148
- private getSyncConfigContent(): storage.PersistedSyncConfigContent {
149
- if (this.syncRulesContent == null) {
150
- throw new Error('Sync config not configured - call updateSyncRules() first');
151
- }
152
- return this.syncRulesContent;
153
- }
154
-
155
- get streamer() {
156
- if (this.storage == null) {
157
- throw new Error('updateSyncRules() first');
158
- }
159
- if (this._walStream) {
160
- return this._walStream;
161
- }
162
- const options: ChangeStreamOptions = {
163
- storage: this.storage,
164
- metrics: METRICS_HELPER.metricsEngine,
165
- connections: this.connectionManager,
166
- abort_signal: this.abortController.signal,
167
- // Specifically reduce this from the default for tests on MongoDB <= 6.0, otherwise it can take
168
- // a long time to abort the stream.
169
- maxAwaitTimeMS: this.streamOptions?.maxAwaitTimeMS ?? 200,
170
- snapshotChunkLength: this.streamOptions?.snapshotChunkLength,
171
- keepaliveIntervalMs: this.streamOptions?.keepaliveIntervalMs,
172
- storageHooks: this.streamOptions?.storageHooks,
173
- snapshotHooks: this.streamOptions?.snapshotHooks,
174
- logger: this.streamOptions?.logger
175
- };
176
- this._walStream = new ChangeStream(options);
177
- return this._walStream!;
178
- }
179
-
180
- async replicateSnapshot() {
181
- this.settledReplicationPromise ??= settledPromise(this.streamer.replicate());
182
- try {
183
- await Promise.race([unsettledPromise(this.settledReplicationPromise), this.streamer.waitForInitialSnapshot()]);
184
- } catch (e) {
185
- if (e instanceof ReplicationAbortedError && e.cause != null) {
186
- throw e.cause;
187
- }
188
- throw e;
189
- }
190
- }
191
-
192
- /**
193
- * A snapshot is not consistent until streaming replication has caught up.
194
- * We simulate that for tests.
195
- * Do not use if there are any writes performed while doing the snapshot, as that
196
- * would result in inconsistent data.
197
- */
198
- async markSnapshotConsistent() {
199
- let checkpoint: string;
200
- if (this.documentDbMode) {
201
- const sentinelCheckpoint = SentinelLSN.fromSerialized(await createSentinelCheckpointLsn(this.client, this.db));
202
- const status = await this.storage!.getStatus();
203
- const resumeFrom = status.resumeLsn;
204
- const resumeToken = resumeFrom ? SentinelLSN.fromSerialized(resumeFrom).resumeToken : null;
205
-
206
- // This helper artificially marks the snapshot as consistent without
207
- // waiting for the stream to observe the sentinel. Keep the sentinel as the
208
- // comparable position, but carry forward the existing snapshot resume
209
- // token so later DocumentDB streaming still resumes from a real token.
210
- checkpoint = new SentinelLSN({
211
- sentinel: sentinelCheckpoint.sentinel,
212
- resume_token: resumeToken
213
- }).comparable;
214
- } else {
215
- checkpoint = await createCheckpoint(this.db, STANDALONE_CHECKPOINT_ID);
216
- }
217
-
218
- await using writer = await this.storage!.createWriter(test_utils.BATCH_OPTIONS);
219
- await writer.keepalive(checkpoint);
220
- await writer.flush();
221
- }
222
-
223
- startStreaming() {
224
- this.settledReplicationPromise ??= settledPromise(this.streamer.replicate());
225
- return this.settledReplicationPromise;
226
- }
227
-
228
- async getCheckpoint(options?: { timeout?: number }) {
229
- let checkpoint = await Promise.race([
230
- getClientCheckpoint(this.client, this.db, this.factory, { timeout: options?.timeout ?? 15_000 }),
231
- this.settledReplicationPromise == null ? undefined : unsettledPromise(this.settledReplicationPromise)
232
- ]);
233
- if (checkpoint == null) {
234
- // This indicates an issue with the test setup - streamingPromise completed instead
235
- // of getClientCheckpoint()
236
- throw new Error('Test failure - streamingPromise completed');
237
- }
238
- return checkpoint;
239
- }
240
-
241
- async getBucketsDataBatch(buckets: Record<string, InternalOpId>, options?: { timeout?: number }) {
242
- let checkpoint = await this.getCheckpoint(options);
243
- const syncConfigContent = this.getSyncConfigContent();
244
- const map = Object.entries(buckets).map(([bucket, start]) => bucketRequest(syncConfigContent, bucket, start));
245
- return test_utils.fromAsync(this.storage!.getBucketDataBatch(checkpoint, map));
246
- }
247
-
248
- async getBucketData(bucket: string, start?: ProtocolOpId | InternalOpId | undefined, options?: { timeout?: number }) {
249
- const checkpoint = await this.getCheckpoint(options);
250
- return this.getBucketDataAtCheckpoint(bucket, checkpoint, start);
251
- }
252
-
253
- async getBucketDataAtLatestCheckpoint(bucket: string, start?: ProtocolOpId | InternalOpId | undefined) {
254
- if (this.storage == null) {
255
- throw new Error('updateSyncRules() first');
256
- }
257
-
258
- const checkpoint = await this.storage.getCheckpoint();
259
- return this.getBucketDataAtCheckpoint(bucket, checkpoint, start);
260
- }
261
-
262
- async getBucketDataAtCheckpoint(
263
- bucket: string,
264
- checkpoint: ReplicationCheckpoint,
265
- start?: ProtocolOpId | InternalOpId | undefined
266
- ) {
267
- start ??= 0n;
268
- if (typeof start == 'string') {
269
- start = BigInt(start);
270
- }
271
- const syncConfigContent = this.getSyncConfigContent();
272
- let map = [bucketRequest(syncConfigContent, bucket, start)];
273
- let data: OplogEntry[] = [];
274
- while (true) {
275
- const batch = this.storage!.getBucketDataBatch(checkpoint, map);
276
-
277
- const chunks = await test_utils.fromAsync(batch);
278
- if (chunks.length == 0) {
279
- break;
280
- }
281
- for (let chunk of chunks) {
282
- if (isBatchEnd(chunk)) {
283
- if (!chunk.hasMore) {
284
- return data;
285
- }
286
- } else {
287
- data = data.concat(chunk.chunkData.data ?? []);
288
- map = [bucketRequest(syncConfigContent, bucket, BigInt(chunk.chunkData.next_after))];
289
- if (!chunk.chunkData.has_more) {
290
- return data;
291
- }
292
- }
293
- }
294
- }
295
- return data;
296
- }
297
-
298
- async getChecksums(buckets: string[], options?: { timeout?: number }): Promise<utils.ChecksumMap> {
299
- let checkpoint = await this.getCheckpoint(options);
300
- const syncConfigContent = this.getSyncConfigContent();
301
- const versionedBuckets = buckets.map((bucket) => bucketRequest(syncConfigContent, bucket, 0n));
302
- const checksums = await this.storage!.getChecksums(checkpoint, versionedBuckets);
303
-
304
- const unversioned: utils.ChecksumMap = new Map();
305
- for (let i = 0; i < buckets.length; i++) {
306
- unversioned.set(buckets[i], checksums.get(versionedBuckets[i].bucket)!);
307
- }
308
- return unversioned;
309
- }
310
-
311
- async getChecksum(bucket: string, options?: { timeout?: number }) {
312
- const checksums = await this.getChecksums([bucket], options);
313
- return checksums.get(bucket);
314
- }
315
- }
316
-
317
- export async function getClientCheckpoint(
318
- client: mongo.MongoClient,
319
- db: mongo.Db,
320
- storageFactory: BucketStorageFactory,
321
- options?: { timeout?: number; documentDbMode?: boolean; storage?: SyncRulesBucketStorage }
322
- ): Promise<ReplicationCheckpoint> {
323
- const start = Date.now();
324
- const documentDbMode = options?.documentDbMode ?? (await detectDocumentDb(db));
325
-
326
- const lsn = documentDbMode
327
- ? await createSentinelCheckpointLsn(client, db)
328
- : await createCheckpoint(db, STANDALONE_CHECKPOINT_ID);
329
- // This old API needs a persisted checkpoint id.
330
- // Since we don't use LSNs anymore, the only way to get that is to wait.
331
-
332
- const timeout = options?.timeout ?? 50_000;
333
- // DocumentDB: the streaming loop skips standalone checkpoint events while a
334
- // batch barrier is pending (see ChangeStream.ts), so a single sentinel bump
335
- // can be missed on an idle stream with no later event to advance the
336
- // checkpoint. Periodically re-bump the sentinel so a standalone event
337
- // eventually commits past `lsn`, mirroring getSnapshotLsn's retry loop.
338
- const NUDGE_INTERVAL_MS = 1000;
339
- let lastNudge = Date.now();
340
- let lastCp: ReplicationCheckpoint | null = null;
341
-
342
- while (Date.now() - start < timeout) {
343
- const storage = (await storageFactory.getActiveSyncConfig())?.storage;
344
- const cp = await storage?.getCheckpoint();
345
- if (cp != null) {
346
- lastCp = cp;
347
- if (cp.lsn && cp.lsn >= lsn) {
348
- return cp;
349
- }
350
- }
351
-
352
- if (documentDbMode && Date.now() - lastNudge >= NUDGE_INTERVAL_MS) {
353
- await createSentinelCheckpointLsn(client, db);
354
- lastNudge = Date.now();
355
- }
356
-
357
- await new Promise((resolve) => setTimeout(resolve, 30));
358
- }
359
-
360
- throw new Error(`Timeout while waiting for checkpoint ${lsn}. Last checkpoint: ${lastCp?.lsn}`);
361
- }
362
-
363
- export async function setSnapshotHistorySeconds(client: mongo.MongoClient, seconds: number) {
364
- const { minSnapshotHistoryWindowInSeconds: currentValue } = await client
365
- .db('admin')
366
- .command({ getParameter: 1, minSnapshotHistoryWindowInSeconds: 1 });
367
-
368
- await client.db('admin').command({ setParameter: 1, minSnapshotHistoryWindowInSeconds: seconds });
369
-
370
- return {
371
- async [Symbol.asyncDispose]() {
372
- await client.db('admin').command({ setParameter: 1, minSnapshotHistoryWindowInSeconds: currentValue });
373
- }
374
- };
375
- }