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

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 (39) hide show
  1. package/CHANGELOG.md +63 -7
  2. package/dist/module/MongoModule.js +2 -1
  3. package/dist/module/MongoModule.js.map +1 -1
  4. package/dist/replication/ChangeStream.d.ts +8 -0
  5. package/dist/replication/ChangeStream.js +71 -14
  6. package/dist/replication/ChangeStream.js.map +1 -1
  7. package/dist/replication/ChangeStreamReplicationJob.js +2 -1
  8. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  9. package/dist/replication/MongoSnapshotter.js +6 -6
  10. package/dist/replication/MongoSnapshotter.js.map +1 -1
  11. package/dist/replication/RawChangeStream.d.ts +3 -11
  12. package/dist/replication/RawChangeStream.js +0 -17
  13. package/dist/replication/RawChangeStream.js.map +1 -1
  14. package/dist/replication/checkpoints/CheckpointImplementation.d.ts +4 -1
  15. package/dist/replication/checkpoints/SentinelCheckpointImplementation.d.ts +4 -1
  16. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js +1 -1
  17. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js.map +1 -1
  18. package/dist/replication/checkpoints/TimestampCheckpointImplementation.d.ts +4 -1
  19. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js +2 -1
  20. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js.map +1 -1
  21. package/dist/types/types.d.ts +5 -0
  22. package/dist/types/types.js +20 -2
  23. package/dist/types/types.js.map +1 -1
  24. package/package.json +9 -9
  25. package/src/module/MongoModule.ts +2 -1
  26. package/src/replication/ChangeStream.ts +75 -16
  27. package/src/replication/ChangeStreamReplicationJob.ts +2 -1
  28. package/src/replication/MongoSnapshotter.ts +6 -6
  29. package/src/replication/RawChangeStream.ts +3 -30
  30. package/src/replication/checkpoints/CheckpointImplementation.ts +1 -1
  31. package/src/replication/checkpoints/SentinelCheckpointImplementation.ts +2 -2
  32. package/src/replication/checkpoints/TimestampCheckpointImplementation.ts +3 -2
  33. package/src/types/types.ts +27 -2
  34. package/test/src/change_stream.test.ts +73 -0
  35. package/test/src/config.test.ts +34 -0
  36. package/test/src/documentdb_mode.test.ts +1 -2
  37. package/test/src/raw_change_stream.test.ts +0 -63
  38. package/test/src/util.ts +11 -29
  39. package/tsconfig.tsbuildinfo +1 -1
@@ -6,14 +6,8 @@ import {
6
6
  ReplicationAssertionError
7
7
  } from '@powersync/lib-services-framework';
8
8
  import { PerformanceTracer } from '@powersync/service-core';
9
- import { performance } from 'node:perf_hooks';
10
- import { setTimeout as delay } from 'node:timers/promises';
11
9
  import { ChangeStreamInvalidatedError } from './ChangeStream.js';
12
10
 
13
- // Keep the DocumentDB idle-poll workaround from adding the full maxAwaitTimeMS
14
- // as local latency when an update arrives just after an empty batch.
15
- const CLIENT_SIDE_MAX_AWAIT_TIME_MS_DELAY_CAP_MS = 1_000;
16
-
17
11
  export interface RawChangeStreamOptions {
18
12
  signal?: AbortSignal;
19
13
 
@@ -21,21 +15,12 @@ export interface RawChangeStreamOptions {
21
15
  * How long to wait for new data per batch (max time for long-polling).
22
16
  * This is sent as maxTimeMS for the getMore command.
23
17
  *
24
- * A value of 0 is allowed for probe-style streams that do not want an idle
25
- * wait; in that case PowerSync also skips the local empty-batch delay.
18
+ * A value of 0 removes the explicit await limit and leaves the server's
19
+ * default awaitData behavior in effect; it does not make getMore return
20
+ * immediately. Snapshot probes use 0 for this purpose.
26
21
  */
27
22
  maxAwaitTimeMS: number;
28
23
 
29
- /**
30
- * Also enforce maxAwaitTimeMS on the client for empty getMore batches.
31
- *
32
- * Azure DocumentDB currently returns idle getMore calls before maxTimeMS. When
33
- * this is enabled, empty batches are delayed locally (capped at 1s) to avoid
34
- * tight polling. We still send maxTimeMS so this remains compatible with
35
- * servers that handle maxAwaitTimeMS correctly.
36
- */
37
- clientSideMaxAwaitTimeMS?: boolean;
38
-
39
24
  /**
40
25
  * Timeout for the initial aggregate command.
41
26
  */
@@ -230,17 +215,12 @@ async function* rawChangeStreamInner(
230
215
  options.signal?.throwIfAborted();
231
216
 
232
217
  using commandSpan = options.tracer?.span('changestream', 'getmore');
233
- const getMoreStartedAt = performance.now();
234
218
  const getMoreCommand: mongo.Document = {
235
219
  getMore: cursorId,
236
220
  collection: nsCollection,
237
221
  batchSize: batchSizer.next(),
238
222
  maxTimeMS: options.maxAwaitTimeMS
239
223
  };
240
- // Azure DocumentDB currently returns empty getMore batches before
241
- // maxTimeMS expires. Keep maxTimeMS for forward compatibility with the
242
- // server-side behavior, and when client-side mode is enabled, enforce the
243
- // capped idle wait locally for empty batches below.
244
224
  const getMoreResult: mongo.Document = await db.command(getMoreCommand, { session, raw: true }).catch((e) => {
245
225
  if (isMongoServerError(e) && e.codeName == 'CursorKilled') {
246
226
  // This may be due to the killCursors command issued when aborting.
@@ -268,13 +248,6 @@ async function* rawChangeStreamInner(
268
248
  // postBatchResumeToken is returned in MongoDB 4.0.7 and later, and we support 6.0+
269
249
  throw new ReplicationAssertionError(`postBatchResumeToken from aggregate response`);
270
250
  }
271
- if (options.clientSideMaxAwaitTimeMS && nextBatch.length == 0) {
272
- const remainingMaxAwaitTimeMS = Math.ceil(options.maxAwaitTimeMS - (performance.now() - getMoreStartedAt));
273
- if (remainingMaxAwaitTimeMS > 0) {
274
- const clientSideDelayMs = Math.min(remainingMaxAwaitTimeMS, CLIENT_SIDE_MAX_AWAIT_TIME_MS_DELAY_CAP_MS);
275
- await delay(clientSideDelayMs, undefined, { signal: options.signal });
276
- }
277
- }
278
251
  yield {
279
252
  events: nextBatch,
280
253
  resumeToken: cursor.postBatchResumeToken,
@@ -128,7 +128,7 @@ export interface CheckpointImplementation {
128
128
  * The timestamp implementation parses the timestamp embedded in the token.
129
129
  * The sentinel implementation pairs the token with the current coordinate.
130
130
  */
131
- lsnFromResumeToken(resumeToken: mongo.ResumeToken): string;
131
+ lsnFromResumeToken(resumeToken: mongo.ResumeToken): { lsn: string; timestamp: Date | null };
132
132
 
133
133
  /**
134
134
  * Source-side replication head for write checkpoints. The LSN passed to the
@@ -108,11 +108,11 @@ export class SentinelCheckpointImplementation implements CheckpointImplementatio
108
108
  );
109
109
  }
110
110
 
111
- lsnFromResumeToken(resumeToken: mongo.ResumeToken): string {
111
+ lsnFromResumeToken(resumeToken: mongo.ResumeToken) {
112
112
  // Pair the bare token with the current coordinate. The coordinate is
113
113
  // frozen between checkpoint events, so a per-batch resume marker only
114
114
  // advances the token; that is all resumption needs (see the design doc).
115
- return new SentinelLSN({ sentinel: this.position, resume_token: resumeToken }).comparable;
115
+ return { lsn: new SentinelLSN({ sentinel: this.position, resume_token: resumeToken }).comparable, timestamp: null };
116
116
  }
117
117
 
118
118
  async createReplicationHead<T>(callback: ReplicationHeadCallback<T>): Promise<T> {
@@ -67,9 +67,10 @@ export class TimestampCheckpointImplementation implements CheckpointImplementati
67
67
  );
68
68
  }
69
69
 
70
- lsnFromResumeToken(resumeToken: mongo.ResumeToken): string {
70
+ lsnFromResumeToken(resumeToken: mongo.ResumeToken) {
71
71
  // The timestamp is embedded in the resume token.
72
- return MongoLSN.fromResumeToken(resumeToken).comparable;
72
+ const lsn = MongoLSN.fromResumeToken(resumeToken);
73
+ return { lsn: lsn.comparable, timestamp: timestampToDate(lsn.timestamp) };
73
74
  }
74
75
 
75
76
  async createReplicationHead<T>(callback: ReplicationHeadCallback<T>): Promise<T> {
@@ -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: service_types.enumLiteral('off', 'auto_configure', 'read_only').optional(),
71
+ /**
72
+ * Interval in seconds between source connection heartbeats. Null or omitted defaults to 60 seconds.
73
+ */
74
+ heartbeat_interval_seconds: service_types.orNull(t.number).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
+ }
@@ -622,6 +622,79 @@ bucket_definitions:
622
622
  expect(data).toMatchObject([test_utils.putOp('test_data', { id: test_id, description: 'test1' })]);
623
623
  });
624
624
 
625
+ test.runIf(storageVersion >= 3)(
626
+ 'does not resnapshot an unchanged event definition during incremental reprocessing',
627
+ async (testContext) => {
628
+ const eventDefinition = `
629
+ event_definitions:
630
+ checkpoint_requests:
631
+ payloads:
632
+ - SELECT user_id, checkpoint, client_id, CASE WHEN checkpoint_requested_at IS NULL THEN true ELSE false END AS is_legacy FROM checkpoints
633
+ `;
634
+ const syncConfig = (deploymentComment: string) => `
635
+ config:
636
+ edition: 3
637
+ storage_version: 3
638
+ # ${deploymentComment}
639
+
640
+ ${eventDefinition}
641
+ streams:
642
+ global:
643
+ auto_subscribe: true
644
+ queries:
645
+ - SELECT *, _id AS id FROM lists
646
+ - SELECT *, _id AS id FROM todos
647
+ `;
648
+
649
+ let replicationStreamId: number;
650
+ {
651
+ await using context = await openContext();
652
+ const firstStorage = await context.updateSyncRules(syncConfig('initial deployment'));
653
+ if (
654
+ !('incrementalReprocessing' in firstStorage.storageConfig) ||
655
+ firstStorage.storageConfig.incrementalReprocessing !== true
656
+ ) {
657
+ testContext.skip('storage does not support incremental reprocessing');
658
+ }
659
+
660
+ const { db } = context;
661
+ await db.collection('lists').insertOne({ name: 'list' });
662
+ await db.collection('todos').insertOne({ description: 'todo' });
663
+ await db.collection('checkpoints').insertOne({
664
+ user_id: 'user-1',
665
+ checkpoint: 1n,
666
+ client_id: 'client-1',
667
+ checkpoint_requested_at: null
668
+ });
669
+
670
+ replicationStreamId = firstStorage.replicationStreamId;
671
+ await context.replicateSnapshot();
672
+ context.startStreaming();
673
+ await context.getCheckpoint();
674
+ }
675
+
676
+ const resnapshottedCollections: string[] = [];
677
+ await using context = await openContext({
678
+ doNotClear: true,
679
+ streamOptions: {
680
+ snapshotHooks: {
681
+ beforeSnapshotStarted: async (table) => {
682
+ resnapshottedCollections.push(table.name);
683
+ }
684
+ }
685
+ }
686
+ });
687
+
688
+ const processingStorage = await context.updateSyncRules(syncConfig('redeployed without behavioral changes'));
689
+ expect(processingStorage.replicationStreamId).toBe(replicationStreamId);
690
+ expect(await context.factory.getDeployingSyncConfig()).not.toBeNull();
691
+
692
+ await context.replicateSnapshot();
693
+
694
+ expect(resnapshottedCollections).toEqual([]);
695
+ }
696
+ );
697
+
625
698
  test('coalesces standalone checkpoints when backlog is buffered', async () => {
626
699
  await using context = await openContext();
627
700
  await context.updateSyncRules(BASIC_SYNC_RULES);
@@ -0,0 +1,34 @@
1
+ import { normalizeConnectionConfig } from '@module/types/types.js';
2
+ import { describe, expect, test } from 'vitest';
3
+
4
+ const BASE_CONFIG = {
5
+ type: 'mongodb' as const,
6
+ uri: 'mongodb://localhost:27017/powersync_test'
7
+ };
8
+
9
+ describe('MongoDB connection config', () => {
10
+ test('defaults heartbeat_interval_seconds to 60 seconds', () => {
11
+ expect(normalizeConnectionConfig(BASE_CONFIG).heartbeat_interval_seconds).toBe(60);
12
+ });
13
+
14
+ test('defaults a null heartbeat_interval_seconds to 60 seconds', () => {
15
+ expect(
16
+ normalizeConnectionConfig({ ...BASE_CONFIG, heartbeat_interval_seconds: null }).heartbeat_interval_seconds
17
+ ).toBe(60);
18
+ });
19
+
20
+ test('allows the MongoDB maximum heartbeat interval', () => {
21
+ expect(
22
+ normalizeConnectionConfig({ ...BASE_CONFIG, heartbeat_interval_seconds: 60 }).heartbeat_interval_seconds
23
+ ).toBe(60);
24
+ });
25
+
26
+ test.each([0, 4, 61, Number.NaN, Number.POSITIVE_INFINITY])(
27
+ 'rejects invalid heartbeat_interval_seconds: %s',
28
+ (heartbeat_interval_seconds) => {
29
+ expect(() => normalizeConnectionConfig({ ...BASE_CONFIG, heartbeat_interval_seconds })).toThrow(
30
+ 'heartbeat_interval_seconds'
31
+ );
32
+ }
33
+ );
34
+ });
@@ -759,8 +759,7 @@ bucket_definitions:
759
759
  expect(JSON.parse(lastOp.data as string)).toMatchObject({ description: 'after_keepalive' });
760
760
  });
761
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 () => {
762
+ test('respects maxAwaitTimeMS for idle getMore calls in documentDbMode', async () => {
764
763
  const maxAwaitTimeMS = 2_000;
765
764
 
766
765
  await using context = await openContext({
@@ -5,7 +5,6 @@ import { getCursorBatchBytes } from '@module/replication/replication-index.js';
5
5
  import { mongo } from '@powersync/lib-service-mongodb';
6
6
  import { bson } from '@powersync/service-core';
7
7
  import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
8
- import { testTimeout } from './test-timeouts.js';
9
8
  import { clearTestDb, connectMongoData, requireFailCommand } from './util.js';
10
9
 
11
10
  // DocumentDB only supports cluster-level change streams — collection- and
@@ -102,68 +101,6 @@ describe('internal mongodb utils', () => {
102
101
  }
103
102
  );
104
103
 
105
- test(
106
- 'keeps getMore maxTimeMS when client-side maxAwaitTimeMS is enabled',
107
- { timeout: testTimeout(30_000, { cloudOverride: 150_000 }) },
108
- async () => {
109
- const { db, client } = await connectMongoData({ monitorCommands: true });
110
- await using _ = { [Symbol.asyncDispose]: async () => await client.close() };
111
- await clearTestDb(db);
112
- const collection = db.collection('test_data');
113
- // Keep the local Mongo path fast, but give Azure DocumentDB enough time
114
- // for slow cloud change-stream delivery when maxTimeMS is sent to getMore.
115
- const maxAwaitTimeMS = testTimeout(50, { cloudOverride: 10_000 });
116
-
117
- const started: any[] = [];
118
- client.on('commandStarted', (event) => {
119
- if (event.commandName == 'aggregate' || event.commandName == 'getMore') {
120
- started.push(event);
121
- }
122
- });
123
-
124
- const stream = rawChangeStream(
125
- DATABASE_TYPE == DatabaseType.DOCUMENTDB ? client.db('admin') : db,
126
- [
127
- {
128
- $changeStream: {
129
- fullDocument: 'updateLookup',
130
- ...(DATABASE_TYPE == DatabaseType.DOCUMENTDB ? { allChangesForCluster: true } : {})
131
- }
132
- },
133
- ...(DATABASE_TYPE == DatabaseType.DOCUMENTDB
134
- ? [
135
- {
136
- $match: {
137
- 'ns.db': db.databaseName,
138
- 'ns.coll': collection.collectionName
139
- }
140
- }
141
- ]
142
- : [])
143
- ],
144
- {
145
- batchSize: 10,
146
- maxAwaitTimeMS,
147
- clientSideMaxAwaitTimeMS: true,
148
- maxTimeMS: 1_000
149
- }
150
- );
151
-
152
- await stream.next();
153
- await collection.insertOne({ test: 1 });
154
- const nextBatch = await readUntilNonEmptyBatch(stream);
155
- await stream.return?.();
156
-
157
- expect(nextBatch.events).toHaveLength(1);
158
-
159
- const aggregate = started.find((event) => event.commandName == 'aggregate');
160
- const getMore = started.find((event) => event.commandName == 'getMore');
161
-
162
- expect(aggregate?.command.maxTimeMS).toEqual(1_000);
163
- expect(getMore?.command.maxTimeMS).toEqual(maxAwaitTimeMS);
164
- }
165
- );
166
-
167
104
  // This test uses configureFailPoint to inject a getMore timeout. Azure
168
105
  // DocumentDB does not support configureFailPoint.
169
106
  test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)(
package/test/src/util.ts CHANGED
@@ -3,13 +3,9 @@ import * as mongo_storage from '@powersync/service-module-mongodb-storage';
3
3
  import * as postgres_storage from '@powersync/service-module-postgres-storage';
4
4
 
5
5
  import * as types from '@module/types/types.js';
6
- import {
7
- BSON_DESERIALIZE_DATA_OPTIONS,
8
- SUPPORTED_STORAGE_VERSIONS,
9
- TestStorageConfig,
10
- TestStorageFactory
11
- } from '@powersync/service-core';
12
- import { describe, TestContext, TestOptions } from 'vitest';
6
+ import { BSON_DESERIALIZE_DATA_OPTIONS, TestStorageFactory } from '@powersync/service-core';
7
+ import { describeStorageCombinations } from '@powersync/service-core-tests';
8
+ import { TestContext, TestOptions } from 'vitest';
13
9
  import { env } from './env.js';
14
10
 
15
11
  export const TEST_URI = env.MONGO_TEST_DATA_URL;
@@ -29,34 +25,20 @@ export const INITIALIZED_POSTGRES_STORAGE_FACTORY = postgres_storage.test_utils.
29
25
  url: env.PG_STORAGE_TEST_URL
30
26
  });
31
27
 
32
- export const TEST_STORAGE_VERSIONS = SUPPORTED_STORAGE_VERSIONS;
33
-
34
28
  export interface StorageVersionTestContext {
35
29
  factory: TestStorageFactory;
36
30
  storageVersion: number;
37
31
  }
38
32
 
39
33
  export function describeWithStorage(options: TestOptions, fn: (context: StorageVersionTestContext) => void) {
40
- const describeFactory = (storageName: string, config: TestStorageConfig) => {
41
- describe(`${storageName} storage`, options, function () {
42
- for (const storageVersion of TEST_STORAGE_VERSIONS) {
43
- describe(`storage v${storageVersion}`, function () {
44
- fn({
45
- factory: config.factory,
46
- storageVersion
47
- });
48
- });
49
- }
50
- });
51
- };
52
-
53
- if (env.TEST_MONGO_STORAGE) {
54
- describeFactory('mongodb', INITIALIZED_MONGO_STORAGE_FACTORY);
55
- }
56
-
57
- if (env.TEST_POSTGRES_STORAGE) {
58
- describeFactory('postgres', INITIALIZED_POSTGRES_STORAGE_FACTORY);
59
- }
34
+ describeStorageCombinations(
35
+ {
36
+ mongodb: env.TEST_MONGO_STORAGE ? INITIALIZED_MONGO_STORAGE_FACTORY : undefined,
37
+ postgres: env.TEST_POSTGRES_STORAGE ? INITIALIZED_POSTGRES_STORAGE_FACTORY : undefined
38
+ },
39
+ options,
40
+ fn
41
+ );
60
42
  }
61
43
 
62
44
  export async function clearTestDb(db: mongo.Db) {