@powersync/service-module-mongodb-storage 0.20.0 → 0.22.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 (192) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/storage/MongoBucketStorage.d.ts +36 -3
  3. package/dist/storage/MongoBucketStorage.js +372 -207
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +17 -5
  6. package/dist/storage/implementation/MongoBucketBatch.js +44 -17
  7. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  8. package/dist/storage/implementation/MongoCompactor.d.ts +19 -79
  9. package/dist/storage/implementation/MongoCompactor.js +27 -238
  10. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  11. package/dist/storage/implementation/MongoParameterCompactor.d.ts +83 -10
  12. package/dist/storage/implementation/MongoParameterCompactor.js +309 -96
  13. package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
  14. package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +9 -1
  15. package/dist/storage/implementation/MongoParsedSyncConfigSet.js +29 -0
  16. package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -1
  17. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +4 -2
  18. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -1
  19. package/dist/storage/implementation/MongoStorageProvider.js +7 -3
  20. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  21. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +27 -11
  22. package/dist/storage/implementation/MongoSyncBucketStorage.js +78 -31
  23. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  24. package/dist/storage/implementation/MongoSyncRulesLock.js +21 -9
  25. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  26. package/dist/storage/implementation/MongoWriteBatch.d.ts +49 -0
  27. package/dist/storage/implementation/MongoWriteBatch.js +149 -0
  28. package/dist/storage/implementation/MongoWriteBatch.js.map +1 -0
  29. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +9 -7
  30. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +9 -30
  31. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  32. package/dist/storage/implementation/SyncRuleStateUpdate.d.ts +11 -5
  33. package/dist/storage/implementation/SyncRuleStateUpdate.js +14 -6
  34. package/dist/storage/implementation/SyncRuleStateUpdate.js.map +1 -1
  35. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +22 -2
  36. package/dist/storage/implementation/common/PersistedBatch.d.ts +32 -4
  37. package/dist/storage/implementation/common/PersistedBatch.js +49 -6
  38. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  39. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.d.ts +3 -0
  40. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +3 -0
  41. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
  42. package/dist/storage/implementation/db.d.ts +9 -1
  43. package/dist/storage/implementation/db.js +27 -1
  44. package/dist/storage/implementation/db.js.map +1 -1
  45. package/dist/storage/implementation/models.d.ts +31 -0
  46. package/dist/storage/implementation/models.js.map +1 -1
  47. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +2 -0
  48. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +33 -4
  49. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  50. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +38 -6
  51. package/dist/storage/implementation/v1/MongoCompactorV1.js +247 -20
  52. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  53. package/dist/storage/implementation/v1/MongoParameterCompactorV1.d.ts +16 -2
  54. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js +20 -9
  55. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js.map +1 -1
  56. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +18 -11
  57. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +65 -16
  58. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  59. package/dist/storage/implementation/v1/PersistedBatchV1.d.ts +7 -5
  60. package/dist/storage/implementation/v1/PersistedBatchV1.js +22 -28
  61. package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -1
  62. package/dist/storage/implementation/v1/models.js +1 -0
  63. package/dist/storage/implementation/v1/models.js.map +1 -1
  64. package/dist/storage/implementation/v3/CompactionLease.d.ts +50 -0
  65. package/dist/storage/implementation/v3/CompactionLease.js +131 -0
  66. package/dist/storage/implementation/v3/CompactionLease.js.map +1 -0
  67. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +10 -2
  68. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +100 -26
  69. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  70. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +82 -25
  71. package/dist/storage/implementation/v3/MongoCompactorV3.js +698 -159
  72. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  73. package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +18 -0
  74. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +23 -0
  75. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +1 -0
  76. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +2 -15
  77. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +60 -92
  78. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
  79. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +19 -9
  80. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +91 -31
  81. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  82. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.d.ts +26 -0
  83. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js +82 -0
  84. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js.map +1 -0
  85. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +14 -8
  86. package/dist/storage/implementation/v3/PersistedBatchV3.js +76 -42
  87. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  88. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +12 -2
  89. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +42 -3
  90. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  91. package/dist/storage/implementation/v3/bucket-format.js +2 -0
  92. package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
  93. package/dist/storage/implementation/v3/compact-utils.d.ts +107 -0
  94. package/dist/storage/implementation/v3/compact-utils.js +216 -0
  95. package/dist/storage/implementation/v3/compact-utils.js.map +1 -0
  96. package/dist/storage/implementation/v3/compaction-constants.d.ts +5 -0
  97. package/dist/storage/implementation/v3/compaction-constants.js +6 -0
  98. package/dist/storage/implementation/v3/compaction-constants.js.map +1 -0
  99. package/dist/storage/implementation/v3/models.d.ts +84 -4
  100. package/dist/storage/implementation/v3/models.js.map +1 -1
  101. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +7 -8
  102. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +16 -5
  103. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
  104. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +14 -11
  105. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +6 -8
  106. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +17 -11
  107. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
  108. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +38 -0
  109. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +164 -0
  110. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -0
  111. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +37 -12
  112. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +197 -25
  113. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -1
  114. package/dist/storage/implementation/v3/source-table-utils.d.ts +6 -6
  115. package/dist/storage/implementation/v3/source-table-utils.js +30 -37
  116. package/dist/storage/implementation/v3/source-table-utils.js.map +1 -1
  117. package/dist/storage/storage-index.d.ts +2 -1
  118. package/dist/storage/storage-index.js +1 -0
  119. package/dist/storage/storage-index.js.map +1 -1
  120. package/dist/types/types.d.ts +28 -1
  121. package/dist/types/types.js +28 -7
  122. package/dist/types/types.js.map +1 -1
  123. package/dist/utils/test-utils.js +1 -0
  124. package/dist/utils/test-utils.js.map +1 -1
  125. package/dist/utils/util.js +3 -1
  126. package/dist/utils/util.js.map +1 -1
  127. package/package.json +10 -9
  128. package/src/storage/MongoBucketStorage.ts +507 -269
  129. package/src/storage/implementation/MongoBucketBatch.ts +67 -24
  130. package/src/storage/implementation/MongoCompactor.ts +35 -346
  131. package/src/storage/implementation/MongoParameterCompactor.ts +388 -102
  132. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +34 -0
  133. package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +4 -2
  134. package/src/storage/implementation/MongoStorageProvider.ts +15 -3
  135. package/src/storage/implementation/MongoSyncBucketStorage.ts +114 -40
  136. package/src/storage/implementation/MongoSyncRulesLock.ts +22 -9
  137. package/src/storage/implementation/MongoWriteBatch.ts +172 -0
  138. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +16 -46
  139. package/src/storage/implementation/SyncRuleStateUpdate.ts +13 -6
  140. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +24 -2
  141. package/src/storage/implementation/common/PersistedBatch.ts +66 -10
  142. package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +4 -0
  143. package/src/storage/implementation/db.ts +35 -2
  144. package/src/storage/implementation/models.ts +34 -0
  145. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +46 -6
  146. package/src/storage/implementation/v1/MongoCompactorV1.ts +327 -40
  147. package/src/storage/implementation/v1/MongoParameterCompactorV1.ts +25 -9
  148. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +87 -27
  149. package/src/storage/implementation/v1/PersistedBatchV1.ts +25 -30
  150. package/src/storage/implementation/v1/models.ts +1 -0
  151. package/src/storage/implementation/v3/CompactionLease.ts +152 -0
  152. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +138 -34
  153. package/src/storage/implementation/v3/MongoCompactorV3.ts +958 -225
  154. package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +34 -0
  155. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +100 -109
  156. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +111 -41
  157. package/src/storage/implementation/v3/MongoWriteCheckpointAPIV3.ts +111 -0
  158. package/src/storage/implementation/v3/PersistedBatchV3.ts +93 -48
  159. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +55 -4
  160. package/src/storage/implementation/v3/bucket-format.ts +2 -0
  161. package/src/storage/implementation/v3/compact-utils.ts +320 -0
  162. package/src/storage/implementation/v3/compaction-constants.ts +5 -0
  163. package/src/storage/implementation/v3/models.ts +91 -3
  164. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +24 -9
  165. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +20 -5
  166. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +26 -14
  167. package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +229 -0
  168. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +279 -29
  169. package/src/storage/implementation/v3/source-table-utils.ts +39 -41
  170. package/src/storage/storage-index.ts +2 -0
  171. package/src/types/types.ts +42 -7
  172. package/src/utils/test-utils.ts +1 -0
  173. package/src/utils/util.ts +3 -1
  174. package/test/src/__snapshots__/storage.test.ts.snap +25 -0
  175. package/test/src/__snapshots__/storage_sync.test.ts.snap +605 -16
  176. package/test/src/chunk_compaction_config.test.ts +38 -0
  177. package/test/src/cleanup-stopped-sync-configs.test.ts +40 -13
  178. package/test/src/compact-utils.test.ts +226 -0
  179. package/test/src/helpers/MemoryObjectStorage.ts +14 -6
  180. package/test/src/object_storage_usage.test.ts +267 -0
  181. package/test/src/parameter_compacting_v1.test.ts +120 -0
  182. package/test/src/parameter_compacting_v3.test.ts +192 -0
  183. package/test/src/parameter_compaction_fence.test.ts +276 -0
  184. package/test/src/storage.test.ts +273 -22
  185. package/test/src/storage_compacting.test.ts +968 -246
  186. package/test/src/storage_s3_checksums.test.ts +3 -4
  187. package/test/src/storage_s3_compaction_lifecycle.test.ts +138 -12
  188. package/test/src/storage_s3_reading.test.ts +290 -2
  189. package/test/src/storage_s3_writing.test.ts +1 -0
  190. package/test/src/storage_sync.test.ts +336 -41
  191. package/test/src/util.ts +1 -1
  192. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,38 @@
1
+ import { MongoStorageConfig, normalizeChunkCompactionConcurrency } from '@module/types/types.js';
2
+ import { describe, expect, test } from 'vitest';
3
+
4
+ describe('chunk compaction concurrency configuration', () => {
5
+ test('defaults to two workers', () => {
6
+ expect(normalizeChunkCompactionConcurrency(undefined)).toBe(2);
7
+ });
8
+
9
+ test('defaults to four workers with object storage', () => {
10
+ expect(normalizeChunkCompactionConcurrency(undefined, true)).toBe(4);
11
+ });
12
+
13
+ test.each([false, true])('preserves an explicit override with object storage %s', (hasObjectStorage) => {
14
+ expect(normalizeChunkCompactionConcurrency(3, hasObjectStorage)).toBe(3);
15
+ });
16
+
17
+ test('decodes a configured worker count', () => {
18
+ const config = MongoStorageConfig.decode({
19
+ type: 'mongodb',
20
+ uri: 'mongodb://localhost:27017/powersync',
21
+ chunk_compaction_concurrency: 4
22
+ });
23
+ expect(normalizeChunkCompactionConcurrency(config.chunk_compaction_concurrency)).toBe(4);
24
+ });
25
+
26
+ test.each([1, 64])('accepts boundary worker count %s', (value) => {
27
+ expect(normalizeChunkCompactionConcurrency(value)).toBe(value);
28
+ });
29
+
30
+ test.each([0, -1, 1.5, 65, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1])(
31
+ 'rejects invalid worker count %s',
32
+ (value) => {
33
+ expect(() => normalizeChunkCompactionConcurrency(value)).toThrow(
34
+ 'storage.chunk_compaction_concurrency must be an integer between 1 and 64'
35
+ );
36
+ }
37
+ );
38
+ });
@@ -324,7 +324,7 @@ streams:
324
324
  test('drops current_data when a table becomes event-only but is kept by a live event', async () => {
325
325
  await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
326
326
 
327
- // The first config syncs `todos` data and also fires events for it.
327
+ // The first config syncs `todos` data and also evaluates the audit event for its rows.
328
328
  const first = await factory.updateSyncRules(
329
329
  updateSyncRulesFromYaml(
330
330
  `
@@ -360,15 +360,22 @@ event_definitions:
360
360
  },
361
361
  afterReplicaId: test_utils.rid('todo-1')
362
362
  });
363
+ const eventDefinitionId = [...resolved.tables[0].eventDefinitionIds!][0];
364
+ firstWriter.addCustomWriteCheckpoint({
365
+ user_id: 'user-1',
366
+ checkpoint: 1n,
367
+ event_id: eventDefinitionId
368
+ });
363
369
  await firstWriter.markAllSnapshotDone('1/1');
364
370
  await firstWriter.commit('1/1');
365
371
 
366
372
  const sourceTableId = resolved.tables[0].id as bson.ObjectId;
367
373
  const sourceRecordsCollection = db.sourceRecords(first.replicationStreamId, sourceTableId).collectionName;
368
374
  const ownerDefinitionId = first.syncConfigContent[0].mapping.allBucketDefinitionIds()[0];
369
- expect(
370
- (await db.sourceTables(first.replicationStreamId).findOne({ _id: sourceTableId }))?.bucket_data_source_ids
371
- ).toEqual([ownerDefinitionId]);
375
+ const initialSourceTable = await db.sourceTables(first.replicationStreamId).findOne({ _id: sourceTableId });
376
+ expect(initialSourceTable?.bucket_data_source_ids).toEqual([ownerDefinitionId]);
377
+ expect(initialSourceTable?.event_definition_ids).toHaveLength(1);
378
+ const eventDefinitionIds = initialSourceTable!.event_definition_ids;
372
379
  // The prior snapshot is persisted in current_data.
373
380
  expect(await collectionExists(db, sourceRecordsCollection)).toBe(true);
374
381
 
@@ -414,13 +421,22 @@ event_definitions:
414
421
  sourceTablesDeleted: 0
415
422
  });
416
423
 
417
- // The source table row survives (the live event still needs it), narrowed to empty
418
- // memberships, but its now-unused current_data collection is dropped.
424
+ // The source table row survives (the live event still needs it), narrowed to empty data and
425
+ // parameter memberships, but its event membership remains and current_data is dropped.
419
426
  const sourceTable = await db.sourceTables(first.replicationStreamId).findOne({ _id: sourceTableId });
420
427
  expect(sourceTable).not.toBeNull();
421
428
  expect(sourceTable?.bucket_data_source_ids).toEqual([]);
422
429
  expect(sourceTable?.parameter_lookup_source_ids).toEqual([]);
430
+ expect(sourceTable?.event_definition_ids).toEqual(eventDefinitionIds);
423
431
  expect(await collectionExists(db, sourceRecordsCollection)).toBe(false);
432
+ await expect(
433
+ db
434
+ .customCheckpointRequests({
435
+ replicationStreamId: first.replicationStreamId,
436
+ eventId: eventDefinitionId
437
+ })
438
+ .findOne({ user_id: 'user-1' })
439
+ ).resolves.not.toBeNull();
424
440
 
425
441
  const streamDoc = (await db.sync_rules.findOne({ _id: first.replicationStreamId })) as ReplicationStreamDocumentV3;
426
442
  expect(streamDoc.sync_configs.map((config) => config.state)).toEqual([storage.SyncRuleState.ACTIVE]);
@@ -429,9 +445,9 @@ event_definitions:
429
445
  test('cleans up event-only source tables no longer triggered by a live sync config', async () => {
430
446
  await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
431
447
 
432
- // The first config syncs `todos` and additionally fires events for `audit_log`. The
433
- // `audit_log` table is referenced only by the event trigger, so its source table carries
434
- // empty membership arrays (an event-only table).
448
+ // The first config syncs `todos` and additionally evaluates the audit event for `audit_log`. The
449
+ // `audit_log` table is referenced only by the event trigger, so its source table has only an
450
+ // event membership (an event-only table).
435
451
  const first = await factory.updateSyncRules(
436
452
  updateSyncRulesFromYaml(
437
453
  `
@@ -485,21 +501,27 @@ event_definitions:
485
501
  },
486
502
  afterReplicaId: test_utils.rid('audit-1')
487
503
  });
504
+ const auditEventDefinitionId = [...auditTable.eventDefinitionIds!][0];
505
+ firstWriter.addCustomWriteCheckpoint({
506
+ user_id: 'audit-user',
507
+ checkpoint: 1n,
508
+ event_id: auditEventDefinitionId
509
+ });
488
510
  await firstWriter.markAllSnapshotDone('1/1');
489
511
  await firstWriter.commit('1/1');
490
512
 
491
513
  const todosTableId = todosTable.id as bson.ObjectId;
492
514
  const auditTableId = auditTable.id as bson.ObjectId;
493
- // The event-only table is persisted with empty membership arrays, so the membership filter
494
- // never selects it.
515
+ // The event-only table has empty bucket and parameter memberships, plus its event id.
495
516
  const auditSourceTable = await db.sourceTables(first.replicationStreamId).findOne({ _id: auditTableId });
496
517
  expect(auditSourceTable?.bucket_data_source_ids).toEqual([]);
497
518
  expect(auditSourceTable?.parameter_lookup_source_ids).toEqual([]);
519
+ expect(auditSourceTable?.event_definition_ids).toHaveLength(1);
498
520
  const auditRecordsCollection = db.sourceRecords(first.replicationStreamId, auditTableId).collectionName;
499
521
 
500
522
  // The second (active) config keeps the same `by_owner` stream but drops the audit event.
501
- // The shared bucket definition stays in use, so no bucket/parameter ids become unused and
502
- // the membership-cleanup block is skipped entirely.
523
+ // The shared bucket definition stays in use, while the unused event id selects this table for
524
+ // membership cleanup.
503
525
  const second = await factory.updateSyncRules(
504
526
  updateSyncRulesFromYaml(
505
527
  `
@@ -537,6 +559,11 @@ streams:
537
559
  // The orphaned event-only table and its source records are removed...
538
560
  expect(await db.sourceTables(first.replicationStreamId).countDocuments({ _id: auditTableId })).toBe(0);
539
561
  expect(await collectionExists(db, auditRecordsCollection)).toBe(false);
562
+ const auditCheckpointCollection = db.customCheckpointRequests({
563
+ replicationStreamId: first.replicationStreamId,
564
+ eventId: auditEventDefinitionId
565
+ }).collectionName;
566
+ expect(await collectionExists(db, auditCheckpointCollection)).toBe(false);
540
567
  // ...while the data table still used by the live config is retained.
541
568
  expect(await db.sourceTables(first.replicationStreamId).countDocuments({ _id: todosTableId })).toBe(1);
542
569
 
@@ -0,0 +1,226 @@
1
+ import {
2
+ applyCompactionDelta,
3
+ applyStatsReplacement,
4
+ bucketStats,
5
+ chooseCompactionKind,
6
+ combineAdjacentStats,
7
+ combineChunkStats,
8
+ CompactIntervalConfig,
9
+ CompactionKind,
10
+ emptyBucketStats,
11
+ forcedCompactionKind,
12
+ fullCompactionCheckAt,
13
+ statsForDocument,
14
+ statsForDocuments
15
+ } from '@module/storage/implementation/v3/compact-utils.js';
16
+ import { BucketStateDocumentV3 } from '@module/storage/implementation/v3/models.js';
17
+ import { describe, expect, test } from 'vitest';
18
+
19
+ const INTERVAL_CONFIG: CompactIntervalConfig = {
20
+ minCompactChunkIntervalMs: 1_000,
21
+ minCompactFullIntervalMs: 10_000,
22
+ maxCompactFullIntervalMs: 100_000
23
+ };
24
+
25
+ function bucketState(overrides: Partial<BucketStateDocumentV3> = {}): BucketStateDocumentV3 {
26
+ return {
27
+ _id: { d: 'definition', b: 'bucket[]' },
28
+ last_op: 10n,
29
+ next_compact_check: new Date(0),
30
+ first_uncompacted_write: new Date(0),
31
+ bucket_stats: { count: 10, bytes: 100n, chunks: 1 },
32
+ ...overrides
33
+ };
34
+ }
35
+
36
+ function compactedState(overrides: Partial<NonNullable<BucketStateDocumentV3['compacted_state']>> = {}) {
37
+ return {
38
+ op_id: 5n,
39
+ checksum: 50n,
40
+ count: 5,
41
+ bytes: 50n,
42
+ chunks: 1,
43
+ at: new Date(0),
44
+ ...overrides
45
+ };
46
+ }
47
+
48
+ describe('V3 compact utilities', () => {
49
+ test.each([
50
+ {
51
+ name: 'uses the minimum interval before the first full compact',
52
+ state: bucketState(),
53
+ expected: new Date(10_000)
54
+ },
55
+ {
56
+ name: 'scales the interval by the uncompacted row ratio',
57
+ state: bucketState({
58
+ bucket_stats: { count: 100, bytes: 100n, chunks: 1 },
59
+ last_full_compact: { op_id: 90n, count: 90, puts: 90, at: new Date(0) }
60
+ }),
61
+ expected: new Date(100_000)
62
+ },
63
+ {
64
+ name: 'uses the maximum interval when there are no uncompacted rows',
65
+ state: bucketState({
66
+ bucket_stats: { count: 100, bytes: 100n, chunks: 1 },
67
+ last_full_compact: { op_id: 10n, count: 100, puts: 100, at: new Date(0) }
68
+ }),
69
+ expected: new Date(100_000)
70
+ }
71
+ ])('$name', ({ state, expected }) => {
72
+ expect(fullCompactionCheckAt(state, INTERVAL_CONFIG)).toEqual(expected);
73
+ });
74
+
75
+ test('rejects a scheduled bucket without an uncompacted write', () => {
76
+ expect(() => fullCompactionCheckAt(bucketState({ first_uncompacted_write: undefined }), INTERVAL_CONFIG)).toThrow(
77
+ 'Scheduled V3 bucket bucket[] has no first uncompacted write'
78
+ );
79
+ });
80
+
81
+ test('chooses a full compact when its check is due', () => {
82
+ const decision = chooseCompactionKind(
83
+ bucketState({ bucket_stats: { count: 10, bytes: 100n, chunks: 10 } }),
84
+ new Date(10_000),
85
+ INTERVAL_CONFIG
86
+ );
87
+
88
+ expect(decision.kind).toBe(CompactionKind.Full);
89
+ expect(decision.nextCompactCheck).toEqual({
90
+ $min: [new Date(70_000), { $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: 1_000 } }]
91
+ });
92
+ });
93
+
94
+ test('chooses a chunk compact after enough chunks and the minimum interval', () => {
95
+ const decision = chooseCompactionKind(
96
+ bucketState({
97
+ bucket_stats: { count: 10, bytes: 100n, chunks: 9 },
98
+ compacted_state: compactedState({ chunks: 1 })
99
+ }),
100
+ new Date(5_000),
101
+ INTERVAL_CONFIG
102
+ );
103
+
104
+ expect(decision.kind).toBe(CompactionKind.Chunks);
105
+ });
106
+
107
+ test('waits when the chunk interval has not elapsed', () => {
108
+ const decision = chooseCompactionKind(
109
+ bucketState({
110
+ bucket_stats: { count: 10, bytes: 100n, chunks: 9 },
111
+ compacted_state: compactedState({ chunks: 1, at: new Date(4_500) })
112
+ }),
113
+ new Date(5_000),
114
+ INTERVAL_CONFIG
115
+ );
116
+
117
+ expect(decision.kind).toBeNull();
118
+ });
119
+
120
+ test('schedules only the full check when too few chunks were added', () => {
121
+ const decision = chooseCompactionKind(
122
+ bucketState({ bucket_stats: { count: 7, bytes: 70n, chunks: 7 } }),
123
+ new Date(5_000),
124
+ INTERVAL_CONFIG
125
+ );
126
+
127
+ expect(decision).toEqual({ kind: null, nextCompactCheck: new Date(70_000) });
128
+ });
129
+
130
+ test.each([
131
+ {
132
+ name: 'does not force an unspecified kind',
133
+ forceKind: undefined,
134
+ compactedOpId: undefined,
135
+ maxOpIdCap: undefined,
136
+ expected: null
137
+ },
138
+ {
139
+ name: 'forces work without previous compacted state',
140
+ forceKind: CompactionKind.Chunks,
141
+ compactedOpId: undefined,
142
+ maxOpIdCap: undefined,
143
+ expected: CompactionKind.Chunks
144
+ },
145
+ {
146
+ name: 'skips work already compacted through the cap',
147
+ forceKind: CompactionKind.Chunks,
148
+ compactedOpId: 5n,
149
+ maxOpIdCap: 5n,
150
+ expected: null
151
+ },
152
+ {
153
+ name: 'skips work already compacted through the bucket head',
154
+ forceKind: CompactionKind.Chunks,
155
+ compactedOpId: 10n,
156
+ maxOpIdCap: undefined,
157
+ expected: null
158
+ },
159
+ {
160
+ name: 'forces work beyond the compacted state',
161
+ forceKind: CompactionKind.Chunks,
162
+ compactedOpId: 5n,
163
+ maxOpIdCap: 6n,
164
+ expected: CompactionKind.Chunks
165
+ }
166
+ ])('$name', ({ forceKind, compactedOpId, maxOpIdCap, expected }) => {
167
+ const state = bucketState({
168
+ compacted_state: compactedOpId == null ? undefined : compactedState({ op_id: compactedOpId })
169
+ });
170
+ expect(forcedCompactionKind(state, forceKind, { maxOpIdCap })).toBe(expected);
171
+ });
172
+
173
+ test('derives and combines bucket statistics', () => {
174
+ const state = bucketState({ bucket_stats: { count: 12, bytes: 120n, chunks: 3 } });
175
+ expect(bucketStats(state)).toEqual({ count: 12, bytes: 120n, chunks: 3 });
176
+ expect(emptyBucketStats()).toEqual({ count: 0, bytes: 0n, chunks: 0, checksum: 0 });
177
+ expect(statsForDocument({ count: 2, size: 20, checksum: 10n })).toEqual({
178
+ count: 2,
179
+ bytes: 20n,
180
+ chunks: 1,
181
+ checksum: 10
182
+ });
183
+ expect(
184
+ statsForDocuments([
185
+ { count: 2, size: 20, checksum: 10n },
186
+ { count: 3, size: 30, checksum: 20n }
187
+ ])
188
+ ).toEqual({ count: 5, bytes: 50n, chunks: 2, checksum: 30 });
189
+ expect(
190
+ combineAdjacentStats(
191
+ { count: 2, bytes: 20n, chunks: 1, checksum: 10 },
192
+ { count: 3, bytes: 30n, chunks: 2, checksum: 20 }
193
+ )
194
+ ).toEqual({ count: 5, bytes: 50n, chunks: 3, checksum: 30 });
195
+ });
196
+
197
+ test('replaces an overlapping chunk in cached statistics', () => {
198
+ expect(
199
+ combineChunkStats(
200
+ compactedState({ count: 10, bytes: 100n, chunks: 4, checksum: 100n }),
201
+ { count: 6, bytes: 60n, chunks: 2, checksum: 50 },
202
+ { count: 3, bytes: 30n, chunks: 1, checksum: 30 }
203
+ )
204
+ ).toEqual({ count: 13, bytes: 130n, chunks: 5, checksum: 120 });
205
+ });
206
+
207
+ test('applies compacted statistics as a delta to total bucket statistics', () => {
208
+ expect(
209
+ applyCompactionDelta(
210
+ { count: 20, bytes: 200n, chunks: 8 },
211
+ { count: 5, bytes: 50n, chunks: 3, checksum: 10 },
212
+ { count: 3, bytes: 30n, chunks: 1, checksum: 10 }
213
+ )
214
+ ).toEqual({ count: 18, bytes: 180n, chunks: 6 });
215
+ });
216
+
217
+ test('applies a stored range replacement to statistics including its checksum', () => {
218
+ expect(
219
+ applyStatsReplacement(
220
+ { count: 20, bytes: 200n, chunks: 8, checksum: 100 },
221
+ { count: 5, bytes: 50n, chunks: 3, checksum: 40 },
222
+ { count: 3, bytes: 30n, chunks: 1, checksum: 20 }
223
+ )
224
+ ).toEqual({ count: 18, bytes: 180n, chunks: 6, checksum: 80 });
225
+ });
226
+ });
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  ObjectStorage,
3
+ ObjectStorageOperationOptions,
3
4
  ObjectStoragePutMetadata
4
5
  } from '@module/storage/implementation/v3/object-storage/ObjectStorage.js';
5
6
 
@@ -9,13 +10,19 @@ export class MemoryObjectStorage implements ObjectStorage {
9
10
  */
10
11
  public readonly store = new Map<string, { data: Uint8Array; metadata: ObjectStoragePutMetadata }>();
11
12
 
12
- async put(path: string, data: Uint8Array, metadata: ObjectStoragePutMetadata): Promise<void> {
13
+ async put(
14
+ path: string,
15
+ data: Uint8Array,
16
+ metadata: ObjectStoragePutMetadata,
17
+ options?: ObjectStorageOperationOptions
18
+ ): Promise<void> {
19
+ options?.signal?.throwIfAborted();
13
20
  this.store.set(path, { data, metadata: metadata });
14
21
  }
15
22
 
16
23
  async get(
17
24
  path: string,
18
- options?: { signal?: AbortSignal }
25
+ options?: ObjectStorageOperationOptions
19
26
  ): Promise<{ data: Uint8Array; metadata: ObjectStoragePutMetadata }> {
20
27
  const signal = options?.signal;
21
28
  signal?.throwIfAborted();
@@ -26,7 +33,7 @@ export class MemoryObjectStorage implements ObjectStorage {
26
33
  return data;
27
34
  }
28
35
 
29
- async *list(prefix: string, options?: { signal?: AbortSignal }): AsyncIterable<string> {
36
+ async *list(prefix: string, options?: ObjectStorageOperationOptions): AsyncIterable<string> {
30
37
  const signal = options?.signal;
31
38
  for (const path of this.store.keys()) {
32
39
  signal?.throwIfAborted();
@@ -36,18 +43,19 @@ export class MemoryObjectStorage implements ObjectStorage {
36
43
  }
37
44
  }
38
45
 
39
- async delete(paths: string[]): Promise<void> {
46
+ async delete(paths: string[], options?: ObjectStorageOperationOptions): Promise<void> {
47
+ options?.signal?.throwIfAborted();
40
48
  for (const p of paths) {
41
49
  this.store.delete(p);
42
50
  }
43
51
  }
44
52
 
45
- async deletePrefix(prefix: string, options?: { signal?: AbortSignal }): Promise<{ objectCount: number }> {
53
+ async deletePrefix(prefix: string, options?: ObjectStorageOperationOptions): Promise<{ objectCount: number }> {
46
54
  const paths: string[] = [];
47
55
  for await (const path of this.list(prefix, options)) {
48
56
  paths.push(path);
49
57
  }
50
- await this.delete(paths);
58
+ await this.delete(paths, options);
51
59
  return { objectCount: paths.length };
52
60
  }
53
61
  }