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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +95 -7
  2. package/ci/test-connection.yaml +12 -0
  3. package/dist/api/MongoRouteAPIAdapter.d.ts +2 -2
  4. package/dist/api/MongoRouteAPIAdapter.js +73 -143
  5. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  6. package/dist/api/infer-collection-schema.d.ts +12 -0
  7. package/dist/api/infer-collection-schema.js +142 -0
  8. package/dist/api/infer-collection-schema.js.map +1 -0
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.js +1 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/module/MongoModule.d.ts +1 -2
  13. package/dist/module/MongoModule.js +2 -2
  14. package/dist/module/MongoModule.js.map +1 -1
  15. package/dist/replication/ChangeStream.d.ts +18 -2
  16. package/dist/replication/ChangeStream.js +275 -307
  17. package/dist/replication/ChangeStream.js.map +1 -1
  18. package/dist/replication/ChangeStreamReplicationJob.d.ts +4 -1
  19. package/dist/replication/ChangeStreamReplicationJob.js +6 -2
  20. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  21. package/dist/replication/ChangeStreamReplicator.d.ts +3 -0
  22. package/dist/replication/ChangeStreamReplicator.js +3 -0
  23. package/dist/replication/ChangeStreamReplicator.js.map +1 -1
  24. package/dist/replication/MongoRelation.js +5 -2
  25. package/dist/replication/MongoRelation.js.map +1 -1
  26. package/dist/replication/MongoReplicationQueryProvider.d.ts +87 -0
  27. package/dist/replication/MongoReplicationQueryProvider.js +23 -0
  28. package/dist/replication/MongoReplicationQueryProvider.js.map +1 -0
  29. package/dist/replication/MongoReplicationStream.d.ts +85 -0
  30. package/dist/replication/MongoReplicationStream.js +101 -0
  31. package/dist/replication/MongoReplicationStream.js.map +1 -0
  32. package/dist/replication/MongoSnapshotQuery.d.ts +2 -0
  33. package/dist/replication/MongoSnapshotQuery.js +12 -2
  34. package/dist/replication/MongoSnapshotQuery.js.map +1 -1
  35. package/dist/replication/MongoSnapshotter.d.ts +7 -2
  36. package/dist/replication/MongoSnapshotter.js +64 -91
  37. package/dist/replication/MongoSnapshotter.js.map +1 -1
  38. package/dist/replication/RawChangeStream.d.ts +4 -12
  39. package/dist/replication/RawChangeStream.js +1 -18
  40. package/dist/replication/RawChangeStream.js.map +1 -1
  41. package/dist/replication/SourceRowConverter.d.ts +1 -0
  42. package/dist/replication/SourceRowConverter.js +4 -2
  43. package/dist/replication/SourceRowConverter.js.map +1 -1
  44. package/dist/replication/bufferToSqlite.d.ts +3 -1
  45. package/dist/replication/bufferToSqlite.js +35 -23
  46. package/dist/replication/bufferToSqlite.js.map +1 -1
  47. package/dist/replication/checkpoints/CheckpointImplementation.d.ts +4 -1
  48. package/dist/replication/checkpoints/SentinelCheckpointImplementation.d.ts +4 -1
  49. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js +1 -1
  50. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js.map +1 -1
  51. package/dist/replication/checkpoints/TimestampCheckpointImplementation.d.ts +4 -1
  52. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js +2 -1
  53. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js.map +1 -1
  54. package/dist/replication/replication-index.d.ts +6 -0
  55. package/dist/replication/replication-index.js +6 -0
  56. package/dist/replication/replication-index.js.map +1 -1
  57. package/dist/test-utils/ChangeStreamTestContext.d.ts +131 -0
  58. package/dist/test-utils/ChangeStreamTestContext.js +391 -0
  59. package/dist/test-utils/ChangeStreamTestContext.js.map +1 -0
  60. package/dist/test-utils/test-utils-index.d.ts +1 -0
  61. package/dist/test-utils/test-utils-index.js +2 -0
  62. package/dist/test-utils/test-utils-index.js.map +1 -0
  63. package/dist/types/types.d.ts +5 -0
  64. package/dist/types/types.js +20 -2
  65. package/dist/types/types.js.map +1 -1
  66. package/package.json +9 -9
  67. package/src/api/MongoRouteAPIAdapter.ts +79 -127
  68. package/src/api/infer-collection-schema.ts +153 -0
  69. package/src/index.ts +1 -0
  70. package/src/module/MongoModule.ts +3 -4
  71. package/src/replication/ChangeStream.ts +303 -350
  72. package/src/replication/ChangeStreamReplicationJob.ts +9 -3
  73. package/src/replication/ChangeStreamReplicator.ts +5 -0
  74. package/src/replication/MongoRelation.ts +8 -1
  75. package/src/replication/MongoReplicationQueryProvider.ts +108 -0
  76. package/src/replication/MongoReplicationStream.ts +201 -0
  77. package/src/replication/MongoSnapshotQuery.ts +19 -3
  78. package/src/replication/MongoSnapshotter.ts +76 -98
  79. package/src/replication/RawChangeStream.ts +5 -32
  80. package/src/replication/SourceRowConverter.ts +4 -2
  81. package/src/replication/bufferToSqlite.ts +43 -23
  82. package/src/replication/checkpoints/CheckpointImplementation.ts +1 -1
  83. package/src/replication/checkpoints/SentinelCheckpointImplementation.ts +2 -2
  84. package/src/replication/checkpoints/TimestampCheckpointImplementation.ts +3 -2
  85. package/src/replication/replication-index.ts +6 -0
  86. package/src/test-utils/ChangeStreamTestContext.ts +520 -0
  87. package/src/test-utils/test-utils-index.ts +1 -0
  88. package/src/types/types.ts +27 -2
  89. package/test/src/buffer_to_sqlite.test.ts +9 -0
  90. package/test/src/change_stream.test.ts +76 -3
  91. package/test/src/change_stream_test_setup.ts +38 -0
  92. package/test/src/chunked_snapshot.test.ts +3 -3
  93. package/test/src/config.test.ts +34 -0
  94. package/test/src/documentdb_mode.test.ts +4 -5
  95. package/test/src/raw_change_stream.test.ts +0 -63
  96. package/test/src/replication_stream.test.ts +311 -0
  97. package/test/src/resume.test.ts +10 -4
  98. package/test/src/resuming_snapshots.test.ts +3 -3
  99. package/test/src/schema.test.ts +223 -0
  100. package/test/src/slow_tests.test.ts +3 -3
  101. package/test/src/snapshot_query_provider.test.ts +95 -0
  102. package/test/src/stream_progress.test.ts +312 -0
  103. package/test/src/util.ts +11 -29
  104. package/tsconfig.tsbuildinfo +1 -1
  105. package/test/src/change_stream_utils.ts +0 -375
@@ -8,7 +8,7 @@ import { test_utils } from '@powersync/service-core-tests';
8
8
 
9
9
  import { MongoRouteAPIAdapter } from '@module/api/MongoRouteAPIAdapter.js';
10
10
  import { PostImagesOption } from '@module/types/types.js';
11
- import { ChangeStreamTestContext } from './change_stream_utils.js';
11
+ import { openChangeStreamTestContext } from './change_stream_test_setup.js';
12
12
  import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
13
13
  import { testTimeout } from './test-timeouts.js';
14
14
  import { describeWithStorage, StorageVersionTestContext, TEST_CONNECTION_OPTIONS } from './util.js';
@@ -27,8 +27,8 @@ describe('change stream', () => {
27
27
  function defineChangeStreamTests({ factory, storageVersion }: StorageVersionTestContext) {
28
28
  const supportsConcurrentSnapshots = storageVersion >= 3;
29
29
 
30
- const openContext = (options?: Parameters<typeof ChangeStreamTestContext.open>[1]) => {
31
- return ChangeStreamTestContext.open(factory, { ...options, storageVersion });
30
+ const openContext = (options?: Parameters<typeof openChangeStreamTestContext>[1]) => {
31
+ return openChangeStreamTestContext(factory, { ...options, storageVersion });
32
32
  };
33
33
  // DocumentDB does not support changeStreamPreAndPostImages, which this test requires via postImages: READ_ONLY.
34
34
  test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('replicating basic values', async () => {
@@ -622,6 +622,79 @@ bucket_definitions:
622
622
  expect(data).toMatchObject([test_utils.putOp('test_data', { id: test_id, description: 'test1' })]);
623
623
  });
624
624
 
625
+ test.runIf(storageVersion >= 3)(
626
+ 'does not resnapshot an unchanged event definition during incremental reprocessing',
627
+ async (testContext) => {
628
+ const eventDefinition = `
629
+ event_definitions:
630
+ checkpoint_requests:
631
+ payloads:
632
+ - SELECT user_id, checkpoint, client_id, CASE WHEN checkpoint_requested_at IS NULL THEN true ELSE false END AS is_legacy FROM checkpoints
633
+ `;
634
+ const syncConfig = (deploymentComment: string) => `
635
+ config:
636
+ edition: 3
637
+ storage_version: 3
638
+ # ${deploymentComment}
639
+
640
+ ${eventDefinition}
641
+ streams:
642
+ global:
643
+ auto_subscribe: true
644
+ queries:
645
+ - SELECT *, _id AS id FROM lists
646
+ - SELECT *, _id AS id FROM todos
647
+ `;
648
+
649
+ let replicationStreamId: number;
650
+ {
651
+ await using context = await openContext();
652
+ const firstStorage = await context.updateSyncRules(syncConfig('initial deployment'));
653
+ if (
654
+ !('incrementalReprocessing' in firstStorage.storageConfig) ||
655
+ firstStorage.storageConfig.incrementalReprocessing !== true
656
+ ) {
657
+ testContext.skip('storage does not support incremental reprocessing');
658
+ }
659
+
660
+ const { db } = context;
661
+ await db.collection('lists').insertOne({ name: 'list' });
662
+ await db.collection('todos').insertOne({ description: 'todo' });
663
+ await db.collection('checkpoints').insertOne({
664
+ user_id: 'user-1',
665
+ checkpoint: 1n,
666
+ client_id: 'client-1',
667
+ checkpoint_requested_at: null
668
+ });
669
+
670
+ replicationStreamId = firstStorage.replicationStreamId;
671
+ await context.replicateSnapshot();
672
+ context.startStreaming();
673
+ await context.getCheckpoint();
674
+ }
675
+
676
+ const resnapshottedCollections: string[] = [];
677
+ await using context = await openContext({
678
+ doNotClear: true,
679
+ streamOptions: {
680
+ snapshotHooks: {
681
+ beforeSnapshotStarted: async (table) => {
682
+ resnapshottedCollections.push(table.name);
683
+ }
684
+ }
685
+ }
686
+ });
687
+
688
+ const processingStorage = await context.updateSyncRules(syncConfig('redeployed without behavioral changes'));
689
+ expect(processingStorage.replicationStreamId).toBe(replicationStreamId);
690
+ expect(await context.factory.getDeployingSyncConfig()).not.toBeNull();
691
+
692
+ await context.replicateSnapshot();
693
+
694
+ expect(resnapshottedCollections).toEqual([]);
695
+ }
696
+ );
697
+
625
698
  test('coalesces standalone checkpoints when backlog is buffered', async () => {
626
699
  await using context = await openContext();
627
700
  await context.updateSyncRules(BASIC_SYNC_RULES);
@@ -0,0 +1,38 @@
1
+ import { test_utils } from '@module/index.js';
2
+ import { mongo } from '@powersync/lib-service-mongodb';
3
+ import { TestStorageFactory } from '@powersync/service-core';
4
+ import { METRICS_HELPER } from '@powersync/service-core-tests';
5
+ import { TEST_CONNECTION_OPTIONS } from './util.js';
6
+
7
+ export const ChangeStreamTestContext = test_utils.ChangeStreamTestContext;
8
+
9
+ /**
10
+ * Supply this suite's environment and metrics to the exported context.
11
+ */
12
+ export function openChangeStreamTestContext(
13
+ factory: TestStorageFactory,
14
+ options?: Omit<test_utils.ChangeStreamTestContextOptions, 'factory' | 'connectionOptions' | 'metrics'> & {
15
+ mongoOptions?: Partial<test_utils.ChangeStreamTestContextOptions['connectionOptions']>;
16
+ }
17
+ ) {
18
+ return ChangeStreamTestContext.open({
19
+ ...options,
20
+ factory,
21
+ connectionOptions: { ...TEST_CONNECTION_OPTIONS, ...options?.mongoOptions },
22
+ metrics: METRICS_HELPER.metricsEngine
23
+ });
24
+ }
25
+
26
+ export async function setSnapshotHistorySeconds(client: mongo.MongoClient, seconds: number) {
27
+ const { minSnapshotHistoryWindowInSeconds: currentValue } = await client
28
+ .db('admin')
29
+ .command({ getParameter: 1, minSnapshotHistoryWindowInSeconds: 1 });
30
+
31
+ await client.db('admin').command({ setParameter: 1, minSnapshotHistoryWindowInSeconds: seconds });
32
+
33
+ return {
34
+ async [Symbol.asyncDispose]() {
35
+ await client.db('admin').command({ setParameter: 1, minSnapshotHistoryWindowInSeconds: currentValue });
36
+ }
37
+ };
38
+ }
@@ -5,7 +5,7 @@ import { JSONBig } from '@powersync/service-jsonbig';
5
5
  import { SqliteJsonValue } from '@powersync/service-sync-rules';
6
6
  import * as timers from 'timers/promises';
7
7
  import { describe, expect, test } from 'vitest';
8
- import { ChangeStreamTestContext } from './change_stream_utils.js';
8
+ import { openChangeStreamTestContext } from './change_stream_test_setup.js';
9
9
  import { describeWithStorage, StorageVersionTestContext } from './util.js';
10
10
 
11
11
  describe('chunked snapshots', () => {
@@ -13,8 +13,8 @@ describe('chunked snapshots', () => {
13
13
  });
14
14
 
15
15
  function defineBatchTests({ factory, storageVersion }: StorageVersionTestContext) {
16
- const openContext = (options?: Parameters<typeof ChangeStreamTestContext.open>[1]) => {
17
- return ChangeStreamTestContext.open(factory, { ...options, storageVersion });
16
+ const openContext = (options?: Parameters<typeof openChangeStreamTestContext>[1]) => {
17
+ return openChangeStreamTestContext(factory, { ...options, storageVersion });
18
18
  };
19
19
 
20
20
  // This is not as sensitive to the id type as postgres, but we still test a couple of cases
@@ -0,0 +1,34 @@
1
+ import { normalizeConnectionConfig } from '@module/types/types.js';
2
+ import { describe, expect, test } from 'vitest';
3
+
4
+ const BASE_CONFIG = {
5
+ type: 'mongodb' as const,
6
+ uri: 'mongodb://localhost:27017/powersync_test'
7
+ };
8
+
9
+ describe('MongoDB connection config', () => {
10
+ test('defaults heartbeat_interval_seconds to 60 seconds', () => {
11
+ expect(normalizeConnectionConfig(BASE_CONFIG).heartbeat_interval_seconds).toBe(60);
12
+ });
13
+
14
+ test('defaults a null heartbeat_interval_seconds to 60 seconds', () => {
15
+ expect(
16
+ normalizeConnectionConfig({ ...BASE_CONFIG, heartbeat_interval_seconds: null }).heartbeat_interval_seconds
17
+ ).toBe(60);
18
+ });
19
+
20
+ test('allows the MongoDB maximum heartbeat interval', () => {
21
+ expect(
22
+ normalizeConnectionConfig({ ...BASE_CONFIG, heartbeat_interval_seconds: 60 }).heartbeat_interval_seconds
23
+ ).toBe(60);
24
+ });
25
+
26
+ test.each([0, 4, 61, Number.NaN, Number.POSITIVE_INFINITY])(
27
+ 'rejects invalid heartbeat_interval_seconds: %s',
28
+ (heartbeat_interval_seconds) => {
29
+ expect(() => normalizeConnectionConfig({ ...BASE_CONFIG, heartbeat_interval_seconds })).toThrow(
30
+ 'heartbeat_interval_seconds'
31
+ );
32
+ }
33
+ );
34
+ });
@@ -9,7 +9,7 @@ import { SentinelLSN } from '@module/common/SentinelLSN.js';
9
9
  import { createSentinelCheckpointLsn, SENTINEL_CHECKPOINT_ID } from '@module/replication/MongoRelation.js';
10
10
  import { CHECKPOINTS_COLLECTION } from '@module/replication/replication-utils.js';
11
11
  import { mongo } from '@powersync/lib-service-mongodb';
12
- import { ChangeStreamTestContext } from './change_stream_utils.js';
12
+ import { openChangeStreamTestContext } from './change_stream_test_setup.js';
13
13
  import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
14
14
  import { env } from './env.js';
15
15
  import { testTimeout } from './test-timeouts.js';
@@ -584,8 +584,8 @@ describe.skipIf(DATABASE_TYPE != DatabaseType.DOCUMENTDB)('documentDbMode', () =
584
584
  });
585
585
 
586
586
  function defineDocumentDBDbModeTests({ factory, storageVersion }: StorageVersionTestContext) {
587
- const openContext = (options?: Parameters<typeof ChangeStreamTestContext.open>[1]) => {
588
- return ChangeStreamTestContext.open(factory, {
587
+ const openContext = (options?: Parameters<typeof openChangeStreamTestContext>[1]) => {
588
+ return openChangeStreamTestContext(factory, {
589
589
  ...options,
590
590
  storageVersion,
591
591
  streamOptions: {
@@ -759,8 +759,7 @@ bucket_definitions:
759
759
  expect(JSON.parse(lastOp.data as string)).toMatchObject({ description: 'after_keepalive' });
760
760
  });
761
761
 
762
- // Skipped until Azure DocumentDB ships the server-side getMore maxAwaitTimeMS fix.
763
- test.skip('respects maxAwaitTimeMS for idle getMore calls in documentDbMode', async () => {
762
+ test('respects maxAwaitTimeMS for idle getMore calls in documentDbMode', async () => {
764
763
  const maxAwaitTimeMS = 2_000;
765
764
 
766
765
  await using context = await openContext({
@@ -5,7 +5,6 @@ import { getCursorBatchBytes } from '@module/replication/replication-index.js';
5
5
  import { mongo } from '@powersync/lib-service-mongodb';
6
6
  import { bson } from '@powersync/service-core';
7
7
  import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
8
- import { testTimeout } from './test-timeouts.js';
9
8
  import { clearTestDb, connectMongoData, requireFailCommand } from './util.js';
10
9
 
11
10
  // DocumentDB only supports cluster-level change streams — collection- and
@@ -102,68 +101,6 @@ describe('internal mongodb utils', () => {
102
101
  }
103
102
  );
104
103
 
105
- test(
106
- 'keeps getMore maxTimeMS when client-side maxAwaitTimeMS is enabled',
107
- { timeout: testTimeout(30_000, { cloudOverride: 150_000 }) },
108
- async () => {
109
- const { db, client } = await connectMongoData({ monitorCommands: true });
110
- await using _ = { [Symbol.asyncDispose]: async () => await client.close() };
111
- await clearTestDb(db);
112
- const collection = db.collection('test_data');
113
- // Keep the local Mongo path fast, but give Azure DocumentDB enough time
114
- // for slow cloud change-stream delivery when maxTimeMS is sent to getMore.
115
- const maxAwaitTimeMS = testTimeout(50, { cloudOverride: 10_000 });
116
-
117
- const started: any[] = [];
118
- client.on('commandStarted', (event) => {
119
- if (event.commandName == 'aggregate' || event.commandName == 'getMore') {
120
- started.push(event);
121
- }
122
- });
123
-
124
- const stream = rawChangeStream(
125
- DATABASE_TYPE == DatabaseType.DOCUMENTDB ? client.db('admin') : db,
126
- [
127
- {
128
- $changeStream: {
129
- fullDocument: 'updateLookup',
130
- ...(DATABASE_TYPE == DatabaseType.DOCUMENTDB ? { allChangesForCluster: true } : {})
131
- }
132
- },
133
- ...(DATABASE_TYPE == DatabaseType.DOCUMENTDB
134
- ? [
135
- {
136
- $match: {
137
- 'ns.db': db.databaseName,
138
- 'ns.coll': collection.collectionName
139
- }
140
- }
141
- ]
142
- : [])
143
- ],
144
- {
145
- batchSize: 10,
146
- maxAwaitTimeMS,
147
- clientSideMaxAwaitTimeMS: true,
148
- maxTimeMS: 1_000
149
- }
150
- );
151
-
152
- await stream.next();
153
- await collection.insertOne({ test: 1 });
154
- const nextBatch = await readUntilNonEmptyBatch(stream);
155
- await stream.return?.();
156
-
157
- expect(nextBatch.events).toHaveLength(1);
158
-
159
- const aggregate = started.find((event) => event.commandName == 'aggregate');
160
- const getMore = started.find((event) => event.commandName == 'getMore');
161
-
162
- expect(aggregate?.command.maxTimeMS).toEqual(1_000);
163
- expect(getMore?.command.maxTimeMS).toEqual(maxAwaitTimeMS);
164
- }
165
- );
166
-
167
104
  // This test uses configureFailPoint to inject a getMore timeout. Azure
168
105
  // DocumentDB does not support configureFailPoint.
169
106
  test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)(
@@ -0,0 +1,311 @@
1
+ import { ChangeStreamInvalidatedError } from '@module/replication/ChangeStream.js';
2
+ import { DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER } from '@module/replication/MongoReplicationQueryProvider.js';
3
+ import { openMongoReplicationStream, readMongoReplicationStream } from '@module/replication/MongoReplicationStream.js';
4
+ import { ChangeStreamBatch, mapChangeStreamError } from '@module/replication/RawChangeStream.js';
5
+ import { mongo } from '@powersync/lib-service-mongodb';
6
+ import { DatabaseConnectionError } from '@powersync/lib-services-framework';
7
+ import { describe, expect, test, vi } from 'vitest';
8
+ import { env } from './env.js';
9
+
10
+ /**
11
+ * Exercise cursor construction and stream decoding without contacting MongoDB. Cursor tests intercept
12
+ * the driver's command method; reader tests supply BSON batches with explicit events and resume tokens.
13
+ */
14
+ describe('MongoDB replication stream reader', () => {
15
+ test.each(['pre', 'post'])('invalidates replication when a required %s-image is missing', (image) => {
16
+ const error = new mongo.MongoServerError({
17
+ code: 47,
18
+ codeName: 'NoMatchingDocument',
19
+ errmsg: `Change stream was configured to require a ${image}-image, but the ${image}-image was not found for event: example`
20
+ });
21
+
22
+ // Invalidated streams trigger a fresh snapshot instead of retrying an unrecoverable position.
23
+ expect(() => mapChangeStreamError(error)).toThrow(ChangeStreamInvalidatedError);
24
+ });
25
+
26
+ test('does not invalidate replication for unrelated NoMatchingDocument errors', () => {
27
+ const error = new mongo.MongoServerError({
28
+ code: 47,
29
+ codeName: 'NoMatchingDocument',
30
+ errmsg: 'No matching document found'
31
+ });
32
+
33
+ expect(() => mapChangeStreamError(error)).toThrow(DatabaseConnectionError);
34
+ });
35
+
36
+ test.each([
37
+ { isDocumentDb: false, multipleDatabases: false, usePostImages: false },
38
+ { isDocumentDb: false, multipleDatabases: false, usePostImages: true },
39
+ { isDocumentDb: false, multipleDatabases: true, usePostImages: true },
40
+ { isDocumentDb: true, multipleDatabases: false, usePostImages: true }
41
+ ])('preserves the base cursor options for %j', async ({ isDocumentDb, multipleDatabases, usePostImages }) => {
42
+ const client = new mongo.MongoClient(env.MONGO_TEST_DATA_URL);
43
+ await using clientDisposer = { [Symbol.asyncDispose]: () => client.close() };
44
+ const db = client.db('app');
45
+ const resumeAfter = { _data: 'exact-position' };
46
+ // Observe the real cursor-opening path, stopping before any network operation. An exact token must
47
+ // take precedence even if the stored position also contains its transaction's shared timestamp.
48
+ const command = vi.spyOn(mongo.Db.prototype, 'command').mockRejectedValue(new Error('observed command'));
49
+ try {
50
+ await expect(
51
+ collect(
52
+ openMongoReplicationStream({
53
+ db,
54
+ queryProvider: DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER,
55
+ namespaceFilter: { $match: { 'ns.coll': 'documents' }, multipleDatabases },
56
+ isDocumentDb,
57
+ usePostImages,
58
+ position: { resumeAfter, startAfter: new mongo.Timestamp({ t: 10, i: 1 }) },
59
+ skipInitialTimestamp: true,
60
+ options: { batchSize: 50, maxAwaitTimeMS: 200, maxTimeMS: 1000 }
61
+ })
62
+ )
63
+ ).rejects.toThrow('observed command');
64
+ expect(command).toHaveBeenCalledOnce();
65
+ expect((command.mock.contexts[0] as mongo.Db).databaseName).toBe(
66
+ isDocumentDb || multipleDatabases ? 'admin' : 'app'
67
+ );
68
+ expect(command.mock.calls[0][0]).toEqual({
69
+ aggregate: 1,
70
+ pipeline: [
71
+ {
72
+ $changeStream: {
73
+ fullDocument: !isDocumentDb && usePostImages ? 'required' : 'updateLookup',
74
+ ...(!isDocumentDb ? { showExpandedEvents: true } : {}),
75
+ ...(isDocumentDb || multipleDatabases ? { allChangesForCluster: true } : {}),
76
+ resumeAfter
77
+ }
78
+ },
79
+ { $match: { 'ns.coll': 'documents' } },
80
+ ...(!isDocumentDb ? [{ $changeStreamSplitLargeEvent: {} }] : [])
81
+ ],
82
+ cursor: { batchSize: 1 },
83
+ maxTimeMS: 1000
84
+ });
85
+ } finally {
86
+ command.mockRestore();
87
+ }
88
+ });
89
+
90
+ test('opens adapter stages after namespace selection, with pre-images and the shared resume settings', async () => {
91
+ // A provider requests pre-images and adds a stage. The shared opener must still supply the source
92
+ // position and post-image settings, and run the provider stage before large events are split.
93
+ const client = new mongo.MongoClient(env.MONGO_TEST_DATA_URL);
94
+ await using clientDisposer = { [Symbol.asyncDispose]: () => client.close() };
95
+ const command = vi.spyOn(mongo.Db.prototype, 'command').mockRejectedValue(new Error('observed command'));
96
+ const timestamp = new mongo.Timestamp({ t: 10, i: 1 });
97
+ try {
98
+ await expect(
99
+ collect(
100
+ openMongoReplicationStream({
101
+ db: client.db('app'),
102
+ queryProvider: {
103
+ ...DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER,
104
+ openChangeStream: ({ open }) =>
105
+ open({
106
+ pipelineStages: [{ $set: { marker: 'keep' } }],
107
+ imageOptions: { fullDocumentBeforeChange: 'whenAvailable' }
108
+ })
109
+ },
110
+ namespaceFilter: { $match: { 'ns.coll': 'documents' }, multipleDatabases: false },
111
+ isDocumentDb: false,
112
+ usePostImages: true,
113
+ position: { startAfter: timestamp },
114
+ options: { batchSize: 50, maxAwaitTimeMS: 200, maxTimeMS: 1000 }
115
+ })
116
+ )
117
+ ).rejects.toThrow('observed command');
118
+ expect(command.mock.calls[0][0].pipeline).toEqual([
119
+ {
120
+ $changeStream: {
121
+ fullDocumentBeforeChange: 'whenAvailable',
122
+ fullDocument: 'required',
123
+ showExpandedEvents: true,
124
+ startAtOperationTime: timestamp
125
+ }
126
+ },
127
+ { $match: { 'ns.coll': 'documents' } },
128
+ { $set: { marker: 'keep' } },
129
+ { $changeStreamSplitLargeEvent: {} }
130
+ ]);
131
+ } finally {
132
+ command.mockRestore();
133
+ }
134
+ });
135
+
136
+ test('keeps raw rows, buffered checkpoint information and idle progress', async () => {
137
+ // Two changes arrive together, followed by an empty response. Preserve raw BSON document bodies
138
+ // and indicate whether another change is buffered, while exposing a resume boundary for each batch.
139
+ const onBatch = vi.fn();
140
+ const batches = [batch([event('first'), event('second')], 'data'), batch([], 'idle')];
141
+ const items = await collect(
142
+ readMongoReplicationStream({
143
+ batches: from(batches),
144
+ defaultSchema: 'app',
145
+ multipleDatabases: false,
146
+ onBatch
147
+ })
148
+ );
149
+ expect(items.map((item) => item.type)).toEqual(['change', 'change', 'progress', 'progress']);
150
+ expect(items[0]).toMatchObject({ type: 'change', hasBufferedChanges: true });
151
+ expect(items[1]).toMatchObject({ type: 'change', hasBufferedChanges: false });
152
+ if (items[0].type != 'change' || !('fullDocument' in items[0].event)) throw new Error('Expected a row');
153
+ expect(Buffer.isBuffer(items[0].event.fullDocument)).toBe(true);
154
+ expect(mongo.BSON.deserialize(items[0].event.fullDocument!)).toEqual({ _id: 'first', body: 'raw row' });
155
+ expect(items.slice(2)).toEqual([
156
+ { type: 'progress', resumeToken: { _data: 'data' }, filteredCount: 0 },
157
+ { type: 'progress', resumeToken: { _data: 'idle' }, filteredCount: 0 }
158
+ ]);
159
+ expect(onBatch.mock.calls.map(([value]) => value)).toEqual(batches);
160
+ });
161
+
162
+ test('withholds progress across split batches, including an idle response between fragments', async () => {
163
+ // A large event spans two nonempty batches with an idle response between them. Saving either
164
+ // intermediate token would lose the in-memory first fragment if replication restarted there.
165
+ const original = event('large');
166
+ const first = { ...original, fullDocument: undefined, splitEvent: { fragment: 1, of: 2 } };
167
+ const last = {
168
+ _id: { _data: 'fragment-2' },
169
+ fullDocument: original.fullDocument,
170
+ splitEvent: { fragment: 2, of: 2 }
171
+ };
172
+ const onBatch = vi.fn();
173
+ const items = await collect(
174
+ readMongoReplicationStream({
175
+ batches: from([
176
+ batch([event('before'), first], 'unsafe'),
177
+ batch([], 'still-unsafe'),
178
+ batch([last, event('after')], 'safe')
179
+ ]),
180
+ defaultSchema: 'app',
181
+ multipleDatabases: false,
182
+ onBatch
183
+ })
184
+ );
185
+ // A consumer must never save either partial token, even though all three transport batches are counted.
186
+ expect(items.map((item) => item.type)).toEqual(['change', 'change', 'change', 'progress']);
187
+ expect(items[1]).toMatchObject({
188
+ type: 'change',
189
+ event: { documentKey: { _id: 'large' }, _id: { _data: 'fragment-2' } }
190
+ });
191
+ expect(items[3]).toEqual({ type: 'progress', resumeToken: { _data: 'safe' }, filteredCount: 0 });
192
+ expect(onBatch).toHaveBeenCalledTimes(3);
193
+ });
194
+
195
+ test.each([
196
+ // The first fragment is missing.
197
+ [{ ...event('broken'), splitEvent: { fragment: 2, of: 2 } }],
198
+ // A fragment in the middle is missing.
199
+ [
200
+ { ...event('broken'), splitEvent: { fragment: 1, of: 3 } },
201
+ { _id: { _data: 'bad' }, splitEvent: { fragment: 3, of: 3 } }
202
+ ],
203
+ // An ordinary event interrupts reassembly.
204
+ [{ ...event('broken'), splitEvent: { fragment: 1, of: 2 } }, event('unrelated')],
205
+ // The source iterator ends before the final fragment arrives.
206
+ [{ ...event('broken'), splitEvent: { fragment: 1, of: 2 } }]
207
+ ])('fails closed on incomplete or out-of-order fragments %#', async (...events) => {
208
+ // Invalid fragment sequences must reject consumption and close the upstream iterator so a caller
209
+ // cannot mistake a partial document for a complete change or continue from its batch token.
210
+ const closed = vi.fn();
211
+ const stream = readMongoReplicationStream({
212
+ batches: from([batch(events, 'unsafe')], closed),
213
+ defaultSchema: 'app',
214
+ multipleDatabases: false
215
+ });
216
+ await expect(collect(stream)).rejects.toThrow('splitEvent');
217
+ expect(closed).toHaveBeenCalledOnce();
218
+ });
219
+
220
+ test.each(['return', 'abort'] as const)(
221
+ 'closes the upstream iterator on %s without acknowledging unread work',
222
+ async (end) => {
223
+ // Stop after the first change while another remains in the same batch. Both consumer return
224
+ // and signal cancellation must run upstream cleanup before the batch's progress is yielded.
225
+ const closed = vi.fn();
226
+ const controller = new AbortController();
227
+ const stream = readMongoReplicationStream({
228
+ batches: from([batch([event('first'), event('unread')], 'unsafe')], closed),
229
+ defaultSchema: 'app',
230
+ multipleDatabases: false,
231
+ signal: controller.signal
232
+ });
233
+ expect((await stream.next()).value).toMatchObject({ type: 'change' });
234
+ if (end == 'return') await stream.return(undefined);
235
+ else {
236
+ controller.abort(new Error('cancelled'));
237
+ await expect(stream.next()).rejects.toThrow('cancelled');
238
+ }
239
+ expect(closed).toHaveBeenCalledOnce();
240
+ }
241
+ );
242
+
243
+ test('normalizes Atlas Flex namespaces and deduplicates legacy positions before adapter evaluation', async () => {
244
+ // Legacy positions contain only a timestamp, so an event at that timestamp is already covered.
245
+ // The remaining event uses an Atlas Flex database prefix that must be removed before delivery.
246
+ const timestamp = new mongo.Timestamp({ t: 10, i: 1 });
247
+ const items = await collect(
248
+ readMongoReplicationStream({
249
+ batches: from([
250
+ batch(
251
+ [
252
+ { ...event('old'), clusterTime: timestamp },
253
+ { ...event('new'), ns: { db: 'prefix_app', coll: 'documents' } }
254
+ ],
255
+ 'safe'
256
+ )
257
+ ]),
258
+ defaultSchema: 'app',
259
+ multipleDatabases: false,
260
+ startAfter: timestamp
261
+ })
262
+ );
263
+ expect(items).toHaveLength(2);
264
+ expect(items[0]).toMatchObject({
265
+ type: 'change',
266
+ event: { ns: { db: 'app', coll: 'documents' }, documentKey: { _id: 'new' } }
267
+ });
268
+ });
269
+ });
270
+
271
+ /**
272
+ * Minimal insert event for the app.documents fixture namespace; callers override fields for each scenario.
273
+ */
274
+ function event(id: string): mongo.Document {
275
+ return {
276
+ _id: { _data: id },
277
+ operationType: 'insert',
278
+ ns: { db: 'app', coll: 'documents' },
279
+ documentKey: { _id: id },
280
+ fullDocument: { _id: id, body: 'raw row' }
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Encode events as raw BSON, matching the reader's transport input. Tokens are opaque test labels;
286
+ * the fixed byte count supports batch accounting assertions without depending on BSON payload size.
287
+ */
288
+ function batch(events: mongo.Document[], token: string): ChangeStreamBatch {
289
+ return {
290
+ events: events.map((event) => Buffer.from(mongo.BSON.serialize(event))),
291
+ resumeToken: { _data: token },
292
+ byteSize: 1234
293
+ };
294
+ }
295
+
296
+ /**
297
+ * Supply deterministic source batches and expose iterator cleanup to cancellation and failure tests.
298
+ */
299
+ async function* from<T>(values: T[], closed?: () => void) {
300
+ try {
301
+ yield* values;
302
+ } finally {
303
+ closed?.();
304
+ }
305
+ }
306
+
307
+ async function collect<T>(stream: AsyncIterable<T>): Promise<T[]> {
308
+ const items: T[] = [];
309
+ for await (const item of stream) items.push(item);
310
+ return items;
311
+ }
@@ -1,8 +1,9 @@
1
1
  import { ChangeStreamInvalidatedError } from '@module/replication/ChangeStream.js';
2
2
  import { MongoManager } from '@module/replication/MongoManager.js';
3
3
  import { normalizeConnectionConfig } from '@module/types/types.js';
4
+ import { METRICS_HELPER } from '@powersync/service-core-tests';
4
5
  import { describe, expect, test } from 'vitest';
5
- import { ChangeStreamTestContext } from './change_stream_utils.js';
6
+ import { ChangeStreamTestContext, openChangeStreamTestContext } from './change_stream_test_setup.js';
6
7
  import { env } from './env.js';
7
8
  import { describeWithStorage, StorageVersionTestContext } from './util.js';
8
9
 
@@ -11,8 +12,8 @@ describe('mongodb resuming replication', () => {
11
12
  });
12
13
 
13
14
  function defineResumeTest({ factory: factoryGenerator, storageVersion }: StorageVersionTestContext) {
14
- const openContext = (options?: Parameters<typeof ChangeStreamTestContext.open>[1]) => {
15
- return ChangeStreamTestContext.open(factoryGenerator, { ...options, storageVersion });
15
+ const openContext = (options?: Parameters<typeof openChangeStreamTestContext>[1]) => {
16
+ return openChangeStreamTestContext(factoryGenerator, { ...options, storageVersion });
16
17
  };
17
18
 
18
19
  test.skip('resuming with a different source database', async () => {
@@ -66,7 +67,12 @@ function defineResumeTest({ factory: factoryGenerator, storageVersion }: Storage
66
67
  const factory = await factoryGenerator({ doNotClear: true });
67
68
 
68
69
  // Create a new context without updating the sync config
69
- await using context2 = new ChangeStreamTestContext(factory, connectionManager, {}, storageVersion);
70
+ await using context2 = new ChangeStreamTestContext({
71
+ factory,
72
+ connectionManager,
73
+ storageVersion,
74
+ metrics: METRICS_HELPER.metricsEngine
75
+ });
70
76
  const active = await factory.getActiveSyncConfig();
71
77
  context2.storage = active!.storage;
72
78
 
@@ -3,7 +3,7 @@ import { METRICS_HELPER } from '@powersync/service-core-tests';
3
3
  import { ReplicationMetric } from '@powersync/service-types';
4
4
  import * as timers from 'node:timers/promises';
5
5
  import { describe, expect, test } from 'vitest';
6
- import { ChangeStreamTestContext } from './change_stream_utils.js';
6
+ import { openChangeStreamTestContext } from './change_stream_test_setup.js';
7
7
  import { env } from './env.js';
8
8
  import { describeWithStorage } from './util.js';
9
9
 
@@ -35,7 +35,7 @@ async function testResumingReplication(factory: TestStorageFactory, storageVersi
35
35
  let startRowCount: number;
36
36
 
37
37
  {
38
- await using context = await ChangeStreamTestContext.open(factory, {
38
+ await using context = await openChangeStreamTestContext(factory, {
39
39
  storageVersion,
40
40
  streamOptions: { snapshotChunkLength: 1000 }
41
41
  });
@@ -88,7 +88,7 @@ async function testResumingReplication(factory: TestStorageFactory, storageVersi
88
88
 
89
89
  {
90
90
  // Bypass the usual "clear db on factory open" step.
91
- await using context2 = await ChangeStreamTestContext.open(factory, {
91
+ await using context2 = await openChangeStreamTestContext(factory, {
92
92
  doNotClear: true,
93
93
  storageVersion,
94
94
  streamOptions: { snapshotChunkLength: 1000 }