@powersync/service-module-mongodb 0.21.1 → 0.22.0

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 (108) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/benchmarks/env.ts +11 -0
  3. package/benchmarks/replication_branch_throughput.benchmark.ts +240 -0
  4. package/ci/test-connection.yaml +12 -0
  5. package/dist/api/MongoRouteAPIAdapter.d.ts +6 -0
  6. package/dist/api/MongoRouteAPIAdapter.js +33 -4
  7. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  8. package/dist/api/infer-collection-schema.d.ts +5 -0
  9. package/dist/api/infer-collection-schema.js +24 -0
  10. package/dist/api/infer-collection-schema.js.map +1 -1
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.js +1 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/module/MongoModule.d.ts +1 -2
  15. package/dist/module/MongoModule.js +1 -1
  16. package/dist/module/MongoModule.js.map +1 -1
  17. package/dist/replication/ChangeStream.d.ts +13 -3
  18. package/dist/replication/ChangeStream.js +130 -196
  19. package/dist/replication/ChangeStream.js.map +1 -1
  20. package/dist/replication/ChangeStreamReplicationJob.d.ts +4 -1
  21. package/dist/replication/ChangeStreamReplicationJob.js +19 -8
  22. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  23. package/dist/replication/ChangeStreamReplicator.d.ts +4 -0
  24. package/dist/replication/ChangeStreamReplicator.js +6 -0
  25. package/dist/replication/ChangeStreamReplicator.js.map +1 -1
  26. package/dist/replication/MongoRelation.js +5 -2
  27. package/dist/replication/MongoRelation.js.map +1 -1
  28. package/dist/replication/MongoReplicationQueryProvider.d.ts +85 -0
  29. package/dist/replication/MongoReplicationQueryProvider.js +23 -0
  30. package/dist/replication/MongoReplicationQueryProvider.js.map +1 -0
  31. package/dist/replication/MongoReplicationStream.d.ts +74 -0
  32. package/dist/replication/MongoReplicationStream.js +228 -0
  33. package/dist/replication/MongoReplicationStream.js.map +1 -0
  34. package/dist/replication/MongoReplicationStreamTypes.d.ts +157 -0
  35. package/dist/replication/MongoReplicationStreamTypes.js +19 -0
  36. package/dist/replication/MongoReplicationStreamTypes.js.map +1 -0
  37. package/dist/replication/MongoSnapshotQuery.d.ts +2 -0
  38. package/dist/replication/MongoSnapshotQuery.js +12 -2
  39. package/dist/replication/MongoSnapshotQuery.js.map +1 -1
  40. package/dist/replication/MongoSnapshotter.d.ts +7 -2
  41. package/dist/replication/MongoSnapshotter.js +90 -88
  42. package/dist/replication/MongoSnapshotter.js.map +1 -1
  43. package/dist/replication/RawChangeStream.d.ts +1 -1
  44. package/dist/replication/RawChangeStream.js +1 -1
  45. package/dist/replication/RawChangeStream.js.map +1 -1
  46. package/dist/replication/SourceRowConverter.d.ts +1 -0
  47. package/dist/replication/SourceRowConverter.js +4 -2
  48. package/dist/replication/SourceRowConverter.js.map +1 -1
  49. package/dist/replication/bufferToSqlite.d.ts +3 -1
  50. package/dist/replication/bufferToSqlite.js +35 -23
  51. package/dist/replication/bufferToSqlite.js.map +1 -1
  52. package/dist/replication/estimateFilteredSnapshotCount.d.ts +12 -0
  53. package/dist/replication/estimateFilteredSnapshotCount.js +79 -0
  54. package/dist/replication/estimateFilteredSnapshotCount.js.map +1 -0
  55. package/dist/replication/replication-index.d.ts +7 -0
  56. package/dist/replication/replication-index.js +7 -0
  57. package/dist/replication/replication-index.js.map +1 -1
  58. package/dist/test-utils/ChangeStreamTestContext.d.ts +131 -0
  59. package/dist/test-utils/ChangeStreamTestContext.js +390 -0
  60. package/dist/test-utils/ChangeStreamTestContext.js.map +1 -0
  61. package/dist/test-utils/test-utils-index.d.ts +1 -0
  62. package/dist/test-utils/test-utils-index.js +2 -0
  63. package/dist/test-utils/test-utils-index.js.map +1 -0
  64. package/package.json +11 -10
  65. package/src/api/MongoRouteAPIAdapter.ts +38 -4
  66. package/src/api/infer-collection-schema.ts +26 -0
  67. package/src/index.ts +1 -0
  68. package/src/module/MongoModule.ts +2 -3
  69. package/src/replication/ChangeStream.ts +143 -226
  70. package/src/replication/ChangeStreamReplicationJob.ts +21 -9
  71. package/src/replication/ChangeStreamReplicator.ts +9 -0
  72. package/src/replication/MongoRelation.ts +8 -1
  73. package/src/replication/MongoReplicationQueryProvider.ts +106 -0
  74. package/src/replication/MongoReplicationStream.ts +314 -0
  75. package/src/replication/MongoReplicationStreamTypes.ts +169 -0
  76. package/src/replication/MongoSnapshotQuery.ts +19 -3
  77. package/src/replication/MongoSnapshotter.ts +101 -92
  78. package/src/replication/RawChangeStream.ts +2 -2
  79. package/src/replication/SourceRowConverter.ts +4 -2
  80. package/src/replication/bufferToSqlite.ts +43 -23
  81. package/src/replication/estimateFilteredSnapshotCount.ts +93 -0
  82. package/src/replication/replication-index.ts +7 -0
  83. package/src/test-utils/ChangeStreamTestContext.ts +520 -0
  84. package/src/test-utils/test-utils-index.ts +1 -0
  85. package/test/src/buffer_to_sqlite.test.ts +9 -0
  86. package/test/src/change_event_memory.test.ts +33 -0
  87. package/test/src/change_stream.test.ts +3 -3
  88. package/test/src/change_stream_abort.test.ts +110 -0
  89. package/test/src/change_stream_test_setup.ts +38 -0
  90. package/test/src/chunked_snapshot.test.ts +3 -3
  91. package/test/src/documentdb_mode.test.ts +3 -3
  92. package/test/src/filtering_example.test.ts +65 -0
  93. package/test/src/filtering_lifecycle.test.ts +101 -0
  94. package/test/src/query_provider_availability.test.ts +77 -0
  95. package/test/src/query_provider_diagnostics.test.ts +55 -0
  96. package/test/src/replication_stream.test.ts +688 -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 +62 -19
  100. package/test/src/slow_tests.test.ts +3 -3
  101. package/test/src/snapshot_count.test.ts +146 -0
  102. package/test/src/snapshot_count_integration.test.ts +70 -0
  103. package/test/src/snapshot_query_provider.test.ts +122 -0
  104. package/test/src/stream_progress.test.ts +334 -0
  105. package/test/tsconfig.json +2 -2
  106. package/tsconfig.tsbuildinfo +1 -1
  107. package/vitest.benchmark.config.ts +15 -0
  108. package/test/src/change_stream_utils.ts +0 -375
@@ -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 }
@@ -154,27 +154,70 @@ describe('collection schema inference', { timeout: testTimeout(20_000) }, () =>
154
154
  expect(executionLimits).toEqual([30_000]);
155
155
  });
156
156
 
157
- test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('fails schema inference on a query timeout', async (ctx) => {
158
- await db.collection('timeout').insertOne({ value: 'text' });
159
- await using adapter = new MongoRouteAPIAdapter({
160
- type: 'mongodb',
161
- ...TEST_CONNECTION_OPTIONS,
162
- database: db.databaseName
163
- });
164
- await using failCommand = await requireFailCommand(client, ctx);
165
- await failCommand.configure({
166
- mode: { times: 1 },
167
- data: {
168
- failCommands: ['aggregate'],
169
- errorCode: 50 // MaxTimeMSExpired
170
- }
157
+ // A collection we cannot sample should cost us that collection's fields, not the whole schema.
158
+ // Exercises the real server/driver error path without waiting for the limits to be reached.
159
+ describe.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('when a collection cannot be sampled', () => {
160
+ // 146 is ExceededMemoryLimit, 292 is QueryExceededMemoryLimitNoDiskUseAllowed, which is what
161
+ // a small collection of multi-megabyte documents produces when $sample has to sort them.
162
+ // The namespace filter below needs a server recent enough to honor it (CI runs 6.0+);
163
+ // older servers ignore it and fail an arbitrary collection's aggregation.
164
+ test.for([146, 292])('reports it without columns on error %i', async (errorCode, ctx) => {
165
+ await db.collection('readable').insertOne({ value: 'text' });
166
+ await db.collection('unreadable').insertOne({ value: 'text' });
167
+ await using adapter = new MongoRouteAPIAdapter({
168
+ type: 'mongodb',
169
+ ...TEST_CONNECTION_OPTIONS,
170
+ database: db.databaseName
171
+ });
172
+ await using failCommand = await requireFailCommand(client, ctx);
173
+ await failCommand.configure({
174
+ mode: { times: 1 },
175
+ data: {
176
+ failCommands: ['aggregate'],
177
+ namespace: `${db.databaseName}.unreadable`,
178
+ errorCode
179
+ }
180
+ });
181
+
182
+ const schema = await adapter.getConnectionSchema();
183
+ const tables = schema.find((s) => s.name == db.databaseName)?.tables;
184
+
185
+ expect(tables).toEqual(
186
+ expect.arrayContaining([
187
+ { name: 'unreadable', columns: [] },
188
+ expect.objectContaining({
189
+ name: 'readable',
190
+ columns: expect.arrayContaining([expect.objectContaining({ name: 'value' })])
191
+ })
192
+ ])
193
+ );
171
194
  });
172
195
 
173
- // Exercise the real server/driver error path without waiting for the full timeout.
174
- await expect(adapter.getConnectionSchema()).rejects.toMatchObject({
175
- name: 'MongoServerError',
176
- code: 50,
177
- codeName: 'MaxTimeMSExpired'
196
+ // 2 is BadValue, standing in for any unexpected error. 50 is MaxTimeMSExpired: timeouts
197
+ // usually mean the source is overloaded rather than one problem collection, so they keep
198
+ // failing the request instead of silently degrading every collection.
199
+ // Neither is retryable, so the driver surfaces them without re-running the aggregation.
200
+ test.for([2, 50])('still fails the request on error %i', async (errorCode, ctx) => {
201
+ await db.collection('broken').insertOne({ value: 'text' });
202
+ await using adapter = new MongoRouteAPIAdapter({
203
+ type: 'mongodb',
204
+ ...TEST_CONNECTION_OPTIONS,
205
+ database: db.databaseName
206
+ });
207
+ await using failCommand = await requireFailCommand(client, ctx);
208
+ await failCommand.configure({
209
+ mode: { times: 1 },
210
+ data: {
211
+ failCommands: ['aggregate'],
212
+ namespace: `${db.databaseName}.broken`,
213
+ errorCode
214
+ }
215
+ });
216
+
217
+ await expect(adapter.getConnectionSchema()).rejects.toMatchObject({
218
+ name: 'MongoServerError',
219
+ code: errorCode
220
+ });
178
221
  });
179
222
  });
180
223
  });
@@ -2,7 +2,7 @@ import { setTimeout } from 'node:timers/promises';
2
2
  import { describe, expect, test } from 'vitest';
3
3
 
4
4
  import { mongo } from '@powersync/lib-service-mongodb';
5
- import { ChangeStreamTestContext, setSnapshotHistorySeconds } from './change_stream_utils.js';
5
+ import { openChangeStreamTestContext, setSnapshotHistorySeconds } from './change_stream_test_setup.js';
6
6
  import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
7
7
  import { env } from './env.js';
8
8
  import { describeWithStorage, StorageVersionTestContext } from './util.js';
@@ -12,8 +12,8 @@ describe.runIf(env.CI || env.SLOW_TESTS)('change stream slow tests', { timeout:
12
12
  });
13
13
 
14
14
  function defineSlowTests({ factory, storageVersion }: StorageVersionTestContext) {
15
- const openContext = (options?: Parameters<typeof ChangeStreamTestContext.open>[1]) => {
16
- return ChangeStreamTestContext.open(factory, { ...options, storageVersion });
15
+ const openContext = (options?: Parameters<typeof openChangeStreamTestContext>[1]) => {
16
+ return openChangeStreamTestContext(factory, { ...options, storageVersion });
17
17
  };
18
18
 
19
19
  // This test uses getParameter/setParameter for minSnapshotHistoryWindowInSeconds,
@@ -0,0 +1,146 @@
1
+ import { estimateFilteredSnapshotCount } from '@module/replication/estimateFilteredSnapshotCount.js';
2
+ import { mongo } from '@powersync/lib-service-mongodb';
3
+ import { createLogger } from '@powersync/lib-services-framework';
4
+ import { afterEach, describe, expect, test, vi } from 'vitest';
5
+
6
+ const FILTER = { $expr: { $eq: ['$label', 'live'] } };
7
+ const LOGGER = createLogger({ silent: true });
8
+
9
+ function fixture() {
10
+ const client = new mongo.MongoClient('mongodb://localhost:27017');
11
+ const collection = client.db('snapshot_count_unit').collection('documents');
12
+ const controller = new AbortController();
13
+ const count = vi.spyOn(collection, 'countDocuments').mockResolvedValue(42);
14
+ const metadata = vi.spyOn(collection, 'estimatedDocumentCount').mockResolvedValue(100_000);
15
+ const cursor = collection.aggregate();
16
+ const next = vi.spyOn(cursor, 'next').mockResolvedValue({ sampled: [{ count: 1_000 }], matches: [{ count: 250 }] });
17
+ const close = vi.spyOn(cursor, 'close').mockResolvedValue();
18
+ const aggregate = vi.spyOn(collection, 'aggregate').mockReturnValue(cursor);
19
+ const estimate = () =>
20
+ estimateFilteredSnapshotCount({ collection, filter: FILTER, signal: controller.signal, logger: LOGGER });
21
+ return { controller, count, metadata, next, close, aggregate, estimate };
22
+ }
23
+
24
+ afterEach(() => {
25
+ vi.restoreAllMocks();
26
+ });
27
+
28
+ describe('filtered snapshot count', () => {
29
+ test('uses a bounded exact count with the snapshot collation', async () => {
30
+ const context = fixture();
31
+ expect(await context.estimate()).toBe(42);
32
+ expect(context.count).toHaveBeenCalledWith(FILTER, {
33
+ collation: { locale: 'simple' },
34
+ readConcern: 'majority',
35
+ timeoutMS: 10_000,
36
+ signal: context.controller.signal
37
+ });
38
+ expect(context.metadata).not.toHaveBeenCalled();
39
+ expect(context.aggregate).not.toHaveBeenCalled();
40
+ });
41
+
42
+ test('preserves an exact zero without sampling', async () => {
43
+ const context = fixture();
44
+ context.count.mockResolvedValue(0);
45
+ expect(await context.estimate()).toBe(0);
46
+ expect(context.metadata).not.toHaveBeenCalled();
47
+ });
48
+
49
+ test('samples before filtering and scales by collection metadata after a count timeout', async () => {
50
+ const context = fixture();
51
+ context.count.mockRejectedValue(new mongo.MongoOperationTimeoutError('count timed out'));
52
+ expect(await context.estimate()).toBe(25_000);
53
+ expect(context.aggregate).toHaveBeenCalledWith(
54
+ [
55
+ { $sample: { size: 1_000 } },
56
+ { $facet: { sampled: [{ $count: 'count' }], matches: [{ $match: FILTER }, { $count: 'count' }] } }
57
+ ],
58
+ expect.objectContaining({
59
+ collation: { locale: 'simple' },
60
+ readConcern: 'majority',
61
+ signal: context.controller.signal,
62
+ allowDiskUse: false
63
+ })
64
+ );
65
+ const timeoutMS = context.aggregate.mock.calls[0][1]!.timeoutMS!;
66
+ expect(timeoutMS).toBeGreaterThan(0);
67
+ expect(timeoutMS).toBeLessThanOrEqual(10_000);
68
+ expect(context.close).toHaveBeenCalled();
69
+ });
70
+
71
+ test.each([
72
+ [10, 1],
73
+ [100_000_000, 10_000]
74
+ ])('bounds the sample size for a collection of %s documents', async (total, sampleSize) => {
75
+ const context = fixture();
76
+ context.count.mockRejectedValue(new Error('count failed'));
77
+ context.metadata.mockResolvedValue(total);
78
+ await context.estimate();
79
+ expect(context.aggregate.mock.calls[0][0]![0]).toEqual({ $sample: { size: sampleSize } });
80
+ });
81
+
82
+ test('uses the actual number sampled when metadata or collection size has changed', async () => {
83
+ const context = fixture();
84
+ context.count.mockRejectedValue(new Error('count failed'));
85
+ context.next.mockResolvedValue({ sampled: [{ count: 80 }], matches: [{ count: 20 }] });
86
+ expect(await context.estimate()).toBe(25_000);
87
+ });
88
+
89
+ test.each([null, { sampled: [], matches: [] }, { sampled: [{ count: 1_000 }], matches: [] }])(
90
+ 'leaves the total unknown for an empty sample or no matches: %j',
91
+ async (sample) => {
92
+ const context = fixture();
93
+ context.count.mockRejectedValue(new Error('count failed'));
94
+ context.next.mockResolvedValue(sample);
95
+ expect(await context.estimate()).toBe(-1);
96
+ expect(context.close).toHaveBeenCalled();
97
+ }
98
+ );
99
+
100
+ test.each(['metadata', 'sample', 'empty metadata', 'budget exhausted'])(
101
+ 'leaves the total unknown after %s',
102
+ async (failure) => {
103
+ const context = fixture();
104
+ context.count.mockRejectedValue(new Error('count failed'));
105
+ if (failure == 'metadata') {
106
+ context.metadata.mockRejectedValue(new Error('metadata failed'));
107
+ } else if (failure == 'sample') {
108
+ context.next.mockRejectedValue(new mongo.MongoOperationTimeoutError('sample timed out'));
109
+ } else if (failure == 'empty metadata') {
110
+ context.metadata.mockResolvedValue(0);
111
+ } else {
112
+ const { performance } = await import('node:perf_hooks');
113
+ vi.spyOn(performance, 'now').mockReturnValueOnce(0).mockReturnValue(10_001);
114
+ }
115
+ expect(await context.estimate()).toBe(-1);
116
+ if (failure == 'sample') {
117
+ expect(context.close).toHaveBeenCalled();
118
+ } else {
119
+ expect(context.aggregate).not.toHaveBeenCalled();
120
+ }
121
+ }
122
+ );
123
+
124
+ test.each(['before count', 'during count', 'during sample'])('propagates cancellation %s', async (when) => {
125
+ const context = fixture();
126
+ const reason = new Error('replication aborted');
127
+ if (when == 'before count') {
128
+ context.controller.abort(reason);
129
+ } else if (when == 'during count') {
130
+ context.count.mockImplementation(async () => {
131
+ context.controller.abort(reason);
132
+ throw reason;
133
+ });
134
+ } else {
135
+ context.count.mockRejectedValue(new Error('count failed'));
136
+ context.next.mockImplementation(async () => {
137
+ context.controller.abort(reason);
138
+ throw reason;
139
+ });
140
+ }
141
+ await expect(context.estimate()).rejects.toBe(reason);
142
+ if (when != 'during sample') {
143
+ expect(context.metadata).not.toHaveBeenCalled();
144
+ }
145
+ });
146
+ });
@@ -0,0 +1,70 @@
1
+ import { estimateFilteredSnapshotCount } from '@module/replication/estimateFilteredSnapshotCount.js';
2
+ import { mongo } from '@powersync/lib-service-mongodb';
3
+ import { createLogger } from '@powersync/lib-services-framework';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { describe, expect, test, vi } from 'vitest';
6
+ import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
7
+ import { connectMongoData } from './util.js';
8
+
9
+ const LOGGER = createLogger({ silent: true });
10
+
11
+ describe.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('filtered snapshot count integration', () => {
12
+ test('counts with simple collation even when the collection uses case-insensitive collation', async () => {
13
+ const { client, db } = await connectMongoData();
14
+ const collection = await db.createCollection(`snapshot_count_${randomUUID()}`, {
15
+ collation: { locale: 'en', strength: 2 }
16
+ });
17
+ try {
18
+ await collection.insertMany([{ label: 'live' }, { label: 'LIVE' }, { label: 'other' }]);
19
+ expect(
20
+ await estimateFilteredSnapshotCount({
21
+ collection,
22
+ filter: { $expr: { $eq: ['$label', 'live'] } },
23
+ signal: new AbortController().signal,
24
+ logger: LOGGER
25
+ })
26
+ ).toBe(1);
27
+ } finally {
28
+ await collection.drop();
29
+ await client.close();
30
+ }
31
+ });
32
+
33
+ test('executes the sample pipeline after a failed count and preserves binary filter semantics', async () => {
34
+ const { client, db } = await connectMongoData();
35
+ const collection = await db.createCollection(`snapshot_count_${randomUUID()}`, {
36
+ collation: { locale: 'en', strength: 2 }
37
+ });
38
+ try {
39
+ // For this small collection the sample contains one document. All rows have the same label,
40
+ // making the sampled result deterministic while exercising the real $sample/$facet pipeline.
41
+ await collection.insertMany(Array.from({ length: 50 }, () => ({ label: 'live' })));
42
+ const count = vi
43
+ .spyOn(collection, 'countDocuments')
44
+ .mockRejectedValue(new mongo.MongoOperationTimeoutError('timeout'));
45
+ try {
46
+ expect(
47
+ await estimateFilteredSnapshotCount({
48
+ collection,
49
+ filter: { $and: [{ label: 'live' }, { $expr: { $eq: ['$label', 'live'] } }] },
50
+ signal: new AbortController().signal,
51
+ logger: LOGGER
52
+ })
53
+ ).toBe(50);
54
+ expect(
55
+ await estimateFilteredSnapshotCount({
56
+ collection,
57
+ filter: { $expr: { $eq: ['$label', 'LIVE'] } },
58
+ signal: new AbortController().signal,
59
+ logger: LOGGER
60
+ })
61
+ ).toBe(-1);
62
+ } finally {
63
+ count.mockRestore();
64
+ }
65
+ } finally {
66
+ await collection.drop();
67
+ await client.close();
68
+ }
69
+ });
70
+ });
@@ -0,0 +1,122 @@
1
+ import { DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER } from '@module/replication/MongoReplicationQueryProvider.js';
2
+ import { mongo } from '@powersync/lib-service-mongodb';
3
+ import { describe, expect, test, vi } from 'vitest';
4
+ import { openChangeStreamTestContext } from './change_stream_test_setup.js';
5
+ import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
6
+ import { describeWithStorage } from './util.js';
7
+
8
+ describe.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('MongoDB snapshot query provider', () => {
9
+ describeWithStorage({ timeout: 30_000 }, ({ factory, storageVersion }) => {
10
+ test.each([
11
+ { filtered: false, retryUnknown: false },
12
+ { filtered: true, retryUnknown: false },
13
+ { filtered: true, retryUnknown: true }
14
+ ])('pages snapshots: %j', async ({ filtered, retryUnknown }) => {
15
+ // Run the same snapshot with and without a provider predicate. The filtered case selects even
16
+ // ids between 2 and 12 whose label is exactly 'live'; null must preserve the full snapshot.
17
+ const getSnapshotFilter = vi.fn(() =>
18
+ filtered
19
+ ? {
20
+ $expr: {
21
+ $and: [
22
+ { $gt: ['$_id', 2] },
23
+ { $lt: ['$_id', 12] },
24
+ { $eq: [{ $mod: ['$_id', 2] }, 0] },
25
+ { $eq: ['$label', 'live'] }
26
+ ]
27
+ }
28
+ }
29
+ : null
30
+ );
31
+ await using context = await openChangeStreamTestContext(factory, {
32
+ storageVersion,
33
+ streamOptions: {
34
+ snapshotChunkLength: 2,
35
+ createReplicationQueryProvider: ({ connectionTag, defaultSchema, syncConfig }) => {
36
+ // Providers need the actual source namespace and parsed source-table options to build their
37
+ // predicates. This sync config has no source-table options, so those are empty.
38
+ expect(connectionTag).toBe(context.connectionTag);
39
+ expect(defaultSchema).toBe(context.db.databaseName);
40
+ expect(syncConfig.sourceTableConfig).toEqual({});
41
+ return { ...DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER, getSnapshotFilter };
42
+ }
43
+ }
44
+ });
45
+ await context.updateSyncRules(/* yaml */ `
46
+ # Sync config fixture.
47
+ config:
48
+ edition: 3
49
+ streams:
50
+ global:
51
+ query: SELECT _id AS id, label FROM documents
52
+ `);
53
+ // The collection treats 'LIVE' and 'live' as equal by default. Provider filters use simple
54
+ // collation, so id 6 must be excluded for its 'LIVE' label even though it passes the id conditions.
55
+ const collection = await context.db.createCollection<{ _id: number; label: string }>('documents', {
56
+ collation: { locale: 'en', strength: 2 }
57
+ });
58
+ await collection.insertMany(
59
+ Array.from({ length: 14 }, (_, i) => ({ _id: i + 1, label: (i + 1) % 3 == 0 ? 'LIVE' : 'live' }))
60
+ );
61
+ const count = vi.spyOn(mongo.Collection.prototype, 'countDocuments');
62
+ const metadata = vi.spyOn(mongo.Collection.prototype, 'estimatedDocumentCount');
63
+ try {
64
+ if (retryUnknown) {
65
+ // Leave the queued estimate unknown, then let the snapshot retry against the real source.
66
+ count.mockRejectedValueOnce(new Error('count unavailable'));
67
+ metadata.mockRejectedValueOnce(new Error('metadata unavailable'));
68
+ }
69
+ await context.replicateSnapshot();
70
+ expect(count).toHaveBeenCalledTimes(retryUnknown ? 2 : filtered ? 1 : 0);
71
+ expect(metadata).toHaveBeenCalledTimes(retryUnknown ? 1 : filtered ? 0 : 2);
72
+ } finally {
73
+ count.mockRestore();
74
+ metadata.mockRestore();
75
+ }
76
+ const rows = (await context.getBucketData('global|0[]')).filter((op) => op.op == 'PUT');
77
+ // Matching ids 4, 8 and 10 require two pages at a page size of two. After the first page ends
78
+ // at id 8, pagination adds its own $expr requiring _id > 8. It must combine that condition
79
+ // with the provider's $expr: id 10 is included, while ids 9 and 11 through 14 remain excluded.
80
+ // Replacing the provider predicate with the continuation predicate would admit those extra rows.
81
+ // With no provider filter, pagination must return all 14 documents instead.
82
+ expect(rows.map((op) => op.object_id).sort()).toEqual(
83
+ (filtered ? [4, 8, 10] : Array.from({ length: 14 }, (_, i) => i + 1)).map(String).sort()
84
+ );
85
+ expect(getSnapshotFilter).toHaveBeenCalledWith(
86
+ expect.objectContaining({ schema: context.db.databaseName, name: 'documents' })
87
+ );
88
+ });
89
+
90
+ test('rejects unsupported sources before opening snapshot or streaming queries', async () => {
91
+ // Simulate a provider requiring a capability the source does not support. Replication must
92
+ // surface that validation error before asking the provider to open a stream or select snapshot rows.
93
+ const validateSource = vi.fn(async () => {
94
+ throw new Error('unsupported test source');
95
+ });
96
+ const openChangeStream = vi.fn(DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER.openChangeStream);
97
+ const getSnapshotFilter = vi.fn(DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER.getSnapshotFilter);
98
+ await using context = await openChangeStreamTestContext(factory, {
99
+ storageVersion,
100
+ streamOptions: {
101
+ createReplicationQueryProvider: () => ({ validateSource, openChangeStream, getSnapshotFilter })
102
+ }
103
+ });
104
+ await context.updateSyncRules(/* yaml */ `
105
+ # Sync config fixture.
106
+ config:
107
+ edition: 3
108
+ streams:
109
+ global:
110
+ query: SELECT _id AS id FROM documents
111
+ `);
112
+ await expect(context.replicateSnapshot()).rejects.toThrow('unsupported test source');
113
+ expect(validateSource).toHaveBeenCalledWith({
114
+ connectionManager: context.connectionManager,
115
+ isDocumentDb: false
116
+ });
117
+ // Spies delegate to the real default hooks if invoked, but validation should prevent either call.
118
+ expect(openChangeStream).not.toHaveBeenCalled();
119
+ expect(getSnapshotFilter).not.toHaveBeenCalled();
120
+ });
121
+ });
122
+ });