@powersync/service-module-mongodb-storage 0.22.0 → 0.23.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/storage/MongoBucketStorage.d.ts +5 -1
  3. package/dist/storage/MongoBucketStorage.js +30 -6
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +23 -10
  6. package/dist/storage/implementation/MongoBucketBatch.js +132 -88
  7. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  8. package/dist/storage/implementation/MongoCompactor.d.ts +2 -0
  9. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  10. package/dist/storage/implementation/MongoIdSequence.d.ts +10 -1
  11. package/dist/storage/implementation/MongoIdSequence.js +14 -1
  12. package/dist/storage/implementation/MongoIdSequence.js.map +1 -1
  13. package/dist/storage/implementation/MongoOpIdAllocator.d.ts +27 -0
  14. package/dist/storage/implementation/MongoOpIdAllocator.js +97 -0
  15. package/dist/storage/implementation/MongoOpIdAllocator.js.map +1 -0
  16. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +5 -0
  17. package/dist/storage/implementation/MongoSyncBucketStorage.js +17 -0
  18. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  19. package/dist/storage/implementation/MongoSyncRulesLock.d.ts +26 -2
  20. package/dist/storage/implementation/MongoSyncRulesLock.js +38 -1
  21. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  22. package/dist/storage/implementation/db.js +1 -0
  23. package/dist/storage/implementation/db.js.map +1 -1
  24. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +5 -0
  25. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +136 -133
  26. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  27. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +1 -1
  28. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  29. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +5 -1
  30. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +131 -148
  31. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  32. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +4 -3
  33. package/dist/storage/implementation/v3/MongoCompactorV3.js +14 -10
  34. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  35. package/dist/storage/implementation/v3/compact-utils.d.ts +3 -2
  36. package/dist/storage/implementation/v3/compact-utils.js +12 -5
  37. package/dist/storage/implementation/v3/compact-utils.js.map +1 -1
  38. package/dist/storage/implementation/v3/models.d.ts +1 -1
  39. package/dist/utils/test-utils.d.ts +2 -1
  40. package/dist/utils/test-utils.js +4 -3
  41. package/dist/utils/test-utils.js.map +1 -1
  42. package/package.json +7 -7
  43. package/src/storage/MongoBucketStorage.ts +35 -7
  44. package/src/storage/implementation/MongoBucketBatch.ts +201 -110
  45. package/src/storage/implementation/MongoCompactor.ts +2 -0
  46. package/src/storage/implementation/MongoIdSequence.ts +21 -1
  47. package/src/storage/implementation/MongoOpIdAllocator.ts +105 -0
  48. package/src/storage/implementation/MongoSyncBucketStorage.ts +19 -0
  49. package/src/storage/implementation/MongoSyncRulesLock.ts +56 -3
  50. package/src/storage/implementation/db.ts +1 -0
  51. package/src/storage/implementation/models.ts +1 -0
  52. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +150 -152
  53. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +1 -1
  54. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +167 -194
  55. package/src/storage/implementation/v3/MongoCompactorV3.ts +19 -22
  56. package/src/storage/implementation/v3/compact-utils.ts +16 -7
  57. package/src/storage/implementation/v3/models.ts +1 -1
  58. package/src/utils/test-utils.ts +5 -3
  59. package/test/src/__snapshots__/storage.test.ts.snap +102 -2
  60. package/test/src/__snapshots__/storage_compacting.test.ts.snap +2 -2
  61. package/test/src/__snapshots__/storage_sync.test.ts.snap +12 -12
  62. package/test/src/cleanup-stopped-sync-configs.test.ts +19 -13
  63. package/test/src/compact-utils.test.ts +33 -8
  64. package/test/src/concurrent_writers.test.ts +599 -0
  65. package/test/src/helpers/concurrentWriter.mjs +29 -0
  66. package/test/src/helpers/s3TestFactory.ts +12 -12
  67. package/test/src/object_storage_usage.test.ts +2 -1
  68. package/test/src/parameter_compacting_v1.test.ts +2 -2
  69. package/test/src/parameter_compacting_v3.test.ts +2 -2
  70. package/test/src/parameter_compaction_fence.test.ts +3 -3
  71. package/test/src/storage.test.ts +32 -33
  72. package/test/src/storage_compacting.test.ts +147 -21
  73. package/test/src/storage_object_storage_inline_threshold.test.ts +3 -3
  74. package/test/src/storage_s3_checksums.test.ts +4 -4
  75. package/test/src/storage_s3_compaction_lifecycle.test.ts +8 -8
  76. package/test/src/storage_s3_reading.test.ts +4 -4
  77. package/test/src/storage_s3_writing.test.ts +3 -3
  78. package/test/src/storage_sync.test.ts +52 -46
  79. package/test/src/storeCurrentData.test.ts +5 -5
  80. package/tsconfig.tsbuildinfo +1 -1
@@ -8,6 +8,7 @@ import { VersionedPowerSyncMongoV3 } from '@module/storage/implementation/v3/Ver
8
8
  import * as lib_mongo from '@powersync/lib-service-mongodb';
9
9
  import { mongo } from '@powersync/lib-service-mongodb';
10
10
  import { updateSyncRulesFromYaml } from '@powersync/service-core';
11
+ import { getTestStorage } from '@powersync/service-core-tests';
11
12
  import { BucketDefinitionId } from '@powersync/service-sync-rules';
12
13
  import { describe, expect, test } from 'vitest';
13
14
  import { INITIALIZED_MONGO_STORAGE_FACTORY } from './util.js';
@@ -33,7 +34,7 @@ type UsageEntry = {
33
34
  async function withUsageContext<T>(callback: (context: UsageContext) => Promise<T>): Promise<T> {
34
35
  await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
35
36
  const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_RULES_YAML, { storageVersion: 3 }));
36
- const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
37
+ const bucketStorage = (await getTestStorage(factory, syncRules)) as MongoSyncBucketStorage;
37
38
  const db = bucketStorage.db as VersionedPowerSyncMongoV3;
38
39
  const definitionId = syncRules.syncConfigContent[0].mapping.allBucketDefinitionIds()[0];
39
40
  return callback({ db, bucketStorage, definitionId });
@@ -1,5 +1,5 @@
1
1
  import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
2
- import { test_utils } from '@powersync/service-core-tests';
2
+ import { getTestStorage, test_utils } from '@powersync/service-core-tests';
3
3
  import * as bson from 'bson';
4
4
  import { describe, expect, test } from 'vitest';
5
5
  import type { SyncRuleDocumentV1 } from '../../src/storage/implementation/v1/models.js';
@@ -20,7 +20,7 @@ async function createActiveStorage() {
20
20
  const syncRules = await factory.updateSyncRules(
21
21
  updateSyncRulesFromYaml(PARAMETER_RULES, { storageVersion: storage.STORAGE_VERSION_2 })
22
22
  );
23
- const processingStorage = factory.getInstance(syncRules);
23
+ const processingStorage = await getTestStorage(factory, syncRules);
24
24
  await using writer = await processingStorage.createWriter(test_utils.BATCH_OPTIONS);
25
25
  await writer.markAllSnapshotDone('1/1');
26
26
  await writer.commit('1/1');
@@ -1,5 +1,5 @@
1
1
  import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
2
- import { test_utils } from '@powersync/service-core-tests';
2
+ import { getTestStorage, test_utils } from '@powersync/service-core-tests';
3
3
  import * as bson from 'bson';
4
4
  import { describe, expect, test } from 'vitest';
5
5
  import { MongoParameterCompactorV3 } from '../../src/storage/implementation/v3/MongoParameterCompactorV3.js';
@@ -31,7 +31,7 @@ async function createActiveStorage(rules = PARAMETER_RULES) {
31
31
  const syncRules = await factory.updateSyncRules(
32
32
  updateSyncRulesFromYaml(rules, { storageVersion: storage.STORAGE_VERSION_3 })
33
33
  );
34
- const processingStorage = factory.getInstance(syncRules);
34
+ const processingStorage = await getTestStorage(factory, syncRules);
35
35
  await using writer = await processingStorage.createWriter(test_utils.BATCH_OPTIONS);
36
36
  await writer.markAllSnapshotDone('1/1');
37
37
  await writer.commit('1/1');
@@ -1,6 +1,6 @@
1
1
  import { mongo } from '@powersync/lib-service-mongodb';
2
2
  import { InternalOpId, storage, updateSyncRulesFromYaml } from '@powersync/service-core';
3
- import { test_utils } from '@powersync/service-core-tests';
3
+ import { getTestStorage, test_utils } from '@powersync/service-core-tests';
4
4
  import { describe, expect, test } from 'vitest';
5
5
  import type { VersionedPowerSyncMongo } from '../../src/storage/implementation/db.js';
6
6
  import type { SyncRuleDocumentBase } from '../../src/storage/implementation/models.js';
@@ -83,7 +83,7 @@ describe('parameter compaction invalidation fence', () => {
83
83
  */
84
84
  async function replicateParameterHistory(factory: storage.BucketStorageFactory) {
85
85
  const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(PARAMETER_RULES, { storageVersion }));
86
- const processingStorage = factory.getInstance(syncRules);
86
+ const processingStorage = await getTestStorage(factory, syncRules);
87
87
  const writer = await processingStorage.createWriter(test_utils.BATCH_OPTIONS);
88
88
  const testTable = await test_utils.resolveTestTable(writer, 'test', ['id'], INITIALIZED_MONGO_STORAGE_FACTORY);
89
89
 
@@ -196,7 +196,7 @@ describe('parameter compaction invalidation fence', () => {
196
196
  // which is the only record of t2's lookup.
197
197
  //
198
198
  // A separate storage instance is used to get a cold checkpoint-changes cache.
199
- const coldStorage = (factory as MongoBucketStorage).getInstance(replicationStream);
199
+ const coldStorage = await getTestStorage(factory as MongoBucketStorage, replicationStream);
200
200
  const atSnapshot = await coldStorage.getCheckpointChanges({
201
201
  lastCheckpoint: checkpoint1,
202
202
  nextCheckpoint: checkpoint3
@@ -1,7 +1,7 @@
1
1
  import { mongoTestStorageFactoryGenerator } from '@module/utils/test-utils.js';
2
2
  import { mongo } from '@powersync/lib-service-mongodb';
3
3
  import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
4
- import { compactActive, register, test_utils } from '@powersync/service-core-tests';
4
+ import { compactActive, getTestStorage, register, test_utils } from '@powersync/service-core-tests';
5
5
  import { describe, expect, test } from 'vitest';
6
6
  import { MongoSyncBucketStorage } from '../../src/storage/implementation/createMongoSyncBucketStorage.js';
7
7
  import { VersionedPowerSyncMongoV3 } from '../../src/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
@@ -35,7 +35,7 @@ bucket_definitions:
35
35
  { storageVersion }
36
36
  )
37
37
  );
38
- const bucketStorage = factory.getInstance(syncRules);
38
+ const bucketStorage = await getTestStorage(factory, syncRules);
39
39
 
40
40
  // user1 has no existing row, so this covers updateMany with upsert.
41
41
  // The initial request stores checkpoint id 42 at source head 5/0.
@@ -144,7 +144,8 @@ bucket_definitions:
144
144
  user_id: 'custom1',
145
145
  checkpoint: 52n
146
146
  });
147
- await writer.flush();
147
+ // A custom-checkpoint-only commit uses the combined final flush path too.
148
+ await writer.commit('8/1');
148
149
  const customGenerated = await factory.db.custom_write_checkpoints.findOne({ user_id: 'custom1' });
149
150
  expect(customGenerated).not.toBeNull();
150
151
  expect(customGenerated?.checkpoint_requested_at).toBeUndefined();
@@ -179,7 +180,7 @@ event_definitions:
179
180
  { storageVersion }
180
181
  )
181
182
  );
182
- const bucketStorage = factory.getInstance(syncRules);
183
+ const bucketStorage = await getTestStorage(factory, syncRules);
183
184
  const db = bucketStorage.db as VersionedPowerSyncMongoV3;
184
185
  const activeSyncConfig = bucketStorage.getParsedSyncRules({ defaultSchema: 'public' });
185
186
  const eventA = activeSyncConfig.eventDescriptors.find((event) => event.name == 'checkpoint_a')!;
@@ -228,7 +229,7 @@ event_definitions:
228
229
 
229
230
  writer.addCustomWriteCheckpoint({ user_id: 'user1', checkpoint: 5n, event_id: eventAId });
230
231
  writer.addCustomWriteCheckpoint({ user_id: 'user1', checkpoint: 8n, event_id: eventBId });
231
- await writer.flush();
232
+ // Collections must be prepared before the combined flush/checkpoint transaction.
232
233
  await writer.keepalive('5/0');
233
234
 
234
235
  const eventACollection = db.customCheckpointRequests({
@@ -296,7 +297,7 @@ event_definitions:
296
297
  // A fresh storage instance has an empty initialization cache, just like
297
298
  // one created after a service restart. Recreating the existing indexes
298
299
  // must be idempotent before another checkpoint is written.
299
- const freshBucketStorage = factory.getInstance(syncRules);
300
+ const freshBucketStorage = await getTestStorage(factory, syncRules);
300
301
  await using freshWriter = await freshBucketStorage.createWriter(test_utils.BATCH_OPTIONS);
301
302
  freshWriter.addCustomWriteCheckpoint({
302
303
  user_id: 'after-restart',
@@ -336,7 +337,7 @@ event_definitions:
336
337
  updateSyncRulesFromYaml(syncConfigYaml("kind = 'write'", false), { storageVersion })
337
338
  );
338
339
  const firstEventId = first.syncConfigContent[0].mapping.eventDefinitionIdByName('write_checkpoints');
339
- const firstStorage = factory.getInstance(first);
340
+ const firstStorage = await getTestStorage(factory, first);
340
341
  await using firstWriter = await firstStorage.createWriter(test_utils.BATCH_OPTIONS);
341
342
  firstWriter.addCustomWriteCheckpoint({ user_id: 'user1', checkpoint: 5n, event_id: firstEventId });
342
343
  await firstWriter.markAllSnapshotDone('1/1');
@@ -348,7 +349,9 @@ event_definitions:
348
349
  const unchangedEventId = unchanged.syncConfigContent[1].mapping.eventDefinitionIdByName('write_checkpoints');
349
350
  expect(unchangedEventId).toBe(firstEventId);
350
351
 
351
- await using unchangedWriter = await factory.getInstance(unchanged).createWriter(test_utils.BATCH_OPTIONS);
352
+ await using unchangedWriter = await (
353
+ await getTestStorage(factory, unchanged)
354
+ ).createWriter(test_utils.BATCH_OPTIONS);
352
355
  await unchangedWriter.markAllSnapshotDone('2/1');
353
356
  await unchangedWriter.commit('2/1');
354
357
 
@@ -369,7 +372,9 @@ event_definitions:
369
372
  const changedEventId = changed.syncConfigContent[1].mapping.eventDefinitionIdByName('write_checkpoints');
370
373
  expect(changedEventId).not.toBe(unchangedEventId);
371
374
 
372
- await using changedWriter = await factory.getInstance(changed).createWriter(test_utils.BATCH_OPTIONS);
375
+ await using changedWriter = await (
376
+ await getTestStorage(factory, changed)
377
+ ).createWriter(test_utils.BATCH_OPTIONS);
373
378
  changedWriter.addCustomWriteCheckpoint({ user_id: 'user1', checkpoint: 9n, event_id: changedEventId });
374
379
  await changedWriter.flush();
375
380
 
@@ -415,7 +420,7 @@ bucket_definitions:
415
420
  { storageVersion }
416
421
  )
417
422
  );
418
- const bucketStorage = factory.getInstance(syncRules);
423
+ const bucketStorage = await getTestStorage(factory, syncRules);
419
424
 
420
425
  // Replicate an extremely rare possibility where there are multiple records
421
426
  await factory.db.write_checkpoints.insertMany([
@@ -486,26 +491,20 @@ bucket_definitions:
486
491
 
487
492
  describe('Sync Bucket Validation', register.registerBucketValidationTests);
488
493
 
489
- describe('Mongo Sync Bucket Storage - split operations', () =>
490
- register.registerDataStorageDataTests(
491
- mongoTestStorageFactoryGenerator({
492
- url: env.MONGO_TEST_URL,
493
- isCI: env.CI,
494
- checksumOptions: {
495
- bucketBatchLimit: 100,
496
- operationBatchLimit: 1
497
- }
498
- })
499
- ));
500
-
501
- describe('Mongo Sync Bucket Storage - split buckets', () =>
502
- register.registerDataStorageDataTests(
503
- mongoTestStorageFactoryGenerator({
504
- url: env.MONGO_TEST_URL,
505
- isCI: env.CI,
506
- checksumOptions: {
507
- bucketBatchLimit: 1,
508
- operationBatchLimit: 100
509
- }
510
- })
511
- ));
494
+ for (const storageVersion of TEST_STORAGE_VERSIONS) {
495
+ for (const [name, checksumOptions] of [
496
+ ['split operations', { bucketBatchLimit: 100, operationBatchLimit: 1 }],
497
+ ['split buckets', { bucketBatchLimit: 1, operationBatchLimit: 100 }]
498
+ ] as const) {
499
+ describe(`Mongo Sync Bucket Storage - ${name} - v${storageVersion}`, () =>
500
+ register.registerDataStorageDataTests({
501
+ ...mongoTestStorageFactoryGenerator({
502
+ url: env.MONGO_TEST_URL,
503
+ isCI: env.CI,
504
+ checksumOptions
505
+ }),
506
+ storageVersion,
507
+ compressedBucketStorage: storageVersion >= 3
508
+ }));
509
+ }
510
+ }
@@ -17,7 +17,7 @@ import {
17
17
  SyncRulesBucketStorage,
18
18
  updateSyncRulesFromYaml
19
19
  } from '@powersync/service-core';
20
- import { bucketRequest, compactActive, register, test_utils } from '@powersync/service-core-tests';
20
+ import { bucketRequest, compactActive, getTestStorage, register, test_utils } from '@powersync/service-core-tests';
21
21
  import * as bson from 'bson';
22
22
  import { describe, expect, test, vi } from 'vitest';
23
23
  import { env } from './env.js';
@@ -106,7 +106,7 @@ bucket_definitions:
106
106
  { storageVersion }
107
107
  )
108
108
  );
109
- const bucketStorage = factory.getInstance(syncRules);
109
+ const bucketStorage = await getTestStorage(factory, syncRules);
110
110
  const syncRulesContent = syncRules.syncConfigContent[0];
111
111
  const { checkpoint } = await populate(bucketStorage, 1);
112
112
 
@@ -164,15 +164,16 @@ bucket_definitions:
164
164
  { storageVersion }
165
165
  )
166
166
  );
167
- const bucketStorage = factory.getInstance(syncRules);
167
+ const bucketStorage = await getTestStorage(factory, syncRules);
168
168
  const syncRulesContent = syncRules.syncConfigContent[0];
169
169
 
170
170
  await populate(bucketStorage, 2);
171
171
  const { checkpoint } = await bucketStorage.getCheckpoint();
172
172
 
173
- // V3's initial lite pass processes every bucket with no prior compact state.
173
+ // Force V3's initial lite pass to process these small buckets for cache verification.
174
174
  // Earlier storage versions use the default minimum-change threshold.
175
175
  const result0 = await bucketStorage.compactInitialReplication({
176
+ forceChunkCompaction: true,
176
177
  maxOpId: checkpoint
177
178
  });
178
179
  expect(result0.buckets).toEqual(storageVersion >= storage.STORAGE_VERSION_3 ? 2 : 0);
@@ -180,6 +181,7 @@ bucket_definitions:
180
181
  // For V1/V2, lower the threshold to populate the checksum cache. V3 has
181
182
  // already updated its compacted state, so another initial pass is a no-op.
182
183
  const result1 = await bucketStorage.compactInitialReplication({
184
+ forceChunkCompaction: true,
183
185
  maxOpId: checkpoint,
184
186
  minBucketChanges: 1
185
187
  });
@@ -187,6 +189,7 @@ bucket_definitions:
187
189
 
188
190
  // Repeating it stays a no-op.
189
191
  const result2 = await bucketStorage.compactInitialReplication({
192
+ forceChunkCompaction: true,
190
193
  maxOpId: checkpoint,
191
194
  minBucketChanges: 1
192
195
  });
@@ -214,6 +217,7 @@ bucket_definitions:
214
217
  .createMongoCompactor({
215
218
  maxOpId: checkpoint,
216
219
  compactChunksOnly: true,
220
+ forceChunkCompaction: true,
217
221
  minCompactChunkIntervalMs: 1
218
222
  })
219
223
  .compact();
@@ -223,7 +227,10 @@ bucket_definitions:
223
227
 
224
228
  test('v3 repeated initial chunk compaction reschedules overdue full work beyond the current run', async () => {
225
229
  const { bucketStorage, checkpoint } = await setup(storage.STORAGE_VERSION_3);
226
- const firstResult = await bucketStorage.compactInitialReplication({ maxOpId: checkpoint });
230
+ const firstResult = await bucketStorage.compactInitialReplication({
231
+ forceChunkCompaction: true,
232
+ maxOpId: checkpoint
233
+ });
227
234
  expect(firstResult.buckets).toBe(2);
228
235
 
229
236
  const bucketStateCollection = (bucketStorage.db as VersionedPowerSyncMongoV3).bucketState(
@@ -243,6 +250,7 @@ bucket_definitions:
243
250
  .aggregate<{ now: Date }>([{ $documents: [{}] }, { $project: { _id: 0, now: '$$NOW' } }])
244
251
  .toArray();
245
252
  const result = await bucketStorage.compactInitialReplication({
253
+ forceChunkCompaction: true,
246
254
  maxOpId: checkpoint,
247
255
  signal: AbortSignal.timeout(2_000)
248
256
  });
@@ -289,7 +297,11 @@ bucket_definitions:
289
297
  });
290
298
 
291
299
  describe('Mongo Sync Parameter Storage Compact', () => {
292
- register.registerParameterCompactTests(INITIALIZED_MONGO_STORAGE_FACTORY);
300
+ for (const storageVersion of TEST_STORAGE_VERSIONS) {
301
+ describe(`storage v${storageVersion}`, () => {
302
+ register.registerParameterCompactTests({ ...INITIALIZED_MONGO_STORAGE_FACTORY, storageVersion });
303
+ });
304
+ }
293
305
  });
294
306
 
295
307
  /**
@@ -348,7 +360,7 @@ bucket_definitions:
348
360
  { storageVersion: 3 }
349
361
  )
350
362
  );
351
- const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
363
+ const bucketStorage = (await getTestStorage(factory, syncRules)) as MongoSyncBucketStorage;
352
364
  const db = bucketStorage.db as VersionedPowerSyncMongoV3;
353
365
  const mapping = syncRules.syncConfigContent[0].mapping;
354
366
  const definitionId = mapping.allBucketDefinitionIds()[0];
@@ -388,6 +400,91 @@ bucket_definitions:
388
400
  });
389
401
  }
390
402
 
403
+ test.each([1, 7])(
404
+ 'initial compaction bulk-reschedules buckets with %s chunks without reading data',
405
+ async (chunks) => {
406
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId, db } = await setupV3Storage();
407
+ const documents = Array.from({ length: chunks }, (_, index) =>
408
+ serializeBucketData(BUCKET, [makeOp(index + 1, String(index), 'data', ctx, sourceTableId)])
409
+ );
410
+ await insertDocs(collection, documents);
411
+ await bucketStateCollection.insertOne({
412
+ _id: { d: ctx.definitionId, b: BUCKET },
413
+ last_op: BigInt(chunks),
414
+ next_compact_check: new Date(0),
415
+ // Even overdue full compaction must not force a small initial chunk merge.
416
+ first_uncompacted_write: new Date(0),
417
+ bucket_stats: { count: chunks, bytes: BigInt(documents.reduce((sum, doc) => sum + doc.size, 0)), chunks }
418
+ });
419
+ const claim = vi.spyOn(CompactionLease, 'claim');
420
+ const data = vi.spyOn(db, 'bucketData');
421
+ try {
422
+ expect(await bucketStorage.compactInitialReplication({ maxOpId: BigInt(chunks) })).toEqual({ buckets: 0 });
423
+ expect(claim).not.toHaveBeenCalled();
424
+ expect(data).not.toHaveBeenCalled();
425
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
426
+ expect(state?.compacted_state).toBeUndefined();
427
+ expect(state?.compact_lease).toBeUndefined();
428
+ expect(state?.last_full_compact).toBeUndefined();
429
+ expect(state!.next_compact_check!.getTime()).toBeGreaterThan(Date.now());
430
+ expect(await collection.find().toArray()).toEqual(documents);
431
+ } finally {
432
+ claim.mockRestore();
433
+ data.mockRestore();
434
+ }
435
+ }
436
+ );
437
+
438
+ test('initial compaction rechecks the chunk threshold after claiming', async () => {
439
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId, db } = await setupV3Storage();
440
+ const documents = Array.from({ length: 8 }, (_, index) =>
441
+ serializeBucketData(BUCKET, [makeOp(index + 1, String(index), 'data', ctx, sourceTableId)])
442
+ );
443
+ await insertDocs(collection, documents);
444
+ const bytes = BigInt(documents.reduce((sum, doc) => sum + doc.size, 0));
445
+ await bucketStateCollection.insertOne({
446
+ _id: { d: ctx.definitionId, b: BUCKET },
447
+ last_op: 8n,
448
+ next_compact_check: new Date(0),
449
+ first_uncompacted_write: new Date(),
450
+ bucket_stats: { count: 8, bytes, chunks: 8 }
451
+ });
452
+ const originalClaim = CompactionLease.claim.bind(CompactionLease);
453
+ const claim = vi.spyOn(CompactionLease, 'claim').mockImplementationOnce(async (...args) => {
454
+ // Another compactor finishes before we acquire the lease, leaving one
455
+ // new chunk beyond its cached prefix instead of the eight we selected.
456
+ await bucketStateCollection.updateOne(
457
+ { _id: { d: ctx.definitionId, b: BUCKET } },
458
+ {
459
+ $set: {
460
+ compacted_state: {
461
+ op_id: 7n,
462
+ checksum: 196n,
463
+ count: 7,
464
+ bytes: bytes - BigInt(documents[7].size),
465
+ chunks: 7,
466
+ at: new Date()
467
+ }
468
+ }
469
+ }
470
+ );
471
+ return originalClaim(...args);
472
+ });
473
+ const data = vi.spyOn(db, 'bucketData');
474
+ try {
475
+ expect(await bucketStorage.compactInitialReplication({ maxOpId: 8n })).toEqual({ buckets: 0 });
476
+ expect(claim).toHaveBeenCalledTimes(1);
477
+ expect(data).not.toHaveBeenCalled();
478
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
479
+ expect(state?.compacted_state?.op_id).toBe(7n);
480
+ expect(state?.compact_lease).toBeUndefined();
481
+ expect(state!.next_compact_check!.getTime()).toBeGreaterThan(Date.now());
482
+ } finally {
483
+ claim.mockRestore();
484
+ data.mockRestore();
485
+ }
486
+ });
487
+
391
488
  test('a successful lease renewal clears a transient renewal error', async () => {
392
489
  const { bucketStateCollection, ctx } = await setupV3Storage();
393
490
  await insertBucketState(bucketStateCollection, ctx.definitionId, 1n);
@@ -449,7 +546,7 @@ bucket_definitions:
449
546
  );
450
547
 
451
548
  await (bucketStorage as MongoSyncBucketStorage)
452
- .createMongoCompactor({ maxOpId: 2n, compactChunksOnly: true })
549
+ .createMongoCompactor({ maxOpId: 2n, compactChunksOnly: true, forceChunkCompaction: true })
453
550
  .compact();
454
551
 
455
552
  const [badState, goodState] = await Promise.all([
@@ -493,6 +590,7 @@ bucket_definitions:
493
590
  .createMongoCompactor({
494
591
  maxOpId: 2n,
495
592
  compactChunksOnly: true,
593
+ forceChunkCompaction: true,
496
594
  signal: abortController.signal,
497
595
  logger: testLogger
498
596
  })
@@ -896,7 +994,7 @@ bucket_definitions:
896
994
  { storageVersion: 3 }
897
995
  )
898
996
  );
899
- const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
997
+ const bucketStorage = (await getTestStorage(factory, syncRules)) as MongoSyncBucketStorage;
900
998
  const db = bucketStorage.db as VersionedPowerSyncMongoV3;
901
999
  const mapping = syncRules.syncConfigContent[0].mapping;
902
1000
  const definitionId = mapping.allBucketDefinitionIds()[0];
@@ -1073,7 +1171,7 @@ bucket_definitions:
1073
1171
  { storageVersion: 3 }
1074
1172
  )
1075
1173
  );
1076
- const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
1174
+ const bucketStorage = (await getTestStorage(factory, syncRules)) as MongoSyncBucketStorage;
1077
1175
  const db = bucketStorage.db as VersionedPowerSyncMongoV3;
1078
1176
  const mapping = syncRules.syncConfigContent[0].mapping;
1079
1177
  const definitionId = mapping.allBucketDefinitionIds()[0];
@@ -1466,7 +1564,7 @@ bucket_definitions:
1466
1564
  { storageVersion: 3 }
1467
1565
  )
1468
1566
  );
1469
- const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
1567
+ const bucketStorage = (await getTestStorage(factory, syncRules)) as MongoSyncBucketStorage;
1470
1568
  const db = bucketStorage.db as VersionedPowerSyncMongoV3;
1471
1569
  const mapping = syncRules.syncConfigContent[0].mapping;
1472
1570
  const definitionId = mapping.allBucketDefinitionIds()[0];
@@ -1655,7 +1753,7 @@ bucket_definitions:
1655
1753
  { storageVersion: 3 }
1656
1754
  )
1657
1755
  );
1658
- const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
1756
+ const bucketStorage = (await getTestStorage(factory, syncRules)) as MongoSyncBucketStorage;
1659
1757
  const db = bucketStorage.db as VersionedPowerSyncMongoV3;
1660
1758
  const definitionId = bucketStorage.storageIds.bucketDefinitionIds[0];
1661
1759
  const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
@@ -1752,6 +1850,29 @@ bucket_definitions:
1752
1850
  expect(state?.bucket_stats).toEqual({ count: 4, bytes, chunks: documents.length });
1753
1851
  }
1754
1852
 
1853
+ test('initial compaction merges eight chunks immediately without full compaction', async () => {
1854
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1855
+ const inputs = Array.from({ length: 8 }, (_, index) =>
1856
+ serializeBucketData(BUCKET, [makeOp(index + 1, 'A', 'data', ctx, sourceTableId)])
1857
+ );
1858
+ await insertDocs(collection, inputs);
1859
+ await bucketStateCollection.insertOne({
1860
+ _id: { d: ctx.definitionId, b: BUCKET },
1861
+ last_op: 8n,
1862
+ next_compact_check: new Date(),
1863
+ first_uncompacted_write: new Date(0),
1864
+ bucket_stats: { count: 8, bytes: BigInt(inputs.reduce((sum, doc) => sum + doc.size, 0)), chunks: 8 }
1865
+ });
1866
+
1867
+ expect(await bucketStorage.compactInitialReplication({ maxOpId: 8n })).toEqual({ buckets: 1 });
1868
+ const documents = await collection.find({ '_id.b': BUCKET }).toArray();
1869
+ expect(documents).toHaveLength(1);
1870
+ expect(documents[0].ops!.map((op) => op.o)).toEqual([1n, 2n, 3n, 4n, 5n, 6n, 7n, 8n]);
1871
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1872
+ expect(state?.compacted_state).toMatchObject({ op_id: 8n, count: 8, checksum: 252n });
1873
+ expect(state?.last_full_compact).toBeUndefined();
1874
+ });
1875
+
1755
1876
  test('initial compaction merges small chunks and refreshes bucket metadata', async () => {
1756
1877
  const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1757
1878
  await insertDocs(collection, [
@@ -1760,7 +1881,7 @@ bucket_definitions:
1760
1881
  ]);
1761
1882
  await insertBucketState(bucketStateCollection, ctx.definitionId, 4n);
1762
1883
 
1763
- const result = await bucketStorage.compactInitialReplication({ maxOpId: 4n });
1884
+ const result = await bucketStorage.compactInitialReplication({ forceChunkCompaction: true, maxOpId: 4n });
1764
1885
 
1765
1886
  expect(result).toEqual({ buckets: 1 });
1766
1887
  const documents = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
@@ -1827,7 +1948,7 @@ bucket_definitions:
1827
1948
  Array.from({ length: 2 }, () =>
1828
1949
  bucketStorage.factory
1829
1950
  .getInstance(syncRules)
1830
- .compactInitialReplication({ maxOpId: 2n, signal: controller.signal })
1951
+ .compactInitialReplication({ forceChunkCompaction: true, maxOpId: 2n, signal: controller.signal })
1831
1952
  )
1832
1953
  ).finally(() => {
1833
1954
  settled = true;
@@ -1858,7 +1979,9 @@ bucket_definitions:
1858
1979
  expect(results.every((result) => result.status === 'rejected')).toBe(true);
1859
1980
  expect(await bucketStateCollection.countDocuments({ compact_lease: { $exists: true } })).toBe(0);
1860
1981
  // Cancellation must return all worker slots.
1861
- expect(await bucketStorage.compactInitialReplication({ maxOpId: 2n })).toEqual({ buckets: bucketCount });
1982
+ expect(await bucketStorage.compactInitialReplication({ forceChunkCompaction: true, maxOpId: 2n })).toEqual({
1983
+ buckets: bucketCount
1984
+ });
1862
1985
  } else {
1863
1986
  expect(results.every((result) => result.status === 'fulfilled')).toBe(true);
1864
1987
  expect(results.reduce((sum, result) => sum + (result.status === 'fulfilled' ? result.value.buckets : 0), 0)).toBe(
@@ -2048,7 +2171,7 @@ bucket_definitions:
2048
2171
  }
2049
2172
  });
2050
2173
 
2051
- const result = await bucketStorage.compactInitialReplication({ maxOpId: 4n });
2174
+ const result = await bucketStorage.compactInitialReplication({ forceChunkCompaction: true, maxOpId: 4n });
2052
2175
 
2053
2176
  expect(result).toEqual({ buckets: 1 });
2054
2177
  const expectedStats = {
@@ -2167,9 +2290,9 @@ bucket_definitions:
2167
2290
  bucket_stats: { count: 4, bytes: 200n, chunks: 4 }
2168
2291
  });
2169
2292
 
2170
- await expect(bucketStorage.createMongoCompactor({ maxOpId: 4n, compactChunksOnly: true }).compact()).resolves.toBe(
2171
- 1
2172
- );
2293
+ await expect(
2294
+ bucketStorage.createMongoCompactor({ maxOpId: 4n, compactChunksOnly: true, forceChunkCompaction: true }).compact()
2295
+ ).resolves.toBe(1);
2173
2296
 
2174
2297
  const currentDocuments = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
2175
2298
  expect(currentDocuments.flatMap((document) => document.ops!.map((op) => op.o))).toEqual([1n, 3n, 4n]);
@@ -2189,7 +2312,8 @@ bucket_definitions:
2189
2312
  const { bucketStorage, collection, bucketStateCollection, ctx } = await setupCompactedTail();
2190
2313
  const failedCompactor = bucketStorage.createMongoCompactor({
2191
2314
  maxOpId: 4n,
2192
- compactChunksOnly: true
2315
+ compactChunksOnly: true,
2316
+ forceChunkCompaction: true
2193
2317
  });
2194
2318
  const originalFlush = (failedCompactor as any).flushCompactionGroup.bind(failedCompactor);
2195
2319
  const flushSpy = vi
@@ -2210,7 +2334,9 @@ bucket_definitions:
2210
2334
  { $set: { next_compact_check: new Date(0) } }
2211
2335
  );
2212
2336
 
2213
- const result = await bucketStorage.createMongoCompactor({ maxOpId: 4n, compactChunksOnly: true }).compact();
2337
+ const result = await bucketStorage
2338
+ .createMongoCompactor({ maxOpId: 4n, compactChunksOnly: true, forceChunkCompaction: true })
2339
+ .compact();
2214
2340
 
2215
2341
  expect(result).toBe(1);
2216
2342
  await expectRecoveredCompactedTail(collection, bucketStateCollection, ctx.definitionId);
@@ -1,6 +1,6 @@
1
1
  import { mongoTestStorageFactoryGenerator } from '@module/utils/test-utils.js';
2
2
  import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
3
- import { bucketRequest, test_utils } from '@powersync/service-core-tests';
3
+ import { bucketRequest, getTestStorage, test_utils } from '@powersync/service-core-tests';
4
4
  import { describe, expect, test } from 'vitest';
5
5
  import { DEFAULT_INLINE_THRESHOLD_BYTES } from '../../src/storage/implementation/common/PersistedBatch.js';
6
6
  import { MongoSyncBucketStorage } from '../../src/storage/implementation/createMongoSyncBucketStorage.js';
@@ -31,7 +31,7 @@ describe('Object storage inline threshold', () => {
31
31
  const { memoryStorage, factoryGen } = s3Factory();
32
32
  await using factory = await factoryGen.factory();
33
33
  const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_RULES_YAML, { storageVersion: 3 }));
34
- const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
34
+ const bucketStorage = (await getTestStorage(factory, syncRules)) as MongoSyncBucketStorage;
35
35
  expect(bucketStorage.inlineThresholdBytes).toBe(DEFAULT_INLINE_THRESHOLD_BYTES);
36
36
 
37
37
  await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
@@ -77,7 +77,7 @@ describe('Object storage inline threshold', () => {
77
77
  const { memoryStorage, factoryGen } = s3Factory(256);
78
78
  await using factory = await factoryGen.factory();
79
79
  const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_RULES_YAML, { storageVersion: 3 }));
80
- const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
80
+ const bucketStorage = (await getTestStorage(factory, syncRules)) as MongoSyncBucketStorage;
81
81
  expect(bucketStorage.inlineThresholdBytes).toBe(256);
82
82
 
83
83
  await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
@@ -4,7 +4,7 @@ import {
4
4
  storage,
5
5
  updateSyncRulesFromYaml
6
6
  } from '@powersync/service-core';
7
- import { bucketRequest, compactActive, test_utils } from '@powersync/service-core-tests';
7
+ import { bucketRequest, compactActive, getTestStorage, test_utils } from '@powersync/service-core-tests';
8
8
  import { describe, expect, test } from 'vitest';
9
9
  import { MongoSyncBucketStorage } from '../../src/storage/implementation/createMongoSyncBucketStorage.js';
10
10
  import { VersionedPowerSyncMongoV3 } from '../../src/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
@@ -28,7 +28,7 @@ describe('V3 checksums with S3 object storage', () => {
28
28
  const { factoryGen } = s3Factory();
29
29
  await using factory = await factoryGen.factory();
30
30
  const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_RULES_YAML, { storageVersion: 3 }));
31
- const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
31
+ const bucketStorage = (await getTestStorage(factory, syncRules)) as MongoSyncBucketStorage;
32
32
  const db = bucketStorage.db as VersionedPowerSyncMongoV3;
33
33
 
34
34
  const request = bucketRequest(syncRules.syncConfigContent[0], 'global[]', 0n);
@@ -75,7 +75,7 @@ describe('V3 checksums with S3 object storage', () => {
75
75
  const { factoryGen } = s3Factory();
76
76
  await using factory = await factoryGen.factory();
77
77
  const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_RULES_YAML, { storageVersion: 3 }));
78
- const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
78
+ const bucketStorage = (await getTestStorage(factory, syncRules)) as MongoSyncBucketStorage;
79
79
  const db = bucketStorage.db as VersionedPowerSyncMongoV3;
80
80
 
81
81
  const request = bucketRequest(syncRules.syncConfigContent[0], 'global[]', 0n);
@@ -114,7 +114,7 @@ describe('V3 checksums with S3 object storage', () => {
114
114
  const { factoryGen } = s3Factory();
115
115
  await using factory = await factoryGen.factory();
116
116
  const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_RULES_YAML, { storageVersion: 3 }));
117
- const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
117
+ const bucketStorage = (await getTestStorage(factory, syncRules)) as MongoSyncBucketStorage;
118
118
  const db = bucketStorage.db as VersionedPowerSyncMongoV3;
119
119
 
120
120
  const request = bucketRequest(syncRules.syncConfigContent[0], 'global[]', 0n);