@powersync/service-module-mongodb 0.0.0-dev-20260602073133 → 0.0.0-dev-20260803144812

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 (70) hide show
  1. package/CHANGELOG.md +97 -5
  2. package/dist/api/MongoRouteAPIAdapter.d.ts +4 -2
  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 +16 -0
  9. package/dist/replication/ChangeStream.js +143 -64
  10. package/dist/replication/ChangeStream.js.map +1 -1
  11. package/dist/replication/ChangeStreamReplicationJob.js +1 -1
  12. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  13. package/dist/replication/ChangeStreamReplicator.js +1 -1
  14. package/dist/replication/ChangeStreamReplicator.js.map +1 -1
  15. package/dist/replication/MongoRelation.d.ts +36 -1
  16. package/dist/replication/MongoRelation.js +133 -18
  17. package/dist/replication/MongoRelation.js.map +1 -1
  18. package/dist/replication/MongoSnapshotter.d.ts +9 -1
  19. package/dist/replication/MongoSnapshotter.js +128 -66
  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 +132 -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 +55 -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 +24 -0
  31. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js +115 -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 +2 -2
  40. package/package.json +11 -21
  41. package/src/api/MongoRouteAPIAdapter.ts +28 -39
  42. package/src/common/SentinelLSN.ts +78 -0
  43. package/src/replication/ChangeStream.ts +159 -73
  44. package/src/replication/ChangeStreamReplicationJob.ts +1 -1
  45. package/src/replication/ChangeStreamReplicator.ts +1 -1
  46. package/src/replication/MongoRelation.ts +153 -21
  47. package/src/replication/MongoSnapshotter.ts +147 -72
  48. package/src/replication/RawChangeStream.ts +54 -32
  49. package/src/replication/checkpoints/CheckpointImplementation.ts +167 -0
  50. package/src/replication/checkpoints/SentinelCheckpointImplementation.ts +269 -0
  51. package/src/replication/checkpoints/TimestampCheckpointImplementation.ts +144 -0
  52. package/src/replication/checkpoints/create-checkpoint-implementation.ts +14 -0
  53. package/src/replication/replication-utils.ts +23 -2
  54. package/test/DOCUMENTDB_TESTING.md +115 -0
  55. package/test/src/DatabaseType.ts +25 -0
  56. package/test/src/change_stream.test.ts +107 -71
  57. package/test/src/change_stream_utils.ts +117 -29
  58. package/test/src/checkpoint_retry.test.ts +134 -0
  59. package/test/src/documentdb_helpers.test.ts +124 -0
  60. package/test/src/documentdb_mode.test.ts +1040 -0
  61. package/test/src/mongo_test.test.ts +15 -5
  62. package/test/src/raw_change_stream.test.ts +209 -125
  63. package/test/src/resume.test.ts +2 -2
  64. package/test/src/resume_token.test.ts +30 -0
  65. package/test/src/slow_tests.test.ts +4 -1
  66. package/test/src/test-timeouts.ts +23 -0
  67. package/test/src/util.ts +3 -3
  68. package/tsconfig.json +1 -4
  69. package/tsconfig.scripts.json +1 -2
  70. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,134 @@
1
+ import { describe, expect, test } from 'vitest';
2
+
3
+ import { mongo } from '@powersync/lib-service-mongodb';
4
+
5
+ import { MongoLSN } from '@module/common/MongoLSN.js';
6
+ import { createCheckpoint } from '@module/replication/MongoRelation.js';
7
+ import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
8
+ import { clearTestDb, connectMongoData, requireFailCommand } from './util.js';
9
+
10
+ // This suite uses configureFailPoint to force retry timing. Azure DocumentDB
11
+ // does not support configureFailPoint.
12
+ describe.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('checkpoint retryable writes', () => {
13
+ test('returns the persisted checkpoint clusterTime after a retryable write', { repeats: 1 }, async (ctx) => {
14
+ // This test relies on very specific timing:
15
+ //
16
+ // 1. _powersync_checkpoints findAndModify command starts and is sent to the server
17
+ // 2. command is written to the server
18
+ // 3. _powersync_checkpoints findAndModify command fails with a retryable error (e.g. connection timeout)
19
+ // 4. command is retried
20
+ // 5. another write lands on the server, increasing the server's operationTime
21
+ // 6. command succeeds with a new operationTime, but without actually writing again (server de-duplicates based on transaction id)
22
+ //
23
+ // It is quite difficult to simulate this, even with failCommand. We currently rely on triggering a socket timeout for the first command,
24
+ // with another write happening right after that.
25
+
26
+ const TIMEOUT = 200;
27
+ const INSERT_COUNT = 5;
28
+
29
+ const { db, client } = await connectMongoData({
30
+ monitorCommands: true,
31
+ socketTimeoutMS: TIMEOUT,
32
+ maxPoolSize: 1
33
+ });
34
+
35
+ await using _client = { [Symbol.asyncDispose]: async () => await client.close() };
36
+ await client.connect();
37
+ await using failCommand = await requireFailCommand(client, ctx);
38
+
39
+ await clearTestDb(db);
40
+
41
+ const checkpointId = new mongo.ObjectId();
42
+ const advanceClusterTime = db.collection('_advance_cluster_time');
43
+
44
+ const changeStream = db.watch([], { maxAwaitTimeMS: TIMEOUT * 0.8 });
45
+ await using _changeStream = { [Symbol.asyncDispose]: async () => await changeStream.close() };
46
+
47
+ const checkpointEventPromise = waitForCheckpointEvent(changeStream, checkpointId, INSERT_COUNT).catch((e) =>
48
+ console.error(e)
49
+ );
50
+
51
+ const commandsStarted: mongo.CommandStartedEvent[] = [];
52
+
53
+ client.on('commandStarted', (event) => {
54
+ commandsStarted.push(event);
55
+ });
56
+
57
+ const DEBUG_COMMANDS = false;
58
+ if (DEBUG_COMMANDS) {
59
+ // For debugging: Log all commands
60
+ client.on('commandStarted', (event) => {
61
+ console.log('commandStarted:', event);
62
+ });
63
+
64
+ client.on('commandSucceeded', (event) => {
65
+ console.log('commandSucceeded:', event);
66
+ });
67
+
68
+ client.on('commandFailed', (event) => {
69
+ console.log('commandFailed:', event);
70
+ });
71
+ }
72
+
73
+ await failCommand.configure({
74
+ mode: { times: 1 },
75
+ data: {
76
+ failCommands: ['commitTransaction', 'findAndModify'],
77
+ blockConnection: true,
78
+ blockTimeMS: TIMEOUT
79
+ }
80
+ });
81
+ const returnedLsnPromise = createCheckpoint(db, checkpointId);
82
+
83
+ for (let i = 0; i < INSERT_COUNT; i++) {
84
+ await advanceClusterTime.insertOne({ at: new Date() });
85
+ }
86
+
87
+ const returnedLsn = await returnedLsnPromise;
88
+ const checkpointEvent = await checkpointEventPromise;
89
+ if (checkpointEvent == null) {
90
+ throw new Error('change stream failed');
91
+ }
92
+
93
+ const eventLsn = new MongoLSN({
94
+ timestamp: checkpointEvent.clusterTime!,
95
+ resume_token: checkpointEvent._id
96
+ }).comparable;
97
+
98
+ if (DEBUG_COMMANDS) {
99
+ const commands = commandsStarted
100
+ .filter((c) => ['findAndModify', 'insert'].includes(c.commandName))
101
+ .map((c) => c.commandName);
102
+ // In the failure case, we had findAndModify, insert, findAndModify, potentially with more inserts in between.
103
+ // With updated behavior, we only have a single findAndModify.
104
+ console.log('Command order:', commands);
105
+ }
106
+
107
+ expect(eventLsn >= returnedLsn).toBe(true);
108
+ expect(checkpointEvent.clusterTime).toEqual(MongoLSN.fromSerialized(returnedLsn).timestamp);
109
+ });
110
+ });
111
+
112
+ async function waitForCheckpointEvent(
113
+ changeStream: mongo.ChangeStream,
114
+ checkpointId: mongo.ObjectId,
115
+ insertCount: number
116
+ ): Promise<mongo.ChangeStreamDocument | null> {
117
+ let lastEvent: mongo.ChangeStreamDocument | null = null;
118
+ let seenInserts = 0;
119
+ for await (const event of changeStream) {
120
+ if (
121
+ (event.operationType == 'insert' || event.operationType == 'update' || event.operationType == 'replace') &&
122
+ checkpointId.equals(event.documentKey._id)
123
+ ) {
124
+ lastEvent = event;
125
+ } else if (event.operationType == 'insert' && event.ns.coll === '_advance_cluster_time') {
126
+ seenInserts++;
127
+ if (seenInserts >= insertCount) {
128
+ // We finish when we hit the expected number of inserts, since the number of checkpoint events are unknown.
129
+ break;
130
+ }
131
+ }
132
+ }
133
+ return lastEvent;
134
+ }
@@ -0,0 +1,124 @@
1
+ import { describe, expect, test } from 'vitest';
2
+
3
+ import { SentinelLSN } from '@module/common/SentinelLSN.js';
4
+ import { getEventTimestamp } from '@module/replication/checkpoints/CheckpointImplementation.js';
5
+ import { createSentinelCheckpointLsn, SENTINEL_CHECKPOINT_ID } from '@module/replication/MongoRelation.js';
6
+ import { CHECKPOINTS_COLLECTION } from '@module/replication/replication-utils.js';
7
+ import { mongo } from '@powersync/lib-service-mongodb';
8
+ import { connectMongoData } from './util.js';
9
+
10
+ describe('DocumentDB helpers', () => {
11
+ describe('getEventTimestamp', () => {
12
+ // getEventTimestamp is used by the timestamp checkpoint implementation (standard
13
+ // MongoDB): it prefers clusterTime and falls back to second-precision wallTime.
14
+ // The DocumentDB sentinel implementation does not use it — DocumentDB LSNs are
15
+ // sentinel-counter based, not timestamp based.
16
+
17
+ test('returns clusterTime when present', () => {
18
+ const clusterTime = mongo.Timestamp.fromBits(1, 1700000000);
19
+ const event = { clusterTime, wallTime: new Date('2024-01-01T00:00:00Z') } as any;
20
+ expect(getEventTimestamp(event)).toEqual(clusterTime);
21
+ });
22
+
23
+ test('falls back to second-precision wallTime when clusterTime is absent', () => {
24
+ const wallTime = new Date('2024-06-15T12:00:00.789Z');
25
+ const result = getEventTimestamp({ wallTime } as any);
26
+ expect(result.getHighBitsUnsigned()).toEqual(Math.floor(wallTime.getTime() / 1000));
27
+ // Increment is always 0 — wallTime only has second resolution in this conversion.
28
+ expect(result.getLowBitsUnsigned()).toEqual(0);
29
+ });
30
+
31
+ test('throws when neither clusterTime nor wallTime is present', () => {
32
+ expect(() => getEventTimestamp({} as any)).toThrow();
33
+ });
34
+ });
35
+
36
+ describe('DocumentDB detection', () => {
37
+ // Detection logic: hello.internal?.documentdb_versions != null
38
+ const isDocumentDb = (hello: any) => hello.internal?.documentdb_versions != null;
39
+
40
+ test('hello with documentdb_versions — detected as DocumentDB', () => {
41
+ const hello = {
42
+ isWritablePrimary: true,
43
+ msg: 'isdbgrid',
44
+ internal: {
45
+ documentdb_versions: ['1.111-0', '1.112.0', '12.1-1']
46
+ }
47
+ };
48
+ expect(isDocumentDb(hello)).toBe(true);
49
+ });
50
+
51
+ test('standard hello response — not DocumentDB', () => {
52
+ const hello = {
53
+ isWritablePrimary: true,
54
+ setName: 'rs0',
55
+ hosts: ['localhost:27017']
56
+ };
57
+ expect(isDocumentDb(hello)).toBe(false);
58
+ });
59
+ });
60
+
61
+ describe('sentinel checkpoint format', () => {
62
+ test('sentinel counter is seeded at a timestamp value on creation', { timeout: 30_000 }, async () => {
63
+ // If a consumer deletes the sentinel checkpoint document in their
64
+ // source database, the re-created counter must not restart below
65
+ // already-committed LSNs. createSentinelCheckpointLsn seeds new counters at
66
+ // the current epoch seconds shifted into the high 32 bits (resembling a
67
+ // MongoDB timestamp), so the coordinate jumps forward instead of resetting.
68
+ const { client, db: sharedDb } = await connectMongoData();
69
+ // Use an isolated database: other test files run in parallel against the
70
+ // shared test database and both bump and clear the sentinel checkpoint
71
+ // document, which would make these exact assertions racy.
72
+ const db = client.db(`${sharedDb.databaseName}_seed_test`);
73
+ const seed = (epochMs: number) => (BigInt(Math.floor(epochMs / 1000)) << 32n) + 1n;
74
+ try {
75
+ const before = Date.now();
76
+ const first = SentinelLSN.fromSerialized(await createSentinelCheckpointLsn(client, db));
77
+ const after = Date.now();
78
+
79
+ // Seeded at (epoch_seconds << 32) + 1, not at 1.
80
+ expect(first.sentinel).toBeGreaterThanOrEqual(seed(before));
81
+ expect(first.sentinel).toBeLessThanOrEqual(seed(after));
82
+
83
+ // Subsequent calls increment normally.
84
+ const second = SentinelLSN.fromSerialized(await createSentinelCheckpointLsn(client, db));
85
+ expect(second.sentinel).toEqual(first.sentinel + 1n);
86
+
87
+ // Simulate a consumer deleting the document after the counter has
88
+ // accumulated increments: the re-created counter resumes ahead. The seed
89
+ // has second granularity, and in production the document lives from
90
+ // initial sync onward, so re-creation always lands in a later second;
91
+ // wait for the clock to advance to reproduce that here.
92
+ await new Promise((resolve) => setTimeout(resolve, 1_100));
93
+ await db.collection(CHECKPOINTS_COLLECTION).deleteOne({ _id: SENTINEL_CHECKPOINT_ID as any });
94
+ const recreated = SentinelLSN.fromSerialized(await createSentinelCheckpointLsn(client, db));
95
+ expect(recreated.sentinel).toBeGreaterThan(second.sentinel);
96
+ } finally {
97
+ await db.dropDatabase().catch(() => {});
98
+ await client.close();
99
+ }
100
+ });
101
+
102
+ test('createSentinelCheckpointLsn stamps stream_id on the sentinel document', async () => {
103
+ // A batch barrier passes the stream's checkpointStreamId, which is stamped
104
+ // on the shared sentinel document so the stream can recognise its own
105
+ // barriers (and ignore other streams') by content.
106
+ const { client, db } = await connectMongoData();
107
+ try {
108
+ const barrierId = new mongo.ObjectId();
109
+ await createSentinelCheckpointLsn(client, db, barrierId);
110
+
111
+ const doc = await db.collection(CHECKPOINTS_COLLECTION).findOne({ _id: SENTINEL_CHECKPOINT_ID as any });
112
+ expect(barrierId.equals(doc!.stream_id)).true;
113
+ } finally {
114
+ await client.close();
115
+ }
116
+ });
117
+ });
118
+
119
+ // Note: the DocumentDB keepalive does not build an LSN from Date.now() — it bumps
120
+ // the shared sentinel via createSentinelCheckpointLsn and commits when the bump's
121
+ // own event is observed. The only Date.now()-derived value is the sentinel
122
+ // counter seed, which is covered by the 'sentinel counter is seeded at a
123
+ // timestamp value on creation' test above.
124
+ });