@powersync/service-module-mongodb-storage 0.19.0 → 0.21.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 (176) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/storage/MongoBucketStorage.d.ts +31 -3
  3. package/dist/storage/MongoBucketStorage.js +365 -206
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +15 -4
  6. package/dist/storage/implementation/MongoBucketBatch.js +35 -9
  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 +3 -1
  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/MongoWriteCheckpointAPI.d.ts +9 -7
  27. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +7 -28
  28. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  29. package/dist/storage/implementation/SyncRuleStateUpdate.d.ts +11 -5
  30. package/dist/storage/implementation/SyncRuleStateUpdate.js +14 -6
  31. package/dist/storage/implementation/SyncRuleStateUpdate.js.map +1 -1
  32. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +22 -2
  33. package/dist/storage/implementation/common/PersistedBatch.d.ts +27 -0
  34. package/dist/storage/implementation/common/PersistedBatch.js +43 -2
  35. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  36. package/dist/storage/implementation/db.d.ts +4 -1
  37. package/dist/storage/implementation/db.js +14 -1
  38. package/dist/storage/implementation/db.js.map +1 -1
  39. package/dist/storage/implementation/models.d.ts +35 -0
  40. package/dist/storage/implementation/models.js.map +1 -1
  41. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +6 -0
  42. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +80 -84
  43. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  44. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +36 -5
  45. package/dist/storage/implementation/v1/MongoCompactorV1.js +229 -8
  46. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  47. package/dist/storage/implementation/v1/MongoParameterCompactorV1.d.ts +16 -2
  48. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js +20 -9
  49. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js.map +1 -1
  50. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +18 -11
  51. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +65 -16
  52. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  53. package/dist/storage/implementation/v1/models.js +1 -0
  54. package/dist/storage/implementation/v1/models.js.map +1 -1
  55. package/dist/storage/implementation/v3/CompactionLease.d.ts +50 -0
  56. package/dist/storage/implementation/v3/CompactionLease.js +131 -0
  57. package/dist/storage/implementation/v3/CompactionLease.js.map +1 -0
  58. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +8 -1
  59. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +120 -39
  60. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  61. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +77 -25
  62. package/dist/storage/implementation/v3/MongoCompactorV3.js +585 -141
  63. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  64. package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +18 -0
  65. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +23 -0
  66. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +1 -0
  67. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +2 -15
  68. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +60 -92
  69. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
  70. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +19 -9
  71. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +91 -31
  72. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  73. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.d.ts +26 -0
  74. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js +82 -0
  75. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js.map +1 -0
  76. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +2 -0
  77. package/dist/storage/implementation/v3/PersistedBatchV3.js +52 -11
  78. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  79. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +12 -2
  80. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +42 -3
  81. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  82. package/dist/storage/implementation/v3/bucket-format.js +2 -0
  83. package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
  84. package/dist/storage/implementation/v3/compact-utils.d.ts +107 -0
  85. package/dist/storage/implementation/v3/compact-utils.js +216 -0
  86. package/dist/storage/implementation/v3/compact-utils.js.map +1 -0
  87. package/dist/storage/implementation/v3/compaction-constants.d.ts +5 -0
  88. package/dist/storage/implementation/v3/compaction-constants.js +6 -0
  89. package/dist/storage/implementation/v3/compaction-constants.js.map +1 -0
  90. package/dist/storage/implementation/v3/models.d.ts +89 -5
  91. package/dist/storage/implementation/v3/models.js.map +1 -1
  92. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +7 -8
  93. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +8 -4
  94. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
  95. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +14 -11
  96. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +3 -5
  97. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +10 -2
  98. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
  99. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +37 -0
  100. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +167 -0
  101. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -0
  102. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +37 -12
  103. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +177 -23
  104. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -1
  105. package/dist/storage/implementation/v3/source-table-utils.d.ts +15 -7
  106. package/dist/storage/implementation/v3/source-table-utils.js +49 -57
  107. package/dist/storage/implementation/v3/source-table-utils.js.map +1 -1
  108. package/dist/storage/storage-index.d.ts +2 -1
  109. package/dist/storage/storage-index.js +1 -0
  110. package/dist/storage/storage-index.js.map +1 -1
  111. package/dist/types/types.d.ts +23 -1
  112. package/dist/types/types.js +16 -7
  113. package/dist/types/types.js.map +1 -1
  114. package/dist/utils/util.js +3 -1
  115. package/dist/utils/util.js.map +1 -1
  116. package/package.json +10 -9
  117. package/src/storage/MongoBucketStorage.ts +495 -268
  118. package/src/storage/implementation/MongoBucketBatch.ts +54 -14
  119. package/src/storage/implementation/MongoCompactor.ts +35 -346
  120. package/src/storage/implementation/MongoParameterCompactor.ts +388 -102
  121. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +34 -0
  122. package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +4 -2
  123. package/src/storage/implementation/MongoStorageProvider.ts +3 -1
  124. package/src/storage/implementation/MongoSyncBucketStorage.ts +114 -40
  125. package/src/storage/implementation/MongoSyncRulesLock.ts +22 -9
  126. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +12 -44
  127. package/src/storage/implementation/SyncRuleStateUpdate.ts +13 -6
  128. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +24 -2
  129. package/src/storage/implementation/common/PersistedBatch.ts +54 -2
  130. package/src/storage/implementation/db.ts +21 -2
  131. package/src/storage/implementation/models.ts +38 -0
  132. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +107 -96
  133. package/src/storage/implementation/v1/MongoCompactorV1.ts +303 -19
  134. package/src/storage/implementation/v1/MongoParameterCompactorV1.ts +25 -9
  135. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +87 -27
  136. package/src/storage/implementation/v1/models.ts +1 -0
  137. package/src/storage/implementation/v3/CompactionLease.ts +152 -0
  138. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +159 -42
  139. package/src/storage/implementation/v3/MongoCompactorV3.ts +744 -190
  140. package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +34 -0
  141. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +100 -109
  142. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +111 -41
  143. package/src/storage/implementation/v3/MongoWriteCheckpointAPIV3.ts +111 -0
  144. package/src/storage/implementation/v3/PersistedBatchV3.ts +57 -12
  145. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +55 -4
  146. package/src/storage/implementation/v3/bucket-format.ts +2 -0
  147. package/src/storage/implementation/v3/compact-utils.ts +320 -0
  148. package/src/storage/implementation/v3/compaction-constants.ts +5 -0
  149. package/src/storage/implementation/v3/models.ts +96 -3
  150. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +16 -8
  151. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +20 -5
  152. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +15 -4
  153. package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +230 -0
  154. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +257 -27
  155. package/src/storage/implementation/v3/source-table-utils.ts +82 -76
  156. package/src/storage/storage-index.ts +2 -0
  157. package/src/types/types.ts +24 -7
  158. package/src/utils/util.ts +3 -1
  159. package/test/src/__snapshots__/storage.test.ts.snap +25 -0
  160. package/test/src/__snapshots__/storage_sync.test.ts.snap +605 -16
  161. package/test/src/cleanup-stopped-sync-configs.test.ts +40 -13
  162. package/test/src/compact-utils.test.ts +226 -0
  163. package/test/src/helpers/MemoryObjectStorage.ts +14 -6
  164. package/test/src/object_storage_usage.test.ts +255 -0
  165. package/test/src/parameter_compacting_v1.test.ts +120 -0
  166. package/test/src/parameter_compacting_v3.test.ts +192 -0
  167. package/test/src/parameter_compaction_fence.test.ts +276 -0
  168. package/test/src/storage.test.ts +273 -22
  169. package/test/src/storage_compacting.test.ts +714 -244
  170. package/test/src/storage_s3_checksums.test.ts +3 -4
  171. package/test/src/storage_s3_compaction_lifecycle.test.ts +138 -12
  172. package/test/src/storage_s3_reading.test.ts +243 -1
  173. package/test/src/storage_s3_writing.test.ts +1 -0
  174. package/test/src/storage_sync.test.ts +365 -41
  175. package/test/src/util.ts +1 -1
  176. package/tsconfig.tsbuildinfo +1 -1
@@ -2,8 +2,13 @@ import { BucketDataDoc } from '@module/storage/implementation/common/BucketDataD
2
2
  import { MongoSyncBucketStorage } from '@module/storage/implementation/createMongoSyncBucketStorage.js';
3
3
  import { loadBucketDataDocument, serializeBucketData } from '@module/storage/implementation/v3/bucket-format.js';
4
4
  import { chunkBucketData, DEFAULT_MAX_DOC_SIZE_BYTES } from '@module/storage/implementation/v3/chunking.js';
5
+ import { DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS } from '@module/storage/implementation/v3/compaction-constants.js';
6
+ import { CompactionLease } from '@module/storage/implementation/v3/CompactionLease.js';
5
7
  import { BucketDataDocumentV3 } from '@module/storage/implementation/v3/models.js';
8
+ import { ObjectStorageError } from '@module/storage/implementation/v3/object-storage/ObjectStorage.js';
6
9
  import { VersionedPowerSyncMongoV3 } from '@module/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
10
+ import { replicaIdToSubkey } from '@module/utils/util.js';
11
+ import { logger as defaultLogger } from '@powersync/lib-services-framework';
7
12
  import {
8
13
  addChecksums,
9
14
  CheckpointChecksumInvalidatedError,
@@ -11,10 +16,37 @@ import {
11
16
  SyncRulesBucketStorage,
12
17
  updateSyncRulesFromYaml
13
18
  } from '@powersync/service-core';
14
- import { bucketRequest, register, test_utils } from '@powersync/service-core-tests';
19
+ import { bucketRequest, compactActive, register, test_utils } from '@powersync/service-core-tests';
15
20
  import * as bson from 'bson';
16
- import { describe, expect, test } from 'vitest';
17
- import { INITIALIZED_MONGO_STORAGE_FACTORY } from './util.js';
21
+ import { describe, expect, test, vi } from 'vitest';
22
+ import { INITIALIZED_MONGO_STORAGE_FACTORY, TEST_STORAGE_VERSIONS } from './util.js';
23
+
24
+ function makeOp(
25
+ opId: number,
26
+ rowId: string,
27
+ data: string,
28
+ ctx: { replicationStreamId: number; definitionId: string; bucket: string },
29
+ sourceTableId: bson.ObjectId,
30
+ overrides?: { op?: 'PUT' | 'REMOVE' }
31
+ ): BucketDataDoc {
32
+ const sourceKey = test_utils.rid(rowId);
33
+ return {
34
+ bucketKey: {
35
+ replicationStreamId: ctx.replicationStreamId,
36
+ definitionId: ctx.definitionId,
37
+ bucket: ctx.bucket
38
+ },
39
+ o: BigInt(opId),
40
+ op: overrides?.op ?? 'PUT',
41
+ source_table: sourceTableId,
42
+ source_key: sourceKey,
43
+ subkey: replicaIdToSubkey(sourceTableId, sourceKey),
44
+ table: 'items',
45
+ row_id: rowId,
46
+ checksum: BigInt(opId * 7),
47
+ data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
48
+ };
49
+ }
18
50
 
19
51
  describe('Mongo Sync Bucket Storage Compact', () => {
20
52
  register.registerCompactTests(INITIALIZED_MONGO_STORAGE_FACTORY);
@@ -58,15 +90,18 @@ describe('Mongo Sync Bucket Storage Compact', () => {
58
90
  return bucketStorage.getCheckpoint();
59
91
  };
60
92
 
61
- const setup = async () => {
93
+ const setup = async (storageVersion?: number) => {
62
94
  await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
63
95
  const syncRules = await factory.updateSyncRules(
64
- updateSyncRulesFromYaml(`
96
+ updateSyncRulesFromYaml(
97
+ `
65
98
  bucket_definitions:
66
99
  by_user:
67
100
  parameters: select request.user_id() as user_id
68
101
  data: [select * from test where owner_id = bucket.user_id]
69
- `)
102
+ `,
103
+ { storageVersion }
104
+ )
70
105
  );
71
106
  const bucketStorage = factory.getInstance(syncRules);
72
107
  const syncRulesContent = syncRules.syncConfigContent[0];
@@ -75,20 +110,17 @@ bucket_definitions:
75
110
  return { bucketStorage, checkpoint, factory, syncRules: syncRulesContent };
76
111
  };
77
112
 
78
- test('full compact', async () => {
113
+ test('V1 full compact with blank bucket_state', async () => {
79
114
  const { bucketStorage, checkpoint, factory, syncRules } = await setup();
80
115
  const storageDb = bucketStorage.db;
81
116
 
82
- // Simulate bucket_state from old version not being available
83
117
  if (storageDb.storageConfig.incrementalReprocessing) {
84
- // This should actually never happen on V3, but we test this anyway.
85
- // Can remove this if it causes issues in the future.
86
- await (storageDb as VersionedPowerSyncMongoV3).bucketState(bucketStorage.replicationStreamId).deleteMany({});
87
- } else {
88
- await factory.db.bucket_state.deleteMany({});
118
+ return;
89
119
  }
120
+ // Simulate a V1 deployment which pre-dates bucket-state population.
121
+ await factory.db.bucket_state.deleteMany({});
90
122
 
91
- await bucketStorage.compact({
123
+ await compactActive(factory, {
92
124
  clearBatchLimit: 200,
93
125
  moveBatchLimit: 10,
94
126
  moveBatchQueryLimit: 10,
@@ -113,18 +145,21 @@ bucket_definitions:
113
145
  });
114
146
  });
115
147
 
116
- test('populatePersistentChecksumCache', async () => {
148
+ test.each(TEST_STORAGE_VERSIONS)('compactInitialReplication (storage v%s)', async (storageVersion) => {
117
149
  // Populate old replication stream
118
- const { factory } = await setup();
150
+ const { factory } = await setup(storageVersion);
119
151
 
120
152
  // Now populate another replication stream (bucket definition name changed)
121
153
  const syncRules = await factory.updateSyncRules(
122
- updateSyncRulesFromYaml(`
154
+ updateSyncRulesFromYaml(
155
+ `
123
156
  bucket_definitions:
124
157
  by_user2:
125
158
  parameters: select request.user_id() as user_id
126
159
  data: [select * from test where owner_id = bucket.user_id]
127
- `)
160
+ `,
161
+ { storageVersion }
162
+ )
128
163
  );
129
164
  const bucketStorage = factory.getInstance(syncRules);
130
165
  const syncRulesContent = syncRules.syncConfigContent[0];
@@ -132,21 +167,23 @@ bucket_definitions:
132
167
  await populate(bucketStorage, 2);
133
168
  const { checkpoint } = await bucketStorage.getCheckpoint();
134
169
 
135
- // Default is to small small numbers - should be a no-op
136
- const result0 = await bucketStorage.populatePersistentChecksumCache({
170
+ // V3's initial lite pass processes every bucket with no prior compact state.
171
+ // Earlier storage versions use the default minimum-change threshold.
172
+ const result0 = await bucketStorage.compactInitialReplication({
137
173
  maxOpId: checkpoint
138
174
  });
139
- expect(result0.buckets).toEqual(0);
175
+ expect(result0.buckets).toEqual(storageVersion >= storage.STORAGE_VERSION_3 ? 2 : 0);
140
176
 
141
- // This should cache the checksums for the two buckets
142
- const result1 = await bucketStorage.populatePersistentChecksumCache({
177
+ // For V1/V2, lower the threshold to populate the checksum cache. V3 has
178
+ // already updated its compacted state, so another initial pass is a no-op.
179
+ const result1 = await bucketStorage.compactInitialReplication({
143
180
  maxOpId: checkpoint,
144
181
  minBucketChanges: 1
145
182
  });
146
- expect(result1.buckets).toEqual(2);
183
+ expect(result1.buckets).toEqual(storageVersion >= storage.STORAGE_VERSION_3 ? 0 : 2);
147
184
 
148
- // This should be a no-op, as the checksums are already cached
149
- const result2 = await bucketStorage.populatePersistentChecksumCache({
185
+ // Repeating it stays a no-op.
186
+ const result2 = await bucketStorage.compactInitialReplication({
150
187
  maxOpId: checkpoint,
151
188
  minBucketChanges: 1
152
189
  });
@@ -168,88 +205,82 @@ bucket_definitions:
168
205
  });
169
206
  });
170
207
 
171
- test('dirty bucket discovery handles bigint bucket_state bytes', async () => {
172
- await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
173
- const syncRules = await factory.updateSyncRules(
174
- updateSyncRulesFromYaml(`
175
- bucket_definitions:
176
- global:
177
- data: [select * from test]
178
- `)
179
- );
180
- const bucketStorage = factory.getInstance(syncRules);
181
- const storageDb = bucketStorage.db;
208
+ test('v3 initial chunk compaction includes writer-scheduled work with a custom chunk interval', async () => {
209
+ const { bucketStorage, checkpoint } = await setup(storage.STORAGE_VERSION_3);
210
+ const result = await (bucketStorage as MongoSyncBucketStorage)
211
+ .createMongoCompactor({
212
+ maxOpId: checkpoint,
213
+ compactChunksOnly: true,
214
+ minCompactChunkIntervalMs: 1
215
+ })
216
+ .compact();
217
+
218
+ expect(result).toBe(2);
219
+ });
182
220
 
183
- // This simulates bucket_state created using bigint bytes.
184
- // This typically happens when buckets get very large (> 2GiB). We don't want to create that much
185
- // data in the tests, so we directly insert the bucket_state here.
186
- if (storageDb.storageConfig.incrementalReprocessing) {
187
- const bucketStateCollection = (storageDb as VersionedPowerSyncMongoV3).bucketState(
188
- bucketStorage.replicationStreamId
189
- );
190
- await bucketStateCollection.insertOne({
191
- _id: {
192
- d: '1',
193
- b: 'global[]'
194
- },
195
- last_op: 5n,
196
- compacted_state: {
197
- op_id: 3n,
198
- count: 3,
199
- checksum: 0n,
200
- bytes: 7n
201
- },
202
- estimate_since_compact: {
203
- count: 2,
204
- bytes: 5n
205
- }
206
- });
207
- } else {
208
- await factory.db.bucket_state.insertOne({
209
- _id: {
210
- g: bucketStorage.replicationStreamId,
211
- b: 'global[]'
212
- },
213
- last_op: 5n,
214
- compacted_state: {
215
- op_id: 3n,
216
- count: 3,
217
- checksum: 0n,
218
- bytes: 7n
219
- },
220
- estimate_since_compact: {
221
- count: 2,
222
- bytes: 5n
223
- }
224
- });
225
- }
221
+ test('v3 repeated initial chunk compaction reschedules overdue full work beyond the current run', async () => {
222
+ const { bucketStorage, checkpoint } = await setup(storage.STORAGE_VERSION_3);
223
+ const firstResult = await bucketStorage.compactInitialReplication({ maxOpId: checkpoint });
224
+ expect(firstResult.buckets).toBe(2);
226
225
 
227
- // This test uses a couple of "internal" APIs of the compactor.
228
- const compactor = bucketStorage.createMongoCompactor({ maxOpId: 5n });
226
+ const bucketStateCollection = (bucketStorage.db as VersionedPowerSyncMongoV3).bucketState(
227
+ bucketStorage.replicationStreamId
228
+ );
229
+ await bucketStateCollection.updateMany(
230
+ {},
231
+ {
232
+ $set: {
233
+ first_uncompacted_write: new Date(0),
234
+ next_compact_check: new Date(0)
235
+ }
236
+ }
237
+ );
229
238
 
230
- const dirtyBuckets = compactor.dirtyBucketBatches({
231
- minBucketChanges: 1,
232
- minChangeRatio: 0.39
239
+ const [{ now }] = await (bucketStorage.db as VersionedPowerSyncMongoV3).db
240
+ .aggregate<{ now: Date }>([{ $documents: [{}] }, { $project: { _id: 0, now: '$$NOW' } }])
241
+ .toArray();
242
+ const result = await bucketStorage.compactInitialReplication({
243
+ maxOpId: checkpoint,
244
+ signal: AbortSignal.timeout(2_000)
233
245
  });
234
- const firstBatch = await dirtyBuckets.next();
235
246
 
236
- expect(firstBatch.done).toBe(false);
237
- expect(firstBatch.value).toHaveLength(1);
238
- expect(firstBatch.value[0].bucket).toBe('global[]');
239
- expect(firstBatch.value[0].estimatedCount).toBe(5);
240
- expect(typeof firstBatch.value[0].estimatedCount).toBe('number');
241
- expect(firstBatch.value[0].dirtyRatio).toBeCloseTo(5 / 12);
247
+ expect(result.buckets).toBe(0);
248
+ const states = await bucketStateCollection.find({}).toArray();
249
+ expect(states).toHaveLength(2);
250
+ for (const state of states) {
251
+ expect(state.next_compact_check!.getTime()).toBeGreaterThan(
252
+ now.getTime() + DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS
253
+ );
254
+ }
255
+ });
242
256
 
243
- const checksumBuckets = await compactor.dirtyBucketBatchForChecksums({
244
- minBucketChanges: 1
245
- });
246
- expect(checksumBuckets).toEqual([
247
- {
248
- bucket: 'global[]',
249
- definitionId: storageDb.storageConfig.incrementalReprocessing ? '1' : null,
250
- estimatedCount: 5
251
- }
252
- ]);
257
+ test('v3 replication writes initialize scheduled compaction state', async () => {
258
+ const { bucketStorage } = await setup();
259
+ const storageDb = bucketStorage.db;
260
+
261
+ if (!storageDb.storageConfig.incrementalReprocessing) {
262
+ return;
263
+ }
264
+ const bucketStateCollection = (storageDb as VersionedPowerSyncMongoV3).bucketState(
265
+ bucketStorage.replicationStreamId
266
+ );
267
+ const states = await bucketStateCollection.find({}).toArray();
268
+ expect(states).toHaveLength(2);
269
+ for (const state of states) {
270
+ expect(state.last_op).toBeGreaterThan(0n);
271
+ expect(state.bucket_stats.count).toBe(1);
272
+ expect(state.bucket_stats.chunks).toBe(1);
273
+ expect(state.first_uncompacted_write).toBeInstanceOf(Date);
274
+ expect(state.next_compact_check).toBeInstanceOf(Date);
275
+ expect(state.compacted_state).toBeUndefined();
276
+ expect(state).not.toHaveProperty('estimate_since_compact');
277
+
278
+ const docs = await (storageDb as VersionedPowerSyncMongoV3)
279
+ .bucketData(bucketStorage.replicationStreamId, state._id.d)
280
+ .find({ '_id.b': state._id.b })
281
+ .toArray();
282
+ expect(state.bucket_stats.bytes).toBe(BigInt(docs.reduce((total, document) => total + document.size, 0)));
283
+ }
253
284
  });
254
285
  });
255
286
  });
@@ -284,14 +315,16 @@ describe('Mongo Sync Parameter Storage Compact', () => {
284
315
  */
285
316
  describe('V3 invariant verification', () => {
286
317
  const BUCKET = 'global[]';
287
- const TABLE = 'items';
288
318
 
289
319
  function makeBucketDataDoc(overrides: Partial<BucketDataDoc> & { o: bigint }): BucketDataDoc {
320
+ const sourceTable = new bson.ObjectId();
321
+ const sourceKey = 'key';
290
322
  return {
291
323
  bucketKey: { replicationStreamId: 1, definitionId: '1', bucket: 'test[]' },
292
324
  op: 'PUT',
293
- source_table: new bson.ObjectId(),
294
- source_key: 'key',
325
+ source_table: sourceTable,
326
+ source_key: sourceKey,
327
+ subkey: replicaIdToSubkey(sourceTable, sourceKey),
295
328
  table: 'test',
296
329
  row_id: 'row1',
297
330
  checksum: 1n,
@@ -329,31 +362,6 @@ bucket_definitions:
329
362
  return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx, definitionId };
330
363
  }
331
364
 
332
- function makeOp(
333
- opId: number,
334
- rowId: string,
335
- data: string,
336
- ctx: { replicationStreamId: number; definitionId: string; bucket: string },
337
- sourceTableId: bson.ObjectId,
338
- overrides?: { op?: 'PUT' | 'REMOVE' }
339
- ): BucketDataDoc {
340
- return {
341
- bucketKey: {
342
- replicationStreamId: ctx.replicationStreamId,
343
- definitionId: ctx.definitionId,
344
- bucket: ctx.bucket
345
- },
346
- o: BigInt(opId),
347
- op: overrides?.op ?? 'PUT',
348
- source_table: sourceTableId,
349
- source_key: test_utils.rid(rowId),
350
- table: TABLE,
351
- row_id: rowId,
352
- checksum: BigInt(opId * 7),
353
- data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
354
- };
355
- }
356
-
357
365
  async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
358
366
  await collection.insertMany(docs);
359
367
  }
@@ -362,7 +370,9 @@ bucket_definitions:
362
370
  await bucketStateCollection.insertOne({
363
371
  _id: { d: definitionId, b: BUCKET },
364
372
  last_op: lastOp,
365
- estimate_since_compact: { count: 10, bytes: 100 }
373
+ next_compact_check: new Date(0),
374
+ first_uncompacted_write: new Date(0),
375
+ bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
366
376
  });
367
377
  }
368
378
 
@@ -371,12 +381,133 @@ bucket_definitions:
371
381
  clearBatchLimit: 200,
372
382
  moveBatchLimit: 10,
373
383
  moveBatchQueryLimit: 10,
374
- minBucketChanges: 1,
375
- minChangeRatio: 0,
376
384
  maxOpId
377
385
  });
378
386
  }
379
387
 
388
+ test('a successful lease renewal clears a transient renewal error', async () => {
389
+ const { bucketStateCollection, ctx } = await setupV3Storage();
390
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 1n);
391
+ const lease = await CompactionLease.claim(
392
+ bucketStateCollection,
393
+ { _id: { d: ctx.definitionId, b: BUCKET } },
394
+ undefined,
395
+ 10 * 60 * 1000
396
+ );
397
+ expect(lease).not.toBeNull();
398
+
399
+ try {
400
+ const transientError = new Error('temporary MongoDB error');
401
+ const renew = (lease as any).renew.bind(lease);
402
+ const updateOne = vi.spyOn(bucketStateCollection, 'updateOne').mockRejectedValueOnce(transientError);
403
+ await renew().catch((error: unknown) => {
404
+ (lease as any).renewalError = error;
405
+ });
406
+ await expect(lease!.throwIfLost()).rejects.toBe(transientError);
407
+
408
+ updateOne.mockRestore();
409
+ await renew();
410
+ await expect(lease!.throwIfLost()).resolves.toBeUndefined();
411
+ } finally {
412
+ await lease?.[Symbol.asyncDispose]();
413
+ }
414
+ });
415
+
416
+ test('a failed scheduled bucket does not block other scheduled buckets', async () => {
417
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
418
+ const badBucket = 'bad[]';
419
+ const goodBucket = 'good[]';
420
+ const goodDocument = serializeBucketData(goodBucket, [
421
+ makeOp(2, 'good', 'good', { ...ctx, bucket: goodBucket }, sourceTableId)
422
+ ]);
423
+ await insertDocs(collection, [goodDocument]);
424
+ await bucketStateCollection.insertMany([
425
+ {
426
+ _id: { d: ctx.definitionId, b: badBucket },
427
+ last_op: 2n,
428
+ next_compact_check: new Date(0),
429
+ // A malformed scheduled bucket with an expired lease must be
430
+ // rescheduled without preventing valid buckets from compacting.
431
+ first_uncompacted_write: new Date(0),
432
+ bucket_stats: { count: 1, bytes: 1n, chunks: 1 },
433
+ compact_lease: { id: new bson.ObjectId(), expires_at: new Date(0) }
434
+ },
435
+ {
436
+ _id: { d: ctx.definitionId, b: goodBucket },
437
+ last_op: 2n,
438
+ next_compact_check: new Date(0),
439
+ first_uncompacted_write: new Date(0),
440
+ bucket_stats: { count: 1, bytes: BigInt(goodDocument.size), chunks: 1 }
441
+ }
442
+ ]);
443
+ await bucketStateCollection.updateOne(
444
+ { _id: { d: ctx.definitionId, b: badBucket } },
445
+ { $unset: { first_uncompacted_write: '' } }
446
+ );
447
+
448
+ await (bucketStorage as MongoSyncBucketStorage)
449
+ .createMongoCompactor({ maxOpId: 2n, compactChunksOnly: true })
450
+ .compact();
451
+
452
+ const [badState, goodState] = await Promise.all([
453
+ bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: badBucket } }),
454
+ bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: goodBucket } })
455
+ ]);
456
+ expect(badState?.next_compact_check).toBeInstanceOf(Date);
457
+ expect(badState!.next_compact_check!.getTime()).toBeGreaterThan(0);
458
+ expect(goodState?.compacted_state?.op_id).toBe(2n);
459
+ });
460
+
461
+ test('aborting scheduled compaction does not reschedule the remaining batch', async () => {
462
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
463
+ const buckets = ['first[]', 'second[]', 'third[]'];
464
+ const documents = buckets.map((bucket) =>
465
+ serializeBucketData(bucket, [makeOp(2, bucket, bucket, { ...ctx, bucket }, sourceTableId)])
466
+ );
467
+ await insertDocs(collection, documents);
468
+ await bucketStateCollection.insertMany(
469
+ documents.map((document, index) => ({
470
+ _id: { d: ctx.definitionId, b: buckets[index] },
471
+ last_op: 2n,
472
+ next_compact_check: new Date(0),
473
+ first_uncompacted_write: new Date(0),
474
+ bucket_stats: { count: document.count, bytes: BigInt(document.size), chunks: 1 }
475
+ }))
476
+ );
477
+
478
+ const abortController = new AbortController();
479
+ const testLogger = defaultLogger.child({});
480
+ vi.spyOn(testLogger, 'info').mockImplementation((message: unknown) => {
481
+ if (typeof message == 'string' && message.startsWith('Compacted bucket chunks ')) {
482
+ abortController.abort();
483
+ }
484
+ return testLogger;
485
+ });
486
+ const errorLog = vi.spyOn(testLogger, 'error');
487
+
488
+ await expect(
489
+ bucketStorage
490
+ .createMongoCompactor({
491
+ maxOpId: 2n,
492
+ compactChunksOnly: true,
493
+ signal: abortController.signal,
494
+ logger: testLogger
495
+ })
496
+ .compact()
497
+ ).rejects.toThrow();
498
+
499
+ const states = await bucketStateCollection.find({ '_id.b': { $in: buckets } }).toArray();
500
+ const completed = states.filter((state) => state.compacted_state != null);
501
+ const remaining = states.filter((state) => state.compacted_state == null);
502
+ expect(completed).toHaveLength(1);
503
+ expect(remaining).toHaveLength(2);
504
+ for (const state of remaining) {
505
+ expect(state.next_compact_check).toEqual(new Date(0));
506
+ expect(state.compact_lease).toBeUndefined();
507
+ }
508
+ expect(errorLog).not.toHaveBeenCalled();
509
+ });
510
+
380
511
  test('1. ops[] ordering - preserves caller ordering (no implicit sort)', () => {
381
512
  const ops = [
382
513
  makeBucketDataDoc({ o: 5n, data: '{"id":"c"}' }),
@@ -746,7 +877,6 @@ bucket_definitions:
746
877
 
747
878
  describe('V3 checksum pipeline straddling', () => {
748
879
  const BUCKET = 'global[]';
749
- const TABLE = 'items';
750
880
 
751
881
  async function setup() {
752
882
  await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
@@ -776,30 +906,6 @@ bucket_definitions:
776
906
  return { bucketStorage, syncRules, db, collection, bucketStateCollection, definitionId, sourceTableId, ctx };
777
907
  }
778
908
 
779
- function makeOp(
780
- opId: number,
781
- rowId: string,
782
- data: string,
783
- ctx: { replicationStreamId: number; definitionId: string; bucket: string },
784
- sourceTableId: bson.ObjectId
785
- ): BucketDataDoc {
786
- return {
787
- bucketKey: {
788
- replicationStreamId: ctx.replicationStreamId,
789
- definitionId: ctx.definitionId,
790
- bucket: ctx.bucket
791
- },
792
- o: BigInt(opId),
793
- op: 'PUT',
794
- source_table: sourceTableId,
795
- source_key: test_utils.rid(rowId),
796
- table: TABLE,
797
- row_id: rowId,
798
- checksum: BigInt(opId * 7),
799
- data: JSON.stringify({ id: rowId, description: data })
800
- };
801
- }
802
-
803
909
  function checksumRequest(): storage.BucketChecksumRequest {
804
910
  return {
805
911
  bucket: BUCKET,
@@ -835,7 +941,9 @@ bucket_definitions:
835
941
  await bucketStateCollection.insertOne({
836
942
  _id: { d: definitionId, b: BUCKET },
837
943
  last_op: 30n,
838
- estimate_since_compact: { count: 3, bytes: 100 }
944
+ next_compact_check: undefined,
945
+ first_uncompacted_write: undefined,
946
+ bucket_stats: { count: 3, bytes: 100n, chunks: 1 }
839
947
  });
840
948
 
841
949
  const request = checksumRequest();
@@ -874,7 +982,9 @@ bucket_definitions:
874
982
  await bucketStateCollection.insertOne({
875
983
  _id: { d: definitionId, b: BUCKET },
876
984
  last_op: 0n,
877
- estimate_since_compact: { count: 0, bytes: 0 }
985
+ next_compact_check: undefined,
986
+ first_uncompacted_write: undefined,
987
+ bucket_stats: { count: 0, bytes: 0n, chunks: 0 }
878
988
  });
879
989
 
880
990
  const request = checksumRequest();
@@ -894,7 +1004,9 @@ bucket_definitions:
894
1004
  await bucketStateCollection.insertOne({
895
1005
  _id: { d: definitionId, b: BUCKET },
896
1006
  last_op: 0n,
897
- estimate_since_compact: { count: 0, bytes: 0 }
1007
+ next_compact_check: undefined,
1008
+ first_uncompacted_write: undefined,
1009
+ bucket_stats: { count: 0, bytes: 0n, chunks: 0 }
898
1010
  });
899
1011
 
900
1012
  const request = checksumRequest();
@@ -913,7 +1025,9 @@ bucket_definitions:
913
1025
  await bucketStateCollection.insertOne({
914
1026
  _id: { d: definitionId, b: BUCKET },
915
1027
  last_op: 10n,
916
- estimate_since_compact: { count: 1, bytes: 100 }
1028
+ next_compact_check: undefined,
1029
+ first_uncompacted_write: undefined,
1030
+ bucket_stats: { count: 1, bytes: 100n, chunks: 1 }
917
1031
  });
918
1032
 
919
1033
  const request = checksumRequest();
@@ -926,7 +1040,7 @@ bucket_definitions:
926
1040
  await collection.insertOne(serializeBucketData(BUCKET, [clear, afterClear]));
927
1041
  await bucketStateCollection.updateOne(
928
1042
  { _id: { d: definitionId, b: BUCKET } },
929
- { $set: { last_op: 30n, 'estimate_since_compact.count': 2 } }
1043
+ { $set: { last_op: 30n, 'bucket_stats.count': 2 } }
930
1044
  );
931
1045
 
932
1046
  const after = await bucketStorage.getChecksums(test_utils.testCheckpoint(30n), [request]);
@@ -940,7 +1054,6 @@ bucket_definitions:
940
1054
 
941
1055
  describe('V3 compaction boundaries', () => {
942
1056
  const BUCKET = 'global[]';
943
- const TABLE = 'items';
944
1057
 
945
1058
  async function setupV3() {
946
1059
  await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
@@ -971,31 +1084,6 @@ bucket_definitions:
971
1084
  return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx };
972
1085
  }
973
1086
 
974
- function makeOp(
975
- opId: number,
976
- rowId: string,
977
- data: string,
978
- ctx: { replicationStreamId: number; definitionId: string; bucket: string },
979
- sourceTableId: bson.ObjectId,
980
- overrides?: { op?: 'PUT' | 'REMOVE' }
981
- ): BucketDataDoc {
982
- return {
983
- bucketKey: {
984
- replicationStreamId: ctx.replicationStreamId,
985
- definitionId: ctx.definitionId,
986
- bucket: ctx.bucket
987
- },
988
- o: BigInt(opId),
989
- op: overrides?.op ?? 'PUT',
990
- source_table: sourceTableId,
991
- source_key: test_utils.rid(rowId),
992
- table: TABLE,
993
- row_id: rowId,
994
- checksum: BigInt(opId * 7),
995
- data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
996
- };
997
- }
998
-
999
1087
  async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
1000
1088
  await collection.insertMany(docs);
1001
1089
  }
@@ -1004,7 +1092,9 @@ bucket_definitions:
1004
1092
  await bucketStateCollection.insertOne({
1005
1093
  _id: { d: definitionId, b: BUCKET },
1006
1094
  last_op: lastOp,
1007
- estimate_since_compact: { count: 10, bytes: 100 }
1095
+ next_compact_check: new Date(0),
1096
+ first_uncompacted_write: new Date(0),
1097
+ bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
1008
1098
  });
1009
1099
  }
1010
1100
 
@@ -1030,6 +1120,156 @@ bucket_definitions:
1030
1120
  return collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1031
1121
  }
1032
1122
 
1123
+ test('scheduled compaction claims only due buckets and clears completed full work', async () => {
1124
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1125
+ const docs = [
1126
+ serializeBucketData(BUCKET, [makeOp(1, 'A', 'old', ctx, sourceTableId)]),
1127
+ serializeBucketData(BUCKET, [makeOp(2, 'A', 'new', ctx, sourceTableId)])
1128
+ ];
1129
+ await insertDocs(collection, docs);
1130
+ await bucketStateCollection.insertOne({
1131
+ _id: { d: ctx.definitionId, b: BUCKET },
1132
+ last_op: 2n,
1133
+ next_compact_check: new Date(Date.now() + 60_000),
1134
+ first_uncompacted_write: new Date(Date.now() - 60_000),
1135
+ bucket_stats: {
1136
+ count: 2,
1137
+ bytes: BigInt(docs[0].size + docs[1].size),
1138
+ chunks: 2
1139
+ }
1140
+ });
1141
+
1142
+ await bucketStorage.compact({ maxOpId: 2n, maxCompactFullIntervalMs: 0 });
1143
+ expect(
1144
+ (await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } }))?.compacted_state
1145
+ ).toBeUndefined();
1146
+
1147
+ await bucketStateCollection.updateOne(
1148
+ { _id: { d: ctx.definitionId, b: BUCKET } },
1149
+ { $set: { next_compact_check: new Date(Date.now() - 1) } }
1150
+ );
1151
+ await bucketStorage.compact({ maxOpId: 2n, maxCompactFullIntervalMs: 0 });
1152
+
1153
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1154
+ expect(state?.last_full_compact?.op_id).toBe(2n);
1155
+ expect(state?.first_uncompacted_write).toBeUndefined();
1156
+ expect(state?.next_compact_check).toBeUndefined();
1157
+ expect(state?.compact_lease).toBeUndefined();
1158
+ });
1159
+
1160
+ test('capped full compaction records its prefix and starts a fresh scheduling window', async () => {
1161
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1162
+ const prefix = serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]);
1163
+ const untouchedTail = serializeBucketData(BUCKET, [makeOp(2, 'B', 'b', ctx, sourceTableId)]);
1164
+ await insertDocs(collection, [prefix, untouchedTail]);
1165
+ await bucketStateCollection.insertOne({
1166
+ _id: { d: ctx.definitionId, b: BUCKET },
1167
+ last_op: 2n,
1168
+ next_compact_check: new Date(0),
1169
+ first_uncompacted_write: new Date(0),
1170
+ bucket_stats: {
1171
+ count: prefix.count + untouchedTail.count,
1172
+ bytes: BigInt(prefix.size + untouchedTail.size),
1173
+ chunks: 2
1174
+ }
1175
+ });
1176
+
1177
+ await bucketStorage.compact({ maxOpId: 1n });
1178
+
1179
+ const partialState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1180
+ expect(partialState?.last_full_compact).toMatchObject({
1181
+ op_id: 1n,
1182
+ count: prefix.count,
1183
+ puts: 1
1184
+ });
1185
+ expect(partialState?.first_uncompacted_write).toBeInstanceOf(Date);
1186
+ expect(partialState!.first_uncompacted_write!.getTime()).toBeGreaterThan(0);
1187
+ expect(partialState!.next_compact_check!.getTime()).toBeGreaterThanOrEqual(
1188
+ partialState!.first_uncompacted_write!.getTime() + DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS
1189
+ );
1190
+
1191
+ const lastFullCompactAt = partialState!.last_full_compact!.at;
1192
+ const freshFirstUncompactedWrite = partialState!.first_uncompacted_write!;
1193
+ await bucketStateCollection.updateOne(
1194
+ { _id: { d: ctx.definitionId, b: BUCKET } },
1195
+ { $set: { next_compact_check: new Date(0) } }
1196
+ );
1197
+
1198
+ await bucketStorage.compact({ maxOpId: 2n });
1199
+
1200
+ const deferredState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1201
+ expect(deferredState?.last_full_compact?.at).toEqual(lastFullCompactAt);
1202
+ expect(deferredState?.first_uncompacted_write).toEqual(freshFirstUncompactedWrite);
1203
+ expect(deferredState!.next_compact_check!.getTime()).toBeGreaterThan(Date.now());
1204
+
1205
+ // Even after the scheduling window has elapsed, a run with the old cap
1206
+ // must not scan and record the same full-compaction prefix again.
1207
+ await bucketStateCollection.updateOne(
1208
+ { _id: { d: ctx.definitionId, b: BUCKET } },
1209
+ { $set: { first_uncompacted_write: new Date(0), next_compact_check: new Date(0) } }
1210
+ );
1211
+
1212
+ await bucketStorage.compact({ maxOpId: 1n });
1213
+
1214
+ const rescheduledState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1215
+ expect(rescheduledState?.last_full_compact?.at).toEqual(lastFullCompactAt);
1216
+ expect(rescheduledState?.first_uncompacted_write).toEqual(new Date(0));
1217
+ expect(rescheduledState!.next_compact_check!.getTime()).toBeGreaterThan(0);
1218
+ });
1219
+
1220
+ test('explicit compaction skips a bucket with no outstanding full-compaction work', async () => {
1221
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1222
+ const document = serializeBucketData(BUCKET, [makeOp(1, 'A', 'value', ctx, sourceTableId)]);
1223
+ await insertDocs(collection, [document]);
1224
+ await bucketStateCollection.insertOne({
1225
+ _id: { d: ctx.definitionId, b: BUCKET },
1226
+ last_op: 1n,
1227
+ next_compact_check: undefined,
1228
+ first_uncompacted_write: undefined,
1229
+ compacted_state: {
1230
+ op_id: 1n,
1231
+ checksum: document.checksum,
1232
+ count: document.count,
1233
+ bytes: BigInt(document.size),
1234
+ chunks: 1,
1235
+ at: new Date()
1236
+ },
1237
+ last_full_compact: {
1238
+ op_id: 1n,
1239
+ count: document.count,
1240
+ puts: 1,
1241
+ at: new Date()
1242
+ },
1243
+ bucket_stats: { count: document.count, bytes: BigInt(document.size), chunks: 1 }
1244
+ });
1245
+
1246
+ await expect(bucketStorage.compact({ compactBuckets: [BUCKET], maxOpId: 1n })).resolves.toBeUndefined();
1247
+
1248
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1249
+ expect(state?.compact_lease).toBeUndefined();
1250
+ expect(state?.next_compact_check).toBeNull();
1251
+ expect(state?.last_full_compact?.op_id).toBe(1n);
1252
+ });
1253
+
1254
+ test('concurrent scheduled compactors lease a bucket to one worker', async () => {
1255
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1256
+ const document = serializeBucketData(BUCKET, [makeOp(1, 'A', 'value', ctx, sourceTableId)]);
1257
+ await insertDocs(collection, [document]);
1258
+ await bucketStateCollection.insertOne({
1259
+ _id: { d: ctx.definitionId, b: BUCKET },
1260
+ last_op: 1n,
1261
+ next_compact_check: new Date(Date.now() - 1),
1262
+ first_uncompacted_write: new Date(0),
1263
+ bucket_stats: { count: 1, bytes: BigInt(document.size), chunks: 1 }
1264
+ });
1265
+
1266
+ await Promise.all([bucketStorage.compact({ maxOpId: 1n }), bucketStorage.compact({ maxOpId: 1n })]);
1267
+
1268
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1269
+ expect(state?.last_full_compact?.op_id).toBe(1n);
1270
+ expect(state?.compact_lease).toBeUndefined();
1271
+ });
1272
+
1033
1273
  test('1. superseded ops become MOVE tombstones', async () => {
1034
1274
  const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1035
1275
  // Doc1: [A@10, B@20, A@30] — A@10 superseded by A@30, becomes MOVE tombstone
@@ -1207,7 +1447,6 @@ bucket_definitions:
1207
1447
 
1208
1448
  describe('V3 MOVE tombstone properties', () => {
1209
1449
  const BUCKET = 'global[]';
1210
- const TABLE = 'items';
1211
1450
 
1212
1451
  async function setupV3() {
1213
1452
  await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
@@ -1238,31 +1477,6 @@ bucket_definitions:
1238
1477
  return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx };
1239
1478
  }
1240
1479
 
1241
- function makeOp(
1242
- opId: number,
1243
- rowId: string,
1244
- data: string,
1245
- ctx: { replicationStreamId: number; definitionId: string; bucket: string },
1246
- sourceTableId: bson.ObjectId,
1247
- overrides?: { op?: 'PUT' | 'REMOVE' }
1248
- ): BucketDataDoc {
1249
- return {
1250
- bucketKey: {
1251
- replicationStreamId: ctx.replicationStreamId,
1252
- definitionId: ctx.definitionId,
1253
- bucket: ctx.bucket
1254
- },
1255
- o: BigInt(opId),
1256
- op: overrides?.op ?? 'PUT',
1257
- source_table: sourceTableId,
1258
- source_key: test_utils.rid(rowId),
1259
- table: TABLE,
1260
- row_id: rowId,
1261
- checksum: BigInt(opId * 7),
1262
- data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
1263
- };
1264
- }
1265
-
1266
1480
  async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
1267
1481
  await collection.insertMany(docs);
1268
1482
  }
@@ -1271,7 +1485,9 @@ bucket_definitions:
1271
1485
  await bucketStateCollection.insertOne({
1272
1486
  _id: { d: definitionId, b: BUCKET },
1273
1487
  last_op: lastOp,
1274
- estimate_since_compact: { count: 10, bytes: 100 }
1488
+ next_compact_check: new Date(0),
1489
+ first_uncompacted_write: new Date(0),
1490
+ bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
1275
1491
  });
1276
1492
  }
1277
1493
 
@@ -1420,7 +1636,6 @@ bucket_definitions:
1420
1636
  */
1421
1637
  describe('Streaming compactor', () => {
1422
1638
  const BUCKET = 'global[]';
1423
- const TABLE = 'items';
1424
1639
 
1425
1640
  async function setupV3() {
1426
1641
  await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
@@ -1450,31 +1665,6 @@ bucket_definitions:
1450
1665
  return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx };
1451
1666
  }
1452
1667
 
1453
- function makeOp(
1454
- opId: number,
1455
- rowId: string,
1456
- data: string,
1457
- ctx: { replicationStreamId: number; definitionId: string; bucket: string },
1458
- sourceTableId: bson.ObjectId,
1459
- overrides?: { op?: 'PUT' | 'REMOVE' }
1460
- ): BucketDataDoc {
1461
- return {
1462
- bucketKey: {
1463
- replicationStreamId: ctx.replicationStreamId,
1464
- definitionId: ctx.definitionId,
1465
- bucket: ctx.bucket
1466
- },
1467
- o: BigInt(opId),
1468
- op: overrides?.op ?? 'PUT',
1469
- source_table: sourceTableId,
1470
- source_key: test_utils.rid(rowId),
1471
- table: TABLE,
1472
- row_id: rowId,
1473
- checksum: BigInt(opId * 7),
1474
- data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
1475
- };
1476
- }
1477
-
1478
1668
  async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
1479
1669
  await collection.insertMany(docs);
1480
1670
  }
@@ -1483,7 +1673,9 @@ bucket_definitions:
1483
1673
  await bucketStateCollection.insertOne({
1484
1674
  _id: { d: definitionId, b: BUCKET },
1485
1675
  last_op: lastOp,
1486
- estimate_since_compact: { count: 10, bytes: 100 }
1676
+ next_compact_check: new Date(0),
1677
+ first_uncompacted_write: new Date(0),
1678
+ bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
1487
1679
  });
1488
1680
  }
1489
1681
 
@@ -1501,6 +1693,277 @@ bucket_definitions:
1501
1693
  );
1502
1694
  }
1503
1695
 
1696
+ async function setupCompactedTail() {
1697
+ const setup = await setupV3();
1698
+ const { collection, bucketStateCollection, ctx, sourceTableId } = setup;
1699
+ const documents = [
1700
+ serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]),
1701
+ serializeBucketData(BUCKET, [makeOp(2, 'B', 'b', ctx, sourceTableId)]),
1702
+ serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId)]),
1703
+ serializeBucketData(BUCKET, [makeOp(4, 'D', 'd', ctx, sourceTableId)])
1704
+ ];
1705
+ await insertDocs(collection, documents);
1706
+ await bucketStateCollection.insertOne({
1707
+ _id: { d: ctx.definitionId, b: BUCKET },
1708
+ last_op: 4n,
1709
+ next_compact_check: new Date(0),
1710
+ first_uncompacted_write: new Date(0),
1711
+ compacted_state: {
1712
+ op_id: 2n,
1713
+ checksum: documents[0].checksum + documents[1].checksum,
1714
+ count: documents[0].count + documents[1].count,
1715
+ bytes: BigInt(documents[0].size + documents[1].size),
1716
+ chunks: 2,
1717
+ at: new Date(0)
1718
+ },
1719
+ bucket_stats: {
1720
+ count: documents.reduce((total, document) => total + document.count, 0),
1721
+ bytes: BigInt(documents.reduce((total, document) => total + document.size, 0)),
1722
+ chunks: documents.length
1723
+ }
1724
+ });
1725
+ return setup;
1726
+ }
1727
+
1728
+ async function expectRecoveredCompactedTail(collection: any, bucketStateCollection: any, definitionId: string) {
1729
+ const documents = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1730
+ expect(documents.flatMap((document: BucketDataDocumentV3) => document.ops!.map((op) => op.o))).toEqual([
1731
+ 1n,
1732
+ 2n,
1733
+ 3n,
1734
+ 4n
1735
+ ]);
1736
+
1737
+ const bytes = BigInt(documents.reduce((total: number, document: BucketDataDocumentV3) => total + document.size, 0));
1738
+ const state = await bucketStateCollection.findOne({ _id: { d: definitionId, b: BUCKET } });
1739
+ expect(state?.compacted_state).toMatchObject({
1740
+ op_id: 4n,
1741
+ checksum: 70n,
1742
+ count: 4,
1743
+ bytes,
1744
+ chunks: documents.length
1745
+ });
1746
+ expect(state?.bucket_stats).toEqual({ count: 4, bytes, chunks: documents.length });
1747
+ }
1748
+
1749
+ test('initial compaction merges small chunks and refreshes bucket metadata', async () => {
1750
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1751
+ await insertDocs(collection, [
1752
+ serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId), makeOp(2, 'B', 'b', ctx, sourceTableId)]),
1753
+ serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId), makeOp(4, 'D', 'd', ctx, sourceTableId)])
1754
+ ]);
1755
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 4n);
1756
+
1757
+ const result = await bucketStorage.compactInitialReplication({ maxOpId: 4n });
1758
+
1759
+ expect(result).toEqual({ buckets: 1 });
1760
+ const documents = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1761
+ expect(documents).toHaveLength(1);
1762
+ expect(documents[0].ops!.map((op) => op.o)).toEqual([1n, 2n, 3n, 4n]);
1763
+
1764
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1765
+ expect(state?.compacted_state).toMatchObject({
1766
+ op_id: 4n,
1767
+ count: 4,
1768
+ checksum: 70n
1769
+ });
1770
+ });
1771
+
1772
+ test('capped chunk compaction repairs stale bucket stats after a committed first merge', async () => {
1773
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1774
+ const operations = [
1775
+ makeOp(1, 'A', 'a', ctx, sourceTableId),
1776
+ makeOp(2, 'B', 'b', ctx, sourceTableId),
1777
+ makeOp(3, 'C', 'c', ctx, sourceTableId),
1778
+ makeOp(4, 'D', 'd', ctx, sourceTableId)
1779
+ ];
1780
+ const originalDocuments = [
1781
+ serializeBucketData(BUCKET, operations.slice(0, 2)),
1782
+ serializeBucketData(BUCKET, operations.slice(2))
1783
+ ];
1784
+ const committedMerge = serializeBucketData(BUCKET, operations);
1785
+ const untouchedTail = serializeBucketData(BUCKET, [makeOp(5, 'E', 'e', ctx, sourceTableId)]);
1786
+ await insertDocs(collection, [committedMerge, untouchedTail]);
1787
+ await bucketStateCollection.insertOne({
1788
+ _id: { d: ctx.definitionId, b: BUCKET },
1789
+ last_op: 5n,
1790
+ next_compact_check: new Date(0),
1791
+ first_uncompacted_write: new Date(0),
1792
+ bucket_stats: {
1793
+ count: originalDocuments.reduce((total, document) => total + document.count, untouchedTail.count),
1794
+ bytes: BigInt(originalDocuments.reduce((total, document) => total + document.size, untouchedTail.size)),
1795
+ chunks: originalDocuments.length + 1
1796
+ }
1797
+ });
1798
+
1799
+ const result = await bucketStorage.compactInitialReplication({ maxOpId: 4n });
1800
+
1801
+ expect(result).toEqual({ buckets: 1 });
1802
+ const expectedStats = {
1803
+ count: committedMerge.count + untouchedTail.count,
1804
+ bytes: BigInt(committedMerge.size + untouchedTail.size),
1805
+ chunks: 2
1806
+ };
1807
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1808
+ expect(state?.bucket_stats).toEqual(expectedStats);
1809
+ expect(state?.compacted_state).toMatchObject({
1810
+ op_id: 4n,
1811
+ checksum: committedMerge.checksum,
1812
+ count: committedMerge.count,
1813
+ bytes: BigInt(committedMerge.size),
1814
+ chunks: 1
1815
+ });
1816
+ });
1817
+
1818
+ test('later full compaction claims fresh state after a committed replacement', async () => {
1819
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1820
+ const documents = [
1821
+ serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]),
1822
+ serializeBucketData(BUCKET, [makeOp(2, 'B', 'b', ctx, sourceTableId)]),
1823
+ serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId)]),
1824
+ serializeBucketData(BUCKET, [makeOp(4, 'D', 'd', ctx, sourceTableId)])
1825
+ ];
1826
+ await insertDocs(collection, documents);
1827
+ await bucketStateCollection.insertOne({
1828
+ _id: { d: ctx.definitionId, b: BUCKET },
1829
+ last_op: 4n,
1830
+ next_compact_check: new Date(0),
1831
+ first_uncompacted_write: new Date(0),
1832
+ bucket_stats: {
1833
+ count: documents.reduce((total, document) => total + document.count, 0),
1834
+ bytes: BigInt(documents.reduce((total, document) => total + document.size, 0)),
1835
+ chunks: documents.length
1836
+ }
1837
+ });
1838
+
1839
+ const concurrentDocument = serializeBucketData(BUCKET, [makeOp(5, 'E', 'e', ctx, sourceTableId)]);
1840
+ const compactor = bucketStorage.createMongoCompactor({ maxOpId: 5n, compactBuckets: [BUCKET] });
1841
+ const originalFlush = (compactor as any).flushCompactionGroup.bind(compactor);
1842
+ let injectedFailure = false;
1843
+ vi.spyOn(compactor as any, 'flushCompactionGroup').mockImplementation(async (...args: any[]) => {
1844
+ const result = await originalFlush(...args);
1845
+ if (!injectedFailure) {
1846
+ injectedFailure = true;
1847
+ await insertDocs(collection, [concurrentDocument]);
1848
+ await bucketStateCollection.updateOne(
1849
+ { _id: { d: ctx.definitionId, b: BUCKET } },
1850
+ {
1851
+ $set: { last_op: 5n },
1852
+ $inc: {
1853
+ 'bucket_stats.count': concurrentDocument.count,
1854
+ 'bucket_stats.bytes': BigInt(concurrentDocument.size),
1855
+ 'bucket_stats.chunks': 1
1856
+ }
1857
+ }
1858
+ );
1859
+ throw new ObjectStorageError('failure after committed full-compaction replacement', {
1860
+ cause: new Error('socket reset'),
1861
+ retryable: true
1862
+ });
1863
+ }
1864
+ return result;
1865
+ });
1866
+
1867
+ await expect(compactor.compact()).rejects.toThrow('failure after committed full-compaction replacement');
1868
+
1869
+ expect(injectedFailure).toBe(true);
1870
+ const interruptedState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1871
+ expect(interruptedState?.last_op).toBe(5n);
1872
+ expect(interruptedState?.compacted_state).toBeUndefined();
1873
+ expect(interruptedState?.compact_lease).toBeUndefined();
1874
+
1875
+ await expect(bucketStorage.createMongoCompactor({ maxOpId: 5n, compactBuckets: [BUCKET] }).compact()).resolves.toBe(
1876
+ 1
1877
+ );
1878
+
1879
+ const currentDocuments = await collection.find({ '_id.b': BUCKET }).toArray();
1880
+ expect(currentDocuments).toHaveLength(1);
1881
+ const expectedStats = {
1882
+ count: currentDocuments.reduce((total, document) => total + document.count, 0),
1883
+ bytes: BigInt(currentDocuments.reduce((total, document) => total + document.size, 0)),
1884
+ chunks: currentDocuments.length
1885
+ };
1886
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1887
+ expect(state?.bucket_stats).toEqual(expectedStats);
1888
+ expect(state?.compacted_state).toMatchObject({ op_id: 5n, ...expectedStats });
1889
+ expect(state?.last_full_compact?.op_id).toBe(5n);
1890
+ expect(state?.first_uncompacted_write).toBeUndefined();
1891
+ expect(state?.next_compact_check).toBeUndefined();
1892
+ });
1893
+
1894
+ test('chunk compaction treats a missing cached op as a resume hint', async () => {
1895
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1896
+ const documents = [
1897
+ serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]),
1898
+ serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId)]),
1899
+ serializeBucketData(BUCKET, [makeOp(4, 'D', 'd', ctx, sourceTableId)])
1900
+ ];
1901
+ await insertDocs(collection, documents);
1902
+ await bucketStateCollection.insertOne({
1903
+ _id: { d: ctx.definitionId, b: BUCKET },
1904
+ last_op: 4n,
1905
+ next_compact_check: new Date(0),
1906
+ first_uncompacted_write: new Date(0),
1907
+ compacted_state: {
1908
+ op_id: 2n,
1909
+ checksum: 21n,
1910
+ count: 2,
1911
+ bytes: 100n,
1912
+ chunks: 2,
1913
+ at: new Date(0)
1914
+ },
1915
+ bucket_stats: { count: 4, bytes: 200n, chunks: 4 }
1916
+ });
1917
+
1918
+ await expect(bucketStorage.createMongoCompactor({ maxOpId: 4n, compactChunksOnly: true }).compact()).resolves.toBe(
1919
+ 1
1920
+ );
1921
+
1922
+ const currentDocuments = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1923
+ expect(currentDocuments.flatMap((document) => document.ops!.map((op) => op.o))).toEqual([1n, 3n, 4n]);
1924
+ const bytes = BigInt(currentDocuments.reduce((total, document) => total + document.size, 0));
1925
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1926
+ expect(state?.compacted_state).toMatchObject({
1927
+ op_id: 4n,
1928
+ checksum: 56n,
1929
+ count: 3,
1930
+ bytes,
1931
+ chunks: currentDocuments.length
1932
+ });
1933
+ expect(state?.bucket_stats).toEqual({ count: 3, bytes, chunks: currentDocuments.length });
1934
+ });
1935
+
1936
+ test('later chunk compaction rebuilds state after a committed partial merge', async () => {
1937
+ const { bucketStorage, collection, bucketStateCollection, ctx } = await setupCompactedTail();
1938
+ const failedCompactor = bucketStorage.createMongoCompactor({
1939
+ maxOpId: 4n,
1940
+ compactChunksOnly: true
1941
+ });
1942
+ const originalFlush = (failedCompactor as any).flushCompactionGroup.bind(failedCompactor);
1943
+ const flushSpy = vi
1944
+ .spyOn(failedCompactor as any, 'flushCompactionGroup')
1945
+ .mockImplementation(async (...args: any[]) => {
1946
+ const result = await originalFlush(...args);
1947
+ throw new Error('failure after committed chunk merge');
1948
+ });
1949
+
1950
+ await expect(failedCompactor.compact()).resolves.toBe(0);
1951
+ flushSpy.mockRestore();
1952
+
1953
+ const interruptedState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1954
+ expect(interruptedState?.compacted_state?.op_id).toBe(2n);
1955
+ expect(await collection.findOne({ _id: { b: BUCKET, o: 2n } })).toBeNull();
1956
+ await bucketStateCollection.updateOne(
1957
+ { _id: { d: ctx.definitionId, b: BUCKET } },
1958
+ { $set: { next_compact_check: new Date(0) } }
1959
+ );
1960
+
1961
+ const result = await bucketStorage.createMongoCompactor({ maxOpId: 4n, compactChunksOnly: true }).compact();
1962
+
1963
+ expect(result).toBe(1);
1964
+ await expectRecoveredCompactedTail(collection, bucketStateCollection, ctx.definitionId);
1965
+ });
1966
+
1504
1967
  test('1. multi-batch compaction preserves checksum and creates MOVE tombstones', async () => {
1505
1968
  const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1506
1969
 
@@ -1616,6 +2079,13 @@ bucket_definitions:
1616
2079
  expect(op600).toBeDefined();
1617
2080
  expect(op600!.op).toBe('PUT');
1618
2081
  expect(op600!.row_id).toBe('F');
2082
+
2083
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
2084
+ expect(state?.bucket_stats).toEqual({
2085
+ count: docsAfter.reduce((total, document) => total + document.count, 0),
2086
+ bytes: BigInt(docsAfter.reduce((total, document) => total + document.size, 0)),
2087
+ chunks: docsAfter.length
2088
+ });
1619
2089
  });
1620
2090
 
1621
2091
  test('3. seen map overflow - some old ops pass through without tombstoning', async () => {