@powersync/service-module-mongodb 0.0.0-dev-20260313100403 → 0.0.0-dev-20260515144844

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 (69) hide show
  1. package/CHANGELOG.md +95 -7
  2. package/dist/api/MongoRouteAPIAdapter.js +2 -2
  3. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  4. package/dist/replication/ChangeStream.d.ts +8 -16
  5. package/dist/replication/ChangeStream.js +284 -371
  6. package/dist/replication/ChangeStream.js.map +1 -1
  7. package/dist/replication/ChangeStreamReplicationJob.d.ts +1 -1
  8. package/dist/replication/ChangeStreamReplicationJob.js +3 -3
  9. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  10. package/dist/replication/ChangeStreamReplicator.d.ts +1 -2
  11. package/dist/replication/ChangeStreamReplicator.js +1 -22
  12. package/dist/replication/ChangeStreamReplicator.js.map +1 -1
  13. package/dist/replication/JsonBufferWriter.d.ts +80 -0
  14. package/dist/replication/JsonBufferWriter.js +342 -0
  15. package/dist/replication/JsonBufferWriter.js.map +1 -0
  16. package/dist/replication/MongoManager.d.ts +1 -1
  17. package/dist/replication/MongoManager.js +1 -1
  18. package/dist/replication/MongoManager.js.map +1 -1
  19. package/dist/replication/MongoRelation.js +4 -0
  20. package/dist/replication/MongoRelation.js.map +1 -1
  21. package/dist/replication/MongoSnapshotQuery.d.ts +3 -1
  22. package/dist/replication/MongoSnapshotQuery.js +9 -4
  23. package/dist/replication/MongoSnapshotQuery.js.map +1 -1
  24. package/dist/replication/RawChangeStream.d.ts +55 -0
  25. package/dist/replication/RawChangeStream.js +322 -0
  26. package/dist/replication/RawChangeStream.js.map +1 -0
  27. package/dist/replication/SourceRowConverter.d.ts +46 -0
  28. package/dist/replication/SourceRowConverter.js +42 -0
  29. package/dist/replication/SourceRowConverter.js.map +1 -0
  30. package/dist/replication/bufferToSqlite.d.ts +43 -0
  31. package/dist/replication/bufferToSqlite.js +740 -0
  32. package/dist/replication/bufferToSqlite.js.map +1 -0
  33. package/dist/replication/internal-mongodb-utils.d.ts +9 -0
  34. package/dist/replication/internal-mongodb-utils.js +16 -0
  35. package/dist/replication/internal-mongodb-utils.js.map +1 -0
  36. package/dist/replication/replication-index.d.ts +5 -2
  37. package/dist/replication/replication-index.js +5 -2
  38. package/dist/replication/replication-index.js.map +1 -1
  39. package/dist/replication/replication-utils.d.ts +1 -1
  40. package/dist/types/types.js.map +1 -1
  41. package/package.json +11 -11
  42. package/scripts/benchmark-change-document-json.mts +358 -0
  43. package/scripts/benchmark-change-document.mts +370 -0
  44. package/src/api/MongoRouteAPIAdapter.ts +2 -2
  45. package/src/replication/ChangeStream.ts +380 -350
  46. package/src/replication/ChangeStreamReplicationJob.ts +3 -3
  47. package/src/replication/ChangeStreamReplicator.ts +2 -26
  48. package/src/replication/JsonBufferWriter.ts +390 -0
  49. package/src/replication/MongoManager.ts +2 -2
  50. package/src/replication/MongoRelation.ts +5 -2
  51. package/src/replication/MongoSnapshotQuery.ts +13 -6
  52. package/src/replication/RawChangeStream.ts +460 -0
  53. package/src/replication/SourceRowConverter.ts +65 -0
  54. package/src/replication/bufferToSqlite.ts +944 -0
  55. package/src/replication/internal-mongodb-utils.ts +25 -0
  56. package/src/replication/replication-index.ts +5 -2
  57. package/src/replication/replication-utils.ts +2 -2
  58. package/src/types/types.ts +1 -1
  59. package/test/src/buffer_to_sqlite.test.ts +1146 -0
  60. package/test/src/change_stream.test.ts +105 -3
  61. package/test/src/change_stream_utils.ts +4 -10
  62. package/test/src/mongo_test.test.ts +66 -64
  63. package/test/src/parse_document_id.test.ts +54 -0
  64. package/test/src/raw_change_stream.test.ts +547 -0
  65. package/test/src/resume.test.ts +12 -2
  66. package/test/src/util.ts +56 -3
  67. package/test/tsconfig.json +0 -1
  68. package/tsconfig.scripts.json +13 -0
  69. package/tsconfig.tsbuildinfo +1 -1
@@ -1,65 +1,16 @@
1
- var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
2
- if (value !== null && value !== void 0) {
3
- if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
4
- var dispose, inner;
5
- if (async) {
6
- if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
7
- dispose = value[Symbol.asyncDispose];
8
- }
9
- if (dispose === void 0) {
10
- if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
11
- dispose = value[Symbol.dispose];
12
- if (async) inner = dispose;
13
- }
14
- if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
15
- if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
16
- env.stack.push({ value: value, dispose: dispose, async: async });
17
- }
18
- else if (async) {
19
- env.stack.push({ async: true });
20
- }
21
- return value;
22
- };
23
- var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
24
- return function (env) {
25
- function fail(e) {
26
- env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
27
- env.hasError = true;
28
- }
29
- var r, s = 0;
30
- function next() {
31
- while (r = env.stack.pop()) {
32
- try {
33
- if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
34
- if (r.dispose) {
35
- var result = r.dispose.call(r.value);
36
- if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
37
- }
38
- else s |= 1;
39
- }
40
- catch (e) {
41
- fail(e);
42
- }
43
- }
44
- if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
45
- if (env.hasError) throw env.error;
46
- }
47
- return next();
48
- };
49
- })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
50
- var e = new Error(message);
51
- return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
- });
53
- import { isMongoNetworkTimeoutError, isMongoServerError, mongo } from '@powersync/lib-service-mongodb';
54
- import { container, DatabaseConnectionError, logger as defaultLogger, ErrorCode, ReplicationAbortedError, ReplicationAssertionError, ServiceError } from '@powersync/lib-services-framework';
55
- import { RelationCache, SaveOperationTag } from '@powersync/service-core';
1
+ import { mongo } from '@powersync/lib-service-mongodb';
2
+ import { container, DatabaseConnectionError, ErrorCode, ReplicationAbortedError, ReplicationAssertionError, ServiceError } from '@powersync/lib-services-framework';
3
+ import { PerformanceTracer, RelationCache, ReplicationLagTracker, SaveOperationTag } from '@powersync/service-core';
56
4
  import { ReplicationMetric } from '@powersync/service-types';
5
+ import { performance } from 'node:perf_hooks';
57
6
  import { MongoLSN } from '../common/MongoLSN.js';
58
7
  import { PostImagesOption } from '../types/types.js';
59
8
  import { escapeRegExp } from '../utils.js';
60
- import { constructAfterRecord, createCheckpoint, getCacheIdentifier, getMongoRelation, STANDALONE_CHECKPOINT_ID } from './MongoRelation.js';
9
+ import { createCheckpoint, getCacheIdentifier, getMongoRelation, STANDALONE_CHECKPOINT_ID } from './MongoRelation.js';
61
10
  import { ChunkedSnapshotQuery } from './MongoSnapshotQuery.js';
11
+ import { parseChangeDocument, rawChangeStream } from './RawChangeStream.js';
62
12
  import { CHECKPOINTS_COLLECTION, timestampToDate } from './replication-utils.js';
13
+ import { DirectSourceRowConverter } from './SourceRowConverter.js';
63
14
  /**
64
15
  * Thrown when the change stream is not valid anymore, and replication
65
16
  * must be restarted.
@@ -85,20 +36,12 @@ export class ChangeStream {
85
36
  maxAwaitTimeMS;
86
37
  abort_signal;
87
38
  relationCache = new RelationCache(getCacheIdentifier);
88
- /**
89
- * Time of the oldest uncommitted change, according to the source db.
90
- * This is used to determine the replication lag.
91
- */
92
- oldestUncommittedChange = null;
93
- /**
94
- * Keep track of whether we have done a commit or keepalive yet.
95
- * We can only compute replication lag if isStartingReplication == false, or oldestUncommittedChange is present.
96
- */
97
- isStartingReplication = true;
39
+ replicationLag = new ReplicationLagTracker();
98
40
  checkpointStreamId = new mongo.ObjectId();
99
41
  logger;
100
42
  snapshotChunkLength;
101
43
  changeStreamTimeout;
44
+ sourceRowConverter;
102
45
  constructor(options) {
103
46
  this.storage = options.storage;
104
47
  this.metrics = options.metrics;
@@ -111,6 +54,7 @@ export class ChangeStream {
111
54
  this.sync_rules = options.storage.getParsedSyncRules({
112
55
  defaultSchema: this.defaultDb.databaseName
113
56
  });
57
+ this.sourceRowConverter = new DirectSourceRowConverter(this.sync_rules.compatibility);
114
58
  // The change stream aggregation command should timeout before the socket times out,
115
59
  // so we use 90% of the socket timeout value.
116
60
  this.changeStreamTimeout = Math.ceil(this.client.options.socketTimeoutMS * 0.9);
@@ -118,7 +62,7 @@ export class ChangeStream {
118
62
  this.abort_signal.addEventListener('abort', () => {
119
63
  // TODO: Fast abort?
120
64
  }, { once: true });
121
- this.logger = options.logger ?? defaultLogger;
65
+ this.logger = options.logger ?? this.storage.logger;
122
66
  }
123
67
  get stopped() {
124
68
  return this.abort_signal.aborted;
@@ -188,48 +132,51 @@ export class ChangeStream {
188
132
  * This LSN can survive initial replication restarts.
189
133
  */
190
134
  async getSnapshotLsn() {
191
- const env_1 = { stack: [], error: void 0, hasError: false };
192
- try {
193
- const hello = await this.defaultDb.command({ hello: 1 });
194
- // Basic sanity check
195
- if (hello.msg == 'isdbgrid') {
196
- throw new ServiceError(ErrorCode.PSYNC_S1341, 'Sharded MongoDB Clusters are not supported yet (including MongoDB Serverless instances).');
135
+ const hello = await this.defaultDb.command({ hello: 1 });
136
+ // Basic sanity check
137
+ if (hello.msg == 'isdbgrid') {
138
+ throw new ServiceError(ErrorCode.PSYNC_S1341, 'Sharded MongoDB Clusters are not supported yet (including MongoDB Serverless instances).');
139
+ }
140
+ else if (hello.setName == null) {
141
+ throw new ServiceError(ErrorCode.PSYNC_S1342, 'Standalone MongoDB instances are not supported - use a replicaset.');
142
+ }
143
+ // Open a change stream just to get a resume token for later use.
144
+ // We could use clusterTime from the hello command, but that won't tell us if the
145
+ // snapshot isn't valid anymore.
146
+ // If we just use the first resumeToken from the stream, we get two potential issues:
147
+ // 1. The resumeToken may just be a wrapped clusterTime, which does not detect changes
148
+ // in source db or other stream issues.
149
+ // 2. The first actual change we get may have the same clusterTime, causing us to incorrect
150
+ // skip that event.
151
+ // Instead, we create a new checkpoint document, and wait until we get that document back in the stream.
152
+ // To avoid potential race conditions with the checkpoint creation, we create a new checkpoint document
153
+ // periodically until the timeout is reached.
154
+ const LSN_TIMEOUT_SECONDS = 60;
155
+ const LSN_CREATE_INTERVAL_SECONDS = 1;
156
+ // Create a checkpoint, and open a change stream using startAtOperationTime with the checkpoint's operationTime.
157
+ const firstCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
158
+ const startTime = performance.now();
159
+ let lastCheckpointCreated = performance.now();
160
+ let eventsSeen = 0;
161
+ let batchesSeen = 0;
162
+ const filters = this.getSourceNamespaceFilters();
163
+ const iter = this.rawChangeStreamBatches({
164
+ lsn: firstCheckpointLsn,
165
+ maxAwaitTimeMS: 0,
166
+ signal: this.abort_signal,
167
+ filters
168
+ });
169
+ for await (let { events } of iter) {
170
+ if (performance.now() - startTime >= LSN_TIMEOUT_SECONDS * 1000) {
171
+ break;
197
172
  }
198
- else if (hello.setName == null) {
199
- throw new ServiceError(ErrorCode.PSYNC_S1342, 'Standalone MongoDB instances are not supported - use a replicaset.');
173
+ if (performance.now() - lastCheckpointCreated >= LSN_CREATE_INTERVAL_SECONDS * 1000) {
174
+ await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
175
+ lastCheckpointCreated = performance.now();
200
176
  }
201
- // Open a change stream just to get a resume token for later use.
202
- // We could use clusterTime from the hello command, but that won't tell us if the
203
- // snapshot isn't valid anymore.
204
- // If we just use the first resumeToken from the stream, we get two potential issues:
205
- // 1. The resumeToken may just be a wrapped clusterTime, which does not detect changes
206
- // in source db or other stream issues.
207
- // 2. The first actual change we get may have the same clusterTime, causing us to incorrect
208
- // skip that event.
209
- // Instead, we create a new checkpoint document, and wait until we get that document back in the stream.
210
- // To avoid potential race conditions with the checkpoint creation, we create a new checkpoint document
211
- // periodically until the timeout is reached.
212
- const LSN_TIMEOUT_SECONDS = 60;
213
- const LSN_CREATE_INTERVAL_SECONDS = 1;
214
- // Create a checkpoint, and open a change stream using startAtOperationTime with the checkpoint's operationTime.
215
- const firstCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
216
- const streamManager = __addDisposableResource(env_1, this.openChangeStream({ lsn: firstCheckpointLsn, maxAwaitTimeMs: 0 }), true);
217
- const { stream } = streamManager;
218
- const startTime = performance.now();
219
- let lastCheckpointCreated = performance.now();
220
- let eventsSeen = 0;
221
- while (performance.now() - startTime < LSN_TIMEOUT_SECONDS * 1000) {
222
- if (performance.now() - lastCheckpointCreated >= LSN_CREATE_INTERVAL_SECONDS * 1000) {
223
- await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
224
- lastCheckpointCreated = performance.now();
225
- }
226
- // tryNext() doesn't block, while next() / hasNext() does block until there is data on the stream
227
- const changeDocument = await stream.tryNext().catch((e) => {
228
- throw mapChangeStreamError(e);
229
- });
230
- if (changeDocument == null) {
231
- continue;
232
- }
177
+ batchesSeen += 1;
178
+ for (let rawChangeDocument of events) {
179
+ const changeDocument = parseChangeDocument(rawChangeDocument);
233
180
  const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
234
181
  if (ns?.coll == CHECKPOINTS_COLLECTION && 'documentKey' in changeDocument) {
235
182
  const checkpointId = changeDocument.documentKey._id;
@@ -244,56 +191,38 @@ export class ChangeStream {
244
191
  }
245
192
  eventsSeen += 1;
246
193
  }
247
- // Could happen if there is a very large replication lag?
248
- throw new ServiceError(ErrorCode.PSYNC_S1301, `Timeout after while waiting for checkpoint document for ${LSN_TIMEOUT_SECONDS}s. Streamed events = ${eventsSeen}`);
249
- }
250
- catch (e_1) {
251
- env_1.error = e_1;
252
- env_1.hasError = true;
253
- }
254
- finally {
255
- const result_1 = __disposeResources(env_1);
256
- if (result_1)
257
- await result_1;
258
194
  }
195
+ // Could happen if there is a very large replication lag?
196
+ throw new ServiceError(ErrorCode.PSYNC_S1301, `Timeout after while waiting for checkpoint document for ${LSN_TIMEOUT_SECONDS}s. Streamed events = ${eventsSeen}, batches = ${batchesSeen}`);
259
197
  }
260
198
  /**
261
199
  * Given a snapshot LSN, validate that we can read from it, by opening a change stream.
262
200
  */
263
201
  async validateSnapshotLsn(lsn) {
264
- const env_2 = { stack: [], error: void 0, hasError: false };
265
- try {
266
- const streamManager = __addDisposableResource(env_2, this.openChangeStream({ lsn: lsn, maxAwaitTimeMs: 0 }), true);
267
- const { stream } = streamManager;
268
- try {
269
- // tryNext() doesn't block, while next() / hasNext() does block until there is data on the stream
270
- await stream.tryNext();
271
- }
272
- catch (e) {
273
- // Note: A timeout here is not handled as a ChangeStreamInvalidatedError, even though
274
- // we possibly cannot recover from it.
275
- throw mapChangeStreamError(e);
276
- }
277
- }
278
- catch (e_2) {
279
- env_2.error = e_2;
280
- env_2.hasError = true;
281
- }
282
- finally {
283
- const result_2 = __disposeResources(env_2);
284
- if (result_2)
285
- await result_2;
202
+ const filters = this.getSourceNamespaceFilters();
203
+ const stream = this.rawChangeStreamBatches({
204
+ lsn: lsn,
205
+ // maxAwaitTimeMS should never actually be used here
206
+ maxAwaitTimeMS: 0,
207
+ filters
208
+ });
209
+ for await (let _batch of stream) {
210
+ // We got a response from the aggregate command, so consider the LSN valid.
211
+ // Close the stream immediately.
212
+ break;
286
213
  }
287
214
  }
288
215
  async initialReplication(snapshotLsn) {
289
216
  const sourceTables = this.sync_rules.getSourceTables();
290
217
  await this.client.connect();
218
+ const tracer = new PerformanceTracer('MongoDB initial replication');
291
219
  const flushResult = await this.storage.startBatch({
292
220
  logger: this.logger,
293
221
  zeroLSN: MongoLSN.ZERO.comparable,
294
222
  defaultSchema: this.defaultDb.databaseName,
295
223
  storeCurrentData: false,
296
- skipExistingRows: true
224
+ skipExistingRows: true,
225
+ tracer
297
226
  }, async (batch) => {
298
227
  if (snapshotLsn == null) {
299
228
  // First replication attempt - get a snapshot and store the timestamp
@@ -414,81 +343,69 @@ export class ChangeStream {
414
343
  }
415
344
  return { $match: nsFilter, multipleDatabases };
416
345
  }
417
- static *getQueryData(results) {
418
- for (let row of results) {
419
- yield constructAfterRecord(row);
420
- }
421
- }
422
346
  async snapshotTable(batch, table) {
423
- const env_3 = { stack: [], error: void 0, hasError: false };
424
- try {
425
- const totalEstimatedCount = await this.estimatedCountNumber(table);
426
- let at = table.snapshotStatus?.replicatedCount ?? 0;
427
- const db = this.client.db(table.schema);
428
- const collection = db.collection(table.name);
429
- const query = __addDisposableResource(env_3, new ChunkedSnapshotQuery({
430
- collection,
431
- key: table.snapshotStatus?.lastKey,
432
- batchSize: this.snapshotChunkLength
433
- }), true);
434
- if (query.lastKey != null) {
435
- this.logger.info(`Replicating ${table.qualifiedName} ${table.formatSnapshotProgress()} - resuming at _id > ${query.lastKey}`);
347
+ const rowsReplicatedMetric = this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED);
348
+ const bytesReplicatedMetric = this.metrics.getCounter(ReplicationMetric.DATA_REPLICATED_BYTES);
349
+ const chunksReplicatedMetric = this.metrics.getCounter(ReplicationMetric.CHUNKS_REPLICATED);
350
+ const totalEstimatedCount = await this.estimatedCountNumber(table);
351
+ let at = table.snapshotStatus?.replicatedCount ?? 0;
352
+ const db = this.client.db(table.schema);
353
+ const collection = db.collection(table.name);
354
+ await using query = new ChunkedSnapshotQuery({
355
+ collection,
356
+ key: table.snapshotStatus?.lastKey,
357
+ batchSize: this.snapshotChunkLength
358
+ });
359
+ if (query.lastKey != null) {
360
+ this.logger.info(`Replicating ${table.qualifiedName} ${table.formatSnapshotProgress()} - resuming at _id > ${query.lastKey}`);
361
+ }
362
+ else {
363
+ this.logger.info(`Replicating ${table.qualifiedName} ${table.formatSnapshotProgress()}`);
364
+ }
365
+ let lastBatch = performance.now();
366
+ let nextChunkPromise = query.nextChunk();
367
+ while (true) {
368
+ const { docs: docBatch, lastKey, bytes: chunkBytes } = await nextChunkPromise;
369
+ if (docBatch.length == 0) {
370
+ // No more data - stop iterating
371
+ break;
436
372
  }
437
- else {
438
- this.logger.info(`Replicating ${table.qualifiedName} ${table.formatSnapshotProgress()}`);
373
+ bytesReplicatedMetric.add(chunkBytes);
374
+ chunksReplicatedMetric.add(1);
375
+ if (this.abort_signal.aborted) {
376
+ throw new ReplicationAbortedError(`Aborted initial replication`, this.abort_signal.reason);
439
377
  }
440
- let lastBatch = performance.now();
441
- let nextChunkPromise = query.nextChunk();
442
- while (true) {
443
- const { docs: docBatch, lastKey } = await nextChunkPromise;
444
- if (docBatch.length == 0) {
445
- // No more data - stop iterating
446
- break;
447
- }
448
- if (this.abort_signal.aborted) {
449
- throw new ReplicationAbortedError(`Aborted initial replication`, this.abort_signal.reason);
450
- }
451
- // Pre-fetch next batch, so that we can read and write concurrently
452
- nextChunkPromise = query.nextChunk();
453
- for (let document of docBatch) {
454
- const record = this.constructAfterRecord(document);
455
- // This auto-flushes when the batch reaches its size limit
456
- await batch.save({
457
- tag: SaveOperationTag.INSERT,
458
- sourceTable: table,
459
- before: undefined,
460
- beforeReplicaId: undefined,
461
- after: record,
462
- afterReplicaId: document._id
463
- });
464
- }
465
- // Important: flush before marking progress
466
- await batch.flush();
467
- at += docBatch.length;
468
- this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED).add(docBatch.length);
469
- table = await batch.updateTableProgress(table, {
470
- lastKey,
471
- replicatedCount: at,
472
- totalEstimatedCount: totalEstimatedCount
378
+ // Pre-fetch next batch, so that we can read and write concurrently
379
+ nextChunkPromise = query.nextChunk();
380
+ for (let buffer of docBatch) {
381
+ const { row: record, replicaId: replicaId } = this.rawToSqliteRow(buffer);
382
+ // This auto-flushes when the batch reaches its size limit
383
+ await batch.save({
384
+ tag: SaveOperationTag.INSERT,
385
+ sourceTable: table,
386
+ before: undefined,
387
+ beforeReplicaId: undefined,
388
+ after: record,
389
+ afterReplicaId: replicaId
473
390
  });
474
- this.relationCache.update(table);
475
- const duration = performance.now() - lastBatch;
476
- lastBatch = performance.now();
477
- this.logger.info(`Replicating ${table.qualifiedName} ${table.formatSnapshotProgress()} in ${duration.toFixed(0)}ms`);
478
- this.touch();
479
391
  }
480
- // In case the loop was interrupted, make sure we await the last promise.
481
- await nextChunkPromise;
482
- }
483
- catch (e_3) {
484
- env_3.error = e_3;
485
- env_3.hasError = true;
486
- }
487
- finally {
488
- const result_3 = __disposeResources(env_3);
489
- if (result_3)
490
- await result_3;
392
+ // Important: flush before marking progress
393
+ await batch.flush();
394
+ at += docBatch.length;
395
+ rowsReplicatedMetric.add(docBatch.length);
396
+ table = await batch.updateTableProgress(table, {
397
+ lastKey,
398
+ replicatedCount: at,
399
+ totalEstimatedCount: totalEstimatedCount
400
+ });
401
+ this.relationCache.update(table);
402
+ const duration = performance.now() - lastBatch;
403
+ lastBatch = performance.now();
404
+ this.logger.info(`Replicating ${table.qualifiedName} ${table.formatSnapshotProgress()} in ${duration.toFixed(0)}ms`);
405
+ this.touch();
491
406
  }
407
+ // In case the loop was interrupted, make sure we await the last promise.
408
+ await nextChunkPromise;
492
409
  }
493
410
  async getRelation(batch, descriptor, options) {
494
411
  const existing = this.relationCache.get(descriptor);
@@ -552,7 +469,7 @@ export class ChangeStream {
552
469
  // Snapshot if:
553
470
  // 1. Snapshot is requested (false for initial snapshot, since that process handles it elsewhere)
554
471
  // 2. Snapshot is not already done, AND:
555
- // 3. The table is used in sync rules.
472
+ // 3. The table is used in sync config.
556
473
  const shouldSnapshot = snapshot && !result.table.snapshotComplete && result.table.syncAny;
557
474
  if (shouldSnapshot) {
558
475
  this.logger.info(`New collection: ${descriptor.schema}.${descriptor.name}`);
@@ -565,24 +482,23 @@ export class ChangeStream {
565
482
  }
566
483
  return result.table;
567
484
  }
568
- constructAfterRecord(document) {
569
- const inputRow = constructAfterRecord(document);
570
- return this.sync_rules.applyRowContext(inputRow);
571
- }
572
485
  async writeChange(batch, table, change) {
573
486
  if (!table.syncAny) {
574
- this.logger.debug(`Collection ${table.qualifiedName} not used in sync rules - skipping`);
487
+ this.logger.debug(`Collection ${table.qualifiedName} not used in sync config - skipping`);
575
488
  return null;
576
489
  }
577
490
  this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED).add(1);
578
491
  if (change.operationType == 'insert') {
579
- const baseRecord = this.constructAfterRecord(change.fullDocument);
492
+ const { row: baseRecord, replicaId: _replicaId } = this.rawToSqliteRow(change.fullDocument);
580
493
  return await batch.save({
581
494
  tag: SaveOperationTag.INSERT,
582
495
  sourceTable: table,
583
496
  before: undefined,
584
497
  beforeReplicaId: undefined,
585
498
  after: baseRecord,
499
+ // Same as _replicaId
500
+ // We specifically need to use the source _id, not the converted one in baseRecord,
501
+ // to preserve _id uniqueness properties.
586
502
  afterReplicaId: change.documentKey._id
587
503
  });
588
504
  }
@@ -596,14 +512,14 @@ export class ChangeStream {
596
512
  beforeReplicaId: change.documentKey._id
597
513
  });
598
514
  }
599
- const after = this.constructAfterRecord(change.fullDocument);
515
+ const { row: after, replicaId: _replicaId } = this.rawToSqliteRow(change.fullDocument);
600
516
  return await batch.save({
601
517
  tag: SaveOperationTag.UPDATE,
602
518
  sourceTable: table,
603
519
  before: undefined,
604
520
  beforeReplicaId: undefined,
605
521
  after: after,
606
- afterReplicaId: change.documentKey._id
522
+ afterReplicaId: change.documentKey._id // Same as _replicaId
607
523
  });
608
524
  }
609
525
  else if (change.operationType == 'delete') {
@@ -640,7 +556,7 @@ export class ChangeStream {
640
556
  }
641
557
  const { lastOpId } = await this.initialReplication(result.snapshotLsn);
642
558
  if (lastOpId != null) {
643
- // Populate the cache _after_ initial replication, but _before_ we switch to this sync rules.
559
+ // Populate the cache _after_ initial replication, but _before_ we switch to this replication stream.
644
560
  await this.storage.populatePersistentChecksumCache({
645
561
  signal: this.abort_signal,
646
562
  // No checkpoint yet, but we do have the opId.
@@ -662,17 +578,11 @@ export class ChangeStream {
662
578
  throw e;
663
579
  }
664
580
  }
665
- openChangeStream(options) {
581
+ rawChangeStreamBatches(options) {
666
582
  const lastLsn = options.lsn ? MongoLSN.fromSerialized(options.lsn) : null;
667
583
  const startAfter = lastLsn?.timestamp;
668
584
  const resumeAfter = lastLsn?.resumeToken;
669
- const filters = this.getSourceNamespaceFilters();
670
- const pipeline = [
671
- {
672
- $match: filters.$match
673
- },
674
- { $changeStreamSplitLargeEvent: {} }
675
- ];
585
+ const filters = options.filters;
676
586
  let fullDocument;
677
587
  if (this.usePostImages) {
678
588
  // 'read_only' or 'auto_configure'
@@ -685,10 +595,17 @@ export class ChangeStream {
685
595
  }
686
596
  const streamOptions = {
687
597
  showExpandedEvents: true,
688
- maxAwaitTimeMS: options.maxAwaitTimeMs ?? this.maxAwaitTimeMS,
689
- fullDocument: fullDocument,
690
- maxTimeMS: this.changeStreamTimeout
598
+ fullDocument: fullDocument
691
599
  };
600
+ const pipeline = [
601
+ {
602
+ $changeStream: streamOptions
603
+ },
604
+ {
605
+ $match: filters.$match
606
+ },
607
+ { $changeStreamSplitLargeEvent: {} }
608
+ ];
692
609
  /**
693
610
  * Only one of these options can be supplied at a time.
694
611
  */
@@ -701,98 +618,103 @@ export class ChangeStream {
701
618
  // This is also relevant for getSnapshotLSN().
702
619
  streamOptions.startAtOperationTime = startAfter;
703
620
  }
704
- let stream;
621
+ let watchDb;
705
622
  if (filters.multipleDatabases) {
706
- // Requires readAnyDatabase@admin on Atlas
707
- stream = this.client.watch(pipeline, streamOptions);
623
+ watchDb = this.client.db('admin');
624
+ streamOptions.allChangesForCluster = true;
708
625
  }
709
626
  else {
710
- // Same general result, but requires less permissions than the above
711
- stream = this.defaultDb.watch(pipeline, streamOptions);
627
+ watchDb = this.defaultDb;
712
628
  }
713
- this.abort_signal.addEventListener('abort', () => {
714
- stream.close();
629
+ return rawChangeStream(watchDb, pipeline, {
630
+ batchSize: options.batchSize ?? this.snapshotChunkLength,
631
+ maxAwaitTimeMS: options.maxAwaitTimeMS ?? this.maxAwaitTimeMS,
632
+ maxTimeMS: this.changeStreamTimeout,
633
+ signal: options.signal,
634
+ logger: this.logger,
635
+ tracer: options.tracer
715
636
  });
716
- return {
717
- stream,
718
- filters,
719
- [Symbol.asyncDispose]: async () => {
720
- return stream.close();
721
- }
722
- };
637
+ }
638
+ rawToSqliteRow(row) {
639
+ return this.sourceRowConverter.rawToSqliteRow(row);
723
640
  }
724
641
  async streamChangesInternal() {
642
+ const transactionsReplicatedMetric = this.metrics.getCounter(ReplicationMetric.TRANSACTIONS_REPLICATED);
643
+ const bytesReplicatedMetric = this.metrics.getCounter(ReplicationMetric.DATA_REPLICATED_BYTES);
644
+ const chunksReplicatedMetric = this.metrics.getCounter(ReplicationMetric.CHUNKS_REPLICATED);
645
+ const tracer = new PerformanceTracer('MongoDB streaming replication');
725
646
  await this.storage.startBatch({
726
647
  logger: this.logger,
727
648
  zeroLSN: MongoLSN.ZERO.comparable,
728
649
  defaultSchema: this.defaultDb.databaseName,
729
650
  // We get a complete postimage for every change, so we don't need to store the current data.
730
- storeCurrentData: false
651
+ storeCurrentData: false,
652
+ tracer
731
653
  }, async (batch) => {
732
- const env_4 = { stack: [], error: void 0, hasError: false };
733
- try {
734
- const { resumeFromLsn } = batch;
735
- if (resumeFromLsn == null) {
736
- throw new ReplicationAssertionError(`No LSN found to resume from`);
737
- }
738
- const lastLsn = MongoLSN.fromSerialized(resumeFromLsn);
739
- const startAfter = lastLsn?.timestamp;
740
- // It is normal for this to be a minute or two old when there is a low volume
741
- // of ChangeStream events.
742
- const tokenAgeSeconds = Math.round((Date.now() - timestampToDate(startAfter).getTime()) / 1000);
743
- this.logger.info(`Resume streaming at ${startAfter?.inspect()} / ${lastLsn} | Token age: ${tokenAgeSeconds}s`);
744
- const streamManager = __addDisposableResource(env_4, this.openChangeStream({ lsn: resumeFromLsn }), true);
745
- const { stream, filters } = streamManager;
654
+ const { resumeFromLsn } = batch;
655
+ if (resumeFromLsn == null) {
656
+ throw new ReplicationAssertionError(`No LSN found to resume from`);
657
+ }
658
+ const lastLsn = MongoLSN.fromSerialized(resumeFromLsn);
659
+ const startAfter = lastLsn?.timestamp;
660
+ let outerSpan = tracer.span('batch');
661
+ // It is normal for this to be a minute or two old when there is a low volume
662
+ // of ChangeStream events.
663
+ const tokenAgeSeconds = Math.round((Date.now() - timestampToDate(startAfter).getTime()) / 1000);
664
+ this.logger.info(`Resume streaming at ${startAfter?.inspect()} / ${lastLsn} | Token age: ${tokenAgeSeconds}s`);
665
+ const filters = this.getSourceNamespaceFilters();
666
+ // This is closed when the for loop below returns/breaks/throws
667
+ const batchStream = this.rawChangeStreamBatches({
668
+ lsn: resumeFromLsn,
669
+ filters,
670
+ signal: this.abort_signal,
671
+ tracer
672
+ });
673
+ // Always start with a checkpoint.
674
+ // This helps us to clear errors when restarting, even if there is
675
+ // no data to replicate.
676
+ let waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
677
+ let splitDocument = null;
678
+ let flexDbNameWorkaroundLogged = false;
679
+ let lastEmptyResume = performance.now();
680
+ let lastTxnKey = null;
681
+ for await (let eventBatch of batchStream) {
682
+ const { events, resumeToken } = eventBatch;
683
+ using batchSpan = tracer.span('processing');
684
+ bytesReplicatedMetric.add(eventBatch.byteSize);
685
+ chunksReplicatedMetric.add(1);
746
686
  if (this.abort_signal.aborted) {
747
- await stream.close();
748
- return;
687
+ break;
749
688
  }
750
- // Always start with a checkpoint.
751
- // This helps us to clear errors when restarting, even if there is
752
- // no data to replicate.
753
- let waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
754
- let splitDocument = null;
755
- let flexDbNameWorkaroundLogged = false;
756
- let changesSinceLastCheckpoint = 0;
757
- let lastEmptyResume = performance.now();
758
- while (true) {
759
- if (this.abort_signal.aborted) {
760
- break;
689
+ this.touch();
690
+ if (events.length == 0) {
691
+ // No changes in this batch, but we still want to keep the connection alive.
692
+ // We do this by persisting a keepalive checkpoint.
693
+ // If we don't update it on empty events, we do keep consistency, but resuming the stream
694
+ // with old tokens may cause connection timeouts.
695
+ if (waitForCheckpointLsn == null && performance.now() - lastEmptyResume > 60_000) {
696
+ const { comparable: lsn, timestamp } = MongoLSN.fromResumeToken(resumeToken);
697
+ await batch.keepalive(lsn);
698
+ this.touch();
699
+ lastEmptyResume = performance.now();
700
+ // Log the token update. This helps as a general "replication is still active" message in the logs.
701
+ // This token would typically be around 10s behind.
702
+ this.logger.info(`Idle change stream. Persisted resumeToken for ${timestampToDate(timestamp).toISOString()}`);
703
+ this.replicationLag.markStarted();
761
704
  }
762
- const originalChangeDocument = await stream.tryNext().catch((e) => {
763
- throw mapChangeStreamError(e);
764
- });
765
- // The stream was closed, we will only ever receive `null` from it
766
- if (!originalChangeDocument && stream.closed) {
767
- break;
705
+ // If we have no changes, we can just persist the keepalive.
706
+ // This is throttled to once per minute.
707
+ if (performance.now() - lastEmptyResume < 60_000) {
708
+ continue;
768
709
  }
710
+ }
711
+ this.touch();
712
+ for (let eventIndex = 0; eventIndex < events.length; eventIndex++) {
713
+ const rawChangeDocument = events[eventIndex];
714
+ const originalChangeDocument = parseChangeDocument(rawChangeDocument);
769
715
  if (this.abort_signal.aborted) {
770
716
  break;
771
717
  }
772
- if (originalChangeDocument == null) {
773
- // We get a new null document after `maxAwaitTimeMS` if there were no other events.
774
- // In this case, stream.resumeToken is the resume token associated with the last response.
775
- // stream.resumeToken is not updated if stream.tryNext() returns data, while stream.next()
776
- // does update it.
777
- // From observed behavior, the actual resumeToken changes around once every 10 seconds.
778
- // If we don't update it on empty events, we do keep consistency, but resuming the stream
779
- // with old tokens may cause connection timeouts.
780
- // We throttle this further by only persisting a keepalive once a minute.
781
- // We add an additional check for waitForCheckpointLsn == null, to make sure we're not
782
- // doing a keepalive in the middle of a transaction.
783
- if (waitForCheckpointLsn == null && performance.now() - lastEmptyResume > 60_000) {
784
- const { comparable: lsn, timestamp } = MongoLSN.fromResumeToken(stream.resumeToken);
785
- await batch.keepalive(lsn);
786
- this.touch();
787
- lastEmptyResume = performance.now();
788
- // Log the token update. This helps as a general "replication is still active" message in the logs.
789
- // This token would typically be around 10s behind.
790
- this.logger.info(`Idle change stream. Persisted resumeToken for ${timestampToDate(timestamp).toISOString()}`);
791
- this.isStartingReplication = false;
792
- }
793
- continue;
794
- }
795
- this.touch();
796
718
  if (startAfter != null && originalChangeDocument.clusterTime?.lte(startAfter)) {
797
719
  continue;
798
720
  }
@@ -846,15 +768,15 @@ export class ChangeStream {
846
768
  * would process below.
847
769
  *
848
770
  * However we don't commit the LSN after collections are dropped.
849
- * The prevents the `startAfter` or `resumeToken` from advancing past the drop events.
771
+ * This prevents the `startAfter` or `resumeToken` from advancing past the drop events.
850
772
  * The stream also closes after the drop events.
851
773
  * This causes an infinite loop of processing the collection drop events.
852
774
  *
853
- * This check here invalidates the change stream if our `_checkpoints` collection
775
+ * This check here invalidates the change stream if our `_powersync_checkpoints` collection
854
776
  * is dropped. This allows for detecting when the DB is dropped.
855
777
  */
856
778
  if (changeDocument.operationType == 'drop') {
857
- throw new ChangeStreamInvalidatedError('Internal collections have been dropped', new Error('_checkpoints collection was dropped'));
779
+ throw new ChangeStreamInvalidatedError('Internal collections have been dropped', new Error('_powersync_checkpoints collection was dropped'));
858
780
  }
859
781
  if (!(changeDocument.operationType == 'insert' ||
860
782
  changeDocument.operationType == 'update' ||
@@ -870,7 +792,21 @@ export class ChangeStream {
870
792
  // It may be useful to also throttle commits due to standalone checkpoints in the future.
871
793
  // However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
872
794
  const checkpointId = changeDocument.documentKey._id;
873
- if (!(checkpointId == STANDALONE_CHECKPOINT_ID || this.checkpointStreamId.equals(checkpointId))) {
795
+ if (checkpointId == STANDALONE_CHECKPOINT_ID) {
796
+ // Standalone / write checkpoint received.
797
+ // When we are caught up, commit immediately to keep write checkpoint latency low.
798
+ // Once there is already a batch checkpoint pending, or the driver has buffered more
799
+ // change stream events, collapse standalone checkpoints into the normal batch
800
+ // checkpoint flow to avoid commit churn under sustained load.
801
+ const hasBufferedChanges = eventIndex < events.length - 1;
802
+ if (waitForCheckpointLsn != null || hasBufferedChanges) {
803
+ if (waitForCheckpointLsn == null) {
804
+ waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
805
+ }
806
+ continue;
807
+ }
808
+ }
809
+ else if (!this.checkpointStreamId.equals(checkpointId)) {
874
810
  continue;
875
811
  }
876
812
  const { comparable: lsn } = new MongoLSN({
@@ -889,12 +825,10 @@ export class ChangeStream {
889
825
  waitForCheckpointLsn = null;
890
826
  }
891
827
  const { checkpointBlocked } = await batch.commit(lsn, {
892
- oldestUncommittedChange: this.oldestUncommittedChange
828
+ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
893
829
  });
894
830
  if (!checkpointBlocked) {
895
- this.oldestUncommittedChange = null;
896
- this.isStartingReplication = false;
897
- changesSinceLastCheckpoint = 0;
831
+ this.replicationLag.markCommitted();
898
832
  }
899
833
  }
900
834
  else if (changeDocument.operationType == 'insert' ||
@@ -909,28 +843,20 @@ export class ChangeStream {
909
843
  // In most cases, we should not need to snapshot this. But if this is the first time we see the collection
910
844
  // for whatever reason, then we do need to snapshot it.
911
845
  // This may result in some duplicate operations when a collection is created for the first time after
912
- // sync rules was deployed.
846
+ // sync config was deployed.
913
847
  snapshot: true
914
848
  });
915
849
  if (table.syncAny) {
916
- if (this.oldestUncommittedChange == null && changeDocument.clusterTime != null) {
917
- this.oldestUncommittedChange = timestampToDate(changeDocument.clusterTime);
918
- }
919
- const flushResult = await this.writeChange(batch, table, changeDocument);
920
- changesSinceLastCheckpoint += 1;
921
- if (flushResult != null && changesSinceLastCheckpoint >= 20_000) {
922
- // When we are catching up replication after an initial snapshot, there may be a very long delay
923
- // before we do a commit(). In that case, we need to periodically persist the resume LSN, so
924
- // we don't restart from scratch if we restart replication.
925
- // The same could apply if we need to catch up on replication after some downtime.
926
- const { comparable: lsn } = new MongoLSN({
927
- timestamp: changeDocument.clusterTime,
928
- resume_token: changeDocument._id
929
- });
930
- this.logger.info(`Updating resume LSN to ${lsn} after ${changesSinceLastCheckpoint} changes`);
931
- await batch.setResumeLsn(lsn);
932
- changesSinceLastCheckpoint = 0;
850
+ this.replicationLag.trackUncommittedChange(changeDocument.clusterTime == null ? null : timestampToDate(changeDocument.clusterTime));
851
+ const transactionKeyValue = transactionKey(changeDocument);
852
+ if (transactionKeyValue == null || lastTxnKey != transactionKeyValue) {
853
+ // Very crude metric for counting transactions replicated.
854
+ // We ignore operations other than basic CRUD, and ignore changes to _powersync_checkpoints.
855
+ // Individual writes may not have a txnNumber, in which case we count them as separate transactions.
856
+ lastTxnKey = transactionKeyValue;
857
+ transactionsReplicatedMetric.add(1);
933
858
  }
859
+ await this.writeChange(batch, table, changeDocument);
934
860
  }
935
861
  }
936
862
  else if (changeDocument.operationType == 'drop') {
@@ -964,30 +890,31 @@ export class ChangeStream {
964
890
  });
965
891
  }
966
892
  }
967
- }
968
- catch (e_4) {
969
- env_4.error = e_4;
970
- env_4.hasError = true;
971
- }
972
- finally {
973
- const result_4 = __disposeResources(env_4);
974
- if (result_4)
975
- await result_4;
893
+ if (splitDocument == null) {
894
+ // We flush and mark progress on every batch of data we receive.
895
+ // Batches are generally large (64MB or 6000 events, whichever comes first),
896
+ // so this is a good natural point to flush and mark progress.
897
+ // We avoid this when splitDocument is set, since we cannot resume in the middle of a split event.
898
+ const { comparable: lsn } = MongoLSN.fromResumeToken(resumeToken);
899
+ await batch.flush({ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange });
900
+ // TODO: We should consider making this standard behavior of flush().
901
+ await batch.setResumeLsn(lsn);
902
+ }
903
+ batchSpan.end();
904
+ const durations = outerSpan.end();
905
+ const duration = batchSpan.endAt - batchSpan.startAt;
906
+ this.logger.info(`Processed batch of ${events.length} changes / ${eventBatch.byteSize} bytes in ${duration}ms`, {
907
+ count: events.length,
908
+ bytes: eventBatch.byteSize,
909
+ duration,
910
+ t: durations
911
+ });
912
+ outerSpan = tracer.span('batch');
976
913
  }
977
914
  });
978
915
  }
979
- async getReplicationLagMillis() {
980
- if (this.oldestUncommittedChange == null) {
981
- if (this.isStartingReplication) {
982
- // We don't have anything to compute replication lag with yet.
983
- return undefined;
984
- }
985
- else {
986
- // We don't have any uncommitted changes, so replication is up-to-date.
987
- return 0;
988
- }
989
- }
990
- return Date.now() - this.oldestUncommittedChange.getTime();
916
+ getReplicationLagMillis() {
917
+ return this.replicationLag.getLagMillis();
991
918
  }
992
919
  lastTouchedAt = performance.now();
993
920
  touch() {
@@ -1000,27 +927,13 @@ export class ChangeStream {
1000
927
  }
1001
928
  }
1002
929
  }
1003
- function mapChangeStreamError(e) {
1004
- if (isMongoNetworkTimeoutError(e)) {
1005
- // This typically has an unhelpful message like "connection 2 to 159.41.94.47:27017 timed out".
1006
- // We wrap the error to make it more useful.
1007
- throw new DatabaseConnectionError(ErrorCode.PSYNC_S1345, `Timeout while reading MongoDB ChangeStream`, e);
1008
- }
1009
- else if (isMongoServerError(e) && e.codeName == 'MaxTimeMSExpired') {
1010
- // maxTimeMS was reached. Example message:
1011
- // MongoServerError: Executor error during aggregate command on namespace: powersync_test_data.$cmd.aggregate :: caused by :: operation exceeded time limit
1012
- throw new DatabaseConnectionError(ErrorCode.PSYNC_S1345, `Timeout while reading MongoDB ChangeStream`, e);
1013
- }
1014
- else if (isMongoServerError(e) &&
1015
- e.codeName == 'NoMatchingDocument' &&
1016
- e.errmsg?.includes('post-image was not found')) {
1017
- throw new ChangeStreamInvalidatedError(e.errmsg, e);
1018
- }
1019
- else if (isMongoServerError(e) && e.hasErrorLabel('NonResumableChangeStreamError')) {
1020
- throw new ChangeStreamInvalidatedError(e.message, e);
1021
- }
1022
- else {
1023
- throw new DatabaseConnectionError(ErrorCode.PSYNC_S1346, `Error reading MongoDB ChangeStream`, e);
930
+ /**
931
+ * Transaction key for a change stream event, used to detect transaction boundaries. Returns null if the event is not part of a transaction.
932
+ */
933
+ function transactionKey(doc) {
934
+ if (doc.txnNumber == null || doc.lsid == null) {
935
+ return null;
1024
936
  }
937
+ return `${doc.lsid.id.toString('hex')}:${doc.txnNumber}`;
1025
938
  }
1026
939
  //# sourceMappingURL=ChangeStream.js.map