@powersync/service-module-mongodb-storage 0.0.0-dev-20260602073133 → 0.0.0-dev-20260909133214

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 (275) hide show
  1. package/CHANGELOG.md +177 -5
  2. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.d.ts +3 -0
  3. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js +41 -0
  4. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js.map +1 -0
  5. package/dist/storage/MongoBucketStorage.d.ts +71 -18
  6. package/dist/storage/MongoBucketStorage.js +516 -202
  7. package/dist/storage/MongoBucketStorage.js.map +1 -1
  8. package/dist/storage/implementation/MongoBucketBatch.d.ts +71 -29
  9. package/dist/storage/implementation/MongoBucketBatch.js +140 -66
  10. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  11. package/dist/storage/implementation/MongoChecksums.d.ts +41 -13
  12. package/dist/storage/implementation/MongoChecksums.js +36 -121
  13. package/dist/storage/implementation/MongoChecksums.js.map +1 -1
  14. package/dist/storage/implementation/MongoCompactor.d.ts +28 -90
  15. package/dist/storage/implementation/MongoCompactor.js +87 -481
  16. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  17. package/dist/storage/implementation/MongoHydrationState.d.ts +11 -0
  18. package/dist/storage/implementation/MongoHydrationState.js +49 -0
  19. package/dist/storage/implementation/MongoHydrationState.js.map +1 -0
  20. package/dist/storage/implementation/MongoParameterCompactor.d.ts +82 -8
  21. package/dist/storage/implementation/MongoParameterCompactor.js +312 -76
  22. package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
  23. package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +21 -0
  24. package/dist/storage/implementation/MongoParsedSyncConfigSet.js +93 -0
  25. package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -0
  26. package/dist/storage/implementation/MongoPersistedReplicationStream.d.ts +36 -0
  27. package/dist/storage/implementation/MongoPersistedReplicationStream.js +90 -0
  28. package/dist/storage/implementation/MongoPersistedReplicationStream.js.map +1 -0
  29. package/dist/storage/implementation/MongoPersistedSyncConfigContent.d.ts +25 -0
  30. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +125 -0
  31. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -0
  32. package/dist/storage/implementation/MongoStorageProvider.js +41 -4
  33. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  34. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +81 -37
  35. package/dist/storage/implementation/MongoSyncBucketStorage.js +159 -86
  36. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  37. package/dist/storage/implementation/MongoSyncRulesLock.d.ts +1 -1
  38. package/dist/storage/implementation/MongoSyncRulesLock.js +23 -11
  39. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  40. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +11 -8
  41. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +159 -33
  42. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  43. package/dist/storage/implementation/SyncRuleStateUpdate.d.ts +11 -5
  44. package/dist/storage/implementation/SyncRuleStateUpdate.js +14 -6
  45. package/dist/storage/implementation/SyncRuleStateUpdate.js.map +1 -1
  46. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +28 -0
  47. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.js +2 -0
  48. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.js.map +1 -0
  49. package/dist/storage/implementation/common/PersistedBatch.d.ts +34 -2
  50. package/dist/storage/implementation/common/PersistedBatch.js +63 -10
  51. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  52. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +1 -1
  53. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
  54. package/dist/storage/implementation/createMongoSyncBucketStorage.d.ts +2 -2
  55. package/dist/storage/implementation/createMongoSyncBucketStorage.js +4 -4
  56. package/dist/storage/implementation/createMongoSyncBucketStorage.js.map +1 -1
  57. package/dist/storage/implementation/db.d.ts +4 -1
  58. package/dist/storage/implementation/db.js +31 -16
  59. package/dist/storage/implementation/db.js.map +1 -1
  60. package/dist/storage/implementation/models.d.ts +55 -13
  61. package/dist/storage/implementation/models.js.map +1 -1
  62. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +15 -1
  63. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +135 -107
  64. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  65. package/dist/storage/implementation/v1/MongoChecksumsV1.d.ts +4 -4
  66. package/dist/storage/implementation/v1/MongoChecksumsV1.js +119 -7
  67. package/dist/storage/implementation/v1/MongoChecksumsV1.js.map +1 -1
  68. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +47 -7
  69. package/dist/storage/implementation/v1/MongoCompactorV1.js +478 -8
  70. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  71. package/dist/storage/implementation/v1/MongoParameterCompactorV1.d.ts +16 -2
  72. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js +20 -9
  73. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js.map +1 -1
  74. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +33 -27
  75. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +146 -73
  76. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  77. package/dist/storage/implementation/v1/SingleBucketStoreV1.d.ts +9 -10
  78. package/dist/storage/implementation/v1/SingleBucketStoreV1.js +2 -8
  79. package/dist/storage/implementation/v1/SingleBucketStoreV1.js.map +1 -1
  80. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.d.ts +2 -1
  81. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js +3 -0
  82. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js.map +1 -1
  83. package/dist/storage/implementation/v1/models.d.ts +10 -1
  84. package/dist/storage/implementation/v1/models.js +1 -0
  85. package/dist/storage/implementation/v1/models.js.map +1 -1
  86. package/dist/storage/implementation/v3/BucketDataContextV3.d.ts +13 -0
  87. package/dist/storage/implementation/v3/BucketDataContextV3.js +23 -0
  88. package/dist/storage/implementation/v3/BucketDataContextV3.js.map +1 -0
  89. package/dist/storage/implementation/v3/CompactionLease.d.ts +50 -0
  90. package/dist/storage/implementation/v3/CompactionLease.js +131 -0
  91. package/dist/storage/implementation/v3/CompactionLease.js.map +1 -0
  92. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +16 -5
  93. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +393 -262
  94. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  95. package/dist/storage/implementation/v3/MongoChecksumsV3.d.ts +30 -9
  96. package/dist/storage/implementation/v3/MongoChecksumsV3.js +161 -20
  97. package/dist/storage/implementation/v3/MongoChecksumsV3.js.map +1 -1
  98. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +108 -18
  99. package/dist/storage/implementation/v3/MongoCompactorV3.js +1115 -47
  100. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  101. package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +12 -3
  102. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +13 -8
  103. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +1 -1
  104. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +54 -0
  105. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +319 -0
  106. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -0
  107. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +61 -36
  108. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +353 -161
  109. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  110. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.d.ts +26 -0
  111. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js +82 -0
  112. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js.map +1 -0
  113. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +8 -3
  114. package/dist/storage/implementation/v3/PersistedBatchV3.js +131 -29
  115. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  116. package/dist/storage/implementation/v3/SourceRecordStoreV3.d.ts +3 -3
  117. package/dist/storage/implementation/v3/SourceRecordStoreV3.js +13 -11
  118. package/dist/storage/implementation/v3/SourceRecordStoreV3.js.map +1 -1
  119. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +26 -14
  120. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +73 -29
  121. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  122. package/dist/storage/implementation/v3/bucket-format.d.ts +13 -0
  123. package/dist/storage/implementation/v3/bucket-format.js +79 -0
  124. package/dist/storage/implementation/v3/bucket-format.js.map +1 -0
  125. package/dist/storage/implementation/v3/chunking.d.ts +10 -0
  126. package/dist/storage/implementation/v3/chunking.js +30 -0
  127. package/dist/storage/implementation/v3/chunking.js.map +1 -0
  128. package/dist/storage/implementation/v3/compact-utils.d.ts +107 -0
  129. package/dist/storage/implementation/v3/compact-utils.js +216 -0
  130. package/dist/storage/implementation/v3/compact-utils.js.map +1 -0
  131. package/dist/storage/implementation/v3/compaction-constants.d.ts +5 -0
  132. package/dist/storage/implementation/v3/compaction-constants.js +6 -0
  133. package/dist/storage/implementation/v3/compaction-constants.js.map +1 -0
  134. package/dist/storage/implementation/v3/models.d.ts +187 -23
  135. package/dist/storage/implementation/v3/models.js +11 -28
  136. package/dist/storage/implementation/v3/models.js.map +1 -1
  137. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +20 -0
  138. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +48 -0
  139. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -0
  140. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +33 -0
  141. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js +12 -0
  142. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js.map +1 -0
  143. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +68 -0
  144. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +154 -0
  145. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -0
  146. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +37 -0
  147. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +167 -0
  148. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -0
  149. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +67 -0
  150. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +439 -0
  151. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -0
  152. package/dist/storage/implementation/v3/source-table-utils.d.ts +63 -0
  153. package/dist/storage/implementation/v3/source-table-utils.js +206 -0
  154. package/dist/storage/implementation/v3/source-table-utils.js.map +1 -0
  155. package/dist/storage/storage-index.d.ts +5 -4
  156. package/dist/storage/storage-index.js +5 -4
  157. package/dist/storage/storage-index.js.map +1 -1
  158. package/dist/types/types.d.ts +71 -3
  159. package/dist/types/types.js +58 -2
  160. package/dist/types/types.js.map +1 -1
  161. package/dist/utils/test-utils.d.ts +1 -2
  162. package/dist/utils/test-utils.js +7 -1
  163. package/dist/utils/test-utils.js.map +1 -1
  164. package/dist/utils/util.d.ts +9 -2
  165. package/dist/utils/util.js +131 -5
  166. package/dist/utils/util.js.map +1 -1
  167. package/package.json +13 -19
  168. package/src/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.ts +55 -0
  169. package/src/storage/MongoBucketStorage.ts +728 -242
  170. package/src/storage/implementation/MongoBucketBatch.ts +196 -91
  171. package/src/storage/implementation/MongoChecksums.ts +93 -153
  172. package/src/storage/implementation/MongoCompactor.ts +106 -631
  173. package/src/storage/implementation/MongoHydrationState.ts +61 -0
  174. package/src/storage/implementation/MongoParameterCompactor.ts +392 -87
  175. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +122 -0
  176. package/src/storage/implementation/MongoPersistedReplicationStream.ts +118 -0
  177. package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +156 -0
  178. package/src/storage/implementation/MongoStorageProvider.ts +48 -4
  179. package/src/storage/implementation/MongoSyncBucketStorage.ts +226 -118
  180. package/src/storage/implementation/MongoSyncRulesLock.ts +28 -12
  181. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +194 -52
  182. package/src/storage/implementation/SyncRuleStateUpdate.ts +13 -6
  183. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +31 -0
  184. package/src/storage/implementation/common/PersistedBatch.ts +83 -15
  185. package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +1 -1
  186. package/src/storage/implementation/createMongoSyncBucketStorage.ts +21 -7
  187. package/src/storage/implementation/db.ts +40 -22
  188. package/src/storage/implementation/models.ts +58 -14
  189. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +174 -125
  190. package/src/storage/implementation/v1/MongoChecksumsV1.ts +154 -12
  191. package/src/storage/implementation/v1/MongoCompactorV1.ts +594 -25
  192. package/src/storage/implementation/v1/MongoParameterCompactorV1.ts +25 -9
  193. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +226 -129
  194. package/src/storage/implementation/v1/SingleBucketStoreV1.ts +18 -27
  195. package/src/storage/implementation/v1/VersionedPowerSyncMongoV1.ts +11 -1
  196. package/src/storage/implementation/v1/models.ts +12 -1
  197. package/src/storage/implementation/v3/BucketDataContextV3.ts +32 -0
  198. package/src/storage/implementation/v3/CompactionLease.ts +152 -0
  199. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +502 -324
  200. package/src/storage/implementation/v3/MongoChecksumsV3.ts +250 -32
  201. package/src/storage/implementation/v3/MongoCompactorV3.ts +1439 -70
  202. package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +19 -9
  203. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +473 -0
  204. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +453 -211
  205. package/src/storage/implementation/v3/MongoWriteCheckpointAPIV3.ts +111 -0
  206. package/src/storage/implementation/v3/PersistedBatchV3.ts +169 -45
  207. package/src/storage/implementation/v3/SourceRecordStoreV3.ts +12 -12
  208. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +99 -38
  209. package/src/storage/implementation/v3/bucket-format.ts +99 -0
  210. package/src/storage/implementation/v3/chunking.ts +40 -0
  211. package/src/storage/implementation/v3/compact-utils.ts +320 -0
  212. package/src/storage/implementation/v3/compaction-constants.ts +5 -0
  213. package/src/storage/implementation/v3/models.ts +228 -58
  214. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +62 -0
  215. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +44 -0
  216. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +182 -0
  217. package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +230 -0
  218. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +573 -0
  219. package/src/storage/implementation/v3/source-table-utils.ts +398 -0
  220. package/src/storage/storage-index.ts +14 -4
  221. package/src/types/types.ts +76 -2
  222. package/src/utils/test-utils.ts +8 -3
  223. package/src/utils/util.ts +188 -7
  224. package/test/src/__snapshots__/storage.test.ts.snap +4 -54
  225. package/test/src/__snapshots__/storage_sync.test.ts.snap +652 -645
  226. package/test/src/cleanup-stopped-sync-configs.test.ts +698 -0
  227. package/test/src/compact-utils.test.ts +226 -0
  228. package/test/src/helpers/MemoryObjectStorage.ts +61 -0
  229. package/test/src/helpers/s3TestFactory.ts +69 -0
  230. package/test/src/object_storage_lifecycle.test.ts +59 -0
  231. package/test/src/object_storage_usage.test.ts +255 -0
  232. package/test/src/parameter_compacting_v1.test.ts +120 -0
  233. package/test/src/parameter_compacting_v3.test.ts +192 -0
  234. package/test/src/parameter_compaction_fence.test.ts +276 -0
  235. package/test/src/setup.ts +6 -1
  236. package/test/src/storage.test.ts +478 -13
  237. package/test/src/storage_compacting.test.ts +2516 -107
  238. package/test/src/storage_object_storage_inline_threshold.test.ts +116 -0
  239. package/test/src/storage_s3_checksums.test.ts +190 -0
  240. package/test/src/storage_s3_compaction_lifecycle.test.ts +543 -0
  241. package/test/src/storage_s3_reading.test.ts +768 -0
  242. package/test/src/storage_s3_writing.test.ts +136 -0
  243. package/test/src/storage_sync.test.ts +1507 -76
  244. package/test/src/storeCurrentData.test.ts +227 -0
  245. package/test/src/util.ts +3 -2
  246. package/tsconfig.json +1 -4
  247. package/tsconfig.tsbuildinfo +1 -1
  248. package/dist/storage/implementation/BucketDefinitionMapping.d.ts +0 -15
  249. package/dist/storage/implementation/BucketDefinitionMapping.js +0 -58
  250. package/dist/storage/implementation/BucketDefinitionMapping.js.map +0 -1
  251. package/dist/storage/implementation/MongoPersistedSyncRules.d.ts +0 -14
  252. package/dist/storage/implementation/MongoPersistedSyncRules.js +0 -67
  253. package/dist/storage/implementation/MongoPersistedSyncRules.js.map +0 -1
  254. package/dist/storage/implementation/MongoPersistedSyncRulesContent.d.ts +0 -29
  255. package/dist/storage/implementation/MongoPersistedSyncRulesContent.js +0 -76
  256. package/dist/storage/implementation/MongoPersistedSyncRulesContent.js.map +0 -1
  257. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.d.ts +0 -13
  258. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.js +0 -2
  259. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.js.map +0 -1
  260. package/dist/storage/implementation/common/SingleBucketStore.d.ts +0 -54
  261. package/dist/storage/implementation/common/SingleBucketStore.js +0 -3
  262. package/dist/storage/implementation/common/SingleBucketStore.js.map +0 -1
  263. package/dist/storage/implementation/v3/MongoParameterLookupV3.d.ts +0 -4
  264. package/dist/storage/implementation/v3/MongoParameterLookupV3.js +0 -9
  265. package/dist/storage/implementation/v3/MongoParameterLookupV3.js.map +0 -1
  266. package/dist/storage/implementation/v3/SingleBucketStoreV3.d.ts +0 -18
  267. package/dist/storage/implementation/v3/SingleBucketStoreV3.js +0 -48
  268. package/dist/storage/implementation/v3/SingleBucketStoreV3.js.map +0 -1
  269. package/src/storage/implementation/BucketDefinitionMapping.ts +0 -75
  270. package/src/storage/implementation/MongoPersistedSyncRules.ts +0 -82
  271. package/src/storage/implementation/MongoPersistedSyncRulesContent.ts +0 -101
  272. package/src/storage/implementation/common/MongoSyncBucketStorageContext.ts +0 -15
  273. package/src/storage/implementation/common/SingleBucketStore.ts +0 -63
  274. package/src/storage/implementation/v3/MongoParameterLookupV3.ts +0 -11
  275. package/src/storage/implementation/v3/SingleBucketStoreV3.ts +0 -68
@@ -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
+ });
@@ -0,0 +1,61 @@
1
+ import {
2
+ ObjectStorage,
3
+ ObjectStorageOperationOptions,
4
+ ObjectStoragePutMetadata
5
+ } from '@module/storage/implementation/v3/object-storage/ObjectStorage.js';
6
+
7
+ export class MemoryObjectStorage implements ObjectStorage {
8
+ /**
9
+ * Public for testing purposes.
10
+ */
11
+ public readonly store = new Map<string, { data: Uint8Array; metadata: ObjectStoragePutMetadata }>();
12
+
13
+ async put(
14
+ path: string,
15
+ data: Uint8Array,
16
+ metadata: ObjectStoragePutMetadata,
17
+ options?: ObjectStorageOperationOptions
18
+ ): Promise<void> {
19
+ options?.signal?.throwIfAborted();
20
+ this.store.set(path, { data, metadata: metadata });
21
+ }
22
+
23
+ async get(
24
+ path: string,
25
+ options?: ObjectStorageOperationOptions
26
+ ): Promise<{ data: Uint8Array; metadata: ObjectStoragePutMetadata }> {
27
+ const signal = options?.signal;
28
+ signal?.throwIfAborted();
29
+ const data = this.store.get(path);
30
+ if (!data) {
31
+ throw new Error(`NotFound: ${path}`);
32
+ }
33
+ return data;
34
+ }
35
+
36
+ async *list(prefix: string, options?: ObjectStorageOperationOptions): AsyncIterable<string> {
37
+ const signal = options?.signal;
38
+ for (const path of this.store.keys()) {
39
+ signal?.throwIfAborted();
40
+ if (path.startsWith(prefix)) {
41
+ yield path;
42
+ }
43
+ }
44
+ }
45
+
46
+ async delete(paths: string[], options?: ObjectStorageOperationOptions): Promise<void> {
47
+ options?.signal?.throwIfAborted();
48
+ for (const p of paths) {
49
+ this.store.delete(p);
50
+ }
51
+ }
52
+
53
+ async deletePrefix(prefix: string, options?: ObjectStorageOperationOptions): Promise<{ objectCount: number }> {
54
+ const paths: string[] = [];
55
+ for await (const path of this.list(prefix, options)) {
56
+ paths.push(path);
57
+ }
58
+ await this.delete(paths, options);
59
+ return { objectCount: paths.length };
60
+ }
61
+ }
@@ -0,0 +1,69 @@
1
+ import { ObjectStorage } from '@module/storage/implementation/v3/object-storage/ObjectStorage.js';
2
+ import { S3ObjectStorage } from '@module/storage/implementation/v3/object-storage/S3ObjectStorage.js';
3
+ import { mongoTestStorageFactoryGenerator } from '@module/utils/test-utils.js';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { MemoryObjectStorage } from './MemoryObjectStorage.js';
6
+
7
+ const minioStorages: S3ObjectStorage[] = [];
8
+
9
+ export interface S3TestFactoryOptions {
10
+ url: string;
11
+ isCI: boolean;
12
+ inlineThresholdBytes?: number;
13
+ }
14
+
15
+ function createTestStorageSuite(options: S3TestFactoryOptions, objectStorage: ObjectStorage) {
16
+ return {
17
+ objectStorage,
18
+ factoryGen: mongoTestStorageFactoryGenerator({
19
+ url: options.url,
20
+ isCI: options.isCI,
21
+ objectStorage,
22
+ inlineThresholdBytes: options.inlineThresholdBytes ?? 0
23
+ })
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Creates an ObjectStorage instance for S3 tests.
29
+ * Set MINIO_ENDPOINT to switch all S3 tests from MemoryObjectStorage
30
+ * to a real MinIO/S3 endpoint.
31
+ * MINIO_ENDPOINT=http://localhost:9000
32
+ */
33
+ export function createS3TestStorageSuite(options: S3TestFactoryOptions) {
34
+ const minioEndpoint = process.env.MINIO_ENDPOINT;
35
+ let objectStorage: ObjectStorage;
36
+ if (minioEndpoint) {
37
+ const s3 = new S3ObjectStorage({
38
+ bucket: 'powersync-s3-test',
39
+ region: 'us-east-1',
40
+ prefix: `test-${process.pid}-${randomUUID()}`,
41
+ endpoint: minioEndpoint,
42
+ forcePathStyle: true,
43
+ accessKeyId: process.env.MINIO_ACCESS_KEY ?? 'minioadmin',
44
+ secretAccessKey: process.env.MINIO_SECRET_KEY ?? 'minioadmin'
45
+ });
46
+ minioStorages.push(s3);
47
+ objectStorage = s3;
48
+ } else {
49
+ objectStorage = new MemoryObjectStorage();
50
+ }
51
+
52
+ return createTestStorageSuite(options, objectStorage);
53
+ }
54
+
55
+ /** Remove all objects created by MinIO-backed suites in the current test. */
56
+ export async function cleanupS3TestStorage(): Promise<void> {
57
+ for (const storage of minioStorages.splice(0)) {
58
+ await storage.deletePrefix('bucket-data/');
59
+ }
60
+ }
61
+
62
+ /** Creates an explicitly memory-backed suite for tests that inspect stored objects. */
63
+ export function createMemoryS3TestStorageSuite(options: S3TestFactoryOptions) {
64
+ const objectStorage = new MemoryObjectStorage();
65
+ return {
66
+ ...createTestStorageSuite(options, objectStorage),
67
+ objectStorage
68
+ };
69
+ }
@@ -0,0 +1,59 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { describe, expect, test } from 'vitest';
3
+ import { ObjectStorageLifecycle } from '../../src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js';
4
+ import { MemoryObjectStorage } from './helpers/MemoryObjectStorage.js';
5
+
6
+ describe('ObjectStorageLifecycle', () => {
7
+ test('escapes bucket names as path segments', () => {
8
+ const lifecycle = new ObjectStorageLifecycle(null as any, 12, null as any);
9
+
10
+ const path = lifecycle.allocatePath('definition', 'bucket/with spaces?and%escapes', 1n, 2n);
11
+
12
+ expect(path).toMatch(/^bucket-data\/12\/definition\/bucket%2Fwith%20spaces%3Fand%25escapes\/1-2-[0-9a-f-]+\.bson$/);
13
+ });
14
+
15
+ test('hashes bucket path segments longer than 128 bytes', () => {
16
+ const lifecycle = new ObjectStorageLifecycle(null as any, 12, null as any);
17
+ const bucket = '💾/long bucket parameter/'.repeat(20);
18
+ const hash = createHash('sha256').update(bucket).digest('base64url');
19
+
20
+ const path = lifecycle.allocatePath('definition', bucket, 1n, 2n);
21
+
22
+ expect(path).toMatch(new RegExp(`^bucket-data/12/definition/sha256-${hash}/1-2-[0-9a-f-]+\\.bson$`));
23
+ });
24
+
25
+ test('deletePrefix removes only objects under the exact prefix', async () => {
26
+ const objectStorage = new MemoryObjectStorage();
27
+ const lifecycle = new ObjectStorageLifecycle(null as any, 1, objectStorage);
28
+ for (const path of [
29
+ 'bucket-data/1/definition/object-1.bson',
30
+ 'bucket-data/12/definition/object-2.bson',
31
+ 'bucket-data/1-other/definition/object-3.bson'
32
+ ]) {
33
+ await objectStorage.put(path, new Uint8Array(), {
34
+ contentType: 'application/bson',
35
+ contentEncoding: null
36
+ });
37
+ }
38
+
39
+ await expect(lifecycle.deletePrefix(lifecycle.streamPrefix())).resolves.toEqual({ objectCount: 1 });
40
+ expect([...objectStorage.store.keys()]).toEqual([
41
+ 'bucket-data/12/definition/object-2.bson',
42
+ 'bucket-data/1-other/definition/object-3.bson'
43
+ ]);
44
+ });
45
+
46
+ test('definition prefixes do not match ids with the same string prefix', async () => {
47
+ const objectStorage = new MemoryObjectStorage();
48
+ const lifecycle = new ObjectStorageLifecycle(null as any, 7, objectStorage);
49
+ for (const path of ['bucket-data/7/1/object-1.bson', 'bucket-data/7/12/object-2.bson']) {
50
+ await objectStorage.put(path, new Uint8Array(), {
51
+ contentType: 'application/bson',
52
+ contentEncoding: null
53
+ });
54
+ }
55
+
56
+ await lifecycle.deletePrefix(lifecycle.definitionPrefix('1'));
57
+ expect([...objectStorage.store.keys()]).toEqual(['bucket-data/7/12/object-2.bson']);
58
+ });
59
+ });
@@ -0,0 +1,255 @@
1
+ import { MongoSyncBucketStorage } from '@module/storage/implementation/createMongoSyncBucketStorage.js';
2
+ import { BucketDataDocumentV3, ObjectStorageUsageDocument } from '@module/storage/implementation/v3/models.js';
3
+ import {
4
+ OBJECT_STORAGE_USAGE_BASE_WRITER_ID,
5
+ ObjectStorageUsage
6
+ } from '@module/storage/implementation/v3/object-storage/ObjectStorageUsage.js';
7
+ import { VersionedPowerSyncMongoV3 } from '@module/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
8
+ import * as lib_mongo from '@powersync/lib-service-mongodb';
9
+ import { mongo } from '@powersync/lib-service-mongodb';
10
+ import { updateSyncRulesFromYaml } from '@powersync/service-core';
11
+ import { BucketDefinitionId } from '@powersync/service-sync-rules';
12
+ import { describe, expect, test } from 'vitest';
13
+ import { INITIALIZED_MONGO_STORAGE_FACTORY } from './util.js';
14
+
15
+ const SYNC_RULES_YAML = `
16
+ bucket_definitions:
17
+ global:
18
+ data:
19
+ - SELECT id FROM items
20
+ `;
21
+
22
+ type UsageContext = {
23
+ db: VersionedPowerSyncMongoV3;
24
+ bucketStorage: MongoSyncBucketStorage;
25
+ definitionId: BucketDefinitionId;
26
+ };
27
+
28
+ type UsageEntry = {
29
+ definition_id: BucketDefinitionId;
30
+ active_bytes: bigint;
31
+ };
32
+
33
+ async function withUsageContext<T>(callback: (context: UsageContext) => Promise<T>): Promise<T> {
34
+ await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
35
+ const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_RULES_YAML, { storageVersion: 3 }));
36
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
37
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
38
+ const definitionId = syncRules.syncConfigContent[0].mapping.allBucketDefinitionIds()[0];
39
+ return callback({ db, bucketStorage, definitionId });
40
+ }
41
+
42
+ async function withTransaction<T>(
43
+ db: VersionedPowerSyncMongoV3,
44
+ callback: (session: mongo.ClientSession) => Promise<T>
45
+ ): Promise<T> {
46
+ const session = db.client.startSession();
47
+ try {
48
+ return await session.withTransaction(() => callback(session));
49
+ } finally {
50
+ await session.endSession();
51
+ }
52
+ }
53
+
54
+ async function readUsageEntries(
55
+ db: VersionedPowerSyncMongoV3,
56
+ replicationStreamId: number,
57
+ session?: mongo.ClientSession
58
+ ): Promise<UsageEntry[]> {
59
+ const entries = await db.objectStorageUsage
60
+ .aggregate<{ _id: BucketDefinitionId; active_bytes: bigint }>(
61
+ [
62
+ { $match: { '_id.g': replicationStreamId } },
63
+ { $project: { definitions: { $objectToArray: '$definitions' } } },
64
+ { $unwind: '$definitions' },
65
+ { $group: { _id: '$definitions.k', active_bytes: { $sum: '$definitions.v' } } }
66
+ ],
67
+ { session, readConcern: 'snapshot' }
68
+ )
69
+ .toArray()
70
+ .catch((error) => {
71
+ if (lib_mongo.isMongoNamespaceNotFoundError(error)) {
72
+ return [];
73
+ }
74
+ throw error;
75
+ });
76
+
77
+ return entries.map((entry) => ({
78
+ definition_id: entry._id,
79
+ active_bytes: BigInt(entry.active_bytes ?? 0)
80
+ }));
81
+ }
82
+
83
+ function usageDocument(
84
+ replicationStreamId: number,
85
+ writerId: string,
86
+ definitions: Record<string, bigint>,
87
+ updatedAt = new Date(0)
88
+ ): ObjectStorageUsageDocument {
89
+ return {
90
+ _id: { g: replicationStreamId, w: writerId },
91
+ updated_at: updatedAt,
92
+ definitions
93
+ };
94
+ }
95
+
96
+ function bucketDataDocument(
97
+ replicationStreamId: number,
98
+ definitionId: BucketDefinitionId,
99
+ fileSize: number
100
+ ): BucketDataDocumentV3 {
101
+ return {
102
+ _id: { b: `${replicationStreamId}-${definitionId}`, o: 1n },
103
+ min_op: 1n,
104
+ checksum: 1n,
105
+ count: 1,
106
+ size: fileSize,
107
+ storage_ref: {
108
+ path: `bucket-data/${replicationStreamId}/${definitionId}/1.bson`,
109
+ file_size: fileSize
110
+ }
111
+ };
112
+ }
113
+
114
+ describe('ObjectStorageUsage', () => {
115
+ test('counts a committed reference exactly once after an aborted transaction is retried', async () => {
116
+ await withUsageContext(async ({ db, bucketStorage, definitionId }) => {
117
+ const replicationStreamId = bucketStorage.replicationStreamId;
118
+ const writerId = 'replication-writer';
119
+ const usage = new ObjectStorageUsage(db, replicationStreamId, writerId);
120
+ const bucketData = db.bucketData(replicationStreamId, definitionId);
121
+ const document = bucketDataDocument(replicationStreamId, definitionId, 123);
122
+
123
+ await expect(
124
+ withTransaction(db, async (session) => {
125
+ await bucketData.insertOne(document, { session });
126
+ await usage.applyDelta(definitionId, 123n, session);
127
+ throw new Error('abort this attempt');
128
+ })
129
+ ).rejects.toThrow('abort this attempt');
130
+
131
+ await withTransaction(db, async (session) => {
132
+ await bucketData.insertOne(document, { session });
133
+ await usage.applyDelta(definitionId, 123n, session);
134
+ });
135
+
136
+ expect(await bucketData.countDocuments({ storage_ref: { $exists: true } })).toBe(1);
137
+ await expect(readUsageEntries(db, replicationStreamId)).resolves.toEqual([
138
+ { definition_id: definitionId, active_bytes: 123n }
139
+ ]);
140
+ });
141
+ });
142
+
143
+ test('snapshot usage reads observe a complete pre-fold view', async () => {
144
+ await withUsageContext(async ({ db, bucketStorage, definitionId }) => {
145
+ const replicationStreamId = bucketStorage.replicationStreamId;
146
+ const usage = new ObjectStorageUsage(db, replicationStreamId);
147
+ await db.objectStorageUsage.insertMany([
148
+ usageDocument(replicationStreamId, OBJECT_STORAGE_USAGE_BASE_WRITER_ID, { [definitionId]: 100n }),
149
+ usageDocument(replicationStreamId, 'replication', { [definitionId]: 20n }),
150
+ usageDocument(replicationStreamId, 'compactor', { [definitionId]: -5n })
151
+ ]);
152
+
153
+ const readSession = db.client.startSession();
154
+ try {
155
+ readSession.startTransaction({ readConcern: { level: 'snapshot' } });
156
+ await db.objectStorageUsage.findOne(
157
+ { _id: { g: replicationStreamId, w: OBJECT_STORAGE_USAGE_BASE_WRITER_ID } },
158
+ { session: readSession }
159
+ );
160
+
161
+ await usage.foldStaleWriterDeltas({ staleWriterMs: 0 });
162
+ const entries = await readUsageEntries(db, replicationStreamId, readSession);
163
+
164
+ await readSession.commitTransaction();
165
+ expect(entries).toEqual([{ definition_id: definitionId, active_bytes: 115n }]);
166
+ } catch (error) {
167
+ await readSession.abortTransaction().catch(() => undefined);
168
+ throw error;
169
+ } finally {
170
+ await readSession.endSession();
171
+ }
172
+
173
+ await expect(readUsageEntries(db, replicationStreamId)).resolves.toEqual([
174
+ { definition_id: definitionId, active_bytes: 115n }
175
+ ]);
176
+ });
177
+ });
178
+
179
+ test('folding preserves totals across both writer/folder commit orders', async () => {
180
+ await withUsageContext(async ({ db, bucketStorage, definitionId }) => {
181
+ const replicationStreamId = bucketStorage.replicationStreamId;
182
+ const writerId = 'reused-writer';
183
+ const usage = new ObjectStorageUsage(db, replicationStreamId, writerId);
184
+
185
+ await withTransaction(db, (session) => usage.applyDelta(definitionId, 10n, session));
186
+ await db.objectStorageUsage.updateOne(
187
+ { _id: { g: replicationStreamId, w: writerId } },
188
+ { $set: { updated_at: new Date(0) } }
189
+ );
190
+ await usage.foldStaleWriterDeltas({ staleWriterMs: 0 });
191
+
192
+ // The folder committed first and deleted the writer shard. The writer's
193
+ // next transaction must recreate it through the upsert.
194
+ await withTransaction(db, (session) => usage.applyDelta(definitionId, 4n, session));
195
+ expect((await readUsageEntries(db, replicationStreamId))[0].active_bytes).toBe(14n);
196
+
197
+ await db.objectStorageUsage.updateOne(
198
+ { _id: { g: replicationStreamId, w: writerId } },
199
+ { $set: { updated_at: new Date(0) } }
200
+ );
201
+ await usage.foldStaleWriterDeltas({ staleWriterMs: 0 });
202
+
203
+ // The writer committed before the folder. Folding the recreated shard
204
+ // must preserve the same visible total.
205
+ expect((await readUsageEntries(db, replicationStreamId))[0].active_bytes).toBe(14n);
206
+ });
207
+ });
208
+
209
+ test('definition cleanup racing a fold removes only that definition, and stream clear is isolated', async () => {
210
+ await withUsageContext(async ({ db, bucketStorage, definitionId }) => {
211
+ const replicationStreamId = bucketStorage.replicationStreamId;
212
+ const otherStreamId = replicationStreamId + 1;
213
+ const otherDefinitionId = 'other_definition';
214
+ const usage = new ObjectStorageUsage(db, replicationStreamId);
215
+
216
+ await db.objectStorageUsage.insertMany([
217
+ usageDocument(replicationStreamId, OBJECT_STORAGE_USAGE_BASE_WRITER_ID, {
218
+ [definitionId]: 3n,
219
+ [otherDefinitionId]: 7n
220
+ }),
221
+ usageDocument(replicationStreamId, 'writer', { [definitionId]: 20n, [otherDefinitionId]: 11n }),
222
+ usageDocument(otherStreamId, 'other-writer', { [definitionId]: 50n })
223
+ ]);
224
+
225
+ await Promise.all([
226
+ usage.foldStaleWriterDeltas({ staleWriterMs: 0 }),
227
+ withTransaction(db, (session) => usage.removeDefinition(definitionId, session))
228
+ ]);
229
+
230
+ await expect(readUsageEntries(db, replicationStreamId)).resolves.toEqual([
231
+ { definition_id: otherDefinitionId, active_bytes: 18n }
232
+ ]);
233
+
234
+ await withTransaction(db, async (session) => {
235
+ await new ObjectStorageUsage(db, otherStreamId).removeStream(session);
236
+ });
237
+ await db.objectStorageUsage.insertOne(usageDocument(otherStreamId, 'other-writer', { [definitionId]: 50n }));
238
+
239
+ await withTransaction(db, async (session) => {
240
+ await usage.removeStream(session);
241
+ });
242
+
243
+ await expect(ObjectStorageUsage.readAllDefinitionUsage(db)).resolves.toEqual([
244
+ { replication_stream_id: otherStreamId, definition_id: definitionId, active_bytes: 50n }
245
+ ]);
246
+ });
247
+ });
248
+
249
+ test('returns no entries for an absent usage collection', async () => {
250
+ await withUsageContext(async ({ db, bucketStorage }) => {
251
+ await db.objectStorageUsage.drop().catch(() => undefined);
252
+ await expect(readUsageEntries(db, bucketStorage.replicationStreamId)).resolves.toEqual([]);
253
+ });
254
+ });
255
+ });