@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
|
@@ -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 {
|
|
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
|
|
17
|
-
return
|
|
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
|
|
@@ -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 {
|
|
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
|
|
588
|
-
return
|
|
587
|
+
const openContext = (options?: Parameters<typeof openChangeStreamTestContext>[1]) => {
|
|
588
|
+
return openChangeStreamTestContext(factory, {
|
|
589
589
|
...options,
|
|
590
590
|
storageVersion,
|
|
591
591
|
streamOptions: {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
filterMongoReplicationStream,
|
|
3
|
+
MongoReplicationFilter,
|
|
4
|
+
MongoReplicationFilterResult,
|
|
5
|
+
MongoReplicationQueryProvider,
|
|
6
|
+
parseMongoReplicationStream,
|
|
7
|
+
RawMongoReplicationStreamBatch
|
|
8
|
+
} from '@module/index.js';
|
|
9
|
+
import { mongo } from '@powersync/lib-service-mongodb';
|
|
10
|
+
import { expect, test } from 'vitest';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Minimal provider example: use the same immutable ID prefix for snapshots and streaming.
|
|
14
|
+
* Real BSON parsing and the public filtering helper run without a database connection.
|
|
15
|
+
*/
|
|
16
|
+
test('example: replicate only documents with public IDs and preserve filtered progress', async () => {
|
|
17
|
+
const filter: MongoReplicationFilter = (event) =>
|
|
18
|
+
String(event.documentKey._id).startsWith('public-')
|
|
19
|
+
? MongoReplicationFilterResult.KEEP
|
|
20
|
+
: MongoReplicationFilterResult.EXCLUDE;
|
|
21
|
+
const provider: MongoReplicationQueryProvider = {
|
|
22
|
+
getSnapshotFilter: () => ({ $expr: { $regexMatch: { input: '$_id', regex: '^public-' } } }),
|
|
23
|
+
openChangeStream: ({ open }) => filterMongoReplicationStream({ batches: open({}), filter })
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
async function* source(): AsyncGenerator<RawMongoReplicationStreamBatch> {
|
|
27
|
+
for (const ids of [['public-one', 'private-one', 'public-two'], ['private-two']]) {
|
|
28
|
+
yield {
|
|
29
|
+
events: ids.map((_id) =>
|
|
30
|
+
Buffer.from(
|
|
31
|
+
mongo.BSON.serialize({
|
|
32
|
+
_id: { _data: _id },
|
|
33
|
+
operationType: 'insert',
|
|
34
|
+
ns: { db: 'app', coll: 'documents' },
|
|
35
|
+
documentKey: { _id },
|
|
36
|
+
fullDocument: { _id }
|
|
37
|
+
})
|
|
38
|
+
)
|
|
39
|
+
),
|
|
40
|
+
resumeToken: { _data: ids.at(-1) }
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const stream = provider.openChangeStream({
|
|
46
|
+
open: () => parseMongoReplicationStream({ batches: source(), defaultSchema: 'app', multipleDatabases: false })
|
|
47
|
+
});
|
|
48
|
+
const retainedIds = [];
|
|
49
|
+
const progress = [];
|
|
50
|
+
for await (const batch of stream) {
|
|
51
|
+
for (const { event } of batch.events) {
|
|
52
|
+
if ('documentKey' in event) {
|
|
53
|
+
retainedIds.push(event.documentKey._id);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Read progress only after exhausting the events, including a fully filtered batch.
|
|
57
|
+
progress.push({ token: batch.getResumeToken(), filteredCount: batch.metadata.filteredCount });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
expect(retainedIds).toEqual(['public-one', 'public-two']);
|
|
61
|
+
expect(progress).toEqual([
|
|
62
|
+
{ token: { _data: 'public-two' }, filteredCount: 1 },
|
|
63
|
+
{ token: { _data: 'private-two' }, filteredCount: 1 }
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {
|
|
2
|
+
filterMongoReplicationStream,
|
|
3
|
+
MongoReplicationFilterResult,
|
|
4
|
+
MongoReplicationQueryProvider,
|
|
5
|
+
PostImagesOption
|
|
6
|
+
} from '@module/index.js';
|
|
7
|
+
import { mongo } from '@powersync/lib-service-mongodb';
|
|
8
|
+
import { describe, expect, test } from 'vitest';
|
|
9
|
+
import { openChangeStreamTestContext } from './change_stream_test_setup.js';
|
|
10
|
+
import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
|
|
11
|
+
import { describeWithStorage } from './util.js';
|
|
12
|
+
|
|
13
|
+
const CONFIG = /* yaml */ `
|
|
14
|
+
# Sync config fixture.
|
|
15
|
+
config:
|
|
16
|
+
edition: 3
|
|
17
|
+
streams:
|
|
18
|
+
global:
|
|
19
|
+
query: SELECT _id AS id, active FROM documents
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Filter membership is decided by the provider, not by the SQL query, so bucket assertions detect leaks.
|
|
24
|
+
* Required pre/post-images let the provider distinguish entries, exits and source deletes precisely.
|
|
25
|
+
*/
|
|
26
|
+
const PROVIDER: MongoReplicationQueryProvider = {
|
|
27
|
+
getSnapshotFilter: () => ({ active: true }),
|
|
28
|
+
openChangeStream: ({ open }) =>
|
|
29
|
+
filterMongoReplicationStream({
|
|
30
|
+
batches: open({ imageOptions: { fullDocumentBeforeChange: 'required' } }),
|
|
31
|
+
filter: (event) => {
|
|
32
|
+
const after = 'fullDocument' in event ? event.fullDocument : undefined;
|
|
33
|
+
const before = 'fullDocumentBeforeChange' in event ? event.fullDocumentBeforeChange : undefined;
|
|
34
|
+
if (after != null && mongo.BSON.deserialize(after).active === true) {
|
|
35
|
+
return MongoReplicationFilterResult.KEEP;
|
|
36
|
+
}
|
|
37
|
+
if (before != null && mongo.BSON.deserialize(before).active === true) {
|
|
38
|
+
return MongoReplicationFilterResult.DELETE;
|
|
39
|
+
}
|
|
40
|
+
return MongoReplicationFilterResult.EXCLUDE;
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
describe.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('MongoDB filtering lifecycle', () => {
|
|
46
|
+
describeWithStorage({ timeout: 30_000 }, ({ factory, storageVersion }) => {
|
|
47
|
+
test('keeps snapshot and streaming membership consistent across updates, deletes and restart', async () => {
|
|
48
|
+
const options = {
|
|
49
|
+
storageVersion,
|
|
50
|
+
mongoOptions: { postImages: PostImagesOption.READ_ONLY },
|
|
51
|
+
streamOptions: { createReplicationQueryProvider: () => PROVIDER }
|
|
52
|
+
};
|
|
53
|
+
let resumeAfter: bigint;
|
|
54
|
+
{
|
|
55
|
+
await using context = await openChangeStreamTestContext(factory, options);
|
|
56
|
+
await context.updateSyncRules(CONFIG);
|
|
57
|
+
const collection = await context.db.createCollection<{ _id: string; active: boolean }>('documents', {
|
|
58
|
+
changeStreamPreAndPostImages: { enabled: true }
|
|
59
|
+
});
|
|
60
|
+
await collection.insertMany([
|
|
61
|
+
{ _id: 'one', active: true },
|
|
62
|
+
{ _id: 'two', active: false },
|
|
63
|
+
{ _id: 'three', active: false }
|
|
64
|
+
]);
|
|
65
|
+
await context.replicateSnapshot();
|
|
66
|
+
let checkpoint = await context.getCheckpoint();
|
|
67
|
+
expect(await context.getBucketDataAtCheckpoint('global|0[]', checkpoint)).toMatchObject([
|
|
68
|
+
{ op: 'PUT', object_id: 'one' }
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
// Enter the selected set through a real update, using the server's write-time post-image.
|
|
72
|
+
await collection.updateOne({ _id: 'two' }, { $set: { active: true } });
|
|
73
|
+
expect(await context.getBucketData('global|0[]', checkpoint.checkpoint)).toMatchObject([
|
|
74
|
+
{ op: 'PUT', object_id: 'two' }
|
|
75
|
+
]);
|
|
76
|
+
checkpoint = await context.getCheckpoint();
|
|
77
|
+
|
|
78
|
+
// Leaving the set must emit a synthetic delete rather than merely suppressing the update.
|
|
79
|
+
await collection.updateOne({ _id: 'one' }, { $set: { active: false } });
|
|
80
|
+
await collection.insertOne({ _id: 'excluded', active: false });
|
|
81
|
+
expect(await context.getBucketData('global|0[]', checkpoint.checkpoint)).toMatchObject([
|
|
82
|
+
{ op: 'REMOVE', object_id: 'one' }
|
|
83
|
+
]);
|
|
84
|
+
resumeAfter = (await context.getCheckpoint()).checkpoint;
|
|
85
|
+
await context.stop();
|
|
86
|
+
|
|
87
|
+
// Queue a source delete and another entry while replication is stopped.
|
|
88
|
+
await collection.deleteOne({ _id: 'two' });
|
|
89
|
+
await collection.updateOne({ _id: 'three' }, { $set: { active: true } });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
await using resumed = await openChangeStreamTestContext(factory, { ...options, doNotClear: true });
|
|
93
|
+
await resumed.loadActiveSyncRules();
|
|
94
|
+
resumed.startStreaming();
|
|
95
|
+
expect(await resumed.getBucketData('global|0[]', resumeAfter)).toMatchObject([
|
|
96
|
+
{ op: 'REMOVE', object_id: 'two' },
|
|
97
|
+
{ op: 'PUT', object_id: 'three' }
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { ErrorCode, logger, ServiceError } from '@powersync/lib-services-framework';
|
|
2
|
+
import { MetricsEngine, storage } from '@powersync/service-core';
|
|
3
|
+
import { DEFAULT_HYDRATION_STATE, nodeSqlite, SqlSyncRules } from '@powersync/service-sync-rules';
|
|
4
|
+
import * as sqlite from 'node:sqlite';
|
|
5
|
+
import { afterEach, expect, test, vi } from 'vitest';
|
|
6
|
+
import { ChangeStream } from '../../src/replication/ChangeStream.js';
|
|
7
|
+
import { MongoManager } from '../../src/replication/MongoManager.js';
|
|
8
|
+
import { DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER } from '../../src/replication/MongoReplicationQueryProvider.js';
|
|
9
|
+
import { MongoSnapshotter } from '../../src/replication/MongoSnapshotter.js';
|
|
10
|
+
import { normalizeConnectionConfig } from '../../src/types/types.js';
|
|
11
|
+
|
|
12
|
+
const CONFIG = /* yaml */ `
|
|
13
|
+
# Sync config fixture.
|
|
14
|
+
config:
|
|
15
|
+
edition: 3
|
|
16
|
+
source_table_options:
|
|
17
|
+
orders%:
|
|
18
|
+
mongodb_filter_expression: { $eq: ['$$doc.active', true] }
|
|
19
|
+
orders:
|
|
20
|
+
mongodb_filter_expression: disabled
|
|
21
|
+
streams:
|
|
22
|
+
orders:
|
|
23
|
+
query: SELECT * FROM orders
|
|
24
|
+
`;
|
|
25
|
+
const RAW_CONNECTION = { type: 'mongodb' as const, uri: 'mongodb://localhost/app' };
|
|
26
|
+
const CONNECTION = { ...RAW_CONNECTION, ...normalizeConnectionConfig(RAW_CONNECTION) };
|
|
27
|
+
afterEach(() => vi.restoreAllMocks());
|
|
28
|
+
|
|
29
|
+
test.each([
|
|
30
|
+
{ filtered: true, factory: false, blocked: true },
|
|
31
|
+
{ filtered: true, factory: true, blocked: false },
|
|
32
|
+
{ filtered: false, factory: false, blocked: false }
|
|
33
|
+
])('checks provider availability: %j', async ({ filtered, factory, blocked }) => {
|
|
34
|
+
const yaml = filtered ? CONFIG : CONFIG.replace("{ $eq: ['$$doc.active', true] }", 'disabled');
|
|
35
|
+
const parsed = SqlSyncRules.fromYaml(yaml, { defaultSchema: 'app' });
|
|
36
|
+
const hydrated = parsed.config.hydrate({ hydrationState: DEFAULT_HYDRATION_STATE, sqlite: nodeSqlite(sqlite) });
|
|
37
|
+
const reportError = vi.fn(async (_error: unknown) => {});
|
|
38
|
+
// Stop allowed cases at the first snapshot check, without connecting to a database.
|
|
39
|
+
const checkSlot = vi
|
|
40
|
+
.spyOn(MongoSnapshotter.prototype, 'checkSlot')
|
|
41
|
+
.mockRejectedValue(new Error('snapshot check reached'));
|
|
42
|
+
const source = new MongoManager(CONNECTION);
|
|
43
|
+
const createProvider = vi.fn(() => DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER);
|
|
44
|
+
try {
|
|
45
|
+
const stream = new ChangeStream({
|
|
46
|
+
connections: source,
|
|
47
|
+
abort_signal: new AbortController().signal,
|
|
48
|
+
metrics: {} as MetricsEngine,
|
|
49
|
+
createReplicationQueryProvider: factory ? createProvider : undefined,
|
|
50
|
+
storage: {
|
|
51
|
+
replicationStreamId: 1,
|
|
52
|
+
replicationStreamName: 'availability',
|
|
53
|
+
getParsedSyncRules: () => hydrated,
|
|
54
|
+
getParsedSyncConfigSet: () => ({ syncConfigs: [parsed] }),
|
|
55
|
+
reportError,
|
|
56
|
+
logger
|
|
57
|
+
} as unknown as storage.SyncRulesBucketStorage
|
|
58
|
+
});
|
|
59
|
+
await expect(stream.replicate()).rejects.toThrow(
|
|
60
|
+
blocked ? 'no query provider factory is registered' : 'snapshot check reached'
|
|
61
|
+
);
|
|
62
|
+
if (blocked) {
|
|
63
|
+
expect(checkSlot).not.toHaveBeenCalled();
|
|
64
|
+
const error = reportError.mock.calls[0]?.[0];
|
|
65
|
+
expect(error).toBeInstanceOf(ServiceError);
|
|
66
|
+
expect(error).toMatchObject({ errorData: { code: ErrorCode.PSYNC_S1348 } });
|
|
67
|
+
expect(reportError).toHaveBeenCalledWith(
|
|
68
|
+
expect.objectContaining({ message: expect.stringContaining('deploy a new sync config') })
|
|
69
|
+
);
|
|
70
|
+
} else {
|
|
71
|
+
expect(checkSlot).toHaveBeenCalledOnce();
|
|
72
|
+
}
|
|
73
|
+
if (factory) expect(createProvider).toHaveBeenCalledWith(expect.objectContaining({ syncConfig: hydrated }));
|
|
74
|
+
} finally {
|
|
75
|
+
await source.client.close();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER } from '@module/index.js';
|
|
2
|
+
import { ErrorCode, ServiceError } from '@powersync/lib-services-framework';
|
|
3
|
+
import { describe, expect, test } from 'vitest';
|
|
4
|
+
import { openChangeStreamTestContext } from './change_stream_test_setup.js';
|
|
5
|
+
import { describeWithStorage } from './util.js';
|
|
6
|
+
|
|
7
|
+
const CONFIG = /* yaml */ `
|
|
8
|
+
# Sync config fixture.
|
|
9
|
+
config:
|
|
10
|
+
edition: 3
|
|
11
|
+
source_table_options:
|
|
12
|
+
orders%:
|
|
13
|
+
mongodb_filter_expression: { $eq: ['$$doc.active', true] }
|
|
14
|
+
orders:
|
|
15
|
+
mongodb_filter_expression: disabled
|
|
16
|
+
streams:
|
|
17
|
+
orders:
|
|
18
|
+
query: SELECT _id AS id FROM orders
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
describe('MongoDB missing-provider diagnostics', () => {
|
|
22
|
+
describeWithStorage({ timeout: 30_000 }, ({ factory, storageVersion }) => {
|
|
23
|
+
test('persists a missing-provider error for an active config after the provider is removed', async () => {
|
|
24
|
+
{
|
|
25
|
+
await using context = await openChangeStreamTestContext(factory, {
|
|
26
|
+
storageVersion,
|
|
27
|
+
streamOptions: { createReplicationQueryProvider: () => DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER }
|
|
28
|
+
});
|
|
29
|
+
// The concrete collection opts out, but the wildcard still requires provider support.
|
|
30
|
+
await context.updateSyncRules(CONFIG);
|
|
31
|
+
await context.db.createCollection('orders');
|
|
32
|
+
await context.replicateSnapshot();
|
|
33
|
+
await context.getCheckpoint();
|
|
34
|
+
}
|
|
35
|
+
{
|
|
36
|
+
await using context = await openChangeStreamTestContext(factory, { storageVersion, doNotClear: true });
|
|
37
|
+
await context.loadActiveSyncRules();
|
|
38
|
+
const result = await context.startStreaming();
|
|
39
|
+
expect(result.status).toBe('rejected');
|
|
40
|
+
if (result.status != 'rejected') {
|
|
41
|
+
throw new Error('Expected missing-provider rejection');
|
|
42
|
+
}
|
|
43
|
+
expect(result.reason).toBeInstanceOf(ServiceError);
|
|
44
|
+
expect(result.reason).toMatchObject({ errorData: { code: ErrorCode.PSYNC_S1348 } });
|
|
45
|
+
}
|
|
46
|
+
// Reopen storage to prove diagnostics were persisted, rather than only reported in memory.
|
|
47
|
+
await using reopened = await openChangeStreamTestContext(factory, { storageVersion, doNotClear: true });
|
|
48
|
+
const active = await reopened.factory.getActiveSyncConfig();
|
|
49
|
+
expect(active).not.toBeNull();
|
|
50
|
+
const status = await active!.content.getSyncConfigStatus();
|
|
51
|
+
expect(status?.last_fatal_error).toContain('no query provider factory is registered');
|
|
52
|
+
expect(status?.last_fatal_error).toContain('deploy a new sync config');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
});
|