@powersync/service-module-mongodb 0.21.2 → 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.
- package/CHANGELOG.md +51 -0
- package/benchmarks/env.ts +11 -0
- package/benchmarks/replication_branch_throughput.benchmark.ts +240 -0
- package/dist/api/MongoRouteAPIAdapter.d.ts +6 -0
- package/dist/api/MongoRouteAPIAdapter.js +21 -2
- package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/module/MongoModule.d.ts +1 -1
- package/dist/module/MongoModule.js +1 -0
- package/dist/module/MongoModule.js.map +1 -1
- package/dist/replication/ChangeStream.d.ts +13 -3
- package/dist/replication/ChangeStream.js +130 -196
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/ChangeStreamReplicationJob.d.ts +4 -1
- package/dist/replication/ChangeStreamReplicationJob.js +19 -8
- package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
- package/dist/replication/ChangeStreamReplicator.d.ts +4 -0
- package/dist/replication/ChangeStreamReplicator.js +6 -0
- package/dist/replication/ChangeStreamReplicator.js.map +1 -1
- package/dist/replication/MongoReplicationQueryProvider.d.ts +85 -0
- package/dist/replication/MongoReplicationQueryProvider.js +23 -0
- package/dist/replication/MongoReplicationQueryProvider.js.map +1 -0
- package/dist/replication/MongoReplicationStream.d.ts +74 -0
- package/dist/replication/MongoReplicationStream.js +228 -0
- package/dist/replication/MongoReplicationStream.js.map +1 -0
- package/dist/replication/MongoReplicationStreamTypes.d.ts +157 -0
- package/dist/replication/MongoReplicationStreamTypes.js +19 -0
- package/dist/replication/MongoReplicationStreamTypes.js.map +1 -0
- package/dist/replication/MongoSnapshotQuery.d.ts +2 -0
- package/dist/replication/MongoSnapshotQuery.js +12 -2
- package/dist/replication/MongoSnapshotQuery.js.map +1 -1
- package/dist/replication/MongoSnapshotter.d.ts +7 -2
- package/dist/replication/MongoSnapshotter.js +90 -88
- package/dist/replication/MongoSnapshotter.js.map +1 -1
- package/dist/replication/RawChangeStream.d.ts +1 -1
- package/dist/replication/RawChangeStream.js +1 -1
- package/dist/replication/RawChangeStream.js.map +1 -1
- package/dist/replication/estimateFilteredSnapshotCount.d.ts +12 -0
- package/dist/replication/estimateFilteredSnapshotCount.js +79 -0
- package/dist/replication/estimateFilteredSnapshotCount.js.map +1 -0
- package/dist/replication/replication-index.d.ts +7 -0
- package/dist/replication/replication-index.js +7 -0
- package/dist/replication/replication-index.js.map +1 -1
- package/dist/test-utils/ChangeStreamTestContext.d.ts +131 -0
- package/dist/test-utils/ChangeStreamTestContext.js +390 -0
- package/dist/test-utils/ChangeStreamTestContext.js.map +1 -0
- package/dist/test-utils/test-utils-index.d.ts +1 -0
- package/dist/test-utils/test-utils-index.js +2 -0
- package/dist/test-utils/test-utils-index.js.map +1 -0
- package/package.json +11 -10
- package/src/api/MongoRouteAPIAdapter.ts +26 -2
- package/src/index.ts +1 -0
- package/src/module/MongoModule.ts +2 -1
- package/src/replication/ChangeStream.ts +143 -226
- package/src/replication/ChangeStreamReplicationJob.ts +21 -9
- package/src/replication/ChangeStreamReplicator.ts +9 -0
- package/src/replication/MongoReplicationQueryProvider.ts +106 -0
- package/src/replication/MongoReplicationStream.ts +314 -0
- package/src/replication/MongoReplicationStreamTypes.ts +169 -0
- package/src/replication/MongoSnapshotQuery.ts +19 -3
- package/src/replication/MongoSnapshotter.ts +101 -92
- package/src/replication/RawChangeStream.ts +2 -2
- package/src/replication/estimateFilteredSnapshotCount.ts +93 -0
- package/src/replication/replication-index.ts +7 -0
- package/src/test-utils/ChangeStreamTestContext.ts +520 -0
- package/src/test-utils/test-utils-index.ts +1 -0
- package/test/src/change_event_memory.test.ts +33 -0
- package/test/src/change_stream.test.ts +3 -3
- package/test/src/change_stream_abort.test.ts +110 -0
- package/test/src/change_stream_test_setup.ts +38 -0
- package/test/src/chunked_snapshot.test.ts +3 -3
- package/test/src/documentdb_mode.test.ts +3 -3
- package/test/src/filtering_example.test.ts +65 -0
- package/test/src/filtering_lifecycle.test.ts +101 -0
- package/test/src/query_provider_availability.test.ts +77 -0
- package/test/src/query_provider_diagnostics.test.ts +55 -0
- package/test/src/replication_stream.test.ts +688 -0
- package/test/src/resume.test.ts +10 -4
- package/test/src/resuming_snapshots.test.ts +3 -3
- package/test/src/slow_tests.test.ts +3 -3
- package/test/src/snapshot_count.test.ts +146 -0
- package/test/src/snapshot_count_integration.test.ts +70 -0
- package/test/src/snapshot_query_provider.test.ts +122 -0
- package/test/src/stream_progress.test.ts +334 -0
- package/test/tsconfig.json +2 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/vitest.benchmark.config.ts +15 -0
- package/test/src/change_stream_utils.ts +0 -375
package/test/src/resume.test.ts
CHANGED
|
@@ -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 './
|
|
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
|
|
15
|
-
return
|
|
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(
|
|
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 {
|
|
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
|
|
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
|
|
91
|
+
await using context2 = await openChangeStreamTestContext(factory, {
|
|
92
92
|
doNotClear: true,
|
|
93
93
|
storageVersion,
|
|
94
94
|
streamOptions: { snapshotChunkLength: 1000 }
|
|
@@ -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 {
|
|
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
|
|
16
|
-
return
|
|
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
|
+
});
|