@powersync/service-module-mongodb 0.18.2 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/api/MongoRouteAPIAdapter.d.ts +2 -0
  3. package/dist/api/MongoRouteAPIAdapter.js +23 -32
  4. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  5. package/dist/common/SentinelLSN.d.ts +37 -0
  6. package/dist/common/SentinelLSN.js +59 -0
  7. package/dist/common/SentinelLSN.js.map +1 -0
  8. package/dist/module/MongoModule.js +2 -1
  9. package/dist/module/MongoModule.js.map +1 -1
  10. package/dist/replication/ChangeStream.d.ts +23 -0
  11. package/dist/replication/ChangeStream.js +167 -47
  12. package/dist/replication/ChangeStream.js.map +1 -1
  13. package/dist/replication/ChangeStreamReplicationJob.js +2 -1
  14. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  15. package/dist/replication/MongoRelation.d.ts +36 -1
  16. package/dist/replication/MongoRelation.js +102 -6
  17. package/dist/replication/MongoRelation.js.map +1 -1
  18. package/dist/replication/MongoSnapshotter.d.ts +9 -0
  19. package/dist/replication/MongoSnapshotter.js +113 -42
  20. package/dist/replication/MongoSnapshotter.js.map +1 -1
  21. package/dist/replication/RawChangeStream.d.ts +12 -1
  22. package/dist/replication/RawChangeStream.js +23 -10
  23. package/dist/replication/RawChangeStream.js.map +1 -1
  24. package/dist/replication/checkpoints/CheckpointImplementation.d.ts +135 -0
  25. package/dist/replication/checkpoints/CheckpointImplementation.js +22 -0
  26. package/dist/replication/checkpoints/CheckpointImplementation.js.map +1 -0
  27. package/dist/replication/checkpoints/SentinelCheckpointImplementation.d.ts +58 -0
  28. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js +224 -0
  29. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js.map +1 -0
  30. package/dist/replication/checkpoints/TimestampCheckpointImplementation.d.ts +27 -0
  31. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js +116 -0
  32. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js.map +1 -0
  33. package/dist/replication/checkpoints/create-checkpoint-implementation.d.ts +6 -0
  34. package/dist/replication/checkpoints/create-checkpoint-implementation.js +10 -0
  35. package/dist/replication/checkpoints/create-checkpoint-implementation.js.map +1 -0
  36. package/dist/replication/replication-utils.d.ts +6 -0
  37. package/dist/replication/replication-utils.js +19 -2
  38. package/dist/replication/replication-utils.js.map +1 -1
  39. package/dist/types/types.d.ts +5 -0
  40. package/dist/types/types.js +20 -2
  41. package/dist/types/types.js.map +1 -1
  42. package/package.json +9 -9
  43. package/src/api/MongoRouteAPIAdapter.ts +26 -37
  44. package/src/common/SentinelLSN.ts +78 -0
  45. package/src/module/MongoModule.ts +2 -1
  46. package/src/replication/ChangeStream.ts +184 -55
  47. package/src/replication/ChangeStreamReplicationJob.ts +2 -1
  48. package/src/replication/MongoRelation.ts +124 -14
  49. package/src/replication/MongoSnapshotter.ts +132 -47
  50. package/src/replication/RawChangeStream.ts +54 -32
  51. package/src/replication/checkpoints/CheckpointImplementation.ts +167 -0
  52. package/src/replication/checkpoints/SentinelCheckpointImplementation.ts +269 -0
  53. package/src/replication/checkpoints/TimestampCheckpointImplementation.ts +145 -0
  54. package/src/replication/checkpoints/create-checkpoint-implementation.ts +14 -0
  55. package/src/replication/replication-utils.ts +23 -2
  56. package/src/types/types.ts +27 -2
  57. package/test/DOCUMENTDB_TESTING.md +115 -0
  58. package/test/src/DatabaseType.ts +25 -0
  59. package/test/src/change_stream.test.ts +97 -65
  60. package/test/src/change_stream_utils.ts +99 -11
  61. package/test/src/checkpoint_retry.test.ts +5 -2
  62. package/test/src/config.test.ts +34 -0
  63. package/test/src/documentdb_helpers.test.ts +124 -0
  64. package/test/src/documentdb_mode.test.ts +1040 -0
  65. package/test/src/mongo_test.test.ts +15 -5
  66. package/test/src/raw_change_stream.test.ts +209 -125
  67. package/test/src/resume_token.test.ts +30 -0
  68. package/test/src/slow_tests.test.ts +4 -1
  69. package/test/src/test-timeouts.ts +23 -0
  70. package/test/src/util.ts +1 -2
  71. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,269 @@
1
+ import { mongo } from '@powersync/lib-service-mongodb';
2
+ import { ReplicationHeadCallback, storage } from '@powersync/service-core';
3
+ import { JSONBig } from '@powersync/service-jsonbig';
4
+ import { SentinelLSN } from '../../common/SentinelLSN.js';
5
+ import { ChangeStreamInvalidatedError } from '../ChangeStream.js';
6
+ import { createSentinelCheckpointLsn, SENTINEL_CHECKPOINT_ID } from '../MongoRelation.js';
7
+ import { ProjectedChangeStreamDocument } from '../RawChangeStream.js';
8
+ import {
9
+ CheckpointEventApi,
10
+ CheckpointEventKind,
11
+ CheckpointImplementation,
12
+ CheckpointImplementationContext,
13
+ getCheckpointId,
14
+ StreamResumePosition
15
+ } from './CheckpointImplementation.js';
16
+
17
+ /**
18
+ * Sentinel checkpoint implementation, used for sources without a usable clusterTime
19
+ * (DocumentDB). The ordered LSN coordinate is a single shared sentinel checkpoint
20
+ * counter ({@link SENTINEL_CHECKPOINT_ID}), observed through the change stream:
21
+ *
22
+ * - Batch checkpoints advance the global counter and stamp it with this stream's
23
+ * id, so the stream recognises its own private barriers by content (stream_id
24
+ * + counter) rather than by cross-document event ordering.
25
+ * - Standalone bumps (write checkpoint heads, snapshot markers) advance the same
26
+ * counter with a null stream_id; every stream observes these as the global
27
+ * coordinate.
28
+ * - Keepalives advance the counter without persisting; the bump's own event
29
+ * flows through the stream and commits via the standalone handling,
30
+ * preserving the "commit only what the stream has seen" barrier property.
31
+ *
32
+ * See docs/documentdb/documentdb-lsn-sentinel-checkpoints.md for the full design.
33
+ */
34
+ export class SentinelCheckpointImplementation implements CheckpointImplementation {
35
+ readonly zeroLsn = SentinelLSN.ZERO.comparable;
36
+
37
+ /**
38
+ * The highest global sentinel value proven so far, merged monotonically from
39
+ * the resume seed, standalone events and embedded barrier values. Needed
40
+ * for lsnFromResumeToken: per-batch resume markers carry only a resume token,
41
+ * so they need the latest observed coordinate paired in.
42
+ */
43
+ private position = SentinelLSN.ZERO.sentinel;
44
+
45
+ constructor(private context: CheckpointImplementationContext) {}
46
+
47
+ parseResumePosition(lsn: string): StreamResumePosition {
48
+ const parsed = SentinelLSN.fromSerialized(lsn);
49
+ return { resumeAfter: parsed.resumeToken ?? null, startAfter: null };
50
+ }
51
+
52
+ seedPosition(lsn: string | null): void {
53
+ this.position = lsn == null ? SentinelLSN.ZERO.sentinel : SentinelLSN.fromSerialized(lsn).sentinel;
54
+ }
55
+
56
+ logResume(lsn: string): void {
57
+ const parsed = SentinelLSN.fromSerialized(lsn);
58
+ this.context.logger.info(`Resume streaming at sentinel ${parsed.sentinel} / ${parsed}`);
59
+ }
60
+
61
+ async createStandaloneCheckpoint(): Promise<string> {
62
+ return createSentinelCheckpointLsn(this.context.client, this.context.db);
63
+ }
64
+
65
+ async createBatchCheckpoint(): Promise<string> {
66
+ // Advance the shared sentinel checkpoint — the global source-database
67
+ // coordinate. It must be shared so the LSN domain survives new
68
+ // ChangeStream instances and new sync rules.
69
+ // This advance is associated with the change stream in order to track stream
70
+ // barriers. The returned LSN (counter only, no resume token) is the barrier
71
+ // marker matched by resolvesBarrier.
72
+ return createSentinelCheckpointLsn(this.context.client, this.context.db, this.context.checkpointStreamId);
73
+ }
74
+
75
+ async createFirstBarrier(): Promise<string | null> {
76
+ // The barrier marker is an opaque content-matched marker, not a resume
77
+ // position, so there is no LSN to open the stream from: a fresh DocumentDB
78
+ // stream opens from "now" and the snapshot loop re-creates barriers until
79
+ // one is observed.
80
+ await this.createBatchCheckpoint();
81
+ return null;
82
+ }
83
+
84
+ async keepalive(_batch: storage.BucketStorageBatch, _resumeToken: mongo.ResumeToken): Promise<void> {
85
+ // Advance the shared sentinel, but do not persist a checkpoint here. The
86
+ // bump is stamped with this stream's id, so its own change event flows
87
+ // through the stream as an own-barrier event and is committed by the
88
+ // own-barrier handling, which advances the ordered LSN prefix and refreshes
89
+ // the resume token (using the event's own token).
90
+ //
91
+ // Why bump at all: with an unchanged sentinel, LSN comparison against the
92
+ // previously persisted LSN falls to the opaque base64 token suffix, which
93
+ // is not lexicographically meaningful — storage would silently reject
94
+ // roughly half of plain token refreshes, leaving the persisted resume
95
+ // token to go stale on an idle stream. (The timestamp implementation avoids this
96
+ // because its keepalive timestamp is parsed from the resume token itself,
97
+ // so the ordered prefix always advances with the token.)
98
+ //
99
+ // Why not persist immediately: writes that landed after this empty batch
100
+ // was read — including a write checkpoint head — would be covered by the
101
+ // persisted LSN before the stream has processed them, allowing write
102
+ // checkpoints to resolve before the corresponding data is replicated.
103
+ // Committing only when the bump's event is observed preserves the
104
+ // "commit only what the stream has seen" barrier property.
105
+ await createSentinelCheckpointLsn(this.context.client, this.context.db, this.context.checkpointStreamId);
106
+ this.context.logger.info(
107
+ `Idle change stream (sentinel implementation). Bumped sentinel to advance the checkpoint.`
108
+ );
109
+ }
110
+
111
+ lsnFromResumeToken(resumeToken: mongo.ResumeToken) {
112
+ // Pair the bare token with the current coordinate. The coordinate is
113
+ // frozen between checkpoint events, so a per-batch resume marker only
114
+ // advances the token; that is all resumption needs (see the design doc).
115
+ return { lsn: new SentinelLSN({ sentinel: this.position, resume_token: resumeToken }).comparable, timestamp: null };
116
+ }
117
+
118
+ async createReplicationHead<T>(callback: ReplicationHeadCallback<T>): Promise<T> {
119
+ const head = await createSentinelCheckpointLsn(this.context.client, this.context.db);
120
+ const { response, shouldAdvance } = await callback(head);
121
+ if (shouldAdvance) {
122
+ // Create another bump to ensure movement after the reported head. This
123
+ // covers the race where the head's own event is committed before the
124
+ // write checkpoint document is stored.
125
+ // Note that this checkpoint should not be associated with a change stream Id.
126
+ await createSentinelCheckpointLsn(this.context.client, this.context.db);
127
+ }
128
+ return response;
129
+ }
130
+
131
+ readonly event: CheckpointEventApi = {
132
+ observe: (doc) => {
133
+ const checkpointId = getCheckpointId(doc);
134
+ if (checkpointId != SENTINEL_CHECKPOINT_ID) {
135
+ // The sentinel implementation only uses the SENTINEL_CHECKPOINT_ID
136
+ // document; anything else (including the timestamp impl's standalone
137
+ // document) is foreign.
138
+ return 'foreign';
139
+ }
140
+ const fullDoc = deserializeFullDocument(doc);
141
+ if (fullDoc == null) {
142
+ // An insert/update/replace on our sentinel document must carry a
143
+ // post-image. A missing fullDocument means the document was deleted or
144
+ // replaced out from under us, which destroys the coordinate; invalidate
145
+ // so replication restarts clean instead of stalling.
146
+ throw new ChangeStreamInvalidatedError(
147
+ 'Sentinel checkpoint event has no fullDocument — cannot read the sentinel',
148
+ new Error(`Unexpected ${doc.operationType} event on the sentinel checkpoint document`)
149
+ );
150
+ }
151
+ const streamId = fullDoc.stream_id;
152
+
153
+ let kind: CheckpointEventKind;
154
+ if (streamId == null) {
155
+ // No stream_id: a standalone bump (write checkpoint head, snapshot
156
+ // marker). Every stream tracks these as the global coordinate.
157
+ kind = 'standalone';
158
+ } else if (this.context.checkpointStreamId.equals(streamId)) {
159
+ // Stamped with our id: one of our own private batch/keepalive barriers.
160
+ kind = 'own-barrier';
161
+ } else {
162
+ // Another stream's private barrier — ignore.
163
+ return 'foreign';
164
+ }
165
+
166
+ // Both kinds carry the global coordinate in `i`; keep our position current.
167
+ this.mergePosition(this.readSentinel(fullDoc));
168
+ return kind;
169
+ },
170
+
171
+ lsn: (doc) => {
172
+ const fullDoc = deserializeFullDocument(doc);
173
+ if (fullDoc == null) {
174
+ throw new ChangeStreamInvalidatedError(
175
+ 'Sentinel checkpoint event has no fullDocument — cannot read the sentinel',
176
+ new Error(`Unexpected ${doc.operationType} event on the sentinel checkpoint document`)
177
+ );
178
+ }
179
+
180
+ // Checkpoint events carry the global coordinate in `i`; pair that exact
181
+ // coordinate with this event's resume token. Plain data-batch resume
182
+ // markers use lsnFromResumeToken, which pairs the token with the tracked
183
+ // position instead.
184
+ return new SentinelLSN({
185
+ sentinel: this.readSentinel(fullDoc),
186
+ resume_token: doc._id
187
+ }).comparable;
188
+ },
189
+
190
+ resolvesBarrier: (marker, doc) => {
191
+ const fullDoc = deserializeFullDocument(doc);
192
+ if (fullDoc == null) {
193
+ this.context.logger.warn('Checkpoint event missing fullDocument — cannot match sentinel barrier');
194
+ return false;
195
+ }
196
+ const parsed = SentinelLSN.fromSerialized(marker);
197
+ return this.context.checkpointStreamId.equals(fullDoc.stream_id) && fullDoc.i >= parsed.sentinel;
198
+ }
199
+ };
200
+
201
+ // The sentinel checkpoint document must survive restarts (hence the $ne
202
+ // below — the startup cleanup deletes every other checkpoint document). Its
203
+ // counter is the globally-ordered component of every committed LSN, including
204
+ // write checkpoint heads. Deleting it would re-seed the counter on the next
205
+ // upsert, risking moving the LSN coordinate system backwards: new commits
206
+ // could compare below the persisted last_checkpoint_lsn (so storage rejects
207
+ // them via checkpointBlocked and the checkpoint stalls), and new write
208
+ // checkpoint heads could resolve against old, higher committed LSNs before
209
+ // their data has actually replicated.
210
+ //
211
+ // Note: this only protects against our own startup cleanup. The global
212
+ // LSN coordinate still lives in a user-visible collection, so a consumer
213
+ // can delete it in their source database. Dropping the whole checkpoints
214
+ // collection is detected (the streaming loop invalidates the stream on the
215
+ // collection drop event). Deleting just this document is mitigated by
216
+ // createSentinelCheckpointLsn seeding re-created counters at the current
217
+ // epoch seconds, so the coordinate jumps forward instead of resetting
218
+ // below already-committed LSNs.
219
+ readonly checkpointClearFilter: mongo.Filter<mongo.Document> = { _id: { $ne: SENTINEL_CHECKPOINT_ID } as any };
220
+
221
+ /** Never move the position backwards — replayed or reordered events must not regress the coordinate. */
222
+ private mergePosition(observed: bigint) {
223
+ if (observed > this.position) {
224
+ this.position = observed;
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Read the sentinel counter from a sentinel checkpoint post-image.
230
+ *
231
+ * The `i` field is required: every sentinel write `$inc`s it. If it is
232
+ * missing, the document was replaced externally, which destroys the LSN
233
+ * coordinate system (a re-created counter restarts below already-committed
234
+ * LSNs). Invalidate the stream so replication restarts from scratch instead
235
+ * of stalling against a broken coordinate.
236
+ */
237
+ private readSentinel(fullDoc: mongo.Document): bigint {
238
+ if (fullDoc.i == null) {
239
+ throw new ChangeStreamInvalidatedError(
240
+ 'Sentinel checkpoint document has no `i` field — cannot read the sentinel',
241
+ // JSONBig.stringify, since the post-image is deserialized with useBigInt64.
242
+ new Error(`Sentinel checkpoint document: ${JSONBig.stringify(fullDoc)}`)
243
+ );
244
+ }
245
+ return fullDoc.i;
246
+ }
247
+ }
248
+
249
+ /**
250
+ * A change event with the decoded `fullDocument` post-image memoized on it, so
251
+ * a consumer that reads it more than once — e.g. observe() then resolvesBarrier()
252
+ * for the same event — decodes the buffer only once. `undefined` until first
253
+ * decoded; `null` when there is no fullDocument.
254
+ */
255
+ type MemoizedChangeStreamDocument = ProjectedChangeStreamDocument & {
256
+ parsedFullDocument?: mongo.Document | null;
257
+ };
258
+
259
+ function deserializeFullDocument(doc: ProjectedChangeStreamDocument): mongo.Document | null {
260
+ const memo = doc as MemoizedChangeStreamDocument;
261
+ if (memo.parsedFullDocument !== undefined) {
262
+ return memo.parsedFullDocument;
263
+ }
264
+ const fullDocument = 'fullDocument' in doc ? doc.fullDocument : null;
265
+ // fullDocument is a raw BSON Buffer from parseChangeDocument.
266
+ const parsed = fullDocument ? mongo.BSON.deserialize(fullDocument as Buffer, { useBigInt64: true }) : null;
267
+ memo.parsedFullDocument = parsed;
268
+ return parsed;
269
+ }
@@ -0,0 +1,145 @@
1
+ import { mongo } from '@powersync/lib-service-mongodb';
2
+ import { ServiceAssertionError } from '@powersync/lib-services-framework';
3
+ import { ReplicationHeadCallback, storage } from '@powersync/service-core';
4
+ import { MongoLSN } from '../../common/MongoLSN.js';
5
+ import { createCheckpoint, SENTINEL_CHECKPOINT_ID, STANDALONE_CHECKPOINT_ID } from '../MongoRelation.js';
6
+ import { CHECKPOINTS_COLLECTION, timestampToDate } from '../replication-utils.js';
7
+ import {
8
+ CheckpointEventApi,
9
+ CheckpointImplementation,
10
+ CheckpointImplementationContext,
11
+ getCheckpointId,
12
+ getEventTimestamp,
13
+ StreamResumePosition
14
+ } from './CheckpointImplementation.js';
15
+
16
+ /**
17
+ * Standard MongoDB checkpoint implementation. The ordered LSN coordinate is the oplog
18
+ * clusterTime — unique per operation, monotonic, and parseable from resume
19
+ * tokens. Barriers and event LSNs are plain comparable LSN strings.
20
+ */
21
+ export class TimestampCheckpointImplementation implements CheckpointImplementation {
22
+ readonly zeroLsn = MongoLSN.ZERO.comparable;
23
+
24
+ constructor(private context: CheckpointImplementationContext) {}
25
+
26
+ parseResumePosition(lsn: string): StreamResumePosition {
27
+ const parsed = MongoLSN.fromSerialized(lsn);
28
+ return { resumeAfter: parsed.resumeToken ?? null, startAfter: parsed.timestamp };
29
+ }
30
+
31
+ seedPosition(_lsn: string | null): void {
32
+ // The coordinate comes from each event's clusterTime; no state to seed.
33
+ }
34
+
35
+ logResume(lsn: string): void {
36
+ const parsed = MongoLSN.fromSerialized(lsn);
37
+ // It is normal for this to be a minute or two old when there is a low volume
38
+ // of ChangeStream events.
39
+ const tokenAgeSeconds = Math.round((Date.now() - timestampToDate(parsed.timestamp).getTime()) / 1000);
40
+ this.context.logger.info(
41
+ `Resume streaming at ${parsed.timestamp.inspect()} / ${parsed} | Token age: ${tokenAgeSeconds}s`
42
+ );
43
+ }
44
+
45
+ async createStandaloneCheckpoint(): Promise<string> {
46
+ return createCheckpoint(this.context.db, STANDALONE_CHECKPOINT_ID);
47
+ }
48
+
49
+ async createBatchCheckpoint(): Promise<string> {
50
+ return createCheckpoint(this.context.db, this.context.checkpointStreamId);
51
+ }
52
+
53
+ async createFirstBarrier(): Promise<string | null> {
54
+ // The barrier marker is a comparable LSN; resume the snapshot stream from it.
55
+ return this.createBatchCheckpoint();
56
+ }
57
+
58
+ async keepalive(batch: storage.BucketStorageBatch, resumeToken: mongo.ResumeToken): Promise<void> {
59
+ // Parse the timestamp from the resume token. The ordered LSN prefix
60
+ // advances together with the token, so persisting is always safe.
61
+ const { comparable: lsn, timestamp } = MongoLSN.fromResumeToken(resumeToken);
62
+ await batch.keepalive(lsn);
63
+ // Log the token update. This helps as a general "replication is still active" message in the logs.
64
+ // This token would typically be around 10s behind.
65
+ this.context.logger.info(
66
+ `Idle change stream. Persisted resumeToken for ${timestampToDate(timestamp).toISOString()}`
67
+ );
68
+ }
69
+
70
+ lsnFromResumeToken(resumeToken: mongo.ResumeToken) {
71
+ // The timestamp is embedded in the resume token.
72
+ const lsn = MongoLSN.fromResumeToken(resumeToken);
73
+ return { lsn: lsn.comparable, timestamp: timestampToDate(lsn.timestamp) };
74
+ }
75
+
76
+ async createReplicationHead<T>(callback: ReplicationHeadCallback<T>): Promise<T> {
77
+ const session = this.context.client.startSession();
78
+ try {
79
+ await this.context.db.command({ hello: 1 }, { session });
80
+ const head = session.clusterTime?.clusterTime;
81
+ if (head == null) {
82
+ throw new ServiceAssertionError(`clusterTime not available for write checkpoint`);
83
+ }
84
+
85
+ const { response, shouldAdvance } = await callback(new MongoLSN({ timestamp: head }).comparable);
86
+
87
+ if (shouldAdvance) {
88
+ // Trigger a change on the changestream, so that the write checkpoint
89
+ // is processed without waiting for other writes.
90
+ await this.context.db.collection(CHECKPOINTS_COLLECTION).findOneAndUpdate(
91
+ {
92
+ _id: STANDALONE_CHECKPOINT_ID as any
93
+ },
94
+ {
95
+ $inc: { i: 1 }
96
+ },
97
+ {
98
+ upsert: true,
99
+ returnDocument: 'after',
100
+ session
101
+ }
102
+ );
103
+ const time = session.operationTime!;
104
+ if (time == null) {
105
+ throw new ServiceAssertionError(`operationTime not available for write checkpoint`);
106
+ } else if (time.lt(head)) {
107
+ throw new ServiceAssertionError(`operationTime must be > clusterTime`);
108
+ }
109
+ }
110
+
111
+ return response;
112
+ } finally {
113
+ await session.endSession();
114
+ }
115
+ }
116
+
117
+ readonly event: CheckpointEventApi = {
118
+ observe: (doc) => {
119
+ const checkpointId = getCheckpointId(doc);
120
+ if (checkpointId == null || checkpointId == SENTINEL_CHECKPOINT_ID) {
121
+ return 'foreign';
122
+ }
123
+ // The STANDALONE_CHECKPOINT_ID is only used for the TimestampCheckpointImplementation
124
+ if (checkpointId == STANDALONE_CHECKPOINT_ID) {
125
+ return 'standalone';
126
+ }
127
+ return this.context.checkpointStreamId.equals(checkpointId) ? 'own-barrier' : 'foreign';
128
+ },
129
+
130
+ lsn: (doc) => {
131
+ return new MongoLSN({
132
+ timestamp: getEventTimestamp(doc),
133
+ resume_token: doc._id
134
+ }).comparable;
135
+ },
136
+
137
+ resolvesBarrier: (marker, doc) => {
138
+ // Barrier markers are comparable LSNs in this implementation.
139
+ return this.event.lsn(doc) >= marker;
140
+ }
141
+ };
142
+
143
+ // It's safe to clear the entire _powersync_checkpoints collection in this mode.
144
+ readonly checkpointClearFilter: mongo.Filter<mongo.Document> = {};
145
+ }
@@ -0,0 +1,14 @@
1
+ import { CheckpointImplementation, CheckpointImplementationContext } from './CheckpointImplementation.js';
2
+ import { SentinelCheckpointImplementation } from './SentinelCheckpointImplementation.js';
3
+ import { TimestampCheckpointImplementation } from './TimestampCheckpointImplementation.js';
4
+
5
+ /**
6
+ * Select the checkpoint implementation for a source: sentinel-based for DocumentDB
7
+ * DB, clusterTime-based for standard MongoDB.
8
+ */
9
+ export function createCheckpointImplementation(
10
+ isDocumentDb: boolean,
11
+ context: CheckpointImplementationContext
12
+ ): CheckpointImplementation {
13
+ return isDocumentDb ? new SentinelCheckpointImplementation(context) : new TimestampCheckpointImplementation(context);
14
+ }
@@ -1,3 +1,4 @@
1
+ import { mongo } from '@powersync/lib-service-mongodb';
1
2
  import { ErrorCode, ServiceError } from '@powersync/lib-services-framework';
2
3
  import * as bson from 'bson';
3
4
  import { PostImagesOption } from '../types/types.js';
@@ -7,16 +8,36 @@ export const CHECKPOINTS_COLLECTION = '_powersync_checkpoints';
7
8
 
8
9
  const REQUIRED_CHECKPOINT_PERMISSIONS = ['find', 'insert', 'update', 'remove', 'changeStream', 'createCollection'];
9
10
 
11
+ /**
12
+ * Whether a `hello` response indicates Azure DocumentDB (formerly Azure Cosmos
13
+ * DB for MongoDB vCore), which reports `documentdb_versions` in the `internal`
14
+ * section.
15
+ */
16
+ function isDocumentDbHello(hello: mongo.Document): boolean {
17
+ return hello.internal?.documentdb_versions != null;
18
+ }
19
+
20
+ /**
21
+ * Detect whether the connected server is DocumentDB. DocumentDB lacks usable
22
+ * clusterTime/operationTime and uses the sentinel checkpoint implementation.
23
+ */
24
+ export async function detectDocumentDb(db: mongo.Db): Promise<boolean> {
25
+ const hello = await db.command({ hello: 1 });
26
+ return isDocumentDbHello(hello);
27
+ }
28
+
10
29
  export async function checkSourceConfiguration(connectionManager: MongoManager): Promise<void> {
11
30
  const db = connectionManager.db;
12
31
 
13
32
  const hello = await db.command({ hello: 1 });
14
- if (hello.msg == 'isdbgrid') {
33
+ const isDocumentDb = isDocumentDbHello(hello);
34
+
35
+ if (hello.msg == 'isdbgrid' && !isDocumentDb) {
15
36
  throw new ServiceError(
16
37
  ErrorCode.PSYNC_S1341,
17
38
  'Sharded MongoDB Clusters are not supported yet (including MongoDB Serverless instances).'
18
39
  );
19
- } else if (hello.setName == null) {
40
+ } else if (hello.setName == null && !isDocumentDb) {
20
41
  throw new ServiceError(ErrorCode.PSYNC_S1342, 'Standalone MongoDB instances are not supported - use a replicaset.');
21
42
  }
22
43
 
@@ -1,5 +1,6 @@
1
1
  import type { MongoConnectionParams } from '@powersync/lib-service-mongodb/types';
2
2
  import * as lib_mongo from '@powersync/lib-service-mongodb/types';
3
+ import { ErrorCode, ServiceError } from '@powersync/lib-services-framework';
3
4
  import * as service_types from '@powersync/service-types';
4
5
  import { LookupFunction } from 'node:net';
5
6
  import * as t from 'ts-codec';
@@ -40,6 +41,10 @@ export enum PostImagesOption {
40
41
  READ_ONLY = 'read_only'
41
42
  }
42
43
 
44
+ const DEFAULT_PING_INTERVAL_SECONDS = 60;
45
+ const MIN_PING_INTERVAL_SECONDS = 5;
46
+ const MAX_PING_INTERVAL_SECONDS = 60;
47
+
43
48
  export interface NormalizedMongoConnectionConfig {
44
49
  id: string;
45
50
  tag: string;
@@ -54,13 +59,19 @@ export interface NormalizedMongoConnectionConfig {
54
59
 
55
60
  postImages: PostImagesOption;
56
61
 
62
+ heartbeat_interval_seconds: number;
63
+
57
64
  connectionParams: MongoConnectionParams;
58
65
  }
59
66
 
60
67
  export const MongoConnectionConfig = service_types.configFile.DataSourceConfig.and(lib_mongo.BaseMongoConfig).and(
61
68
  t.object({
62
69
  // Replication specific settings
63
- post_images: t.literal('off').or(t.literal('auto_configure')).or(t.literal('read_only')).optional()
70
+ post_images: t.literal('off').or(t.literal('auto_configure')).or(t.literal('read_only')).optional(),
71
+ /**
72
+ * Interval in seconds between source connection heartbeats. Null or omitted defaults to 60 seconds.
73
+ */
74
+ heartbeat_interval_seconds: t.number.or(t.Null).optional()
64
75
  })
65
76
  );
66
77
 
@@ -87,6 +98,20 @@ export function normalizeConnectionConfig(options: MongoConnectionConfigDecoded)
87
98
  ...base,
88
99
  id: options.id ?? 'default',
89
100
  tag: options.tag ?? 'default',
90
- postImages: (options.post_images as PostImagesOption | undefined) ?? PostImagesOption.OFF
101
+ postImages: (options.post_images as PostImagesOption | undefined) ?? PostImagesOption.OFF,
102
+ heartbeat_interval_seconds: normalizeHeartbeatInterval(options.heartbeat_interval_seconds)
91
103
  };
92
104
  }
105
+
106
+ function normalizeHeartbeatInterval(value: number | null | undefined): number {
107
+ if (value == null) {
108
+ return DEFAULT_PING_INTERVAL_SECONDS;
109
+ }
110
+ if (!Number.isFinite(value) || value < MIN_PING_INTERVAL_SECONDS || value > MAX_PING_INTERVAL_SECONDS) {
111
+ throw new ServiceError(
112
+ ErrorCode.PSYNC_S1109,
113
+ `MongoDB connection: heartbeat_interval_seconds must be between ${MIN_PING_INTERVAL_SECONDS} and ${MAX_PING_INTERVAL_SECONDS} seconds; null or omitted defaults to ${DEFAULT_PING_INTERVAL_SECONDS}`
114
+ );
115
+ }
116
+ return value;
117
+ }
@@ -0,0 +1,115 @@
1
+ # Running Tests Against DocumentDB
2
+
3
+ These instructions cover running the `module-mongodb` test suite against an Azure DocumentDB (formerly Cosmos DB for MongoDB vCore) cluster.
4
+
5
+ ## Prerequisites
6
+
7
+ - An Azure DocumentDB (Cosmos DB for MongoDB vCore) cluster with change stream support
8
+ - Local PostgreSQL for PowerSync's internal storage (not the source database)
9
+ - The connection URI for the DocumentDB cluster
10
+
11
+ > **The open-source `documentdb-local` Docker image cannot be used for these tests.** The
12
+ > open-source DocumentDB engine does not implement change streams (`$changeStream is not
13
+ supported yet in native pipeline`), does not report the `documentdb_versions` `hello` field
14
+ > the suite uses for detection, and presents as `msg: isdbgrid`. An Azure-managed DocumentDB
15
+ > (vCore) cluster is required.
16
+
17
+ ## Environment Variables
18
+
19
+ DocumentDB is detected automatically from the server: the test suite runs
20
+ `detectDocumentDb()` once at startup (see `DatabaseType.ts`) and gates the
21
+ DocumentDB-specific tests on the result. There is no separate enable flag — pointing
22
+ `MONGO_TEST_DATA_URL` at a DocumentDB cluster is what activates the DocumentDB tests.
23
+
24
+ | Variable | Required | Description |
25
+ | ------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
26
+ | `MONGO_TEST_DATA_URL` | Yes | DocumentDB connection URI. Must include a database name in the path (see below). Pointing this at a DocumentDB cluster enables the DocumentDB tests. |
27
+ | `PG_STORAGE_TEST_URL` | No | PostgreSQL connection for PowerSync storage. Defaults to `postgres://postgres:postgres@localhost:5432/powersync_storage_test`. |
28
+ | `TEST_MONGO_STORAGE` | No | Set to `false` to skip MongoDB storage tests. Recommended when testing against DocumentDB to avoid using it as a storage backend. |
29
+ | `TEST_TIMEOUT_MULTIPLIER` | No | Scale factor `testTimeout()` applies to a default timeout on DocumentDB when no explicit cloud override is given. Defaults to **6**. Increase for a slow/remote cluster. |
30
+
31
+ ### Connection URI format
32
+
33
+ The `MONGO_TEST_DATA_URL` must include a database name in the path. DocumentDB URIs typically don't have one, so you need to add it before the query string:
34
+
35
+ ```
36
+ # Original URI (no database):
37
+ mongodb+srv://user:pass@cluster.mongocluster.cosmos.azure.com/
38
+
39
+ # With database added:
40
+ mongodb+srv://user:pass@cluster.mongocluster.cosmos.azure.com/powersync_test
41
+ ```
42
+
43
+ If your password contains special characters (`=`, `@`, `+`, `/`), they must be URL-encoded in the URI (e.g., `=` becomes `%3D`). DocumentDB auto-generated passwords often contain `=` (base64).
44
+
45
+ ## Commands
46
+
47
+ All commands run from the module directory: `modules/module-mongodb/`
48
+
49
+ ```bash
50
+ # Run all DocumentDB tests (integration + unit helpers):
51
+ MONGO_TEST_DATA_URL="mongodb+srv://user:pass@cluster.mongocluster.cosmos.azure.com/powersync_test" \
52
+ TEST_MONGO_STORAGE=false \
53
+ npx vitest run documentdb --reporter=verbose
54
+
55
+ # Run only integration tests:
56
+ MONGO_TEST_DATA_URL="<uri>" \
57
+ TEST_MONGO_STORAGE=false \
58
+ npx vitest run documentdb_mode --reporter=verbose
59
+
60
+ # Run only unit helper tests (no DocumentDB cluster needed):
61
+ npx vitest run documentdb_helpers --reporter=verbose
62
+
63
+ # Run a specific test by name:
64
+ MONGO_TEST_DATA_URL="<uri>" \
65
+ TEST_MONGO_STORAGE=false \
66
+ npx vitest run documentdb_mode -t "resume after restart" --reporter=verbose
67
+ ```
68
+
69
+ If you have the URI in an environment variable (e.g., `$DOCUMENTDB_URI`), you can construct the test URL inline:
70
+
71
+ ```bash
72
+ DOCUMENTDB_TEST_URL=$(echo "$DOCUMENTDB_URI" | sed 's|\?|powersync_test?|')
73
+ MONGO_TEST_DATA_URL="$DOCUMENTDB_TEST_URL" \
74
+ TEST_MONGO_STORAGE=false \
75
+ npx vitest run documentdb --reporter=verbose
76
+ ```
77
+
78
+ ## GitHub Actions
79
+
80
+ The `.github/workflows/documentdb-integration.yml` workflow runs these tests manually via `workflow_dispatch` only. Add a repository or organization secret named `AZURE_DOCUMENTDB_TEST_DATA_URL`, then dispatch the workflow. The workflow maps that secret to the test suite's `MONGO_TEST_DATA_URL` environment variable.
81
+
82
+ The URI must include a database name in the path. The tests clear/drop this database as part of setup, so use a dedicated test database and cluster.
83
+
84
+ The workflow starts a local MongoDB storage service, then runs the complete `modules/module-mongodb` `test` script against that storage backend. `MONGO_TEST_DATA_URL` is the remote DocumentDB source; `MONGO_TEST_URL` points at the local MongoDB storage test instance.
85
+
86
+ ```bash
87
+ TEST_MONGO_STORAGE=true
88
+ TEST_POSTGRES_STORAGE=false
89
+ ```
90
+
91
+ ## Test Files
92
+
93
+ | File | Requires DocumentDB | Description |
94
+ | ---------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
95
+ | `documentdb_mode.test.ts` | Yes | Integration tests: replication, sentinel checkpoints, write checkpoints, keepalive, resume. Skipped automatically unless `MONGO_TEST_DATA_URL` points at a DocumentDB cluster. |
96
+ | `documentdb_helpers.test.ts` | No (1 test needs MongoDB) | Unit tests: `getEventTimestamp`, sentinel parsing/matching, detection logic. Runs against any MongoDB or standalone. |
97
+
98
+ ## What the Integration Tests Cover
99
+
100
+ Each integration test runs against 3 storage versions (v1, v2, v3) = 15 integration tests. Plus 15 unit tests in helpers = 30 total.
101
+
102
+ | Test | What it validates |
103
+ | ----------------------- | ----------------------------------------------------------------------------------------------- |
104
+ | basic replication | Insert, update, delete through change stream with wallTime timestamps |
105
+ | sentinel checkpoint | Checkpoint created with `mode: 'sentinel'`, resolved by matching document content in the stream |
106
+ | keepalive | Stream idles past the keepalive interval without crashing on DocumentDB resume tokens |
107
+ | write checkpoint | Full `createReplicationHead` → sentinel → polling flow for client write consistency |
108
+ | data events not dropped | Verifies `.lte()` dedup guard is skipped — events in the same wall-clock second are not lost |
109
+ | resume after restart | Stop streaming, create new context, resume from stored token |
110
+
111
+ There is also a **characterization test**, `does not report collection drop and rename events`,
112
+ that documents the current limitation: it writes DDL plus a post-DDL marker, waits for the marker
113
+ (proving the stream caught up), then asserts no `drop` / `rename` events were delivered. It passes
114
+ today and **fails if a future DocumentDB engine starts delivering DDL events** — a signal to add
115
+ real drop/rename replication support.
@@ -0,0 +1,25 @@
1
+ import { detectDocumentDb } from '@module/replication/replication-utils.js';
2
+ import { logger } from '@powersync/lib-services-framework';
3
+ import { connectMongoData } from './util.js';
4
+
5
+ export enum DatabaseType {
6
+ DOCUMENTDB = 'DOCUMENTDB',
7
+ MONGODB = 'MONGODB'
8
+ }
9
+
10
+ let _databaseType: DatabaseType = DatabaseType.MONGODB;
11
+
12
+ // Detected once at import time. Close the client afterwards so this detection
13
+ // does not leak a connection: this module is imported by many test files, and
14
+ // the client created here is otherwise never closed, accumulating open handles
15
+ // in Vitest.
16
+ const { client, db } = await connectMongoData();
17
+ try {
18
+ _databaseType = (await detectDocumentDb(db)) ? DatabaseType.DOCUMENTDB : DatabaseType.MONGODB;
19
+ } catch (ex) {
20
+ logger.warn(`Could not determine MongoDB database type`, ex);
21
+ } finally {
22
+ await client.close().catch(() => {});
23
+ }
24
+
25
+ export const DATABASE_TYPE = _databaseType;