@powersync/service-module-mongodb 0.10.4 → 0.12.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.
@@ -52,7 +52,7 @@ export async function checkSourceConfiguration(connectionManager: MongoManager):
52
52
  const fullName = `${db.databaseName}.${CHECKPOINTS_COLLECTION}`;
53
53
  throw new ServiceError(
54
54
  ErrorCode.PSYNC_S1307,
55
- `MongoDB user does not have the required ${missingCheckpointActions.map((a) => `"${a}"`).join(', ')} priviledge(s) on "${fullName}".`
55
+ `MongoDB user does not have the required ${missingCheckpointActions.map((a) => `"${a}"`).join(', ')} privilege(s) on "${fullName}".`
56
56
  );
57
57
  }
58
58
 
@@ -62,14 +62,14 @@ export async function checkSourceConfiguration(connectionManager: MongoManager):
62
62
  if (!anyCollectionActions.has('collMod')) {
63
63
  throw new ServiceError(
64
64
  ErrorCode.PSYNC_S1307,
65
- `MongoDB user does not have the required "collMod" priviledge on "${db.databaseName}", required for "post_images: auto_configure".`
65
+ `MongoDB user does not have the required "collMod" privilege on "${db.databaseName}", required for "post_images: auto_configure".`
66
66
  );
67
67
  }
68
68
  }
69
69
  if (!anyCollectionActions.has('listCollections')) {
70
70
  throw new ServiceError(
71
71
  ErrorCode.PSYNC_S1307,
72
- `MongoDB user does not have the required "listCollections" priviledge on "${db.databaseName}".`
72
+ `MongoDB user does not have the required "listCollections" privilege on "${db.databaseName}".`
73
73
  );
74
74
  }
75
75
  } else {
@@ -23,7 +23,9 @@ describe('change stream', () => {
23
23
 
24
24
  function defineChangeStreamTests(factory: storage.TestStorageFactory) {
25
25
  test('replicating basic values', async () => {
26
- await using context = await ChangeStreamTestContext.open(factory);
26
+ await using context = await ChangeStreamTestContext.open(factory, {
27
+ mongoOptions: { postImages: PostImagesOption.READ_ONLY }
28
+ });
27
29
  const { db } = context;
28
30
  await context.updateSyncRules(`
29
31
  bucket_definitions:
@@ -32,7 +34,7 @@ bucket_definitions:
32
34
  - SELECT _id as id, description, num FROM "test_data"`);
33
35
 
34
36
  await db.createCollection('test_data', {
35
- changeStreamPreAndPostImages: { enabled: false }
37
+ changeStreamPreAndPostImages: { enabled: true }
36
38
  });
37
39
  const collection = db.collection('test_data');
38
40
 
@@ -42,11 +44,8 @@ bucket_definitions:
42
44
 
43
45
  const result = await collection.insertOne({ description: 'test1', num: 1152921504606846976n });
44
46
  const test_id = result.insertedId;
45
- await setTimeout(30);
46
47
  await collection.updateOne({ _id: test_id }, { $set: { description: 'test2' } });
47
- await setTimeout(30);
48
48
  await collection.replaceOne({ _id: test_id }, { description: 'test3' });
49
- await setTimeout(30);
50
49
  await collection.deleteOne({ _id: test_id });
51
50
 
52
51
  const data = await context.getBucketData('global[]');
@@ -354,6 +353,9 @@ bucket_definitions:
354
353
  const test_id = result.insertedId.toHexString();
355
354
 
356
355
  await context.replicateSnapshot();
356
+ // Note: snapshot is only consistent some time into the streaming request.
357
+ // At the point that we get the first acknowledged checkpoint, as is required
358
+ // for getBucketData(), the data should be consistent.
357
359
  context.startStreaming();
358
360
 
359
361
  const data = await context.getBucketData('global[]');
@@ -512,10 +514,13 @@ bucket_definitions:
512
514
  await collection.insertOne({ description: 'test1', num: 1152921504606846976n });
513
515
 
514
516
  await context.replicateSnapshot();
517
+ await context.markSnapshotConsistent();
515
518
 
516
519
  // Simulate an error
517
520
  await context.storage!.reportError(new Error('simulated error'));
518
- expect((await context.factory.getActiveSyncRulesContent())?.last_fatal_error).toEqual('simulated error');
521
+ const syncRules = await context.factory.getActiveSyncRulesContent();
522
+ expect(syncRules).toBeTruthy();
523
+ expect(syncRules?.last_fatal_error).toEqual('simulated error');
519
524
 
520
525
  // startStreaming() should automatically clear the error.
521
526
  context.startStreaming();
@@ -17,7 +17,7 @@ import { MongoManager } from '@module/replication/MongoManager.js';
17
17
  import { createCheckpoint, STANDALONE_CHECKPOINT_ID } from '@module/replication/MongoRelation.js';
18
18
  import { NormalizedMongoConnectionConfig } from '@module/types/types.js';
19
19
 
20
- import { TEST_CONNECTION_OPTIONS, clearTestDb } from './util.js';
20
+ import { clearTestDb, TEST_CONNECTION_OPTIONS } from './util.js';
21
21
 
22
22
  export class ChangeStreamTestContext {
23
23
  private _walStream?: ChangeStream;
@@ -119,7 +119,20 @@ export class ChangeStreamTestContext {
119
119
 
120
120
  async replicateSnapshot() {
121
121
  await this.walStream.initReplication();
122
- await this.storage!.autoActivate();
122
+ }
123
+
124
+ /**
125
+ * A snapshot is not consistent until streaming replication has caught up.
126
+ * We simulate that for tests.
127
+ * Do not use if there are any writes performed while doing the snapshot, as that
128
+ * would result in inconsistent data.
129
+ */
130
+ async markSnapshotConsistent() {
131
+ const checkpoint = await createCheckpoint(this.client, this.db, STANDALONE_CHECKPOINT_ID);
132
+
133
+ await this.storage!.startBatch(test_utils.BATCH_OPTIONS, async (batch) => {
134
+ await batch.keepalive(checkpoint);
135
+ });
123
136
  }
124
137
 
125
138
  startStreaming() {
@@ -195,12 +208,11 @@ export async function getClientCheckpoint(
195
208
  while (Date.now() - start < timeout) {
196
209
  const storage = await storageFactory.getActiveStorage();
197
210
  const cp = await storage?.getCheckpoint();
198
- if (cp == null) {
199
- throw new Error('No sync rules available');
200
- }
201
- lastCp = cp;
202
- if (cp.lsn && cp.lsn >= lsn) {
203
- return cp.checkpoint;
211
+ if (cp != null) {
212
+ lastCp = cp;
213
+ if (cp.lsn && cp.lsn >= lsn) {
214
+ return cp.checkpoint;
215
+ }
204
216
  }
205
217
  await new Promise((resolve) => setTimeout(resolve, 30));
206
218
  }
@@ -1,5 +1,11 @@
1
1
  import { mongo } from '@powersync/lib-service-mongodb';
2
- import { SqliteRow, SqlSyncRules } from '@powersync/service-sync-rules';
2
+ import {
3
+ applyRowContext,
4
+ CompatibilityContext,
5
+ CompatibilityEdition,
6
+ SqliteInputRow,
7
+ SqlSyncRules
8
+ } from '@powersync/service-sync-rules';
3
9
  import { describe, expect, test } from 'vitest';
4
10
 
5
11
  import { MongoRouteAPIAdapter } from '@module/api/MongoRouteAPIAdapter.js';
@@ -138,8 +144,10 @@ describe('mongo data types', () => {
138
144
  ]);
139
145
  }
140
146
 
141
- function checkResults(transformed: Record<string, any>[]) {
142
- expect(transformed[0]).toMatchObject({
147
+ function checkResults(transformed: SqliteInputRow[]) {
148
+ const sqliteValue = transformed.map((e) => applyRowContext(e, CompatibilityContext.FULL_BACKWARDS_COMPATIBILITY));
149
+
150
+ expect(sqliteValue[0]).toMatchObject({
143
151
  _id: 1n,
144
152
  text: 'text',
145
153
  uuid: 'baeb2514-4c57-436d-b3cc-c1256211656d',
@@ -152,17 +160,17 @@ describe('mongo data types', () => {
152
160
  null: null,
153
161
  decimal: '3.14'
154
162
  });
155
- expect(transformed[1]).toMatchObject({
163
+ expect(sqliteValue[1]).toMatchObject({
156
164
  _id: 2n,
157
165
  nested: '{"test":"thing"}'
158
166
  });
159
167
 
160
- expect(transformed[2]).toMatchObject({
168
+ expect(sqliteValue[2]).toMatchObject({
161
169
  _id: 3n,
162
170
  date: '2023-03-06 13:47:00.000Z'
163
171
  });
164
172
 
165
- expect(transformed[3]).toMatchObject({
173
+ expect(sqliteValue[3]).toMatchObject({
166
174
  _id: 4n,
167
175
  objectId: '66e834cc91d805df11fa0ecb',
168
176
  timestamp: 1958505087099n,
@@ -177,9 +185,9 @@ describe('mongo data types', () => {
177
185
  });
178
186
 
179
187
  // This must specifically be null, and not undefined.
180
- expect(transformed[4].undefined).toBeNull();
188
+ expect(sqliteValue[4].undefined).toBeNull();
181
189
 
182
- expect(transformed[5]).toMatchObject({
190
+ expect(sqliteValue[5]).toMatchObject({
183
191
  _id: 6n,
184
192
  int4: -1n,
185
193
  int8: -9007199254740993n,
@@ -188,8 +196,10 @@ describe('mongo data types', () => {
188
196
  });
189
197
  }
190
198
 
191
- function checkResultsNested(transformed: Record<string, any>[]) {
192
- expect(transformed[0]).toMatchObject({
199
+ function checkResultsNested(transformed: SqliteInputRow[]) {
200
+ const sqliteValue = transformed.map((e) => applyRowContext(e, CompatibilityContext.FULL_BACKWARDS_COMPATIBILITY));
201
+
202
+ expect(sqliteValue[0]).toMatchObject({
193
203
  _id: 1n,
194
204
  text: `["text"]`,
195
205
  uuid: '["baeb2514-4c57-436d-b3cc-c1256211656d"]',
@@ -204,22 +214,22 @@ describe('mongo data types', () => {
204
214
 
205
215
  // Note: Depending on to what extent we use the original postgres value, the whitespace may change, and order may change.
206
216
  // We do expect that decimals and big numbers are preserved.
207
- expect(transformed[1]).toMatchObject({
217
+ expect(sqliteValue[1]).toMatchObject({
208
218
  _id: 2n,
209
219
  nested: '[{"test":"thing"}]'
210
220
  });
211
221
 
212
- expect(transformed[2]).toMatchObject({
222
+ expect(sqliteValue[2]).toMatchObject({
213
223
  _id: 3n,
214
224
  date: '["2023-03-06 13:47:00.000Z"]'
215
225
  });
216
226
 
217
- expect(transformed[3]).toMatchObject({
227
+ expect(sqliteValue[3]).toMatchObject({
218
228
  _id: 5n,
219
229
  undefined: '[null]'
220
230
  });
221
231
 
222
- expect(transformed[4]).toMatchObject({
232
+ expect(sqliteValue[4]).toMatchObject({
223
233
  _id: 6n,
224
234
  int4: '[-1]',
225
235
  int8: '[-9007199254740993]',
@@ -227,7 +237,7 @@ describe('mongo data types', () => {
227
237
  decimal: '["-3.14"]'
228
238
  });
229
239
 
230
- expect(transformed[5]).toMatchObject({
240
+ expect(sqliteValue[5]).toMatchObject({
231
241
  _id: 10n,
232
242
  objectId: '["66e834cc91d805df11fa0ecb"]',
233
243
  timestamp: '[1958505087099]',
@@ -522,13 +532,45 @@ bucket_definitions:
522
532
  errors: []
523
533
  });
524
534
  });
535
+
536
+ test('date format', async () => {
537
+ const { db, client } = await connectMongoData();
538
+ const collection = db.collection('test_data');
539
+ try {
540
+ await setupTable(db);
541
+ await collection.insertOne({
542
+ fraction: new Date('2023-03-06 15:47:01.123+02'),
543
+ noFraction: new Date('2023-03-06 15:47:01+02')
544
+ });
545
+
546
+ const rawResults = await db
547
+ .collection('test_data')
548
+ .find({}, { sort: { _id: 1 } })
549
+ .toArray();
550
+ const [row] = [...ChangeStream.getQueryData(rawResults)];
551
+
552
+ const oldFormat = applyRowContext(row, CompatibilityContext.FULL_BACKWARDS_COMPATIBILITY);
553
+ expect(oldFormat).toMatchObject({
554
+ fraction: '2023-03-06 13:47:01.123Z',
555
+ noFraction: '2023-03-06 13:47:01.000Z'
556
+ });
557
+
558
+ const newFormat = applyRowContext(row, new CompatibilityContext(CompatibilityEdition.SYNC_STREAMS));
559
+ expect(newFormat).toMatchObject({
560
+ fraction: '2023-03-06T13:47:01.123Z',
561
+ noFraction: '2023-03-06T13:47:01.000Z'
562
+ });
563
+ } finally {
564
+ await client.close();
565
+ }
566
+ });
525
567
  });
526
568
 
527
569
  /**
528
570
  * Return all the inserts from the first transaction in the replication stream.
529
571
  */
530
572
  async function getReplicationTx(replicationStream: mongo.ChangeStream, count: number) {
531
- let transformed: SqliteRow[] = [];
573
+ let transformed: SqliteInputRow[] = [];
532
574
  for await (const doc of replicationStream) {
533
575
  // Specifically filter out map_input / map_output collections
534
576
  if (!(doc as any)?.ns?.coll?.startsWith('test_data')) {