@powersync/service-module-mongodb 0.18.2 → 0.19.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 (60) hide show
  1. package/CHANGELOG.md +16 -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/replication/ChangeStream.d.ts +15 -0
  9. package/dist/replication/ChangeStream.js +105 -44
  10. package/dist/replication/ChangeStream.js.map +1 -1
  11. package/dist/replication/MongoRelation.d.ts +36 -1
  12. package/dist/replication/MongoRelation.js +102 -6
  13. package/dist/replication/MongoRelation.js.map +1 -1
  14. package/dist/replication/MongoSnapshotter.d.ts +9 -0
  15. package/dist/replication/MongoSnapshotter.js +108 -42
  16. package/dist/replication/MongoSnapshotter.js.map +1 -1
  17. package/dist/replication/RawChangeStream.d.ts +12 -1
  18. package/dist/replication/RawChangeStream.js +30 -7
  19. package/dist/replication/RawChangeStream.js.map +1 -1
  20. package/dist/replication/checkpoints/CheckpointImplementation.d.ts +132 -0
  21. package/dist/replication/checkpoints/CheckpointImplementation.js +22 -0
  22. package/dist/replication/checkpoints/CheckpointImplementation.js.map +1 -0
  23. package/dist/replication/checkpoints/SentinelCheckpointImplementation.d.ts +55 -0
  24. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js +222 -0
  25. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js.map +1 -0
  26. package/dist/replication/checkpoints/TimestampCheckpointImplementation.d.ts +24 -0
  27. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js +113 -0
  28. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js.map +1 -0
  29. package/dist/replication/checkpoints/create-checkpoint-implementation.d.ts +6 -0
  30. package/dist/replication/checkpoints/create-checkpoint-implementation.js +10 -0
  31. package/dist/replication/checkpoints/create-checkpoint-implementation.js.map +1 -0
  32. package/dist/replication/replication-utils.d.ts +6 -0
  33. package/dist/replication/replication-utils.js +19 -2
  34. package/dist/replication/replication-utils.js.map +1 -1
  35. package/package.json +8 -8
  36. package/src/api/MongoRouteAPIAdapter.ts +26 -37
  37. package/src/common/SentinelLSN.ts +78 -0
  38. package/src/replication/ChangeStream.ts +118 -50
  39. package/src/replication/MongoRelation.ts +124 -14
  40. package/src/replication/MongoSnapshotter.ts +122 -47
  41. package/src/replication/RawChangeStream.ts +64 -28
  42. package/src/replication/checkpoints/CheckpointImplementation.ts +167 -0
  43. package/src/replication/checkpoints/SentinelCheckpointImplementation.ts +267 -0
  44. package/src/replication/checkpoints/TimestampCheckpointImplementation.ts +142 -0
  45. package/src/replication/checkpoints/create-checkpoint-implementation.ts +14 -0
  46. package/src/replication/replication-utils.ts +23 -2
  47. package/test/DOCUMENTDB_TESTING.md +115 -0
  48. package/test/src/DatabaseType.ts +25 -0
  49. package/test/src/change_stream.test.ts +97 -65
  50. package/test/src/change_stream_utils.ts +83 -7
  51. package/test/src/checkpoint_retry.test.ts +5 -2
  52. package/test/src/documentdb_helpers.test.ts +124 -0
  53. package/test/src/documentdb_mode.test.ts +1040 -0
  54. package/test/src/mongo_test.test.ts +15 -5
  55. package/test/src/raw_change_stream.test.ts +209 -125
  56. package/test/src/resume_token.test.ts +30 -0
  57. package/test/src/slow_tests.test.ts +4 -1
  58. package/test/src/test-timeouts.ts +23 -0
  59. package/test/src/util.ts +1 -2
  60. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,1040 @@
1
+ import { setTimeout } from 'node:timers/promises';
2
+ import { describe, expect, test, vi } from 'vitest';
3
+
4
+ import { createWriteCheckpoint, WriteCheckpointBatcher } from '@powersync/service-core';
5
+ import { test_utils } from '@powersync/service-core-tests';
6
+
7
+ import { MongoRouteAPIAdapter } from '@module/api/MongoRouteAPIAdapter.js';
8
+ import { SentinelLSN } from '@module/common/SentinelLSN.js';
9
+ import { createSentinelCheckpointLsn, SENTINEL_CHECKPOINT_ID } from '@module/replication/MongoRelation.js';
10
+ import { CHECKPOINTS_COLLECTION } from '@module/replication/replication-utils.js';
11
+ import { mongo } from '@powersync/lib-service-mongodb';
12
+ import { ChangeStreamTestContext } from './change_stream_utils.js';
13
+ import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
14
+ import { env } from './env.js';
15
+ import { testTimeout } from './test-timeouts.js';
16
+ import { connectMongoData, describeWithStorage, StorageVersionTestContext, TEST_CONNECTION_OPTIONS } from './util.js';
17
+
18
+ const BASIC_SYNC_RULES = `
19
+ bucket_definitions:
20
+ global:
21
+ data:
22
+ - SELECT _id as id, description FROM "test_data"
23
+ `;
24
+
25
+ const DOCUMENTDB_BUCKET_DATA_TIMEOUT = testTimeout(20_000, { cloudOverride: 120_000 });
26
+ const DOCUMENTDB_LARGE_BUCKET_DATA_TIMEOUT = testTimeout(60_000, { cloudOverride: 180_000 });
27
+ const DOCUMENTDB_LARGE_DOCUMENT_TIMEOUT = testTimeout(120_000, { cloudOverride: 360_000 });
28
+ const DOCUMENTDB_LARGE_DOCUMENT_MAX_AWAIT_TIME_MS = testTimeout(10_000, { cloudOverride: 30_000 });
29
+ const DOCUMENTDB_RESTART_TIMEOUT = testTimeout(50_000, { cloudOverride: 120_000 });
30
+
31
+ // These tests require a real DocumentDB cluster. See test/DOCUMENTDB_TESTING.md for setup.
32
+ //
33
+ // Why these can't run against standard MongoDB: the DocumentDB workarounds involve
34
+ // different change stream initialization ordering (lazy ChangeStream + no
35
+ // startAtOperationTime) and wall-clock LSN precision (increment 0 instead of
36
+ // operationTime's real increments). These produce LSN comparison failures when
37
+ // mixed with standard MongoDB's operationTime-based checkpoints. A test flag that
38
+ // partially simulates DocumentDB creates more problems than it solves — see the
39
+ // commit history on the cosmos branch for the full investigation.
40
+ describe.skipIf(DATABASE_TYPE != DatabaseType.DOCUMENTDB)('documentDbMode', () => {
41
+ test('prints hello response and detects DocumentDB', async () => {
42
+ const { client, db } = await connectMongoData();
43
+ try {
44
+ const hello = await db.command({ hello: 1 });
45
+ console.dir({ hello }, { depth: null });
46
+ expect(hello.internal?.documentdb_versions != null).toBe(true);
47
+ } finally {
48
+ await client.close();
49
+ }
50
+ });
51
+
52
+ // Verifies a core assumption of the DocumentDB sentinel LSN design: that
53
+ // `fullDocument` on update events is the write-time post-image, not a
54
+ // read-time lookup of the document's current state.
55
+ //
56
+ // SentinelCheckpointImplementation tracks its position from `fullDocument.i` on
57
+ // updates to the sentinel checkpoint document, and commits LSNs based on
58
+ // that value. With read-time (updateLookup) semantics, a backlogged stream
59
+ // reading an old event would see a *future* counter value, letting the
60
+ // committed LSN run ahead of unprocessed data events — which would allow
61
+ // write checkpoints to resolve before the caller's write has replicated.
62
+ //
63
+ // The test bursts increments while no cursor is reading, then replays the
64
+ // events. Write-time semantics yield one event per increment, each carrying
65
+ // its own value. Read-time semantics yield events all reporting the final
66
+ // value. Collapsed/missing events would indicate DocumentDB coalesces rapid
67
+ // same-document updates, which would be its own significant finding.
68
+ test('fullDocument on update events is the write-time post-image', { timeout: 120_000 }, async () => {
69
+ const { client, db } = await connectMongoData();
70
+ try {
71
+ const collection = db.collection<{ _id: string; i: number }>(CHECKPOINTS_COLLECTION);
72
+ const docId = '_test_fulldoc_semantics';
73
+
74
+ const pipeline = [
75
+ {
76
+ $match: {
77
+ operationType: 'update',
78
+ 'ns.db': db.databaseName,
79
+ 'ns.coll': CHECKPOINTS_COLLECTION,
80
+ 'documentKey._id': docId
81
+ }
82
+ }
83
+ ];
84
+
85
+ // Establish a resume position past the priming writes, so the replay
86
+ // below starts cleanly before the burst.
87
+ //
88
+ // The stream only delivers events written after the cursor is
89
+ // established (DocumentDB has no startAtOperationTime), and the driver opens
90
+ // the cursor lazily on the first read. So keep writing priming
91
+ // increments until the first event comes through — the same approach the
92
+ // production retry loop uses during initial LSN acquisition.
93
+ let resumeToken: unknown;
94
+ const before = client.watch(pipeline, { fullDocument: 'updateLookup', maxAwaitTimeMS: 500 });
95
+ try {
96
+ const deadline = Date.now() + 60_000;
97
+ let lastPrimedI = 0;
98
+ let drainedTo = -1;
99
+ while (drainedTo < 0 && Date.now() < deadline) {
100
+ const result = await collection.findOneAndUpdate(
101
+ { _id: docId },
102
+ { $inc: { i: 1 } },
103
+ { upsert: true, returnDocument: 'after' }
104
+ );
105
+ lastPrimedI = result!.i;
106
+ const event = await before.tryNext();
107
+ if (event != null && 'fullDocument' in event) {
108
+ drainedTo = event.fullDocument?.i ?? -1;
109
+ }
110
+ }
111
+ expect(drainedTo, 'change stream never delivered the priming events').toBeGreaterThan(0);
112
+
113
+ // Drain the remaining priming events so they don't leak into the
114
+ // replay below.
115
+ while (drainedTo < lastPrimedI && Date.now() < deadline) {
116
+ const event = await before.tryNext();
117
+ if (event != null && 'fullDocument' in event) {
118
+ drainedTo = event.fullDocument?.i ?? drainedTo;
119
+ }
120
+ }
121
+ expect(drainedTo, 'timed out draining priming events').toEqual(lastPrimedI);
122
+
123
+ resumeToken = before.resumeToken;
124
+ expect(resumeToken).toBeTruthy();
125
+ } finally {
126
+ await before.close();
127
+ }
128
+
129
+ // Burst increments with no cursor reading. All writes complete before
130
+ // any event is fetched, so a read-time lookup cannot accidentally
131
+ // return event-time values.
132
+ const expected: number[] = [];
133
+ for (let k = 0; k < 5; k++) {
134
+ const result = await collection.findOneAndUpdate(
135
+ { _id: docId },
136
+ { $inc: { i: 1 } },
137
+ { upsert: true, returnDocument: 'after' }
138
+ );
139
+ expected.push(result!.i);
140
+ }
141
+
142
+ // Replay the burst and record the value each event carries.
143
+ const seen: number[] = [];
144
+ const after = client.watch(pipeline, {
145
+ fullDocument: 'updateLookup',
146
+ maxAwaitTimeMS: 500,
147
+ resumeAfter: resumeToken
148
+ });
149
+ try {
150
+ const start = Date.now();
151
+ const deadline = start + 60_000;
152
+ while (seen.length < expected.length && Date.now() < deadline) {
153
+ const event = await after.tryNext();
154
+ if (event != null && 'fullDocument' in event) {
155
+ seen.push(event.fullDocument?.i);
156
+ }
157
+ }
158
+ } finally {
159
+ await after.close();
160
+ }
161
+
162
+ expect(seen).toEqual(expected);
163
+ } finally {
164
+ await client.close();
165
+ }
166
+ });
167
+
168
+ // Empirically probe whether DocumentDB delivers change events for *different*
169
+ // documents in the order they were written.
170
+ //
171
+ // DocumentDB only guarantees change order per shard key (RU docs) / nothing
172
+ // documented (vCore) — not globally. The sentinel write-checkpoint design
173
+ // assumes no cross-document order: a write checkpoint head is the
174
+ // `_sentinel_checkpoint` counter, and the caller's data write lives in a
175
+ // different document. If the sentinel event can be delivered *before* the
176
+ // data write that preceded it, a write checkpoint can resolve before its data
177
+ // has replicated (see docs/documentdb/documentdb-lsn-sentinel-checkpoints.md#change-ordering).
178
+ //
179
+ // This reproduces that exact scenario using the real checkpointing write path
180
+ // (createSentinelCheckpointLsn): each round writes a data document, then bumps
181
+ // the sentinel counter, and we record the delivery order off a single
182
+ // cluster-level change stream.
183
+ //
184
+ // We do NOT assert on cross-document order (delivery order is not contractual
185
+ // — a single-shard cluster may happen to preserve it); the finding is logged
186
+ // so we can document the cluster's observed behaviour. We DO assert that
187
+ // same-document order is preserved, since sentinel `i` matching relies on it.
188
+ test.skipIf(!env.SLOW_TESTS)('change stream delivery order across documents', { timeout: 30_000 }, async () => {
189
+ const { client, db } = await connectMongoData();
190
+ const ROUNDS = 20;
191
+ const dataColl = 'order_probe_data';
192
+ const dataId = '_order_probe_doc';
193
+ try {
194
+ const data = db.collection<{ _id: string; seq: number }>(dataColl);
195
+ await data.deleteMany({});
196
+
197
+ // Watch the whole database; classify events by documentKey in code.
198
+ const pipeline = [
199
+ {
200
+ $match: {
201
+ operationType: { $in: ['insert', 'update', 'replace'] },
202
+ 'ns.db': db.databaseName
203
+ }
204
+ }
205
+ ];
206
+
207
+ const cursor = client.watch(pipeline, { fullDocument: 'updateLookup', maxAwaitTimeMS: 500 });
208
+ try {
209
+ // Prime the cursor (DocumentDB has no startAtOperationTime, and the driver
210
+ // opens the cursor lazily) by writing the data doc with descending
211
+ // negative seqs until an event comes through, then drain the backlog.
212
+ const primeDeadline = Date.now() + 60_000;
213
+ let primed = false;
214
+ let primeSeq = 0;
215
+ while (!primed && Date.now() < primeDeadline) {
216
+ primeSeq -= 1;
217
+ await data.updateOne({ _id: dataId }, { $set: { seq: primeSeq } }, { upsert: true });
218
+ if ((await cursor.tryNext()) != null) {
219
+ primed = true;
220
+ }
221
+ }
222
+ expect(primed, 'change stream cursor never went live').toBe(true);
223
+ while ((await cursor.tryNext()) != null) {
224
+ // drain remaining priming events
225
+ }
226
+
227
+ // Issue interleaved writes: data(round) then sentinel bump, per round.
228
+ type Issued = { label: string; kind: 'data' | 'sentinel'; round: number; sentinel?: bigint };
229
+ const issued: Issued[] = [];
230
+ const sentinelValueToRound = new Map<string, number>();
231
+ for (let round = 1; round <= ROUNDS; round++) {
232
+ await data.updateOne({ _id: dataId }, { $set: { seq: round } });
233
+ issued.push({ label: `D${round}`, kind: 'data', round });
234
+
235
+ const sentinel = SentinelLSN.fromSerialized(await createSentinelCheckpointLsn(client, db)).sentinel;
236
+ issued.push({ label: `S${round}`, kind: 'sentinel', round, sentinel });
237
+ sentinelValueToRound.set(sentinel.toString(), round);
238
+ }
239
+
240
+ // Collect the delivered events in arrival order.
241
+ const arrivals: { label: string; kind: 'data' | 'sentinel'; value: number | bigint }[] = [];
242
+ const collectDeadline = Date.now() + 90_000;
243
+ while (arrivals.length < issued.length && Date.now() < collectDeadline) {
244
+ const ev = await cursor.tryNext();
245
+ if (ev == null || !('documentKey' in ev) || !('fullDocument' in ev) || ev.fullDocument == null) {
246
+ continue;
247
+ }
248
+ const id = String(ev.documentKey._id);
249
+ if (id === dataId) {
250
+ const seq = (ev.fullDocument as any).seq as number;
251
+ if (seq >= 1) {
252
+ arrivals.push({ label: `D${seq}`, kind: 'data', value: seq });
253
+ }
254
+ } else if (id === SENTINEL_CHECKPOINT_ID) {
255
+ const value = BigInt((ev.fullDocument as any).i);
256
+ const round = sentinelValueToRound.get(value.toString());
257
+ if (round != null) {
258
+ arrivals.push({ label: `S${round}`, kind: 'sentinel', value });
259
+ }
260
+ }
261
+ }
262
+
263
+ // --- Analysis ---
264
+ const dataArrivals = arrivals.filter((a) => a.kind === 'data').map((a) => a.value as number);
265
+ const sentinelArrivals = arrivals.filter((a) => a.kind === 'sentinel').map((a) => a.value as bigint);
266
+ const dataOrderPreserved = dataArrivals.every((v, i) => i === 0 || v > dataArrivals[i - 1]);
267
+ const sentinelOrderPreserved = sentinelArrivals.every((v, i) => i === 0 || v > sentinelArrivals[i - 1]);
268
+
269
+ // General cross-document inversions: any event delivered out of global issue order.
270
+ const issueIndex = new Map(issued.map((it, i) => [it.label, i] as const));
271
+ const crossDocumentInversions: { afterDelivering: string; received: string }[] = [];
272
+ for (let i = 1; i < arrivals.length; i++) {
273
+ if (issueIndex.get(arrivals[i].label)! < issueIndex.get(arrivals[i - 1].label)!) {
274
+ crossDocumentInversions.push({ afterDelivering: arrivals[i - 1].label, received: arrivals[i].label });
275
+ }
276
+ }
277
+
278
+ // Bug-specific signal: a sentinel bump overtaking the data write that
279
+ // preceded it in the same round (write checkpoint resolves too early).
280
+ const sentinelOvertakingItsDataWrite: number[] = [];
281
+ for (let round = 1; round <= ROUNDS; round++) {
282
+ const dIdx = arrivals.findIndex((a) => a.label === `D${round}`);
283
+ const sIdx = arrivals.findIndex((a) => a.label === `S${round}`);
284
+ if (dIdx >= 0 && sIdx >= 0 && sIdx < dIdx) {
285
+ sentinelOvertakingItsDataWrite.push(round);
286
+ }
287
+ }
288
+
289
+ console.dir(
290
+ {
291
+ crossDocumentOrderingProbe: {
292
+ rounds: ROUNDS,
293
+ issued: issued.length,
294
+ arrived: arrivals.length,
295
+ deliveryOrder: arrivals.map((a) => a.label),
296
+ dataOrderPreserved,
297
+ sentinelOrderPreserved,
298
+ crossDocumentInversions,
299
+ sentinelOvertakingItsDataWrite
300
+ }
301
+ },
302
+ { depth: null }
303
+ );
304
+
305
+ // We expect to receive every issued event within the deadline.
306
+ expect(arrivals.length, 'did not receive all issued events before the deadline').toBe(issued.length);
307
+ // Per-document ordering is the one guarantee DocumentDB documents — assert it.
308
+ expect(dataOrderPreserved, 'data-document events arrived out of order').toBe(true);
309
+ expect(sentinelOrderPreserved, 'sentinel-document events arrived out of order').toBe(true);
310
+ // Cross-document order is intentionally NOT asserted; inversions (if any)
311
+ // are logged above as the finding.
312
+ } finally {
313
+ await cursor.close();
314
+ }
315
+ await data.deleteMany({});
316
+ } finally {
317
+ await client.close();
318
+ }
319
+ });
320
+
321
+ // Follow-up to the probe above. The sequential probe preserves order on a
322
+ // single-shard cluster, where there is effectively one ordered feed. This
323
+ // variant adds a concurrent write storm across many other documents and a much
324
+ // higher round count, to test whether a single-shard cluster has *internal*
325
+ // sub-partitions whose feeds can interleave out of order under load.
326
+ //
327
+ // The measured D→S pairs are still issued strictly sequentially (so they keep a
328
+ // well-defined happens-before order to violate); the background writers only
329
+ // generate feed pressure. Same reporting; cross-document order is logged, not
330
+ // asserted.
331
+ test.skipIf(!env.SLOW_TESTS)(
332
+ 'change stream delivery order across documents — under concurrent load',
333
+ { timeout: 240_000 },
334
+ async () => {
335
+ const { client, db } = await connectMongoData();
336
+ const ROUNDS = 100;
337
+ const BACKGROUND_WRITERS = 6;
338
+ const dataColl = 'order_probe_data';
339
+ const dataId = '_order_probe_doc';
340
+ const backgroundCollections = Array.from({ length: BACKGROUND_WRITERS }, (_, w) => `order_probe_bg_${w}`);
341
+ try {
342
+ const data = db.collection<{ _id: string; seq: number }>(dataColl);
343
+ await data.deleteMany({});
344
+
345
+ // Only deliver the two measured namespaces, so background churn does not
346
+ // flood the cursor we are measuring.
347
+ const pipeline = [
348
+ {
349
+ $match: {
350
+ operationType: { $in: ['insert', 'update', 'replace'] },
351
+ 'ns.db': db.databaseName,
352
+ 'ns.coll': { $in: [dataColl, CHECKPOINTS_COLLECTION] }
353
+ }
354
+ }
355
+ ];
356
+ const cursor = client.watch(pipeline, { fullDocument: 'updateLookup', maxAwaitTimeMS: 500 });
357
+
358
+ // Start the background write storm: 6 loops, each with one write in flight,
359
+ // churning 100 rotating documents per collection.
360
+ let stopBackground = false;
361
+ const background = backgroundCollections.map((name) =>
362
+ (async () => {
363
+ const coll = db.collection<{ _id: string; n: number }>(name);
364
+ let n = 0;
365
+ while (!stopBackground) {
366
+ n += 1;
367
+ await coll.updateOne({ _id: `bg_${n % 100}` }, { $set: { n } }, { upsert: true }).catch(() => {});
368
+ }
369
+ })()
370
+ );
371
+
372
+ try {
373
+ // Prime the cursor.
374
+ const primeDeadline = Date.now() + 60_000;
375
+ let primed = false;
376
+ let primeSeq = 0;
377
+ while (!primed && Date.now() < primeDeadline) {
378
+ primeSeq -= 1;
379
+ await data.updateOne({ _id: dataId }, { $set: { seq: primeSeq } }, { upsert: true });
380
+ if ((await cursor.tryNext()) != null) {
381
+ primed = true;
382
+ }
383
+ }
384
+ expect(primed, 'change stream cursor never went live').toBe(true);
385
+ while ((await cursor.tryNext()) != null) {
386
+ // drain priming events
387
+ }
388
+
389
+ // Measured sequential D→S pairs.
390
+ const issued: { label: string; round: number }[] = [];
391
+ const sentinelValueToRound = new Map<string, number>();
392
+ for (let round = 1; round <= ROUNDS; round++) {
393
+ await data.updateOne({ _id: dataId }, { $set: { seq: round } });
394
+ issued.push({ label: `D${round}`, round });
395
+ const sentinel = SentinelLSN.fromSerialized(await createSentinelCheckpointLsn(client, db)).sentinel;
396
+ issued.push({ label: `S${round}`, round });
397
+ sentinelValueToRound.set(sentinel.toString(), round);
398
+ }
399
+
400
+ // Collect.
401
+ const arrivals: string[] = [];
402
+ const expectedLabels = new Set(issued.map((i) => i.label));
403
+ const collectDeadline = Date.now() + 120_000;
404
+ while (arrivals.length < issued.length && Date.now() < collectDeadline) {
405
+ const ev = await cursor.tryNext();
406
+ if (ev == null || !('documentKey' in ev) || !('fullDocument' in ev) || ev.fullDocument == null) {
407
+ continue;
408
+ }
409
+ const id = String(ev.documentKey._id);
410
+ let label: string | null = null;
411
+ if (id === dataId) {
412
+ const seq = (ev.fullDocument as any).seq as number;
413
+ if (seq >= 1) {
414
+ label = `D${seq}`;
415
+ }
416
+ } else if (id === SENTINEL_CHECKPOINT_ID) {
417
+ const round = sentinelValueToRound.get(BigInt((ev.fullDocument as any).i).toString());
418
+ if (round != null) {
419
+ label = `S${round}`;
420
+ }
421
+ }
422
+ if (label != null && expectedLabels.has(label)) {
423
+ arrivals.push(label);
424
+ }
425
+ }
426
+
427
+ // Analysis (same as the sequential probe).
428
+ const issueIndex = new Map(issued.map((it, i) => [it.label, i] as const));
429
+ const crossDocumentInversions: { afterDelivering: string; received: string }[] = [];
430
+ for (let i = 1; i < arrivals.length; i++) {
431
+ if (issueIndex.get(arrivals[i])! < issueIndex.get(arrivals[i - 1])!) {
432
+ crossDocumentInversions.push({ afterDelivering: arrivals[i - 1], received: arrivals[i] });
433
+ }
434
+ }
435
+ const sentinelOvertakingItsDataWrite: number[] = [];
436
+ for (let round = 1; round <= ROUNDS; round++) {
437
+ const dIdx = arrivals.indexOf(`D${round}`);
438
+ const sIdx = arrivals.indexOf(`S${round}`);
439
+ if (dIdx >= 0 && sIdx >= 0 && sIdx < dIdx) {
440
+ sentinelOvertakingItsDataWrite.push(round);
441
+ }
442
+ }
443
+
444
+ console.dir(
445
+ {
446
+ crossDocumentOrderingUnderLoad: {
447
+ rounds: ROUNDS,
448
+ backgroundWriters: BACKGROUND_WRITERS,
449
+ issued: issued.length,
450
+ arrived: arrivals.length,
451
+ crossDocumentInversions,
452
+ sentinelOvertakingItsDataWrite
453
+ }
454
+ },
455
+ { depth: null }
456
+ );
457
+
458
+ expect(arrivals.length, 'did not receive all issued events before the deadline').toBe(issued.length);
459
+ } finally {
460
+ stopBackground = true;
461
+ await Promise.allSettled(background);
462
+ await cursor.close();
463
+ }
464
+
465
+ await data.deleteMany({});
466
+ for (const name of backgroundCollections) {
467
+ await db
468
+ .collection(name)
469
+ .drop()
470
+ .catch(() => {});
471
+ }
472
+ } finally {
473
+ await client.close();
474
+ }
475
+ }
476
+ );
477
+
478
+ // Characterize whether DocumentDB's change stream reports collection `drop` and
479
+ // `rename` DDL events. PowerSync does not currently replicate these on
480
+ // DocumentDB (see docs/documentdb/documentdb-limitations.md, "Collection drop
481
+ // and rename are not replicated") — they are delivered differently, or not at
482
+ // all, through the cluster-level change stream.
483
+ //
484
+ // After the DDL, a normal insert (the "marker") is written and we wait for its
485
+ // event to arrive. That proves the stream is live and has caught up past the
486
+ // DDL, so the absence of `drop`/`rename` events is genuine, not just
487
+ // not-waited-long-enough. The test then asserts those events are NOT delivered,
488
+ // documenting the current limitation. If a future DocumentDB engine starts
489
+ // delivering them, this test fails — a signal to add real drop/rename support.
490
+ test('does not report collection drop and rename events', { timeout: 120_000 }, async () => {
491
+ const { client, db } = await connectMongoData();
492
+ const dropColl = 'ddl_probe_drop';
493
+ const renameSrc = 'ddl_probe_rename_src';
494
+ const renameDst = 'ddl_probe_rename_dst';
495
+ const markerColl = 'ddl_probe_marker';
496
+ const markerId = 'ddl_probe_marker_doc';
497
+ const cleanup = async () => {
498
+ for (const c of [dropColl, renameSrc, renameDst, markerColl]) {
499
+ await db
500
+ .collection(c)
501
+ .drop()
502
+ .catch(() => {});
503
+ }
504
+ };
505
+ try {
506
+ await cleanup();
507
+
508
+ // Cluster-level stream (DocumentDB only supports cluster-level), watching
509
+ // the whole database so DDL events on any collection are visible.
510
+ const pipeline = [{ $match: { 'ns.db': db.databaseName } }];
511
+ const cursor = client.watch(pipeline, { fullDocument: 'updateLookup', maxAwaitTimeMS: 500 });
512
+ try {
513
+ // Prime the cursor so it is live before the DDL operations.
514
+ const primeDeadline = Date.now() + 60_000;
515
+ let primed = false;
516
+ let n = 0;
517
+ while (!primed && Date.now() < primeDeadline) {
518
+ n += 1;
519
+ await db.collection(renameSrc).updateOne({ _id: 'prime' as any }, { $set: { n } }, { upsert: true });
520
+ if ((await cursor.tryNext()) != null) {
521
+ primed = true;
522
+ }
523
+ }
524
+ expect(primed, 'change stream cursor never went live').toBe(true);
525
+ while ((await cursor.tryNext()) != null) {
526
+ // drain priming events
527
+ }
528
+
529
+ // Perform the DDL: drop one collection, rename another.
530
+ await db.collection(dropColl).insertOne({ x: 1 });
531
+ await db.collection(dropColl).drop();
532
+ await client.db('admin').command({
533
+ renameCollection: `${db.databaseName}.${renameSrc}`,
534
+ to: `${db.databaseName}.${renameDst}`
535
+ });
536
+
537
+ // Marker: a normal insert issued *after* the DDL. When its event is
538
+ // delivered, the stream has provably caught up past the DDL operations.
539
+ await db.collection(markerColl).insertOne({ _id: markerId as any });
540
+
541
+ // Collect events until the marker event arrives (the "caught up" signal).
542
+ const seen: string[] = [];
543
+ let markerSeen = false;
544
+ const deadline = Date.now() + 30_000;
545
+ while (!markerSeen && Date.now() < deadline) {
546
+ const ev = await cursor.tryNext();
547
+ if (ev == null) {
548
+ continue;
549
+ }
550
+ if (ev.operationType === 'insert' && 'documentKey' in ev && String(ev.documentKey._id) === markerId) {
551
+ markerSeen = true;
552
+ break;
553
+ }
554
+ seen.push(ev.operationType);
555
+ }
556
+ // Drain anything already buffered after the marker, in case a DDL event
557
+ // was delivered slightly out of order behind it.
558
+ let trailing: Awaited<ReturnType<typeof cursor.tryNext>>;
559
+ while ((trailing = await cursor.tryNext()) != null) {
560
+ seen.push(trailing.operationType);
561
+ }
562
+
563
+ console.dir({ ddlEventProbe: { observedOperationTypes: [...new Set(seen)], markerSeen } }, { depth: null });
564
+
565
+ // The marker proves the stream is live and has reached past the DDL, so
566
+ // the absence of DDL events below is meaningful.
567
+ expect(markerSeen, 'post-DDL marker event was never delivered — the change stream stalled').toBe(true);
568
+ // Documents the current limitation. If either fails, DocumentDB has
569
+ // started delivering DDL events — add drop/rename support and flip these.
570
+ expect(seen, '`drop` event was delivered — DocumentDB may now support DDL events').not.toContain('drop');
571
+ expect(seen, '`rename` event was delivered — DocumentDB may now support DDL events').not.toContain('rename');
572
+ } finally {
573
+ await cursor.close();
574
+ }
575
+ } finally {
576
+ await cleanup();
577
+ await client.close();
578
+ }
579
+ });
580
+
581
+ // Remote DocumentDB clusters can have 10-30s latency spikes for change
582
+ // stream delivery. Tests that poll for data need headroom.
583
+ describeWithStorage({ timeout: testTimeout(120_000, { cloudOverride: 180_000 }) }, defineDocumentDBDbModeTests);
584
+ });
585
+
586
+ function defineDocumentDBDbModeTests({ factory, storageVersion }: StorageVersionTestContext) {
587
+ const openContext = (options?: Parameters<typeof ChangeStreamTestContext.open>[1]) => {
588
+ return ChangeStreamTestContext.open(factory, {
589
+ ...options,
590
+ storageVersion,
591
+ streamOptions: {
592
+ ...options?.streamOptions
593
+ }
594
+ });
595
+ };
596
+
597
+ test('basic replication in documentDbMode', async () => {
598
+ await using context = await openContext();
599
+ const { db } = context;
600
+ await context.updateSyncRules(`
601
+ bucket_definitions:
602
+ global:
603
+ data:
604
+ - SELECT _id as id, description FROM "test_data"`);
605
+
606
+ await db.createCollection('test_data');
607
+ const collection = db.collection('test_data');
608
+
609
+ await context.replicateSnapshot();
610
+ context.startStreaming();
611
+
612
+ const result = await collection.insertOne({ description: 'test1' });
613
+ const test_id = result.insertedId;
614
+ await collection.updateOne({ _id: test_id }, { $set: { description: 'test2' } });
615
+ await collection.deleteOne({ _id: test_id });
616
+
617
+ const data = await context.getBucketData('global[]', undefined, { timeout: DOCUMENTDB_BUCKET_DATA_TIMEOUT });
618
+
619
+ expect(data).toMatchObject([
620
+ test_utils.putOp('test_data', { id: test_id.toHexString(), description: 'test1' }),
621
+ test_utils.putOp('test_data', { id: test_id.toHexString(), description: 'test2' }),
622
+ test_utils.removeOp('test_data', test_id.toHexString())
623
+ ]);
624
+ });
625
+
626
+ test('sentinel checkpoint resolution', async () => {
627
+ await using context = await openContext();
628
+ const { db } = context;
629
+ await context.updateSyncRules(`
630
+ bucket_definitions:
631
+ global:
632
+ data:
633
+ - SELECT _id as id, description FROM "test_data"`);
634
+
635
+ await db.createCollection('test_data');
636
+ const collection = db.collection('test_data');
637
+
638
+ await context.replicateSnapshot();
639
+ context.startStreaming();
640
+
641
+ const insertResult = await collection.insertOne({ description: 'sentinel_test' });
642
+ const insertedId = insertResult.insertedId.toHexString();
643
+
644
+ // getCheckpoint() internally calls createCheckpoint, which should return a sentinel
645
+ // format on DocumentDB. The streaming loop must resolve it by matching the sentinel event.
646
+ const checkpoint = await context.getCheckpoint();
647
+ expect(checkpoint).toBeTruthy();
648
+
649
+ const data = await context.getBucketData('global[]', undefined, { timeout: DOCUMENTDB_BUCKET_DATA_TIMEOUT });
650
+ expect(data).toMatchObject([test_utils.putOp('test_data', { id: insertedId, description: 'sentinel_test' })]);
651
+ });
652
+
653
+ // DocumentDB does not support $changeStreamSplitLargeEvent, so large change
654
+ // events cannot be split into fragments the way the standard MongoDB path
655
+ // does. This verifies that a large document still replicates end-to-end —
656
+ // both on insert (the event carries the full document) and on update
657
+ // (updateLookup fetches the full document into the event) — and that the
658
+ // large value is persisted through to bucket storage, not just delivered on
659
+ // the change stream.
660
+ //
661
+ // The payload is sized just under PowerSync's MAX_ROW_SIZE (15 MiB). Rows at
662
+ // or above that limit are dropped by bucket storage regardless of source DB
663
+ // ("Row too large ... Removing"), so the persisted value cannot approach the
664
+ // 16 MiB BSON document limit. 14 MiB still forces a large change event
665
+ // through the DocumentDB stream while keeping the projected row under the limit.
666
+ // Assertions check the payload length rather than inlining a 14MB string.
667
+ // Note: DocumentDB delivers large change events very slowly (observed ~18s to fetch a
668
+ // single ~14 MiB event), so this test allows generous checkpoint timeouts. See
669
+ // docs/documentdb/documentdb-limitations.md.
670
+ test(
671
+ 'replicates a large document near the row size limit',
672
+ // Fetching the large row takes very long in DocumentDB cloud.
673
+ { timeout: DOCUMENTDB_LARGE_DOCUMENT_TIMEOUT },
674
+ async () => {
675
+ await using context = await openContext({
676
+ streamOptions: {
677
+ // The test context normally uses 200ms so local MongoDB streams abort
678
+ // quickly, but DocumentDB needs a realistic getMore maxTimeMS for
679
+ // intentionally large change events.
680
+ maxAwaitTimeMS: DOCUMENTDB_LARGE_DOCUMENT_MAX_AWAIT_TIME_MS
681
+ }
682
+ });
683
+ const { db } = context;
684
+ await context.updateSyncRules(`
685
+ bucket_definitions:
686
+ global:
687
+ data:
688
+ - SELECT _id as id, marker, payload FROM "test_data"`);
689
+
690
+ await db.createCollection('test_data');
691
+ const collection = db.collection('test_data');
692
+
693
+ await context.replicateSnapshot();
694
+ context.startStreaming();
695
+
696
+ // 14 MiB payload — large enough to exercise the large-event path, but the
697
+ // projected row (payload + small envelope) stays under MAX_ROW_SIZE so it
698
+ // is not dropped by bucket storage.
699
+ const largePayload = 'x'.repeat(14 * 1024 * 1024);
700
+
701
+ const insertResult = await collection.insertOne({ marker: 'big_insert', payload: largePayload });
702
+ const id = insertResult.insertedId.toHexString();
703
+
704
+ // Large events are slow to fetch on DocumentDB, so allow well beyond the 15s default.
705
+ const afterInsert = await context.getBucketData('global[]', undefined, {
706
+ timeout: DOCUMENTDB_LARGE_BUCKET_DATA_TIMEOUT
707
+ });
708
+ expect(afterInsert.length).toEqual(1);
709
+ const insertData = JSON.parse(afterInsert[0].data as string);
710
+ expect(insertData).toMatchObject({ id, marker: 'big_insert' });
711
+ expect(insertData.payload.length).toEqual(largePayload.length);
712
+
713
+ // Update a small field; the payload stays large, so the updateLookup
714
+ // fullDocument on the change event is still ~15MB.
715
+ await collection.updateOne({ _id: insertResult.insertedId }, { $set: { marker: 'big_update' } });
716
+
717
+ // Fetching the large row takes very long in DocumentDB cloud.
718
+ const afterUpdate = await context.getBucketData('global[]', undefined, {
719
+ timeout: DOCUMENTDB_LARGE_BUCKET_DATA_TIMEOUT
720
+ });
721
+ expect(afterUpdate.length).toEqual(2);
722
+ const updateData = JSON.parse(afterUpdate[1].data as string);
723
+ expect(updateData).toMatchObject({ id, marker: 'big_update' });
724
+ expect(updateData.payload.length).toEqual(largePayload.length);
725
+ }
726
+ );
727
+
728
+ test('keepalive in documentDbMode', async () => {
729
+ await using context = await openContext({
730
+ streamOptions: {
731
+ keepaliveIntervalMs: 2000
732
+ }
733
+ });
734
+ const { db } = context;
735
+ await context.updateSyncRules(BASIC_SYNC_RULES);
736
+
737
+ await db.createCollection('test_data');
738
+
739
+ await context.replicateSnapshot();
740
+ context.startStreaming();
741
+
742
+ // Wait for the initial checkpoint to be processed
743
+ await context.getCheckpoint({ timeout: DOCUMENTDB_BUCKET_DATA_TIMEOUT });
744
+
745
+ // Wait past the keepalive interval so the idle keepalive path fires.
746
+ // On DocumentDB, this must NOT crash from parseResumeTokenTimestamp
747
+ // (DocumentDB resume tokens are base64, not hex).
748
+ await setTimeout(3000);
749
+
750
+ // Insert data after the keepalive interval to verify the stream is still alive
751
+ const collection = db.collection('test_data');
752
+ await collection.insertOne({ description: 'after_keepalive' });
753
+
754
+ const data = await context.getBucketData('global[]', undefined, {
755
+ timeout: DOCUMENTDB_BUCKET_DATA_TIMEOUT
756
+ });
757
+ expect(data.length).toBeGreaterThanOrEqual(1);
758
+ const lastOp = data[data.length - 1];
759
+ expect(JSON.parse(lastOp.data as string)).toMatchObject({ description: 'after_keepalive' });
760
+ });
761
+
762
+ // Skipped until Azure DocumentDB ships the server-side getMore maxAwaitTimeMS fix.
763
+ test.skip('respects maxAwaitTimeMS for idle getMore calls in documentDbMode', async () => {
764
+ const maxAwaitTimeMS = 2_000;
765
+
766
+ await using context = await openContext({
767
+ streamOptions: {
768
+ maxAwaitTimeMS
769
+ }
770
+ });
771
+
772
+ // DocumentDB uses a cluster-level change stream through client.db('admin'), so
773
+ // spying on only context.db.command would miss the getMore calls. Spying on
774
+ // the Db prototype captures command calls from both the test DB and admin DB
775
+ // while still delegating to the real MongoDB driver implementation.
776
+ const dbPrototype = Object.getPrototypeOf(context.db);
777
+ const originalCommand = dbPrototype.command;
778
+ const getMoreTimings: {
779
+ collection: unknown;
780
+ maxTimeMS: unknown;
781
+ durationMS: number;
782
+ nextBatchLength: number | undefined;
783
+ }[] = [];
784
+ const commandSpy = vi.spyOn(dbPrototype, 'command').mockImplementation(async function (
785
+ this: unknown,
786
+ command: any,
787
+ options?: any
788
+ ) {
789
+ if (command?.getMore == null) {
790
+ return originalCommand.call(this, command, options);
791
+ }
792
+
793
+ // Measure the actual round-trip duration of the driver's getMore command.
794
+ // This verifies the server waits for maxTimeMS when the change stream is
795
+ // idle, rather than returning empty batches immediately.
796
+ const start = performance.now();
797
+ let result: any;
798
+ try {
799
+ result = await originalCommand.call(this, command, options);
800
+ return result;
801
+ } finally {
802
+ const cursor = Buffer.isBuffer(result?.cursor)
803
+ ? mongo.BSON.deserialize(result.cursor, {
804
+ useBigInt64: true,
805
+ fieldsAsRaw: { nextBatch: true },
806
+ validation: { utf8: false }
807
+ })
808
+ : result?.cursor;
809
+
810
+ getMoreTimings.push({
811
+ collection: command.collection,
812
+ maxTimeMS: command.maxTimeMS,
813
+ durationMS: Math.round(performance.now() - start),
814
+ nextBatchLength: cursor?.nextBatch?.length
815
+ });
816
+ }
817
+ });
818
+
819
+ try {
820
+ const { db } = context;
821
+ await context.updateSyncRules(BASIC_SYNC_RULES);
822
+
823
+ await db.createCollection('test_data');
824
+ const collection = db.collection('test_data');
825
+
826
+ await context.replicateSnapshot();
827
+ context.startStreaming();
828
+
829
+ const result = await collection.insertOne({ description: 'maxAwaitTimeMS_test' });
830
+ await context.getBucketData('global[]');
831
+
832
+ // Once the stream has caught up, the latest getMore call should eventually
833
+ // be an idle poll. That idle poll should wait for maxTimeMS instead of
834
+ // returning immediately, and the response should contain an empty batch.
835
+ await vi.waitFor(
836
+ () => {
837
+ const lastGetMore = getMoreTimings.at(-1);
838
+ expect(lastGetMore?.durationMS).toBeGreaterThanOrEqual(maxAwaitTimeMS);
839
+ expect(lastGetMore?.nextBatchLength).toEqual(0);
840
+ },
841
+ {
842
+ timeout: maxAwaitTimeMS + 2_000,
843
+ interval: 100
844
+ }
845
+ );
846
+
847
+ console.dir({ getMoreMaxAwaitTimeMSTimings: getMoreTimings }, { depth: null });
848
+
849
+ expect(result.insertedId).toBeTruthy();
850
+ expect(getMoreTimings.length).toBeGreaterThan(0);
851
+ } finally {
852
+ commandSpy.mockRestore();
853
+ }
854
+ });
855
+
856
+ test('write checkpoint flow in documentDbMode', async () => {
857
+ await using context = await openContext();
858
+ const { db } = context;
859
+ await context.updateSyncRules(BASIC_SYNC_RULES);
860
+
861
+ await db.createCollection('test_data');
862
+ const collection = db.collection('test_data');
863
+
864
+ await context.replicateSnapshot();
865
+ await context.markSnapshotConsistent();
866
+
867
+ await using api = new MongoRouteAPIAdapter({
868
+ type: 'mongodb',
869
+ ...TEST_CONNECTION_OPTIONS
870
+ });
871
+
872
+ context.startStreaming();
873
+
874
+ // Wait until stream is active
875
+ await context.getCheckpoint();
876
+
877
+ // Insert data so the stream has something to process
878
+ await collection.insertOne({ description: 'write_cp_test' });
879
+
880
+ const writeCheckpointBatcher = new WriteCheckpointBatcher(
881
+ () => api,
882
+ () => context.factory
883
+ );
884
+
885
+ // Exercise the write checkpoint flow. On DocumentDB, createReplicationHead
886
+ // advances the sentinel counter with a standalone (null stream_id) bump and
887
+ // uses the resulting sentinel-based LSN as the source-side head. The sentinel
888
+ // write also nudges replication forward on an idle stream.
889
+ const result = await createWriteCheckpoint({
890
+ userId: 'test_user',
891
+ clientId: 'test_client',
892
+ batcher: writeCheckpointBatcher
893
+ });
894
+
895
+ // The write checkpoint should resolve with a valid result
896
+ expect(result).toBeTruthy();
897
+ expect(result.writeCheckpoint).toBeTruthy();
898
+ expect(result.replicationHead).toBeTruthy();
899
+ });
900
+
901
+ test('data events not dropped after restart (lte guard)', async () => {
902
+ // Verifies that the .lte() dedup guard in streamChangesInternal does NOT
903
+ // drop data events on DocumentDB after restart. On DocumentDB, wallTime has
904
+ // second precision (increment 0). Without the isDocumentDb guard, events
905
+ // within the same wall-clock second as the last checkpoint would be silently
906
+ // dropped — causing data loss.
907
+ //
908
+ // This test avoids getClientCheckpoint (which has its own timing issues)
909
+ // and instead polls the storage directly until the data appears.
910
+
911
+ // Phase 1: initial sync + streaming + checkpoint
912
+ await using context = await openContext();
913
+ const { db } = context;
914
+ await context.updateSyncRules(BASIC_SYNC_RULES);
915
+
916
+ await db.createCollection('test_data');
917
+ const collection = db.collection('test_data');
918
+
919
+ await context.replicateSnapshot();
920
+ context.startStreaming();
921
+
922
+ await collection.insertOne({ description: 'phase1_data' });
923
+ await context.getCheckpoint();
924
+
925
+ // Stop streaming
926
+ context.abort();
927
+ await context.dispose();
928
+
929
+ // Phase 2: restart and insert immediately (same second as last checkpoint)
930
+ await using context2 = await openContext({ doNotClear: true });
931
+ const db2 = context2.db;
932
+
933
+ await context2.loadActiveSyncRules();
934
+
935
+ context2.startStreaming();
936
+
937
+ // Wait for the stream to be fully initialized — the streaming loop must
938
+ // process its initial checkpoint before we insert test data. Without this,
939
+ // the insert can happen before the ChangeStream's lazy aggregate command
940
+ // is sent, causing the event to be missed entirely (not a .lte() issue).
941
+ await context2.getCheckpoint({ timeout: DOCUMENTDB_RESTART_TIMEOUT });
942
+
943
+ // Insert — if .lte() drops same-second events, this data will never appear.
944
+ const collection2 = db2.collection('test_data');
945
+ const result2 = await collection2.insertOne({ description: 'post_restart_data' });
946
+ const id2 = result2.insertedId;
947
+
948
+ // Poll for the data by repeatedly calling getBucketData with a longer timeout.
949
+ // We bypass the flaky getClientCheckpoint timing by polling until the data appears
950
+ // or the timeout expires. If the .lte() guard drops same-second events, the data
951
+ // will never appear — deterministic failure.
952
+ const deadline = Date.now() + DOCUMENTDB_RESTART_TIMEOUT;
953
+ let found = false;
954
+ while (Date.now() < deadline) {
955
+ try {
956
+ const data = await context2.getBucketData('global[]', undefined, { timeout: 5_000 });
957
+ const match = data.find((op) => op.object_id === id2.toHexString() && op.op === 'PUT');
958
+ if (match) {
959
+ const parsed = JSON.parse(match.data as string);
960
+ expect(parsed).toMatchObject({ description: 'post_restart_data' });
961
+ found = true;
962
+ break;
963
+ }
964
+ } catch {
965
+ // getCheckpoint may timeout on first attempts — retry
966
+ }
967
+ await setTimeout(200);
968
+ }
969
+
970
+ expect(
971
+ found,
972
+ 'Data event after restart was dropped — .lte() guard may be incorrectly filtering same-second events'
973
+ ).toBe(true);
974
+ });
975
+
976
+ test('resume after restart in documentDbMode', async () => {
977
+ // Phase 1: replicate some data, then stop
978
+ await using context = await openContext();
979
+ const { db } = context;
980
+ await context.updateSyncRules(`
981
+ bucket_definitions:
982
+ global:
983
+ data:
984
+ - SELECT _id as id, description FROM "test_data"`);
985
+
986
+ await db.createCollection('test_data');
987
+ const collection = db.collection('test_data');
988
+
989
+ await context.replicateSnapshot();
990
+ context.startStreaming();
991
+
992
+ const result1 = await collection.insertOne({ description: 'before_restart' });
993
+ const id1 = result1.insertedId;
994
+
995
+ // Wait for the data to be replicated and checkpoint to advance
996
+ await context.getCheckpoint({ timeout: DOCUMENTDB_BUCKET_DATA_TIMEOUT });
997
+
998
+ const dataBefore = await context.getBucketDataAtLatestCheckpoint('global[]');
999
+ expect(dataBefore).toMatchObject([
1000
+ test_utils.putOp('test_data', { id: id1.toHexString(), description: 'before_restart' })
1001
+ ]);
1002
+
1003
+ // Stop streaming (simulates a restart)
1004
+ context.abort();
1005
+ await context.dispose();
1006
+
1007
+ // Phase 2: reopen without clearing and resume
1008
+ await using context2 = await openContext({ doNotClear: true });
1009
+ const db2 = context2.db;
1010
+
1011
+ await context2.loadActiveSyncRules();
1012
+
1013
+ context2.startStreaming();
1014
+
1015
+ // Wait for the stream to fully initialize and process the initial checkpoint
1016
+ // before inserting new data.
1017
+ await context2.getCheckpoint({ timeout: DOCUMENTDB_RESTART_TIMEOUT });
1018
+
1019
+ const collection2 = db2.collection('test_data');
1020
+ const result2 = await collection2.insertOne({ description: 'after_restart' });
1021
+ const id2 = result2.insertedId;
1022
+
1023
+ // On DocumentDB, wall-clock LSNs have second precision and stored LSNs may
1024
+ // include resume-token suffixes. Avoid creating a fresh sentinel checkpoint
1025
+ // on every poll; read at the latest persisted checkpoint instead.
1026
+ const deadline = Date.now() + DOCUMENTDB_RESTART_TIMEOUT;
1027
+ let found = false;
1028
+ while (Date.now() < deadline) {
1029
+ const dataAfter = await context2.getBucketDataAtLatestCheckpoint('global[]');
1030
+ const afterRestartOps = dataAfter.filter((op) => op.object_id === id2.toHexString() && op.op === 'PUT');
1031
+ if (afterRestartOps.length >= 1) {
1032
+ expect(JSON.parse(afterRestartOps[0].data as string)).toMatchObject({ description: 'after_restart' });
1033
+ found = true;
1034
+ break;
1035
+ }
1036
+ await setTimeout(200);
1037
+ }
1038
+ expect(found, 'Data event after restart was not replicated within timeout').toBe(true);
1039
+ });
1040
+ }