@powersync/service-module-mongodb-storage 0.12.0 → 0.12.2

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 (37) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/migrations/db/migrations/1741697235857-bucket-state-index.js +1 -4
  3. package/dist/migrations/db/migrations/1741697235857-bucket-state-index.js.map +1 -1
  4. package/dist/storage/MongoBucketStorage.d.ts +3 -2
  5. package/dist/storage/MongoBucketStorage.js +4 -2
  6. package/dist/storage/MongoBucketStorage.js.map +1 -1
  7. package/dist/storage/implementation/MongoChecksums.d.ts +66 -0
  8. package/dist/storage/implementation/MongoChecksums.js +287 -0
  9. package/dist/storage/implementation/MongoChecksums.js.map +1 -0
  10. package/dist/storage/implementation/MongoCompactor.d.ts +9 -2
  11. package/dist/storage/implementation/MongoCompactor.js +116 -39
  12. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  13. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +7 -4
  14. package/dist/storage/implementation/MongoSyncBucketStorage.js +14 -132
  15. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  16. package/dist/storage/implementation/MongoTestStorageFactoryGenerator.d.ts +2 -0
  17. package/dist/storage/implementation/MongoTestStorageFactoryGenerator.js +4 -3
  18. package/dist/storage/implementation/MongoTestStorageFactoryGenerator.js.map +1 -1
  19. package/dist/storage/implementation/db.d.ts +4 -0
  20. package/dist/storage/implementation/db.js +10 -0
  21. package/dist/storage/implementation/db.js.map +1 -1
  22. package/dist/storage/implementation/models.d.ts +5 -1
  23. package/dist/storage/implementation/util.js.map +1 -1
  24. package/package.json +4 -4
  25. package/src/migrations/db/migrations/1741697235857-bucket-state-index.ts +1 -7
  26. package/src/storage/MongoBucketStorage.ts +4 -3
  27. package/src/storage/implementation/MongoChecksums.ts +342 -0
  28. package/src/storage/implementation/MongoCompactor.ts +156 -64
  29. package/src/storage/implementation/MongoSyncBucketStorage.ts +21 -152
  30. package/src/storage/implementation/MongoTestStorageFactoryGenerator.ts +7 -4
  31. package/src/storage/implementation/db.ts +14 -0
  32. package/src/storage/implementation/models.ts +5 -1
  33. package/src/storage/implementation/util.ts +1 -1
  34. package/test/src/__snapshots__/storage.test.ts.snap +17 -1
  35. package/test/src/storage.test.ts +38 -1
  36. package/test/src/storage_compacting.test.ts +120 -5
  37. package/tsconfig.tsbuildinfo +1 -1
@@ -1,11 +1,11 @@
1
- import { mongo } from '@powersync/lib-service-mongodb';
1
+ import { mongo, MONGO_OPERATION_TIMEOUT_MS } from '@powersync/lib-service-mongodb';
2
2
  import { logger, ReplicationAssertionError, ServiceAssertionError } from '@powersync/lib-services-framework';
3
- import { addChecksums, InternalOpId, storage, utils } from '@powersync/service-core';
3
+ import { addChecksums, InternalOpId, isPartialChecksum, storage, utils } from '@powersync/service-core';
4
4
 
5
5
  import { PowerSyncMongo } from './db.js';
6
6
  import { BucketDataDocument, BucketDataKey, BucketStateDocument } from './models.js';
7
+ import { MongoSyncBucketStorage } from './MongoSyncBucketStorage.js';
7
8
  import { cacheKey } from './OperationBatch.js';
8
- import { readSingleBatch } from './util.js';
9
9
 
10
10
  interface CurrentBucketState {
11
11
  /** Bucket name */
@@ -68,12 +68,14 @@ export class MongoCompactor {
68
68
  private maxOpId: bigint;
69
69
  private buckets: string[] | undefined;
70
70
  private signal?: AbortSignal;
71
+ private group_id: number;
71
72
 
72
73
  constructor(
74
+ private storage: MongoSyncBucketStorage,
73
75
  private db: PowerSyncMongo,
74
- private group_id: number,
75
76
  options?: MongoCompactOptions
76
77
  ) {
78
+ this.group_id = storage.group_id;
77
79
  this.idLimitBytes = (options?.memoryLimitMB ?? DEFAULT_MEMORY_LIMIT_MB) * 1024 * 1024;
78
80
  this.moveBatchLimit = options?.moveBatchLimit ?? DEFAULT_MOVE_BATCH_LIMIT;
79
81
  this.moveBatchQueryLimit = options?.moveBatchQueryLimit ?? DEFAULT_MOVE_BATCH_QUERY_LIMIT;
@@ -136,33 +138,57 @@ export class MongoCompactor {
136
138
  o: new mongo.MaxKey() as any
137
139
  };
138
140
 
141
+ const doneWithBucket = async () => {
142
+ if (currentState == null) {
143
+ return;
144
+ }
145
+ // Free memory before clearing bucket
146
+ currentState.seen.clear();
147
+ if (currentState.lastNotPut != null && currentState.opsSincePut >= 1) {
148
+ logger.info(
149
+ `Inserting CLEAR at ${this.group_id}:${currentState.bucket}:${currentState.lastNotPut} to remove ${currentState.opsSincePut} operations`
150
+ );
151
+ // Need flush() before clear()
152
+ await this.flush();
153
+ await this.clearBucket(currentState);
154
+ }
155
+
156
+ // Do this _after_ clearBucket so that we have accurate counts.
157
+ this.updateBucketChecksums(currentState);
158
+ };
159
+
139
160
  while (!this.signal?.aborted) {
140
161
  // Query one batch at a time, to avoid cursor timeouts
141
- const cursor = this.db.bucket_data.aggregate<BucketDataDocument & { size: number | bigint }>([
142
- {
143
- $match: {
144
- _id: {
145
- $gte: lowerBound,
146
- $lt: upperBound
162
+ const cursor = this.db.bucket_data.aggregate<BucketDataDocument & { size: number | bigint }>(
163
+ [
164
+ {
165
+ $match: {
166
+ _id: {
167
+ $gte: lowerBound,
168
+ $lt: upperBound
169
+ }
170
+ }
171
+ },
172
+ { $sort: { _id: -1 } },
173
+ { $limit: this.moveBatchQueryLimit },
174
+ {
175
+ $project: {
176
+ _id: 1,
177
+ op: 1,
178
+ table: 1,
179
+ row_id: 1,
180
+ source_table: 1,
181
+ source_key: 1,
182
+ checksum: 1,
183
+ size: { $bsonSize: '$$ROOT' }
147
184
  }
148
185
  }
149
- },
150
- { $sort: { _id: -1 } },
151
- { $limit: this.moveBatchQueryLimit },
152
- {
153
- $project: {
154
- _id: 1,
155
- op: 1,
156
- table: 1,
157
- row_id: 1,
158
- source_table: 1,
159
- source_key: 1,
160
- checksum: 1,
161
- size: { $bsonSize: '$$ROOT' }
162
- }
163
- }
164
- ]);
165
- const { data: batch } = await readSingleBatch(cursor);
186
+ ],
187
+ { batchSize: this.moveBatchQueryLimit }
188
+ );
189
+ // We don't limit to a single batch here, since that often causes MongoDB to scan through more than it returns.
190
+ // Instead, we load up to the limit.
191
+ const batch = await cursor.toArray();
166
192
 
167
193
  if (batch.length == 0) {
168
194
  // We've reached the end
@@ -174,24 +200,8 @@ export class MongoCompactor {
174
200
 
175
201
  for (let doc of batch) {
176
202
  if (currentState == null || doc._id.b != currentState.bucket) {
177
- if (currentState != null) {
178
- if (currentState.lastNotPut != null && currentState.opsSincePut >= 1) {
179
- // Important to flush before clearBucket()
180
- // Does not have to happen before flushBucketChecksums()
181
- await this.flush();
182
- logger.info(
183
- `Inserting CLEAR at ${this.group_id}:${currentState.bucket}:${currentState.lastNotPut} to remove ${currentState.opsSincePut} operations`
184
- );
185
-
186
- // Free memory before clearing bucket
187
- currentState!.seen.clear();
188
-
189
- await this.clearBucket(currentState);
190
- }
203
+ await doneWithBucket();
191
204
 
192
- // Should happen after clearBucket() for accurate stats
193
- this.updateBucketChecksums(currentState);
194
- }
195
205
  currentState = {
196
206
  bucket: doc._id.b,
197
207
  seen: new Map(),
@@ -274,21 +284,14 @@ export class MongoCompactor {
274
284
  await this.flush();
275
285
  }
276
286
  }
277
- }
278
287
 
279
- currentState?.seen.clear();
280
- if (currentState?.lastNotPut != null && currentState?.opsSincePut > 1) {
281
- logger.info(
282
- `Inserting CLEAR at ${this.group_id}:${currentState.bucket}:${currentState.lastNotPut} to remove ${currentState.opsSincePut} operations`
283
- );
284
- // Need flush() before clear()
285
- await this.flush();
286
- await this.clearBucket(currentState);
287
- }
288
- if (currentState != null) {
289
- // Do this _after_ clearBucket so that we have accurate counts.
290
- this.updateBucketChecksums(currentState);
288
+ if (currentState != null) {
289
+ logger.info(`Processed batch of length ${batch.length} current bucket: ${currentState.bucket}`);
290
+ }
291
291
  }
292
+
293
+ await doneWithBucket();
294
+
292
295
  // Need another flush after updateBucketChecksums()
293
296
  await this.flush();
294
297
  }
@@ -325,15 +328,11 @@ export class MongoCompactor {
325
328
  count: 0,
326
329
  bytes: 0
327
330
  }
328
- },
329
- $setOnInsert: {
330
- // Only set this if we're creating the document.
331
- // In all other cases, the replication process will have a set a more accurate id.
332
- last_op: this.maxOpId
333
331
  }
334
332
  },
335
- // We generally expect this to have been created before, but do handle cases of old unchanged buckets
336
- upsert: true
333
+ // We generally expect this to have been created before.
334
+ // We don't create new ones here, to avoid issues with the unique index on bucket_updates.
335
+ upsert: false
337
336
  }
338
337
  });
339
338
  }
@@ -475,4 +474,97 @@ export class MongoCompactor {
475
474
  await session.endSession();
476
475
  }
477
476
  }
477
+
478
+ /**
479
+ * Subset of compact, only populating checksums where relevant.
480
+ */
481
+ async populateChecksums() {
482
+ // This is updated after each batch
483
+ let lowerBound: BucketStateDocument['_id'] = {
484
+ g: this.group_id,
485
+ b: new mongo.MinKey() as any
486
+ };
487
+ // This is static
488
+ const upperBound: BucketStateDocument['_id'] = {
489
+ g: this.group_id,
490
+ b: new mongo.MaxKey() as any
491
+ };
492
+ while (!this.signal?.aborted) {
493
+ // By filtering buckets, we effectively make this "resumeable".
494
+ const filter: mongo.Filter<BucketStateDocument> = {
495
+ _id: {
496
+ $gt: lowerBound,
497
+ $lt: upperBound
498
+ },
499
+ compacted_state: { $exists: false }
500
+ };
501
+
502
+ const bucketsWithoutChecksums = await this.db.bucket_state
503
+ .find(filter, {
504
+ projection: {
505
+ _id: 1
506
+ },
507
+ sort: {
508
+ _id: 1
509
+ },
510
+ limit: 5_000,
511
+ maxTimeMS: MONGO_OPERATION_TIMEOUT_MS
512
+ })
513
+ .toArray();
514
+ if (bucketsWithoutChecksums.length == 0) {
515
+ // All done
516
+ break;
517
+ }
518
+
519
+ logger.info(`Calculating checksums for batch of ${bucketsWithoutChecksums.length} buckets`);
520
+
521
+ await this.updateChecksumsBatch(bucketsWithoutChecksums.map((b) => b._id.b));
522
+
523
+ lowerBound = bucketsWithoutChecksums[bucketsWithoutChecksums.length - 1]._id;
524
+ }
525
+ }
526
+
527
+ private async updateChecksumsBatch(buckets: string[]) {
528
+ const checksums = await this.storage.checksums.computePartialChecksumsDirect(
529
+ buckets.map((bucket) => {
530
+ return {
531
+ bucket,
532
+ end: this.maxOpId
533
+ };
534
+ })
535
+ );
536
+
537
+ for (let bucketChecksum of checksums.values()) {
538
+ if (isPartialChecksum(bucketChecksum)) {
539
+ // Should never happen since we don't specify `start`
540
+ throw new ServiceAssertionError(`Full checksum expected, got ${JSON.stringify(bucketChecksum)}`);
541
+ }
542
+
543
+ this.bucketStateUpdates.push({
544
+ updateOne: {
545
+ filter: {
546
+ _id: {
547
+ g: this.group_id,
548
+ b: bucketChecksum.bucket
549
+ }
550
+ },
551
+ update: {
552
+ $set: {
553
+ compacted_state: {
554
+ op_id: this.maxOpId,
555
+ count: bucketChecksum.count,
556
+ checksum: BigInt(bucketChecksum.checksum),
557
+ bytes: null
558
+ }
559
+ }
560
+ },
561
+ // We don't create new ones here - it gets tricky to get the last_op right with the unique index on:
562
+ // bucket_updates: {'id.g': 1, 'last_op': 1}
563
+ upsert: false
564
+ }
565
+ });
566
+ }
567
+
568
+ await this.flush();
569
+ }
478
570
  }
@@ -7,9 +7,7 @@ import {
7
7
  ServiceAssertionError
8
8
  } from '@powersync/lib-services-framework';
9
9
  import {
10
- addPartialChecksums,
11
10
  BroadcastIterable,
12
- BucketChecksum,
13
11
  CHECKPOINT_INVALIDATE_ALL,
14
12
  CheckpointChanges,
15
13
  CompactOptions,
@@ -18,7 +16,6 @@ import {
18
16
  InternalOpId,
19
17
  internalToExternalOpId,
20
18
  maxLsn,
21
- PartialChecksum,
22
19
  ProtocolOpId,
23
20
  ReplicationCheckpoint,
24
21
  storage,
@@ -34,21 +31,22 @@ import { MongoBucketStorage } from '../MongoBucketStorage.js';
34
31
  import { PowerSyncMongo } from './db.js';
35
32
  import { BucketDataDocument, BucketDataKey, BucketStateDocument, SourceKey, SourceTableDocument } from './models.js';
36
33
  import { MongoBucketBatch } from './MongoBucketBatch.js';
34
+ import { MongoChecksumOptions, MongoChecksums } from './MongoChecksums.js';
37
35
  import { MongoCompactor } from './MongoCompactor.js';
38
36
  import { MongoParameterCompactor } from './MongoParameterCompactor.js';
39
37
  import { MongoWriteCheckpointAPI } from './MongoWriteCheckpointAPI.js';
40
38
  import { idPrefixFilter, mapOpEntry, readSingleBatch, setSessionSnapshotTime } from './util.js';
41
39
 
40
+ export interface MongoSyncBucketStorageOptions {
41
+ checksumOptions?: MongoChecksumOptions;
42
+ }
43
+
42
44
  export class MongoSyncBucketStorage
43
45
  extends BaseObserver<storage.SyncRulesBucketStorageListener>
44
46
  implements storage.SyncRulesBucketStorage
45
47
  {
46
48
  private readonly db: PowerSyncMongo;
47
- private checksumCache = new storage.ChecksumCache({
48
- fetchChecksums: (batch) => {
49
- return this.getChecksumsInternal(batch);
50
- }
51
- });
49
+ readonly checksums: MongoChecksums;
52
50
 
53
51
  private parsedSyncRulesCache: { parsed: SqlSyncRules; options: storage.ParseSyncRulesOptions } | undefined;
54
52
  private writeCheckpointAPI: MongoWriteCheckpointAPI;
@@ -58,13 +56,15 @@ export class MongoSyncBucketStorage
58
56
  public readonly group_id: number,
59
57
  private readonly sync_rules: storage.PersistedSyncRulesContent,
60
58
  public readonly slot_name: string,
61
- writeCheckpointMode: storage.WriteCheckpointMode = storage.WriteCheckpointMode.MANAGED
59
+ writeCheckpointMode?: storage.WriteCheckpointMode,
60
+ options?: MongoSyncBucketStorageOptions
62
61
  ) {
63
62
  super();
64
63
  this.db = factory.db;
64
+ this.checksums = new MongoChecksums(this.db, this.group_id, options?.checksumOptions);
65
65
  this.writeCheckpointAPI = new MongoWriteCheckpointAPI({
66
66
  db: this.db,
67
- mode: writeCheckpointMode,
67
+ mode: writeCheckpointMode ?? storage.WriteCheckpointMode.MANAGED,
68
68
  sync_rules_id: group_id
69
69
  });
70
70
  }
@@ -491,145 +491,11 @@ export class MongoSyncBucketStorage
491
491
  }
492
492
 
493
493
  async getChecksums(checkpoint: utils.InternalOpId, buckets: string[]): Promise<utils.ChecksumMap> {
494
- return this.checksumCache.getChecksumMap(checkpoint, buckets);
494
+ return this.checksums.getChecksums(checkpoint, buckets);
495
495
  }
496
496
 
497
497
  clearChecksumCache() {
498
- this.checksumCache.clear();
499
- }
500
-
501
- private async getChecksumsInternal(batch: storage.FetchPartialBucketChecksum[]): Promise<storage.PartialChecksumMap> {
502
- if (batch.length == 0) {
503
- return new Map();
504
- }
505
-
506
- const preFilters: any[] = [];
507
- for (let request of batch) {
508
- if (request.start == null) {
509
- preFilters.push({
510
- _id: {
511
- g: this.group_id,
512
- b: request.bucket
513
- },
514
- 'compacted_state.op_id': { $exists: true, $lte: request.end }
515
- });
516
- }
517
- }
518
-
519
- const preStates = new Map<string, { opId: InternalOpId; checksum: BucketChecksum }>();
520
-
521
- if (preFilters.length > 0) {
522
- // For un-cached bucket checksums, attempt to use the compacted state first.
523
- const states = await this.db.bucket_state
524
- .find({
525
- $or: preFilters
526
- })
527
- .toArray();
528
- for (let state of states) {
529
- const compactedState = state.compacted_state!;
530
- preStates.set(state._id.b, {
531
- opId: compactedState.op_id,
532
- checksum: {
533
- bucket: state._id.b,
534
- checksum: Number(compactedState.checksum),
535
- count: compactedState.count
536
- }
537
- });
538
- }
539
- }
540
-
541
- const filters: any[] = [];
542
- for (let request of batch) {
543
- let start = request.start;
544
- if (start == null) {
545
- const preState = preStates.get(request.bucket);
546
- if (preState != null) {
547
- start = preState.opId;
548
- }
549
- }
550
-
551
- filters.push({
552
- _id: {
553
- $gt: {
554
- g: this.group_id,
555
- b: request.bucket,
556
- o: start ?? new bson.MinKey()
557
- },
558
- $lte: {
559
- g: this.group_id,
560
- b: request.bucket,
561
- o: request.end
562
- }
563
- }
564
- });
565
- }
566
-
567
- const aggregate = await this.db.bucket_data
568
- .aggregate(
569
- [
570
- {
571
- $match: {
572
- $or: filters
573
- }
574
- },
575
- {
576
- $group: {
577
- _id: '$_id.b',
578
- // Historically, checksum may be stored as 'int' or 'double'.
579
- // More recently, this should be a 'long'.
580
- // $toLong ensures that we always sum it as a long, avoiding inaccuracies in the calculations.
581
- checksum_total: { $sum: { $toLong: '$checksum' } },
582
- count: { $sum: 1 },
583
- has_clear_op: {
584
- $max: {
585
- $cond: [{ $eq: ['$op', 'CLEAR'] }, 1, 0]
586
- }
587
- }
588
- }
589
- }
590
- ],
591
- { session: undefined, readConcern: 'snapshot', maxTimeMS: lib_mongo.db.MONGO_CHECKSUM_TIMEOUT_MS }
592
- )
593
- .toArray()
594
- .catch((e) => {
595
- throw lib_mongo.mapQueryError(e, 'while reading checksums');
596
- });
597
-
598
- const partialChecksums = new Map<string, storage.PartialOrFullChecksum>(
599
- aggregate.map((doc) => {
600
- const partialChecksum = Number(BigInt(doc.checksum_total) & 0xffffffffn) & 0xffffffff;
601
- const bucket = doc._id;
602
- return [
603
- bucket,
604
- doc.has_clear_op == 1
605
- ? ({
606
- // full checksum - replaces any previous one
607
- bucket,
608
- checksum: partialChecksum,
609
- count: doc.count
610
- } satisfies BucketChecksum)
611
- : ({
612
- // partial checksum - is added to a previous one
613
- bucket,
614
- partialCount: doc.count,
615
- partialChecksum
616
- } satisfies PartialChecksum)
617
- ];
618
- })
619
- );
620
-
621
- return new Map<string, storage.PartialOrFullChecksum>(
622
- batch.map((request) => {
623
- const bucket = request.bucket;
624
- // Could be null if this is either (1) a partial request, or (2) no compacted checksum was available
625
- const preState = preStates.get(bucket);
626
- // Could be null if we got no data
627
- const partialChecksum = partialChecksums.get(bucket);
628
- const merged = addPartialChecksums(bucket, preState?.checksum ?? null, partialChecksum ?? null);
629
-
630
- return [bucket, merged];
631
- })
632
- );
498
+ this.checksums.clearCache();
633
499
  }
634
500
 
635
501
  async terminate(options?: storage.TerminateOptions) {
@@ -779,22 +645,25 @@ export class MongoSyncBucketStorage
779
645
  const checkpoint = await this.getCheckpointInternal();
780
646
  maxOpId = checkpoint?.checkpoint ?? undefined;
781
647
  }
782
- await new MongoCompactor(this.db, this.group_id, { ...options, maxOpId }).compact();
648
+ await new MongoCompactor(this, this.db, { ...options, maxOpId }).compact();
649
+
783
650
  if (maxOpId != null && options?.compactParameterData) {
784
651
  await new MongoParameterCompactor(this.db, this.group_id, maxOpId, options).compact();
785
652
  }
786
653
  }
787
654
 
788
- async populatePersistentChecksumCache(options: Pick<CompactOptions, 'signal' | 'maxOpId'>): Promise<void> {
655
+ async populatePersistentChecksumCache(options: Required<Pick<CompactOptions, 'signal' | 'maxOpId'>>): Promise<void> {
656
+ logger.info(`Populating persistent checksum cache...`);
789
657
  const start = Date.now();
790
- // We do a minimal compact, primarily to populate the checksum cache
791
- await this.compact({
658
+ // We do a minimal compact here.
659
+ // We can optimize this in the future.
660
+ const compactor = new MongoCompactor(this, this.db, {
792
661
  ...options,
793
- // Skip parameter data
794
- compactParameterData: false,
795
662
  // Don't track updates for MOVE compacting
796
663
  memoryLimitMB: 0
797
664
  });
665
+
666
+ await compactor.populateChecksums();
798
667
  const duration = Date.now() - start;
799
668
  logger.info(`Populated persistent checksum cache in ${(duration / 1000).toFixed(1)}s`);
800
669
  }
@@ -1,10 +1,12 @@
1
1
  import { TestStorageOptions } from '@powersync/service-core';
2
2
  import { MongoBucketStorage } from '../MongoBucketStorage.js';
3
3
  import { connectMongoForTests } from './util.js';
4
+ import { MongoSyncBucketStorageOptions } from './MongoSyncBucketStorage.js';
4
5
 
5
6
  export type MongoTestStorageOptions = {
6
7
  url: string;
7
8
  isCI: boolean;
9
+ internalOptions?: MongoSyncBucketStorageOptions;
8
10
  };
9
11
 
10
12
  export const MongoTestStorageFactoryGenerator = (factoryOptions: MongoTestStorageOptions) => {
@@ -16,13 +18,14 @@ export const MongoTestStorageFactoryGenerator = (factoryOptions: MongoTestStorag
16
18
  await db.db.createCollection('bucket_parameters');
17
19
  }
18
20
 
19
- // Full migrations are not currently run for tests, so we manually create this
20
- await db.createCheckpointEventsCollection();
21
-
22
21
  if (!options?.doNotClear) {
23
22
  await db.clear();
24
23
  }
25
24
 
26
- return new MongoBucketStorage(db, { slot_name_prefix: 'test_' });
25
+ // Full migrations are not currently run for tests, so we manually create the important ones
26
+ await db.createCheckpointEventsCollection();
27
+ await db.createBucketStateIndex();
28
+
29
+ return new MongoBucketStorage(db, { slot_name_prefix: 'test_' }, factoryOptions.internalOptions);
27
30
  };
28
31
  };
@@ -127,6 +127,20 @@ export class PowerSyncMongo {
127
127
  max: 50 // max number of documents
128
128
  });
129
129
  }
130
+
131
+ /**
132
+ * Only use in migrations and tests.
133
+ */
134
+ async createBucketStateIndex() {
135
+ // TODO: Implement a better mechanism to use migrations in tests
136
+ await this.bucket_state.createIndex(
137
+ {
138
+ '_id.g': 1,
139
+ last_op: 1
140
+ },
141
+ { name: 'bucket_updates', unique: true }
142
+ );
143
+ }
130
144
  }
131
145
 
132
146
  export function createPowerSyncMongo(config: MongoStorageConfig, options?: lib_mongo.MongoConnectionOptions) {
@@ -97,6 +97,10 @@ export interface BucketStateDocument {
97
97
  g: number;
98
98
  b: string;
99
99
  };
100
+ /**
101
+ * Important: There is an unique index on {'_id.g': 1, last_op: 1}.
102
+ * That means the last_op must match an actual op in the bucket, and not the commit checkpoint.
103
+ */
100
104
  last_op: bigint;
101
105
  /**
102
106
  * If set, this can be treated as "cache" of a checksum at a specific point.
@@ -106,7 +110,7 @@ export interface BucketStateDocument {
106
110
  op_id: InternalOpId;
107
111
  count: number;
108
112
  checksum: bigint;
109
- bytes: number;
113
+ bytes: number | null;
110
114
  };
111
115
 
112
116
  estimate_since_compact?: {
@@ -3,7 +3,7 @@ import * as crypto from 'crypto';
3
3
  import * as uuid from 'uuid';
4
4
 
5
5
  import { mongo } from '@powersync/lib-service-mongodb';
6
- import { storage, utils } from '@powersync/service-core';
6
+ import { BucketChecksum, PartialChecksum, PartialOrFullChecksum, storage, utils } from '@powersync/service-core';
7
7
 
8
8
  import { PowerSyncMongo } from './db.js';
9
9
  import { BucketDataDocument } from './models.js';
@@ -1,6 +1,22 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
- exports[`Mongo Sync Bucket Storage > empty storage metrics 1`] = `
3
+ exports[`Mongo Sync Bucket Storage - Data > empty storage metrics 1`] = `
4
+ {
5
+ "operations_size_bytes": 0,
6
+ "parameters_size_bytes": 0,
7
+ "replication_size_bytes": 0,
8
+ }
9
+ `;
10
+
11
+ exports[`Mongo Sync Bucket Storage - split buckets > empty storage metrics 1`] = `
12
+ {
13
+ "operations_size_bytes": 0,
14
+ "parameters_size_bytes": 0,
15
+ "replication_size_bytes": 0,
16
+ }
17
+ `;
18
+
19
+ exports[`Mongo Sync Bucket Storage - split operations > empty storage metrics 1`] = `
4
20
  {
5
21
  "operations_size_bytes": 0,
6
22
  "parameters_size_bytes": 0,
@@ -1,7 +1,44 @@
1
1
  import { register } from '@powersync/service-core-tests';
2
2
  import { describe } from 'vitest';
3
3
  import { INITIALIZED_MONGO_STORAGE_FACTORY } from './util.js';
4
+ import { env } from './env.js';
5
+ import { MongoTestStorageFactoryGenerator } from '@module/storage/implementation/MongoTestStorageFactoryGenerator.js';
4
6
 
5
- describe('Mongo Sync Bucket Storage', () => register.registerDataStorageTests(INITIALIZED_MONGO_STORAGE_FACTORY));
7
+ describe('Mongo Sync Bucket Storage - Parameters', () =>
8
+ register.registerDataStorageParameterTests(INITIALIZED_MONGO_STORAGE_FACTORY));
9
+
10
+ describe('Mongo Sync Bucket Storage - Data', () =>
11
+ register.registerDataStorageDataTests(INITIALIZED_MONGO_STORAGE_FACTORY));
12
+
13
+ describe('Mongo Sync Bucket Storage - Checkpoints', () =>
14
+ register.registerDataStorageCheckpointTests(INITIALIZED_MONGO_STORAGE_FACTORY));
6
15
 
7
16
  describe('Sync Bucket Validation', register.registerBucketValidationTests);
17
+
18
+ describe('Mongo Sync Bucket Storage - split operations', () =>
19
+ register.registerDataStorageDataTests(
20
+ MongoTestStorageFactoryGenerator({
21
+ url: env.MONGO_TEST_URL,
22
+ isCI: env.CI,
23
+ internalOptions: {
24
+ checksumOptions: {
25
+ bucketBatchLimit: 100,
26
+ operationBatchLimit: 1
27
+ }
28
+ }
29
+ })
30
+ ));
31
+
32
+ describe('Mongo Sync Bucket Storage - split buckets', () =>
33
+ register.registerDataStorageDataTests(
34
+ MongoTestStorageFactoryGenerator({
35
+ url: env.MONGO_TEST_URL,
36
+ isCI: env.CI,
37
+ internalOptions: {
38
+ checksumOptions: {
39
+ bucketBatchLimit: 1,
40
+ operationBatchLimit: 100
41
+ }
42
+ }
43
+ })
44
+ ));