@powersync/service-module-mongodb 0.0.0-dev-20260225160713 → 0.0.0-dev-20260511080634

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 (70) hide show
  1. package/CHANGELOG.md +119 -6
  2. package/dist/api/MongoRouteAPIAdapter.js +4 -4
  3. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  4. package/dist/replication/ChangeStream.d.ts +8 -16
  5. package/dist/replication/ChangeStream.js +291 -373
  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 +4 -4
  45. package/src/replication/ChangeStream.ts +388 -352
  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 +39 -36
  62. package/test/src/env.ts +1 -1
  63. package/test/src/mongo_test.test.ts +66 -64
  64. package/test/src/parse_document_id.test.ts +54 -0
  65. package/test/src/raw_change_stream.test.ts +547 -0
  66. package/test/src/resume.test.ts +15 -4
  67. package/test/src/util.ts +62 -9
  68. package/test/tsconfig.json +0 -1
  69. package/tsconfig.scripts.json +13 -0
  70. 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,46 +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
- const streamManager = __addDisposableResource(env_1, this.openChangeStream({ lsn: null, maxAwaitTimeMs: 0 }), true);
215
- const { stream } = streamManager;
216
- const startTime = performance.now();
217
- let lastCheckpointCreated = -10_000;
218
- let eventsSeen = 0;
219
- while (performance.now() - startTime < LSN_TIMEOUT_SECONDS * 1000) {
220
- if (performance.now() - lastCheckpointCreated >= LSN_CREATE_INTERVAL_SECONDS * 1000) {
221
- await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
222
- lastCheckpointCreated = performance.now();
223
- }
224
- // tryNext() doesn't block, while next() / hasNext() does block until there is data on the stream
225
- const changeDocument = await stream.tryNext().catch((e) => {
226
- throw mapChangeStreamError(e);
227
- });
228
- if (changeDocument == null) {
229
- continue;
230
- }
177
+ batchesSeen += 1;
178
+ for (let rawChangeDocument of events) {
179
+ const changeDocument = parseChangeDocument(rawChangeDocument);
231
180
  const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
232
181
  if (ns?.coll == CHECKPOINTS_COLLECTION && 'documentKey' in changeDocument) {
233
182
  const checkpointId = changeDocument.documentKey._id;
@@ -242,56 +191,38 @@ export class ChangeStream {
242
191
  }
243
192
  eventsSeen += 1;
244
193
  }
245
- // Could happen if there is a very large replication lag?
246
- throw new ServiceError(ErrorCode.PSYNC_S1301, `Timeout after while waiting for checkpoint document for ${LSN_TIMEOUT_SECONDS}s. Streamed events = ${eventsSeen}`);
247
- }
248
- catch (e_1) {
249
- env_1.error = e_1;
250
- env_1.hasError = true;
251
- }
252
- finally {
253
- const result_1 = __disposeResources(env_1);
254
- if (result_1)
255
- await result_1;
256
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}`);
257
197
  }
258
198
  /**
259
199
  * Given a snapshot LSN, validate that we can read from it, by opening a change stream.
260
200
  */
261
201
  async validateSnapshotLsn(lsn) {
262
- const env_2 = { stack: [], error: void 0, hasError: false };
263
- try {
264
- const streamManager = __addDisposableResource(env_2, this.openChangeStream({ lsn: lsn, maxAwaitTimeMs: 0 }), true);
265
- const { stream } = streamManager;
266
- try {
267
- // tryNext() doesn't block, while next() / hasNext() does block until there is data on the stream
268
- await stream.tryNext();
269
- }
270
- catch (e) {
271
- // Note: A timeout here is not handled as a ChangeStreamInvalidatedError, even though
272
- // we possibly cannot recover from it.
273
- throw mapChangeStreamError(e);
274
- }
275
- }
276
- catch (e_2) {
277
- env_2.error = e_2;
278
- env_2.hasError = true;
279
- }
280
- finally {
281
- const result_2 = __disposeResources(env_2);
282
- if (result_2)
283
- 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;
284
213
  }
285
214
  }
286
215
  async initialReplication(snapshotLsn) {
287
216
  const sourceTables = this.sync_rules.getSourceTables();
288
217
  await this.client.connect();
218
+ const tracer = new PerformanceTracer('MongoDB initial replication');
289
219
  const flushResult = await this.storage.startBatch({
290
220
  logger: this.logger,
291
221
  zeroLSN: MongoLSN.ZERO.comparable,
292
222
  defaultSchema: this.defaultDb.databaseName,
293
223
  storeCurrentData: false,
294
- skipExistingRows: true
224
+ skipExistingRows: true,
225
+ tracer
295
226
  }, async (batch) => {
296
227
  if (snapshotLsn == null) {
297
228
  // First replication attempt - get a snapshot and store the timestamp
@@ -328,14 +259,14 @@ export class ChangeStream {
328
259
  }
329
260
  for (let table of tablesWithStatus) {
330
261
  await this.snapshotTable(batch, table);
331
- await batch.markSnapshotDone([table], MongoLSN.ZERO.comparable);
262
+ await batch.markTableSnapshotDone([table]);
332
263
  this.touch();
333
264
  }
334
265
  // The checkpoint here is a marker - we need to replicate up to at least this
335
266
  // point before the data can be considered consistent.
336
267
  // We could do this for each individual table, but may as well just do it once for the entire snapshot.
337
268
  const checkpoint = await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
338
- await batch.markSnapshotDone([], checkpoint);
269
+ await batch.markAllSnapshotDone(checkpoint);
339
270
  // This will not create a consistent checkpoint yet, but will persist the op.
340
271
  // Actual checkpoint will be created when streaming replication caught up.
341
272
  await batch.commit(snapshotLsn);
@@ -412,81 +343,69 @@ export class ChangeStream {
412
343
  }
413
344
  return { $match: nsFilter, multipleDatabases };
414
345
  }
415
- static *getQueryData(results) {
416
- for (let row of results) {
417
- yield constructAfterRecord(row);
418
- }
419
- }
420
346
  async snapshotTable(batch, table) {
421
- const env_3 = { stack: [], error: void 0, hasError: false };
422
- try {
423
- const totalEstimatedCount = await this.estimatedCountNumber(table);
424
- let at = table.snapshotStatus?.replicatedCount ?? 0;
425
- const db = this.client.db(table.schema);
426
- const collection = db.collection(table.name);
427
- const query = __addDisposableResource(env_3, new ChunkedSnapshotQuery({
428
- collection,
429
- key: table.snapshotStatus?.lastKey,
430
- batchSize: this.snapshotChunkLength
431
- }), true);
432
- if (query.lastKey != null) {
433
- 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;
434
372
  }
435
- else {
436
- 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);
437
377
  }
438
- let lastBatch = performance.now();
439
- let nextChunkPromise = query.nextChunk();
440
- while (true) {
441
- const { docs: docBatch, lastKey } = await nextChunkPromise;
442
- if (docBatch.length == 0) {
443
- // No more data - stop iterating
444
- break;
445
- }
446
- if (this.abort_signal.aborted) {
447
- throw new ReplicationAbortedError(`Aborted initial replication`);
448
- }
449
- // Pre-fetch next batch, so that we can read and write concurrently
450
- nextChunkPromise = query.nextChunk();
451
- for (let document of docBatch) {
452
- const record = this.constructAfterRecord(document);
453
- // This auto-flushes when the batch reaches its size limit
454
- await batch.save({
455
- tag: SaveOperationTag.INSERT,
456
- sourceTable: table,
457
- before: undefined,
458
- beforeReplicaId: undefined,
459
- after: record,
460
- afterReplicaId: document._id
461
- });
462
- }
463
- // Important: flush before marking progress
464
- await batch.flush();
465
- at += docBatch.length;
466
- this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED).add(docBatch.length);
467
- table = await batch.updateTableProgress(table, {
468
- lastKey,
469
- replicatedCount: at,
470
- 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
471
390
  });
472
- this.relationCache.update(table);
473
- const duration = performance.now() - lastBatch;
474
- lastBatch = performance.now();
475
- this.logger.info(`Replicating ${table.qualifiedName} ${table.formatSnapshotProgress()} in ${duration.toFixed(0)}ms`);
476
- this.touch();
477
391
  }
478
- // In case the loop was interrupted, make sure we await the last promise.
479
- await nextChunkPromise;
480
- }
481
- catch (e_3) {
482
- env_3.error = e_3;
483
- env_3.hasError = true;
484
- }
485
- finally {
486
- const result_3 = __disposeResources(env_3);
487
- if (result_3)
488
- 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();
489
406
  }
407
+ // In case the loop was interrupted, make sure we await the last promise.
408
+ await nextChunkPromise;
490
409
  }
491
410
  async getRelation(batch, descriptor, options) {
492
411
  const existing = this.relationCache.get(descriptor);
@@ -550,7 +469,7 @@ export class ChangeStream {
550
469
  // Snapshot if:
551
470
  // 1. Snapshot is requested (false for initial snapshot, since that process handles it elsewhere)
552
471
  // 2. Snapshot is not already done, AND:
553
- // 3. The table is used in sync rules.
472
+ // 3. The table is used in sync config.
554
473
  const shouldSnapshot = snapshot && !result.table.snapshotComplete && result.table.syncAny;
555
474
  if (shouldSnapshot) {
556
475
  this.logger.info(`New collection: ${descriptor.schema}.${descriptor.name}`);
@@ -558,29 +477,28 @@ export class ChangeStream {
558
477
  await batch.truncate([result.table]);
559
478
  await this.snapshotTable(batch, result.table);
560
479
  const no_checkpoint_before_lsn = await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
561
- const [table] = await batch.markSnapshotDone([result.table], no_checkpoint_before_lsn);
480
+ const [table] = await batch.markTableSnapshotDone([result.table], no_checkpoint_before_lsn);
562
481
  return table;
563
482
  }
564
483
  return result.table;
565
484
  }
566
- constructAfterRecord(document) {
567
- const inputRow = constructAfterRecord(document);
568
- return this.sync_rules.applyRowContext(inputRow);
569
- }
570
485
  async writeChange(batch, table, change) {
571
486
  if (!table.syncAny) {
572
- 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`);
573
488
  return null;
574
489
  }
575
490
  this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED).add(1);
576
491
  if (change.operationType == 'insert') {
577
- const baseRecord = this.constructAfterRecord(change.fullDocument);
492
+ const { row: baseRecord, replicaId: _replicaId } = this.rawToSqliteRow(change.fullDocument);
578
493
  return await batch.save({
579
494
  tag: SaveOperationTag.INSERT,
580
495
  sourceTable: table,
581
496
  before: undefined,
582
497
  beforeReplicaId: undefined,
583
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.
584
502
  afterReplicaId: change.documentKey._id
585
503
  });
586
504
  }
@@ -594,14 +512,14 @@ export class ChangeStream {
594
512
  beforeReplicaId: change.documentKey._id
595
513
  });
596
514
  }
597
- const after = this.constructAfterRecord(change.fullDocument);
515
+ const { row: after, replicaId: _replicaId } = this.rawToSqliteRow(change.fullDocument);
598
516
  return await batch.save({
599
517
  tag: SaveOperationTag.UPDATE,
600
518
  sourceTable: table,
601
519
  before: undefined,
602
520
  beforeReplicaId: undefined,
603
521
  after: after,
604
- afterReplicaId: change.documentKey._id
522
+ afterReplicaId: change.documentKey._id // Same as _replicaId
605
523
  });
606
524
  }
607
525
  else if (change.operationType == 'delete') {
@@ -638,7 +556,7 @@ export class ChangeStream {
638
556
  }
639
557
  const { lastOpId } = await this.initialReplication(result.snapshotLsn);
640
558
  if (lastOpId != null) {
641
- // 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.
642
560
  await this.storage.populatePersistentChecksumCache({
643
561
  signal: this.abort_signal,
644
562
  // No checkpoint yet, but we do have the opId.
@@ -660,17 +578,11 @@ export class ChangeStream {
660
578
  throw e;
661
579
  }
662
580
  }
663
- openChangeStream(options) {
581
+ rawChangeStreamBatches(options) {
664
582
  const lastLsn = options.lsn ? MongoLSN.fromSerialized(options.lsn) : null;
665
583
  const startAfter = lastLsn?.timestamp;
666
584
  const resumeAfter = lastLsn?.resumeToken;
667
- const filters = this.getSourceNamespaceFilters();
668
- const pipeline = [
669
- {
670
- $match: filters.$match
671
- },
672
- { $changeStreamSplitLargeEvent: {} }
673
- ];
585
+ const filters = options.filters;
674
586
  let fullDocument;
675
587
  if (this.usePostImages) {
676
588
  // 'read_only' or 'auto_configure'
@@ -683,10 +595,17 @@ export class ChangeStream {
683
595
  }
684
596
  const streamOptions = {
685
597
  showExpandedEvents: true,
686
- maxAwaitTimeMS: options.maxAwaitTimeMs ?? this.maxAwaitTimeMS,
687
- fullDocument: fullDocument,
688
- maxTimeMS: this.changeStreamTimeout
598
+ fullDocument: fullDocument
689
599
  };
600
+ const pipeline = [
601
+ {
602
+ $changeStream: streamOptions
603
+ },
604
+ {
605
+ $match: filters.$match
606
+ },
607
+ { $changeStreamSplitLargeEvent: {} }
608
+ ];
690
609
  /**
691
610
  * Only one of these options can be supplied at a time.
692
611
  */
@@ -696,100 +615,106 @@ export class ChangeStream {
696
615
  else {
697
616
  // Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
698
617
  // case if we have an old one.
618
+ // This is also relevant for getSnapshotLSN().
699
619
  streamOptions.startAtOperationTime = startAfter;
700
620
  }
701
- let stream;
621
+ let watchDb;
702
622
  if (filters.multipleDatabases) {
703
- // Requires readAnyDatabase@admin on Atlas
704
- stream = this.client.watch(pipeline, streamOptions);
623
+ watchDb = this.client.db('admin');
624
+ streamOptions.allChangesForCluster = true;
705
625
  }
706
626
  else {
707
- // Same general result, but requires less permissions than the above
708
- stream = this.defaultDb.watch(pipeline, streamOptions);
627
+ watchDb = this.defaultDb;
709
628
  }
710
- this.abort_signal.addEventListener('abort', () => {
711
- 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
712
636
  });
713
- return {
714
- stream,
715
- filters,
716
- [Symbol.asyncDispose]: async () => {
717
- return stream.close();
718
- }
719
- };
637
+ }
638
+ rawToSqliteRow(row) {
639
+ return this.sourceRowConverter.rawToSqliteRow(row);
720
640
  }
721
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');
722
646
  await this.storage.startBatch({
723
647
  logger: this.logger,
724
648
  zeroLSN: MongoLSN.ZERO.comparable,
725
649
  defaultSchema: this.defaultDb.databaseName,
726
650
  // We get a complete postimage for every change, so we don't need to store the current data.
727
- storeCurrentData: false
651
+ storeCurrentData: false,
652
+ tracer
728
653
  }, async (batch) => {
729
- const env_4 = { stack: [], error: void 0, hasError: false };
730
- try {
731
- const { resumeFromLsn } = batch;
732
- if (resumeFromLsn == null) {
733
- throw new ReplicationAssertionError(`No LSN found to resume from`);
734
- }
735
- const lastLsn = MongoLSN.fromSerialized(resumeFromLsn);
736
- const startAfter = lastLsn?.timestamp;
737
- // It is normal for this to be a minute or two old when there is a low volume
738
- // of ChangeStream events.
739
- const tokenAgeSeconds = Math.round((Date.now() - timestampToDate(startAfter).getTime()) / 1000);
740
- this.logger.info(`Resume streaming at ${startAfter?.inspect()} / ${lastLsn} | Token age: ${tokenAgeSeconds}s`);
741
- const streamManager = __addDisposableResource(env_4, this.openChangeStream({ lsn: resumeFromLsn }), true);
742
- 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);
743
686
  if (this.abort_signal.aborted) {
744
- await stream.close();
745
- return;
687
+ break;
746
688
  }
747
- // Always start with a checkpoint.
748
- // This helps us to clear errors when restarting, even if there is
749
- // no data to replicate.
750
- let waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
751
- let splitDocument = null;
752
- let flexDbNameWorkaroundLogged = false;
753
- let changesSinceLastCheckpoint = 0;
754
- let lastEmptyResume = performance.now();
755
- while (true) {
756
- if (this.abort_signal.aborted) {
757
- 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();
758
704
  }
759
- const originalChangeDocument = await stream.tryNext().catch((e) => {
760
- throw mapChangeStreamError(e);
761
- });
762
- // The stream was closed, we will only ever receive `null` from it
763
- if (!originalChangeDocument && stream.closed) {
764
- 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;
765
709
  }
710
+ }
711
+ this.touch();
712
+ for (let eventIndex = 0; eventIndex < events.length; eventIndex++) {
713
+ const rawChangeDocument = events[eventIndex];
714
+ const originalChangeDocument = parseChangeDocument(rawChangeDocument);
766
715
  if (this.abort_signal.aborted) {
767
716
  break;
768
717
  }
769
- if (originalChangeDocument == null) {
770
- // We get a new null document after `maxAwaitTimeMS` if there were no other events.
771
- // In this case, stream.resumeToken is the resume token associated with the last response.
772
- // stream.resumeToken is not updated if stream.tryNext() returns data, while stream.next()
773
- // does update it.
774
- // From observed behavior, the actual resumeToken changes around once every 10 seconds.
775
- // If we don't update it on empty events, we do keep consistency, but resuming the stream
776
- // with old tokens may cause connection timeouts.
777
- // We throttle this further by only persisting a keepalive once a minute.
778
- // We add an additional check for waitForCheckpointLsn == null, to make sure we're not
779
- // doing a keepalive in the middle of a transaction.
780
- if (waitForCheckpointLsn == null && performance.now() - lastEmptyResume > 60_000) {
781
- const { comparable: lsn, timestamp } = MongoLSN.fromResumeToken(stream.resumeToken);
782
- await batch.keepalive(lsn);
783
- this.touch();
784
- lastEmptyResume = performance.now();
785
- // Log the token update. This helps as a general "replication is still active" message in the logs.
786
- // This token would typically be around 10s behind.
787
- this.logger.info(`Idle change stream. Persisted resumeToken for ${timestampToDate(timestamp).toISOString()}`);
788
- this.isStartingReplication = false;
789
- }
790
- continue;
791
- }
792
- this.touch();
793
718
  if (startAfter != null && originalChangeDocument.clusterTime?.lte(startAfter)) {
794
719
  continue;
795
720
  }
@@ -843,15 +768,15 @@ export class ChangeStream {
843
768
  * would process below.
844
769
  *
845
770
  * However we don't commit the LSN after collections are dropped.
846
- * 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.
847
772
  * The stream also closes after the drop events.
848
773
  * This causes an infinite loop of processing the collection drop events.
849
774
  *
850
- * This check here invalidates the change stream if our `_checkpoints` collection
775
+ * This check here invalidates the change stream if our `_powersync_checkpoints` collection
851
776
  * is dropped. This allows for detecting when the DB is dropped.
852
777
  */
853
778
  if (changeDocument.operationType == 'drop') {
854
- 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'));
855
780
  }
856
781
  if (!(changeDocument.operationType == 'insert' ||
857
782
  changeDocument.operationType == 'update' ||
@@ -867,7 +792,21 @@ export class ChangeStream {
867
792
  // It may be useful to also throttle commits due to standalone checkpoints in the future.
868
793
  // However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
869
794
  const checkpointId = changeDocument.documentKey._id;
870
- 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)) {
871
810
  continue;
872
811
  }
873
812
  const { comparable: lsn } = new MongoLSN({
@@ -885,11 +824,11 @@ export class ChangeStream {
885
824
  if (waitForCheckpointLsn != null && lsn >= waitForCheckpointLsn) {
886
825
  waitForCheckpointLsn = null;
887
826
  }
888
- const didCommit = await batch.commit(lsn, { oldestUncommittedChange: this.oldestUncommittedChange });
889
- if (didCommit) {
890
- this.oldestUncommittedChange = null;
891
- this.isStartingReplication = false;
892
- changesSinceLastCheckpoint = 0;
827
+ const { checkpointBlocked } = await batch.commit(lsn, {
828
+ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
829
+ });
830
+ if (!checkpointBlocked) {
831
+ this.replicationLag.markCommitted();
893
832
  }
894
833
  }
895
834
  else if (changeDocument.operationType == 'insert' ||
@@ -904,28 +843,20 @@ export class ChangeStream {
904
843
  // In most cases, we should not need to snapshot this. But if this is the first time we see the collection
905
844
  // for whatever reason, then we do need to snapshot it.
906
845
  // This may result in some duplicate operations when a collection is created for the first time after
907
- // sync rules was deployed.
846
+ // sync config was deployed.
908
847
  snapshot: true
909
848
  });
910
849
  if (table.syncAny) {
911
- if (this.oldestUncommittedChange == null && changeDocument.clusterTime != null) {
912
- this.oldestUncommittedChange = timestampToDate(changeDocument.clusterTime);
913
- }
914
- const flushResult = await this.writeChange(batch, table, changeDocument);
915
- changesSinceLastCheckpoint += 1;
916
- if (flushResult != null && changesSinceLastCheckpoint >= 20_000) {
917
- // When we are catching up replication after an initial snapshot, there may be a very long delay
918
- // before we do a commit(). In that case, we need to periodically persist the resume LSN, so
919
- // we don't restart from scratch if we restart replication.
920
- // The same could apply if we need to catch up on replication after some downtime.
921
- const { comparable: lsn } = new MongoLSN({
922
- timestamp: changeDocument.clusterTime,
923
- resume_token: changeDocument._id
924
- });
925
- this.logger.info(`Updating resume LSN to ${lsn} after ${changesSinceLastCheckpoint} changes`);
926
- await batch.setResumeLsn(lsn);
927
- 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);
928
858
  }
859
+ await this.writeChange(batch, table, changeDocument);
929
860
  }
930
861
  }
931
862
  else if (changeDocument.operationType == 'drop') {
@@ -959,30 +890,31 @@ export class ChangeStream {
959
890
  });
960
891
  }
961
892
  }
962
- }
963
- catch (e_4) {
964
- env_4.error = e_4;
965
- env_4.hasError = true;
966
- }
967
- finally {
968
- const result_4 = __disposeResources(env_4);
969
- if (result_4)
970
- 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');
971
913
  }
972
914
  });
973
915
  }
974
- async getReplicationLagMillis() {
975
- if (this.oldestUncommittedChange == null) {
976
- if (this.isStartingReplication) {
977
- // We don't have anything to compute replication lag with yet.
978
- return undefined;
979
- }
980
- else {
981
- // We don't have any uncommitted changes, so replication is up-to-date.
982
- return 0;
983
- }
984
- }
985
- return Date.now() - this.oldestUncommittedChange.getTime();
916
+ getReplicationLagMillis() {
917
+ return this.replicationLag.getLagMillis();
986
918
  }
987
919
  lastTouchedAt = performance.now();
988
920
  touch() {
@@ -995,27 +927,13 @@ export class ChangeStream {
995
927
  }
996
928
  }
997
929
  }
998
- function mapChangeStreamError(e) {
999
- if (isMongoNetworkTimeoutError(e)) {
1000
- // This typically has an unhelpful message like "connection 2 to 159.41.94.47:27017 timed out".
1001
- // We wrap the error to make it more useful.
1002
- throw new DatabaseConnectionError(ErrorCode.PSYNC_S1345, `Timeout while reading MongoDB ChangeStream`, e);
1003
- }
1004
- else if (isMongoServerError(e) && e.codeName == 'MaxTimeMSExpired') {
1005
- // maxTimeMS was reached. Example message:
1006
- // MongoServerError: Executor error during aggregate command on namespace: powersync_test_data.$cmd.aggregate :: caused by :: operation exceeded time limit
1007
- throw new DatabaseConnectionError(ErrorCode.PSYNC_S1345, `Timeout while reading MongoDB ChangeStream`, e);
1008
- }
1009
- else if (isMongoServerError(e) &&
1010
- e.codeName == 'NoMatchingDocument' &&
1011
- e.errmsg?.includes('post-image was not found')) {
1012
- throw new ChangeStreamInvalidatedError(e.errmsg, e);
1013
- }
1014
- else if (isMongoServerError(e) && e.hasErrorLabel('NonResumableChangeStreamError')) {
1015
- throw new ChangeStreamInvalidatedError(e.message, e);
1016
- }
1017
- else {
1018
- 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;
1019
936
  }
937
+ return `${doc.lsid.id.toString('hex')}:${doc.txnNumber}`;
1020
938
  }
1021
939
  //# sourceMappingURL=ChangeStream.js.map