@powersync/service-module-mongodb 0.0.0-dev-20260909133214 → 0.0.0-dev-20260929095716
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -6
- package/ci/test-connection.yaml +12 -0
- package/dist/api/MongoRouteAPIAdapter.d.ts +2 -2
- package/dist/api/MongoRouteAPIAdapter.js +73 -143
- package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
- package/dist/api/infer-collection-schema.d.ts +12 -0
- package/dist/api/infer-collection-schema.js +142 -0
- package/dist/api/infer-collection-schema.js.map +1 -0
- 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 -2
- package/dist/module/MongoModule.js +0 -1
- package/dist/module/MongoModule.js.map +1 -1
- package/dist/replication/ChangeStream.d.ts +10 -2
- package/dist/replication/ChangeStream.js +228 -317
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/ChangeStreamReplicationJob.d.ts +4 -1
- package/dist/replication/ChangeStreamReplicationJob.js +4 -1
- package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
- package/dist/replication/ChangeStreamReplicator.d.ts +3 -0
- package/dist/replication/ChangeStreamReplicator.js +3 -0
- package/dist/replication/ChangeStreamReplicator.js.map +1 -1
- package/dist/replication/MongoRelation.js +5 -2
- package/dist/replication/MongoRelation.js.map +1 -1
- package/dist/replication/MongoReplicationQueryProvider.d.ts +87 -0
- package/dist/replication/MongoReplicationQueryProvider.js +23 -0
- package/dist/replication/MongoReplicationQueryProvider.js.map +1 -0
- package/dist/replication/MongoReplicationStream.d.ts +85 -0
- package/dist/replication/MongoReplicationStream.js +101 -0
- package/dist/replication/MongoReplicationStream.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 +58 -85
- 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/SourceRowConverter.d.ts +1 -0
- package/dist/replication/SourceRowConverter.js +4 -2
- package/dist/replication/SourceRowConverter.js.map +1 -1
- package/dist/replication/bufferToSqlite.d.ts +3 -1
- package/dist/replication/bufferToSqlite.js +35 -23
- package/dist/replication/bufferToSqlite.js.map +1 -1
- package/dist/replication/replication-index.d.ts +6 -0
- package/dist/replication/replication-index.js +6 -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 +391 -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 +8 -8
- package/src/api/MongoRouteAPIAdapter.ts +79 -127
- package/src/api/infer-collection-schema.ts +153 -0
- package/src/index.ts +1 -0
- package/src/module/MongoModule.ts +1 -3
- package/src/replication/ChangeStream.ts +253 -359
- package/src/replication/ChangeStreamReplicationJob.ts +7 -2
- package/src/replication/ChangeStreamReplicator.ts +5 -0
- package/src/replication/MongoRelation.ts +8 -1
- package/src/replication/MongoReplicationQueryProvider.ts +108 -0
- package/src/replication/MongoReplicationStream.ts +201 -0
- package/src/replication/MongoSnapshotQuery.ts +19 -3
- package/src/replication/MongoSnapshotter.ts +70 -92
- package/src/replication/RawChangeStream.ts +2 -2
- package/src/replication/SourceRowConverter.ts +4 -2
- package/src/replication/bufferToSqlite.ts +43 -23
- package/src/replication/replication-index.ts +6 -0
- package/src/test-utils/ChangeStreamTestContext.ts +520 -0
- package/src/test-utils/test-utils-index.ts +1 -0
- package/test/src/buffer_to_sqlite.test.ts +9 -0
- package/test/src/change_stream.test.ts +3 -3
- 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/replication_stream.test.ts +311 -0
- package/test/src/resume.test.ts +10 -4
- package/test/src/resuming_snapshots.test.ts +3 -3
- package/test/src/schema.test.ts +223 -0
- package/test/src/slow_tests.test.ts +3 -3
- package/test/src/snapshot_query_provider.test.ts +95 -0
- package/test/src/stream_progress.test.ts +312 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/test/src/change_stream_utils.ts +0 -375
|
@@ -3,18 +3,22 @@ import { replication } from '@powersync/service-core';
|
|
|
3
3
|
|
|
4
4
|
import { ChangeStream, ChangeStreamInvalidatedError } from './ChangeStream.js';
|
|
5
5
|
import { ConnectionManagerFactory } from './ConnectionManagerFactory.js';
|
|
6
|
+
import { MongoReplicationQueryProviderFactory } from './MongoReplicationQueryProvider.js';
|
|
6
7
|
|
|
7
8
|
export interface ChangeStreamReplicationJobOptions extends replication.AbstractReplicationJobOptions {
|
|
8
9
|
connectionFactory: ConnectionManagerFactory;
|
|
10
|
+
createReplicationQueryProvider?: MongoReplicationQueryProviderFactory;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
export class ChangeStreamReplicationJob extends replication.AbstractReplicationJob {
|
|
12
|
-
private connectionFactory: ConnectionManagerFactory;
|
|
14
|
+
private readonly connectionFactory: ConnectionManagerFactory;
|
|
15
|
+
private readonly createReplicationQueryProvider: MongoReplicationQueryProviderFactory | undefined;
|
|
13
16
|
private lastStream: ChangeStream | null = null;
|
|
14
17
|
|
|
15
18
|
constructor(options: ChangeStreamReplicationJobOptions) {
|
|
16
19
|
super(options);
|
|
17
20
|
this.connectionFactory = options.connectionFactory;
|
|
21
|
+
this.createReplicationQueryProvider = options.createReplicationQueryProvider;
|
|
18
22
|
// We use a custom formatter to process the prefix
|
|
19
23
|
this.logger = options.storage.logger;
|
|
20
24
|
}
|
|
@@ -72,7 +76,8 @@ export class ChangeStreamReplicationJob extends replication.AbstractReplicationJ
|
|
|
72
76
|
metrics: this.options.metrics,
|
|
73
77
|
connections: connectionManager,
|
|
74
78
|
keepaliveIntervalMs: connectionManager.options.heartbeat_interval_seconds * 1_000,
|
|
75
|
-
logger: this.logger
|
|
79
|
+
logger: this.logger,
|
|
80
|
+
createReplicationQueryProvider: this.createReplicationQueryProvider
|
|
76
81
|
});
|
|
77
82
|
this.lastStream = stream;
|
|
78
83
|
await stream.replicate();
|
|
@@ -2,17 +2,21 @@ import { replication, storage } from '@powersync/service-core';
|
|
|
2
2
|
import { MongoModule } from '../module/MongoModule.js';
|
|
3
3
|
import { ChangeStreamReplicationJob } from './ChangeStreamReplicationJob.js';
|
|
4
4
|
import { ConnectionManagerFactory } from './ConnectionManagerFactory.js';
|
|
5
|
+
import { MongoReplicationQueryProviderFactory } from './MongoReplicationQueryProvider.js';
|
|
5
6
|
|
|
6
7
|
export interface ChangeStreamReplicatorOptions extends replication.AbstractReplicatorOptions {
|
|
7
8
|
connectionFactory: ConnectionManagerFactory;
|
|
9
|
+
createReplicationQueryProvider?: MongoReplicationQueryProviderFactory;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
export class ChangeStreamReplicator extends replication.AbstractReplicator<ChangeStreamReplicationJob> {
|
|
11
13
|
private readonly connectionFactory: ConnectionManagerFactory;
|
|
14
|
+
private readonly createReplicationQueryProvider: MongoReplicationQueryProviderFactory | undefined;
|
|
12
15
|
|
|
13
16
|
constructor(options: ChangeStreamReplicatorOptions) {
|
|
14
17
|
super(options);
|
|
15
18
|
this.connectionFactory = options.connectionFactory;
|
|
19
|
+
this.createReplicationQueryProvider = options.createReplicationQueryProvider;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
createJob(options: replication.CreateJobOptions): ChangeStreamReplicationJob {
|
|
@@ -21,6 +25,7 @@ export class ChangeStreamReplicator extends replication.AbstractReplicator<Chang
|
|
|
21
25
|
storage: options.storage,
|
|
22
26
|
metrics: this.metrics,
|
|
23
27
|
connectionFactory: this.connectionFactory,
|
|
28
|
+
createReplicationQueryProvider: this.createReplicationQueryProvider,
|
|
24
29
|
lock: options.lock,
|
|
25
30
|
rateLimiter: this.rateLimiter
|
|
26
31
|
});
|
|
@@ -3,11 +3,14 @@ import { storage } from '@powersync/service-core';
|
|
|
3
3
|
import { JsonContainer } from '@powersync/service-jsonbig';
|
|
4
4
|
import {
|
|
5
5
|
CompatibilityContext,
|
|
6
|
+
CompatibilityOption,
|
|
6
7
|
CustomArray,
|
|
7
8
|
CustomObject,
|
|
8
9
|
CustomSqliteValue,
|
|
9
10
|
DateTimeSourceOptions,
|
|
10
11
|
DateTimeValue,
|
|
12
|
+
SQLITE_FALSE,
|
|
13
|
+
SQLITE_TRUE,
|
|
11
14
|
SqliteInputRow,
|
|
12
15
|
SqliteInputValue,
|
|
13
16
|
TimeValuePrecision
|
|
@@ -127,7 +130,11 @@ function filterJsonData(data: any, context: CompatibilityContext, depth = 0): an
|
|
|
127
130
|
return data;
|
|
128
131
|
}
|
|
129
132
|
} else if (typeof data == 'boolean') {
|
|
130
|
-
|
|
133
|
+
if (context.isEnabled(CompatibilityOption.fixedBooleanInJson)) {
|
|
134
|
+
return data;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return data ? SQLITE_TRUE : SQLITE_FALSE;
|
|
131
138
|
} else if (typeof data == 'bigint') {
|
|
132
139
|
return data;
|
|
133
140
|
} else if (data instanceof Date) {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { mongo } from '@powersync/lib-service-mongodb';
|
|
2
|
+
import { SourceTable } from '@powersync/service-core';
|
|
3
|
+
import { HydratedSyncConfig } from '@powersync/service-sync-rules';
|
|
4
|
+
import { MongoManager } from './MongoManager.js';
|
|
5
|
+
import { MongoReplicationStreamContext, MongoReplicationStreamItem } from './MongoReplicationStream.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Sync config and source namespace information used to construct a provider for a replication attempt.
|
|
9
|
+
*/
|
|
10
|
+
export interface MongoReplicationQueryProviderContext {
|
|
11
|
+
/**
|
|
12
|
+
* Hydrated sync config from which the provider can read its connection-specific options.
|
|
13
|
+
*/
|
|
14
|
+
syncConfig: HydratedSyncConfig;
|
|
15
|
+
/**
|
|
16
|
+
* Identifies the source connection whose options and tables this provider handles.
|
|
17
|
+
*/
|
|
18
|
+
connectionTag: string;
|
|
19
|
+
/**
|
|
20
|
+
* MongoDB database used to resolve table references without an explicit schema.
|
|
21
|
+
*/
|
|
22
|
+
defaultSchema: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Adapts snapshot selection and the stream of source changes for one replication implementation.
|
|
27
|
+
* The MongoDB module owns cursor options, namespaces, BSON parsing, large-event reassembly and durable progress.
|
|
28
|
+
*/
|
|
29
|
+
export interface MongoReplicationQueryProvider {
|
|
30
|
+
/**
|
|
31
|
+
* Validate source capabilities before either snapshot queries or streaming cursors are opened.
|
|
32
|
+
* Throw if required capabilities are unavailable. Both the stream and snapshotter may call this hook
|
|
33
|
+
* on the same provider, so validation must be safe to repeat.
|
|
34
|
+
*/
|
|
35
|
+
validateSource?(options: { connectionManager: MongoManager; isDocumentDb: boolean }): Promise<void>;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Call context.open() with any additional stages/pre-image options, then optionally wrap its iterator.
|
|
39
|
+
* Unlike pipeline stages alone, the wrapper can exclude marked events while still forwarding progress.
|
|
40
|
+
* Yield changes and safe progress, including when all data events are excluded.
|
|
41
|
+
* Count excluded complete events in the next progress item's filteredCount, resetting it after each
|
|
42
|
+
* progress item. Preserve the reader's safe resume token rather than advancing past buffered changes.
|
|
43
|
+
* An exit from a filtered set may be represented as a synthetic delete retaining its original key and token.
|
|
44
|
+
* Checkpoint/DDL events must remain ordered and intact. Progress alone must never stand in for a checkpoint.
|
|
45
|
+
* Each call opens a separate iterator, also used for snapshot barriers and resume validation. Keep
|
|
46
|
+
* per-iterator state separate and close the underlying iterator when the wrapper exits.
|
|
47
|
+
*/
|
|
48
|
+
openChangeStream(context: MongoReplicationStreamContext): AsyncIterableIterator<MongoReplicationStreamItem>;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Returns an additional MongoDB find predicate for a physical source table.
|
|
52
|
+
* Return null to snapshot without an additional filter. Snapshot selection must agree with the
|
|
53
|
+
* provider's streaming rules so both paths replicate the same set of documents.
|
|
54
|
+
* Expressions can be supplied as { $expr: ... }. The snapshot query combines the filter with its
|
|
55
|
+
* _id continuation predicate and uses simple collation whenever a filter is supplied.
|
|
56
|
+
*/
|
|
57
|
+
getSnapshotFilter(table: SourceTable): mongo.Filter<mongo.Document> | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Creates a provider shared by a replication attempt's change stream, snapshotter and source probes.
|
|
62
|
+
*/
|
|
63
|
+
export type MongoReplicationQueryProviderFactory = (
|
|
64
|
+
context: MongoReplicationQueryProviderContext
|
|
65
|
+
) => MongoReplicationQueryProvider;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Uses the standard MongoDB reader and snapshot selection without additional filtering or event translation.
|
|
69
|
+
*/
|
|
70
|
+
export const DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER: MongoReplicationQueryProvider = {
|
|
71
|
+
getSnapshotFilter: () => null,
|
|
72
|
+
openChangeStream: ({ open }) => open({})
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Places provider stages after namespace selection and before optional large-event splitting.
|
|
77
|
+
* This lets provider stages inspect complete events; the shared reader reassembles any resulting fragments.
|
|
78
|
+
*/
|
|
79
|
+
export function buildMongoChangeStreamPipeline(options: {
|
|
80
|
+
/**
|
|
81
|
+
* Shared reader options, including resume position, stream scope and requested document images.
|
|
82
|
+
*/
|
|
83
|
+
streamOptions: mongo.ChangeStreamOptions & mongo.Document;
|
|
84
|
+
/**
|
|
85
|
+
* Namespace predicate restricting the stream to the selected source collections and checkpoint events.
|
|
86
|
+
*/
|
|
87
|
+
namespaceMatch: mongo.Document;
|
|
88
|
+
/**
|
|
89
|
+
* Additional aggregation stages supplied by the provider, in execution order.
|
|
90
|
+
*/
|
|
91
|
+
pipelineStages: mongo.Document[];
|
|
92
|
+
/**
|
|
93
|
+
* Append MongoDB's large-event split stage. Disabled for DocumentDB by the shared reader.
|
|
94
|
+
*/
|
|
95
|
+
includeSplitLargeEvent: boolean;
|
|
96
|
+
}): mongo.Document[] {
|
|
97
|
+
const pipeline: mongo.Document[] = [
|
|
98
|
+
{ $changeStream: options.streamOptions },
|
|
99
|
+
{ $match: options.namespaceMatch },
|
|
100
|
+
...options.pipelineStages
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
if (options.includeSplitLargeEvent) {
|
|
104
|
+
pipeline.push({ $changeStreamSplitLargeEvent: {} });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return pipeline;
|
|
108
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { mongo } from '@powersync/lib-service-mongodb';
|
|
2
|
+
import { Logger, ReplicationAssertionError } from '@powersync/lib-services-framework';
|
|
3
|
+
import { buildMongoChangeStreamPipeline, MongoReplicationQueryProvider } from './MongoReplicationQueryProvider.js';
|
|
4
|
+
import {
|
|
5
|
+
ChangeStreamBatch,
|
|
6
|
+
parseChangeDocument,
|
|
7
|
+
ProjectedChangeStreamDocument,
|
|
8
|
+
rawChangeStream,
|
|
9
|
+
RawChangeStreamOptions
|
|
10
|
+
} from './RawChangeStream.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Ordered adapter output. Progress advances source recovery, not the client-visible checkpoint.
|
|
14
|
+
*/
|
|
15
|
+
export type MongoReplicationStreamItem =
|
|
16
|
+
| {
|
|
17
|
+
type: 'change';
|
|
18
|
+
event: ProjectedChangeStreamDocument;
|
|
19
|
+
/**
|
|
20
|
+
* More envelopes remain in the received batch, including ones an adapter may exclude.
|
|
21
|
+
*/
|
|
22
|
+
hasBufferedChanges: boolean;
|
|
23
|
+
}
|
|
24
|
+
| {
|
|
25
|
+
type: 'progress';
|
|
26
|
+
/**
|
|
27
|
+
* Covers only complete events already delivered or intentionally excluded by the adapter.
|
|
28
|
+
*/
|
|
29
|
+
resumeToken: mongo.ResumeToken;
|
|
30
|
+
/**
|
|
31
|
+
* Excluded complete events since the previous progress item; reset after every boundary.
|
|
32
|
+
*/
|
|
33
|
+
filteredCount: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export interface MongoReplicationStreamOptions {
|
|
37
|
+
/**
|
|
38
|
+
* Inserted after namespace selection and before the final large-event split stage.
|
|
39
|
+
*/
|
|
40
|
+
pipelineStages?: mongo.Document[];
|
|
41
|
+
/**
|
|
42
|
+
* Request pre-images without overriding the reader's post-image, resume or scope options.
|
|
43
|
+
*/
|
|
44
|
+
imageOptions?: Pick<mongo.ChangeStreamOptions, 'fullDocumentBeforeChange'>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface MongoReplicationStreamContext {
|
|
48
|
+
/**
|
|
49
|
+
* Open the source reader with the current position, namespace, deadlines and cancellation already bound.
|
|
50
|
+
* The reader returns parsed, reassembled changes followed by safe progress boundaries. A provider can
|
|
51
|
+
* add stages here and wrap the result to discard marked events or turn filter exits into deletes.
|
|
52
|
+
* Supplying this callback keeps cursor setup shared with snapshot barriers and resume validation.
|
|
53
|
+
*
|
|
54
|
+
* The default provider simply returns open({}). Wrappers must preserve event/progress ordering and
|
|
55
|
+
* close the returned iterator on completion, cancellation or failure (for await handles this).
|
|
56
|
+
*/
|
|
57
|
+
open(options: MongoReplicationStreamOptions): AsyncIterableIterator<MongoReplicationStreamItem>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Shared opening path for ongoing replication, snapshot barriers and resume validation.
|
|
62
|
+
*/
|
|
63
|
+
export function openMongoReplicationStream({
|
|
64
|
+
db,
|
|
65
|
+
queryProvider,
|
|
66
|
+
namespaceFilter,
|
|
67
|
+
isDocumentDb,
|
|
68
|
+
usePostImages,
|
|
69
|
+
position,
|
|
70
|
+
skipInitialTimestamp = false,
|
|
71
|
+
options,
|
|
72
|
+
onBatch
|
|
73
|
+
}: {
|
|
74
|
+
db: mongo.Db;
|
|
75
|
+
queryProvider: MongoReplicationQueryProvider;
|
|
76
|
+
namespaceFilter: { $match: mongo.Document; multipleDatabases: boolean };
|
|
77
|
+
isDocumentDb: boolean;
|
|
78
|
+
usePostImages: boolean;
|
|
79
|
+
position: { resumeAfter?: mongo.ResumeToken | null; startAfter?: mongo.Timestamp | null } | null;
|
|
80
|
+
/**
|
|
81
|
+
* Legacy streaming positions exclude the initial timestamp; snapshot barrier probes must include it.
|
|
82
|
+
*/
|
|
83
|
+
skipInitialTimestamp?: boolean;
|
|
84
|
+
options: RawChangeStreamOptions;
|
|
85
|
+
onBatch?: (batch: ChangeStreamBatch) => Disposable | void;
|
|
86
|
+
}): AsyncIterableIterator<MongoReplicationStreamItem> {
|
|
87
|
+
const open: MongoReplicationStreamContext['open'] = ({ pipelineStages = [], imageOptions = {} }) => {
|
|
88
|
+
const clusterScope = isDocumentDb || namespaceFilter.multipleDatabases;
|
|
89
|
+
const streamOptions: mongo.Document = {
|
|
90
|
+
...imageOptions,
|
|
91
|
+
fullDocument: !isDocumentDb && usePostImages ? 'required' : 'updateLookup',
|
|
92
|
+
...(!isDocumentDb ? { showExpandedEvents: true } : {}),
|
|
93
|
+
...(clusterScope ? { allChangesForCluster: true } : {})
|
|
94
|
+
};
|
|
95
|
+
// Only one resume option is legal. Legacy MongoDB snapshot positions may contain just a timestamp;
|
|
96
|
+
// a fresh DocumentDB stream has neither and opens from now.
|
|
97
|
+
if (position?.resumeAfter != null) {
|
|
98
|
+
streamOptions.resumeAfter = position.resumeAfter;
|
|
99
|
+
} else if (position?.startAfter != null) {
|
|
100
|
+
streamOptions.startAtOperationTime = position.startAfter;
|
|
101
|
+
}
|
|
102
|
+
const pipeline = buildMongoChangeStreamPipeline({
|
|
103
|
+
streamOptions,
|
|
104
|
+
namespaceMatch: namespaceFilter.$match,
|
|
105
|
+
pipelineStages,
|
|
106
|
+
includeSplitLargeEvent: !isDocumentDb
|
|
107
|
+
});
|
|
108
|
+
return readMongoReplicationStream({
|
|
109
|
+
batches: rawChangeStream(clusterScope ? db.client.db('admin') : db, pipeline, options),
|
|
110
|
+
defaultSchema: db.databaseName,
|
|
111
|
+
multipleDatabases: namespaceFilter.multipleDatabases,
|
|
112
|
+
// An exact token can fall inside a transaction. Later events may share its timestamp and must
|
|
113
|
+
// still be delivered. Timestamp deduplication applies only to legacy positions without tokens.
|
|
114
|
+
startAfter: skipInitialTimestamp && position?.resumeAfter == null ? position?.startAfter : undefined,
|
|
115
|
+
signal: options.signal,
|
|
116
|
+
logger: options.logger,
|
|
117
|
+
onBatch
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
return queryProvider.openChangeStream({ open });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Parse envelopes once, retaining raw BSON row bodies and withholding progress during split reassembly.
|
|
125
|
+
*/
|
|
126
|
+
export async function* readMongoReplicationStream({
|
|
127
|
+
batches,
|
|
128
|
+
defaultSchema,
|
|
129
|
+
multipleDatabases,
|
|
130
|
+
startAfter,
|
|
131
|
+
signal,
|
|
132
|
+
logger,
|
|
133
|
+
onBatch
|
|
134
|
+
}: {
|
|
135
|
+
batches: AsyncIterable<ChangeStreamBatch>;
|
|
136
|
+
defaultSchema: string;
|
|
137
|
+
multipleDatabases: boolean;
|
|
138
|
+
startAfter?: mongo.Timestamp | null;
|
|
139
|
+
signal?: AbortSignal;
|
|
140
|
+
logger?: Logger;
|
|
141
|
+
/**
|
|
142
|
+
* Transport accounting happens before parsing/filtering, including partial fragments and empty batches.
|
|
143
|
+
*/
|
|
144
|
+
onBatch?: (batch: ChangeStreamBatch) => Disposable | void;
|
|
145
|
+
}): AsyncGenerator<MongoReplicationStreamItem> {
|
|
146
|
+
let splitDocument: ProjectedChangeStreamDocument | null = null;
|
|
147
|
+
let flexDbNameWorkaroundLogged = false;
|
|
148
|
+
for await (const batch of batches) {
|
|
149
|
+
signal?.throwIfAborted();
|
|
150
|
+
// The optional scope measures batch processing, excluding the next network wait.
|
|
151
|
+
using batchScope = onBatch?.(batch) || undefined;
|
|
152
|
+
for (const [index, rawEvent] of batch.events.entries()) {
|
|
153
|
+
signal?.throwIfAborted();
|
|
154
|
+
let event = parseChangeDocument(rawEvent);
|
|
155
|
+
const fragment = event.splitEvent;
|
|
156
|
+
if (fragment != null) {
|
|
157
|
+
// A retry can resume after a received fragment inside the raw reader. Keep the partial event in
|
|
158
|
+
// memory across batches/retries, but never expose its token as a durable recovery boundary.
|
|
159
|
+
const expectedFragment = splitDocument == null ? 1 : splitDocument.splitEvent!.fragment + 1;
|
|
160
|
+
if (
|
|
161
|
+
fragment.fragment != expectedFragment ||
|
|
162
|
+
fragment.fragment > fragment.of ||
|
|
163
|
+
(splitDocument != null && fragment.of != splitDocument.splitEvent!.of)
|
|
164
|
+
) {
|
|
165
|
+
throw new ReplicationAssertionError(`Unexpected splitEvent: ${JSON.stringify(fragment)}`);
|
|
166
|
+
}
|
|
167
|
+
splitDocument = splitDocument == null ? event : Object.assign(splitDocument, event);
|
|
168
|
+
if (fragment.fragment != fragment.of) continue;
|
|
169
|
+
event = splitDocument;
|
|
170
|
+
splitDocument = null;
|
|
171
|
+
} else if (splitDocument != null) {
|
|
172
|
+
throw new ReplicationAssertionError(`Incomplete splitEvent: ${JSON.stringify(splitDocument.splitEvent)}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (startAfter != null && event.clusterTime?.lte(startAfter)) continue;
|
|
176
|
+
|
|
177
|
+
if (
|
|
178
|
+
!multipleDatabases &&
|
|
179
|
+
'ns' in event &&
|
|
180
|
+
event.ns.db != defaultSchema &&
|
|
181
|
+
event.ns.db.endsWith(`_${defaultSchema}`)
|
|
182
|
+
) {
|
|
183
|
+
// Atlas Flex can prefix the database name on events recorded while replication was paused.
|
|
184
|
+
// Normalize before adapters inspect namespaces, just as the ordinary replication loop does.
|
|
185
|
+
const originalDatabase = event.ns.db;
|
|
186
|
+
event.ns.db = defaultSchema;
|
|
187
|
+
if (!flexDbNameWorkaroundLogged) {
|
|
188
|
+
flexDbNameWorkaroundLogged = true;
|
|
189
|
+
logger?.warn(`Incorrect DB name in change stream: ${originalDatabase}. Changed to ${defaultSchema}.`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
yield { type: 'change', event, hasBufferedChanges: index < batch.events.length - 1 };
|
|
193
|
+
}
|
|
194
|
+
if (splitDocument == null) {
|
|
195
|
+
yield { type: 'progress', resumeToken: batch.resumeToken, filteredCount: 0 };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (splitDocument != null) {
|
|
199
|
+
throw new ReplicationAssertionError(`Incomplete splitEvent: ${JSON.stringify(splitDocument.splitEvent)}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -15,12 +15,19 @@ export class ChunkedSnapshotQuery implements AsyncDisposable {
|
|
|
15
15
|
private lastCursor: mongo.FindCursor | null = null;
|
|
16
16
|
private collection: mongo.Collection;
|
|
17
17
|
private batchSize: number;
|
|
18
|
+
private readonly filter: mongo.Filter<mongo.Document> | null;
|
|
18
19
|
|
|
19
|
-
public constructor(options: {
|
|
20
|
+
public constructor(options: {
|
|
21
|
+
collection: mongo.Collection;
|
|
22
|
+
batchSize: number;
|
|
23
|
+
key?: Uint8Array | null;
|
|
24
|
+
filter?: mongo.Filter<mongo.Document> | null;
|
|
25
|
+
}) {
|
|
20
26
|
this.lastKey = options.key ? bson.deserialize(options.key, { useBigInt64: true })._id : null;
|
|
21
27
|
this.lastCursor = null;
|
|
22
28
|
this.collection = options.collection;
|
|
23
29
|
this.batchSize = options.batchSize;
|
|
30
|
+
this.filter = options.filter ?? null;
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
async nextChunk(): Promise<
|
|
@@ -39,8 +46,14 @@ export class ChunkedSnapshotQuery implements AsyncDisposable {
|
|
|
39
46
|
// any parsing as an operator.
|
|
40
47
|
// Starting in MongoDB 5.0, this filter can use the _id index. Source:
|
|
41
48
|
// https://www.mongodb.com/docs/manual/release-notes/5.0/#general-aggregation-improvements
|
|
42
|
-
const
|
|
49
|
+
const continuationFilter: mongo.Filter<mongo.Document> =
|
|
43
50
|
this.lastKey == null ? {} : { $expr: { $gt: ['$_id', { $literal: this.lastKey }] } };
|
|
51
|
+
const filter: mongo.Filter<mongo.Document> =
|
|
52
|
+
this.filter == null
|
|
53
|
+
? continuationFilter
|
|
54
|
+
: this.lastKey == null
|
|
55
|
+
? this.filter
|
|
56
|
+
: { $and: [this.filter, continuationFilter] };
|
|
44
57
|
cursor = this.collection.find(filter, {
|
|
45
58
|
readConcern: 'majority',
|
|
46
59
|
limit: this.batchSize,
|
|
@@ -48,7 +61,10 @@ export class ChunkedSnapshotQuery implements AsyncDisposable {
|
|
|
48
61
|
// See https://github.com/mongodb/node-mongodb-native/pull/4580
|
|
49
62
|
batchSize: this.batchSize + 1,
|
|
50
63
|
sort: { _id: 1 },
|
|
51
|
-
raw: true
|
|
64
|
+
raw: true,
|
|
65
|
+
// Replication predicates have stable binary semantics independent of a
|
|
66
|
+
// collection's default collation.
|
|
67
|
+
...(this.filter == null ? {} : { collation: { locale: 'simple' } })
|
|
52
68
|
});
|
|
53
69
|
newCursor = true;
|
|
54
70
|
}
|