@powersync/service-module-mongodb 0.0.0-dev-20261007110800 → 0.0.0-dev-20261015132441
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 +14 -11
- package/benchmarks/env.ts +11 -0
- package/benchmarks/replication_branch_throughput.benchmark.ts +240 -0
- package/dist/api/MongoRouteAPIAdapter.d.ts +5 -0
- package/dist/api/MongoRouteAPIAdapter.js +11 -4
- package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
- package/dist/module/MongoModule.js +1 -0
- package/dist/module/MongoModule.js.map +1 -1
- package/dist/replication/ChangeStream.d.ts +2 -1
- package/dist/replication/ChangeStream.js +164 -150
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/ChangeStreamReplicationJob.js +17 -9
- package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
- package/dist/replication/MongoReplicationQueryProvider.d.ts +9 -11
- package/dist/replication/MongoReplicationQueryProvider.js.map +1 -1
- package/dist/replication/MongoReplicationStream.d.ts +37 -48
- package/dist/replication/MongoReplicationStream.js +157 -30
- package/dist/replication/MongoReplicationStream.js.map +1 -1
- 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/MongoSnapshotter.js +59 -30
- package/dist/replication/MongoSnapshotter.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 +1 -0
- package/dist/replication/replication-index.js +1 -0
- package/dist/replication/replication-index.js.map +1 -1
- package/package.json +11 -10
- package/src/api/MongoRouteAPIAdapter.ts +12 -5
- package/src/module/MongoModule.ts +1 -0
- package/src/replication/ChangeStream.ts +183 -173
- package/src/replication/ChangeStreamReplicationJob.ts +16 -9
- package/src/replication/MongoReplicationQueryProvider.ts +9 -11
- package/src/replication/MongoReplicationStream.ts +196 -83
- package/src/replication/MongoReplicationStreamTypes.ts +169 -0
- package/src/replication/MongoSnapshotter.ts +64 -33
- package/src/replication/estimateFilteredSnapshotCount.ts +93 -0
- package/src/replication/replication-index.ts +1 -0
- package/test/src/change_event_memory.test.ts +33 -0
- package/test/src/change_stream_abort.test.ts +110 -0
- 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 +1 -0
- package/test/src/query_provider_diagnostics.test.ts +55 -0
- package/test/src/replication_stream.test.ts +411 -34
- 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 +21 -2
- package/test/src/stream_progress.test.ts +62 -44
- package/test/tsconfig.json +2 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/vitest.benchmark.config.ts +15 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { mongo } from '@powersync/lib-service-mongodb';
|
|
2
|
+
import type { Logger } from '@powersync/lib-services-framework';
|
|
3
|
+
import { performance } from 'node:perf_hooks';
|
|
4
|
+
|
|
5
|
+
const COUNT_TIMEOUT_MS = 10_000;
|
|
6
|
+
/**
|
|
7
|
+
* Keep below 5% for MongoDB's random-cursor optimization on large collections.
|
|
8
|
+
* https://www.mongodb.com/docs/manual/reference/operator/aggregation/sample/
|
|
9
|
+
*/
|
|
10
|
+
const SAMPLE_FRACTION = 0.01;
|
|
11
|
+
const MAX_SAMPLE_SIZE = 10_000;
|
|
12
|
+
|
|
13
|
+
interface SampleCounts {
|
|
14
|
+
sampled: { count: number }[];
|
|
15
|
+
matches: { count: number }[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Try an exact filtered count, then estimate selectivity from a bounded sample.
|
|
20
|
+
* Each attempt has a separate 10-second budget. Failures leave progress unknown (-1).
|
|
21
|
+
*/
|
|
22
|
+
export async function estimateFilteredSnapshotCount(options: {
|
|
23
|
+
collection: mongo.Collection;
|
|
24
|
+
filter: mongo.Filter<mongo.Document>;
|
|
25
|
+
signal: AbortSignal;
|
|
26
|
+
logger: Logger;
|
|
27
|
+
}): Promise<number> {
|
|
28
|
+
const { collection, filter, signal, logger } = options;
|
|
29
|
+
signal.throwIfAborted();
|
|
30
|
+
try {
|
|
31
|
+
return await collection.countDocuments(filter, {
|
|
32
|
+
collation: { locale: 'simple' },
|
|
33
|
+
readConcern: 'majority',
|
|
34
|
+
timeoutMS: COUNT_TIMEOUT_MS,
|
|
35
|
+
signal
|
|
36
|
+
});
|
|
37
|
+
} catch (error) {
|
|
38
|
+
signal.throwIfAborted();
|
|
39
|
+
logger.debug(`Filtered snapshot count unavailable for ${collection.namespace}; trying a sample strategy`, error);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const sampleStartedAt = performance.now();
|
|
43
|
+
try {
|
|
44
|
+
const metadataOptions = { timeoutMS: COUNT_TIMEOUT_MS, signal };
|
|
45
|
+
const totalCount = await collection.estimatedDocumentCount(metadataOptions);
|
|
46
|
+
signal.throwIfAborted();
|
|
47
|
+
if (totalCount <= 0) {
|
|
48
|
+
// Metadata alone cannot establish that a filtered result is empty after the count failed.
|
|
49
|
+
return -1;
|
|
50
|
+
}
|
|
51
|
+
const remainingTimeMS = Math.floor(COUNT_TIMEOUT_MS - (performance.now() - sampleStartedAt));
|
|
52
|
+
if (remainingTimeMS <= 0) {
|
|
53
|
+
return -1;
|
|
54
|
+
}
|
|
55
|
+
// Keep $sample first and below 5% for MongoDB's random-cursor optimization on large collections.
|
|
56
|
+
// https://www.mongodb.com/docs/manual/reference/operator/aggregation/sample/
|
|
57
|
+
const sampleSize = Math.min(MAX_SAMPLE_SIZE, Math.max(1, Math.floor(totalCount * SAMPLE_FRACTION)));
|
|
58
|
+
const cursor = collection.aggregate<SampleCounts>(
|
|
59
|
+
[
|
|
60
|
+
{ $sample: { size: sampleSize } },
|
|
61
|
+
{
|
|
62
|
+
$facet: {
|
|
63
|
+
sampled: [{ $count: 'count' }],
|
|
64
|
+
matches: [{ $match: filter }, { $count: 'count' }]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
],
|
|
68
|
+
{
|
|
69
|
+
collation: { locale: 'simple' },
|
|
70
|
+
readConcern: 'majority',
|
|
71
|
+
timeoutMS: remainingTimeMS,
|
|
72
|
+
signal,
|
|
73
|
+
allowDiskUse: false
|
|
74
|
+
}
|
|
75
|
+
);
|
|
76
|
+
try {
|
|
77
|
+
const sample = await cursor.next();
|
|
78
|
+
const sampledCount = sample?.sampled[0]?.count ?? 0;
|
|
79
|
+
const matchedCount = sample?.matches[0]?.count ?? 0;
|
|
80
|
+
if (sampledCount == 0 || matchedCount == 0) {
|
|
81
|
+
// A rare predicate can miss the whole sample; that does not establish an empty result.
|
|
82
|
+
return -1;
|
|
83
|
+
}
|
|
84
|
+
return Math.round((matchedCount / sampledCount) * totalCount);
|
|
85
|
+
} finally {
|
|
86
|
+
await cursor.close();
|
|
87
|
+
}
|
|
88
|
+
} catch (error) {
|
|
89
|
+
signal.throwIfAborted();
|
|
90
|
+
logger.debug(`Filtered snapshot sample unavailable for ${collection.namespace}; total remains unknown`, error);
|
|
91
|
+
return -1;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -7,6 +7,7 @@ export * from './MongoErrorRateLimiter.js';
|
|
|
7
7
|
export * from './MongoRelation.js';
|
|
8
8
|
export * from './MongoReplicationQueryProvider.js';
|
|
9
9
|
export * from './MongoReplicationStream.js';
|
|
10
|
+
export * from './MongoReplicationStreamTypes.js';
|
|
10
11
|
export * from './MongoSnapshotter.js';
|
|
11
12
|
export * from './RawChangeStream.js';
|
|
12
13
|
export { CHECKPOINTS_COLLECTION } from './replication-utils.js';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { parseChangeDocument } from '@module/replication/RawChangeStream.js';
|
|
2
|
+
import { mongo } from '@powersync/lib-service-mongodb';
|
|
3
|
+
import { expect, test } from 'vitest';
|
|
4
|
+
|
|
5
|
+
test('parsed document images share the raw event allocation', () => {
|
|
6
|
+
const raw = createRawEvent('first');
|
|
7
|
+
const parsed = parseChangeDocument(raw);
|
|
8
|
+
if (parsed.operationType != 'update') throw new Error('Expected an update');
|
|
9
|
+
|
|
10
|
+
for (const image of [parsed.fullDocument, parsed.fullDocumentBeforeChange]) {
|
|
11
|
+
expect(Buffer.isBuffer(image)).toBe(true);
|
|
12
|
+
expect(image!.buffer).toBe(raw.buffer);
|
|
13
|
+
expect(image!.byteOffset).toBeGreaterThan(raw.byteOffset);
|
|
14
|
+
expect(image!.byteOffset + image!.byteLength).toBeLessThanOrEqual(raw.byteOffset + raw.byteLength);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Allocate each event independently so Node's shared small-buffer pool cannot explain retained memory.
|
|
20
|
+
*/
|
|
21
|
+
function createRawEvent(id: string): Buffer {
|
|
22
|
+
const encoded = mongo.BSON.serialize({
|
|
23
|
+
_id: { _data: id },
|
|
24
|
+
operationType: 'update',
|
|
25
|
+
ns: { db: 'app', coll: 'documents' },
|
|
26
|
+
documentKey: { _id: id },
|
|
27
|
+
fullDocument: { _id: id, body: 'x'.repeat(16_384) },
|
|
28
|
+
fullDocumentBeforeChange: { _id: id, body: 'y'.repeat(16_384) }
|
|
29
|
+
});
|
|
30
|
+
const raw = Buffer.alloc(encoded.byteLength);
|
|
31
|
+
raw.set(encoded);
|
|
32
|
+
return raw;
|
|
33
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER } from '@module/replication/MongoReplicationQueryProvider.js';
|
|
2
|
+
import { createMongoReplicationStreamBatch } from '@module/replication/MongoReplicationStream.js';
|
|
3
|
+
import type { MongoReplicationStreamBatch } from '@module/replication/MongoReplicationStreamTypes.js';
|
|
4
|
+
import { ReplicationAbortedError } from '@powersync/lib-services-framework';
|
|
5
|
+
import { describe, expect, test } from 'vitest';
|
|
6
|
+
import { openChangeStreamTestContext } from './change_stream_test_setup.js';
|
|
7
|
+
import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
|
|
8
|
+
import { describeWithStorage } from './util.js';
|
|
9
|
+
|
|
10
|
+
type AbortPoint = 'mid-batch' | 'after-last-event';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Abort replication from inside event processing, using real source changes and bucket storage.
|
|
14
|
+
* The abort fires while the consumer holds an event, so it lands between the parser's own abort checks.
|
|
15
|
+
*/
|
|
16
|
+
describe.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('MongoDB change stream abort during a batch', () => {
|
|
17
|
+
describeWithStorage({ timeout: 30_000 }, ({ factory, storageVersion }) => {
|
|
18
|
+
test.each<AbortPoint>(['mid-batch', 'after-last-event'])('stops replication when aborted %s', async (point) => {
|
|
19
|
+
{
|
|
20
|
+
await using context = await openChangeStreamTestContext(factory, { storageVersion });
|
|
21
|
+
await context.updateSyncRules(/* yaml */ `
|
|
22
|
+
# Sync config fixture.
|
|
23
|
+
config:
|
|
24
|
+
edition: 3
|
|
25
|
+
streams:
|
|
26
|
+
global:
|
|
27
|
+
query: SELECT _id AS id FROM documents
|
|
28
|
+
`);
|
|
29
|
+
await context.db.createCollection('documents');
|
|
30
|
+
await context.replicateSnapshot();
|
|
31
|
+
await context.getCheckpoint();
|
|
32
|
+
await context.stop();
|
|
33
|
+
await context.db
|
|
34
|
+
.collection<{ _id: number }>('documents')
|
|
35
|
+
.insertMany(Array.from({ length: 20 }, (_, i) => ({ _id: i + 1 })));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const abortCause = new Error('test abort');
|
|
39
|
+
let abort: (cause: Error) => void = () => {};
|
|
40
|
+
let triggered = false;
|
|
41
|
+
let resumeBeforeAbortedBatch: string | null | undefined;
|
|
42
|
+
let readResumeLsn: () => Promise<string | null | undefined>;
|
|
43
|
+
let sawDocument = false;
|
|
44
|
+
const provider = {
|
|
45
|
+
...DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER,
|
|
46
|
+
openChangeStream: ({ open }: Parameters<typeof DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER.openChangeStream>[0]) =>
|
|
47
|
+
(async function* (): AsyncGenerator<MongoReplicationStreamBatch> {
|
|
48
|
+
for await (const batch of open({})) {
|
|
49
|
+
const resumeBeforeBatch = await readResumeLsn();
|
|
50
|
+
yield createMongoReplicationStreamBatch({
|
|
51
|
+
events: (function* () {
|
|
52
|
+
for (const change of batch.events) {
|
|
53
|
+
if ('ns' in change.event && change.event.ns.coll == 'documents') {
|
|
54
|
+
sawDocument = true;
|
|
55
|
+
}
|
|
56
|
+
yield change;
|
|
57
|
+
// Runs when the consumer asks for the next event, after it has processed this one.
|
|
58
|
+
const atPoint = point == 'mid-batch' ? change.hasBufferedChanges : !change.hasBufferedChanges;
|
|
59
|
+
if (!triggered && sawDocument && atPoint) {
|
|
60
|
+
triggered = true;
|
|
61
|
+
resumeBeforeAbortedBatch = resumeBeforeBatch;
|
|
62
|
+
abort(abortCause);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
})(),
|
|
66
|
+
resolveResumeToken: () => batch.getResumeToken(),
|
|
67
|
+
resolveMetadata: () => batch.metadata
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
})()
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
{
|
|
74
|
+
await using context = await openChangeStreamTestContext(factory, {
|
|
75
|
+
storageVersion,
|
|
76
|
+
doNotClear: true,
|
|
77
|
+
streamOptions: { createReplicationQueryProvider: () => provider }
|
|
78
|
+
});
|
|
79
|
+
readResumeLsn = async () => (await context.storage!.getStatus()).resumeLsn;
|
|
80
|
+
abort = (cause) => context.abort(cause);
|
|
81
|
+
await context.loadActiveSyncRules();
|
|
82
|
+
const result = await context.startStreaming();
|
|
83
|
+
|
|
84
|
+
expect(triggered).toBe(true);
|
|
85
|
+
expect(result.status).toBe('rejected');
|
|
86
|
+
if (result.status != 'rejected') {
|
|
87
|
+
throw new Error('Expected replication to abort');
|
|
88
|
+
}
|
|
89
|
+
if (point == 'after-last-event') {
|
|
90
|
+
// Every event of the batch was processed, so the consumer's own abort check ends the stream
|
|
91
|
+
// before it persists the batch's resume token.
|
|
92
|
+
expect(result.reason).toBeInstanceOf(ReplicationAbortedError);
|
|
93
|
+
}
|
|
94
|
+
const cause = result.reason instanceof ReplicationAbortedError ? result.reason.cause : result.reason;
|
|
95
|
+
expect(cause).toBe(abortCause);
|
|
96
|
+
expect(resumeBeforeAbortedBatch).toBeTruthy();
|
|
97
|
+
expect((await context.storage!.getStatus()).resumeLsn).toBe(resumeBeforeAbortedBatch);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Reopen the real storage after the interrupted batch and verify all source rows are recovered.
|
|
101
|
+
await using resumed = await openChangeStreamTestContext(factory, { storageVersion, doNotClear: true });
|
|
102
|
+
await resumed.loadActiveSyncRules();
|
|
103
|
+
resumed.startStreaming();
|
|
104
|
+
const rows = (await resumed.getBucketData('global|0[]')).filter((op) => op.op == 'PUT');
|
|
105
|
+
expect([...new Set(rows.map((op) => op.object_id))].sort()).toEqual(
|
|
106
|
+
Array.from({ length: 20 }, (_, index) => String(index + 1)).sort()
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -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,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
|
+
});
|