@powersync/service-module-mongodb 0.21.0 → 0.21.1

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.
@@ -213,7 +213,7 @@ export class ChangeStream {
213
213
  this.isDocumentDb = await detectDocumentDb(this.defaultDb);
214
214
  if (this.isDocumentDb) {
215
215
  this.logger.warn(
216
- 'Azure DocumentDB support is experimental. APIs and behavior may change, and long-term stability is not yet guaranteed.'
216
+ 'Azure DocumentDB support is in alpha. APIs and behavior may change, and long-term stability is not yet guaranteed.'
217
217
  );
218
218
  }
219
219
  this._checkpointImplementation = createCheckpointImplementation(this.isDocumentDb, {
@@ -595,9 +595,6 @@ export class ChangeStream {
595
595
  return rawChangeStream(watchDb, pipeline, {
596
596
  batchSize: options.batchSize ?? this.snapshotChunkLength,
597
597
  maxAwaitTimeMS,
598
- // maxAwaitTimeMS can be 0 for probe-style streams that do not want an idle wait.
599
- // In that case there is no client-side wait to emulate for DocumentDB.
600
- clientSideMaxAwaitTimeMS: this.isDocumentDb && maxAwaitTimeMS > 0,
601
598
  maxTimeMS: this.changeStreamTimeout,
602
599
 
603
600
  signal: options.signal,
@@ -785,9 +785,6 @@ export class MongoSnapshotter {
785
785
  return rawChangeStream(watchDb, pipeline, {
786
786
  batchSize: options.batchSize ?? this.snapshotChunkLength,
787
787
  maxAwaitTimeMS,
788
- // maxAwaitTimeMS can be 0 for probe-style streams that do not want an idle wait.
789
- // In that case there is no client-side wait to emulate for DocumentDB.
790
- clientSideMaxAwaitTimeMS: this.isDocumentDb && maxAwaitTimeMS > 0,
791
788
  maxTimeMS: this.changeStreamTimeout,
792
789
  signal: options.signal,
793
790
  logger: this.logger,
@@ -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,
@@ -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)(
@@ -0,0 +1,180 @@
1
+ import { mongo } from '@powersync/lib-service-mongodb';
2
+ import { afterEach, beforeEach, describe, expect, test } from 'vitest';
3
+
4
+ import { inferCollectionSchema } from '@module/api/infer-collection-schema.js';
5
+ import { MongoRouteAPIAdapter } from '@module/api/MongoRouteAPIAdapter.js';
6
+ import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
7
+ import { testTimeout } from './test-timeouts.js';
8
+ import { connectMongoData, requireFailCommand, TEST_CONNECTION_OPTIONS } from './util.js';
9
+
10
+ const isDocumentDb = DATABASE_TYPE == DatabaseType.DOCUMENTDB;
11
+
12
+ describe('collection schema inference', { timeout: testTimeout(20_000) }, () => {
13
+ let client: mongo.MongoClient;
14
+ let db: mongo.Db;
15
+
16
+ beforeEach(async () => {
17
+ ({ client } = await connectMongoData({ monitorCommands: true }));
18
+ db = client.db(`${TEST_CONNECTION_OPTIONS.database}_schema`);
19
+ await db.dropDatabase();
20
+ });
21
+
22
+ afterEach(async () => {
23
+ await db.dropDatabase();
24
+ await client.close();
25
+ });
26
+
27
+ test('merges types across documents and only infers top-level fields', async () => {
28
+ const collection = db.collection('mixed');
29
+ await collection.insertMany([
30
+ {
31
+ _id: 1 as any,
32
+ value: null,
33
+ whole: new mongo.Double(42),
34
+ huge: new mongo.Double(1e100),
35
+ fractional: new mongo.Double(-1.25),
36
+ nan: NaN,
37
+ positiveInfinity: Infinity,
38
+ negativeInfinity: -Infinity,
39
+ long: mongo.Long.fromString('9007199254740993'),
40
+ array: [{ nested: 'value' }],
41
+ object: { nested: true }
42
+ },
43
+ { _id: 2 as any, value: 'text', whole: new mongo.Int32(-42), array: [] },
44
+ { _id: 3 as any, value: 42, whole: new mongo.Double(-0) },
45
+ { _id: 4 as any, value: 1.25 },
46
+ { _id: 5 as any, value: new mongo.Double(42) }
47
+ ]);
48
+
49
+ expect(await inferCollectionSchema(collection, isDocumentDb)).toMatchObject([
50
+ { name: '_id', sqlite_type: 4, internal_type: 'Integer' },
51
+ { name: 'array', sqlite_type: 2, internal_type: 'Array' },
52
+ { name: 'fractional', sqlite_type: 8, internal_type: 'Double' },
53
+ { name: 'huge', sqlite_type: 4, internal_type: 'Integer' },
54
+ { name: 'long', sqlite_type: 4, internal_type: 'Long' },
55
+ { name: 'nan', sqlite_type: 8, internal_type: 'Double' },
56
+ { name: 'negativeInfinity', sqlite_type: 8, internal_type: 'Double' },
57
+ { name: 'object', sqlite_type: 2, internal_type: 'Object' },
58
+ { name: 'positiveInfinity', sqlite_type: 8, internal_type: 'Double' },
59
+ { name: 'value', sqlite_type: 14, internal_type: 'Double | Integer | Null | String' },
60
+ { name: 'whole', sqlite_type: 4, internal_type: 'Integer' }
61
+ ]);
62
+ });
63
+
64
+ test('distinguishes UUIDs from binary values by subtype and length', async () => {
65
+ const collection = db.collection('binary');
66
+ await collection.insertMany([
67
+ {
68
+ _id: 1 as any,
69
+ uuid: new mongo.UUID('00000000-0000-0000-0000-000000000000'),
70
+ binary: new mongo.Binary(Buffer.alloc(16)),
71
+ mixed: new mongo.UUID()
72
+ },
73
+ {
74
+ _id: 2 as any,
75
+ uuid: new mongo.UUID('ffffffff-ffff-ffff-ffff-ffffffffffff'),
76
+ binary: new mongo.Binary(Buffer.alloc(16, 255)),
77
+ mixed: new mongo.Binary(Buffer.alloc(16), mongo.Binary.SUBTYPE_UUID_OLD)
78
+ },
79
+ {
80
+ _id: 3 as any,
81
+ uuid: new mongo.UUID(),
82
+ binary: new mongo.Binary(Buffer.alloc(16), mongo.Binary.SUBTYPE_MD5)
83
+ },
84
+ { _id: 4 as any, binary: new mongo.Binary(Buffer.alloc(15, 255), mongo.Binary.SUBTYPE_USER_DEFINED) },
85
+ { _id: 5 as any, binary: new mongo.Binary(Buffer.alloc(17)) },
86
+ { _id: 6 as any, binary: new mongo.Binary(Buffer.alloc(0)) }
87
+ ]);
88
+
89
+ expect(await inferCollectionSchema(collection, isDocumentDb)).toMatchObject([
90
+ { name: '_id', sqlite_type: 4, internal_type: 'Integer' },
91
+ { name: 'binary', sqlite_type: 1, internal_type: 'Binary' },
92
+ { name: 'mixed', sqlite_type: 3, internal_type: 'Binary | UUID' },
93
+ { name: 'uuid', sqlite_type: 2, internal_type: 'UUID' }
94
+ ]);
95
+ });
96
+
97
+ test('handles empty collections and documents with only an id', async () => {
98
+ const collection = await db.createCollection('empty');
99
+ expect(await inferCollectionSchema(collection, isDocumentDb)).toEqual([]);
100
+
101
+ await collection.insertOne({});
102
+ expect(await inferCollectionSchema(collection, isDocumentDb)).toEqual([
103
+ { name: '_id', sqlite_type: 2, type: 'ObjectId', internal_type: 'ObjectId', pg_type: 'ObjectId' }
104
+ ]);
105
+ });
106
+
107
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)(
108
+ 'preserves field names with a non-simple collation',
109
+ async () => {
110
+ const collection = await db.createCollection('collation', { collation: { locale: 'en', strength: 1 } });
111
+ await collection.insertOne({ Name: 'text', name: 1, 'with.dot': true, $field: [] });
112
+
113
+ const columns = await inferCollectionSchema(collection, isDocumentDb);
114
+ expect(columns).toEqual(
115
+ expect.arrayContaining([
116
+ expect.objectContaining({ name: 'Name', sqlite_type: 2 }),
117
+ expect.objectContaining({ name: 'name', sqlite_type: 4 }),
118
+ expect.objectContaining({ name: 'with.dot', sqlite_type: 4 }),
119
+ expect.objectContaining({ name: '$field', internal_type: 'Array' })
120
+ ])
121
+ );
122
+ expect(columns).toHaveLength(5);
123
+ }
124
+ );
125
+
126
+ test('returns only small schema metadata for multi-megabyte documents', async () => {
127
+ const collection = db.collection('large');
128
+ const largeString = 'x'.repeat(2 * 1024 * 1024);
129
+ for (let i = 0; i < 4; i++) {
130
+ await collection.insertOne({ text: largeString, binary: Buffer.alloc(2 * 1024 * 1024), array: [largeString] });
131
+ }
132
+
133
+ const responseSizes: number[] = [];
134
+ const executionLimits: number[] = [];
135
+ client.on('commandStarted', (event: mongo.CommandStartedEvent) => {
136
+ if (event.commandName == 'aggregate') {
137
+ executionLimits.push(event.command.maxTimeMS);
138
+ }
139
+ });
140
+ client.on('commandSucceeded', (event: mongo.CommandSucceededEvent) => {
141
+ if (event.commandName == 'aggregate' || event.commandName == 'getMore') {
142
+ responseSizes.push(mongo.BSON.calculateObjectSize(event.reply as mongo.Document));
143
+ }
144
+ });
145
+
146
+ expect(await inferCollectionSchema(collection, isDocumentDb)).toMatchObject([
147
+ { name: '_id', sqlite_type: 2, internal_type: 'ObjectId' },
148
+ { name: 'array', sqlite_type: 2, internal_type: 'Array' },
149
+ { name: 'binary', sqlite_type: 1, internal_type: 'Binary' },
150
+ { name: 'text', sqlite_type: 2, internal_type: 'String' }
151
+ ]);
152
+ expect(responseSizes.length).toBeGreaterThan(0);
153
+ expect(Math.max(...responseSizes)).toBeLessThan(4096);
154
+ expect(executionLimits).toEqual([30_000]);
155
+ });
156
+
157
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('fails schema inference on a query timeout', async (ctx) => {
158
+ await db.collection('timeout').insertOne({ value: 'text' });
159
+ await using adapter = new MongoRouteAPIAdapter({
160
+ type: 'mongodb',
161
+ ...TEST_CONNECTION_OPTIONS,
162
+ database: db.databaseName
163
+ });
164
+ await using failCommand = await requireFailCommand(client, ctx);
165
+ await failCommand.configure({
166
+ mode: { times: 1 },
167
+ data: {
168
+ failCommands: ['aggregate'],
169
+ errorCode: 50 // MaxTimeMSExpired
170
+ }
171
+ });
172
+
173
+ // Exercise the real server/driver error path without waiting for the full timeout.
174
+ await expect(adapter.getConnectionSchema()).rejects.toMatchObject({
175
+ name: 'MongoServerError',
176
+ code: 50,
177
+ codeName: 'MaxTimeMSExpired'
178
+ });
179
+ });
180
+ });