@powersync/service-module-mongodb-storage 0.17.0 → 0.18.1

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 (185) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/storage/MongoBucketStorage.d.ts +34 -16
  3. package/dist/storage/MongoBucketStorage.js +219 -70
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +42 -23
  6. package/dist/storage/implementation/MongoBucketBatch.js +61 -44
  7. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  8. package/dist/storage/implementation/MongoChecksums.d.ts +10 -9
  9. package/dist/storage/implementation/MongoChecksums.js +3 -111
  10. package/dist/storage/implementation/MongoChecksums.js.map +1 -1
  11. package/dist/storage/implementation/MongoCompactor.d.ts +2 -0
  12. package/dist/storage/implementation/MongoCompactor.js +18 -9
  13. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  14. package/dist/storage/implementation/MongoHydrationState.d.ts +11 -0
  15. package/dist/storage/implementation/MongoHydrationState.js +49 -0
  16. package/dist/storage/implementation/MongoHydrationState.js.map +1 -0
  17. package/dist/storage/implementation/MongoParameterCompactor.d.ts +6 -5
  18. package/dist/storage/implementation/MongoParameterCompactor.js +24 -1
  19. package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
  20. package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +13 -0
  21. package/dist/storage/implementation/MongoParsedSyncConfigSet.js +64 -0
  22. package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -0
  23. package/dist/storage/implementation/MongoPersistedReplicationStream.d.ts +36 -0
  24. package/dist/storage/implementation/MongoPersistedReplicationStream.js +90 -0
  25. package/dist/storage/implementation/MongoPersistedReplicationStream.js.map +1 -0
  26. package/dist/storage/implementation/MongoPersistedSyncConfigContent.d.ts +25 -0
  27. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +123 -0
  28. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -0
  29. package/dist/storage/implementation/MongoStorageProvider.js +5 -2
  30. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  31. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +41 -23
  32. package/dist/storage/implementation/MongoSyncBucketStorage.js +51 -43
  33. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  34. package/dist/storage/implementation/MongoSyncRulesLock.d.ts +1 -1
  35. package/dist/storage/implementation/MongoSyncRulesLock.js +3 -3
  36. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  37. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +6 -0
  38. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.js +2 -0
  39. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.js.map +1 -0
  40. package/dist/storage/implementation/common/PersistedBatch.d.ts +1 -2
  41. package/dist/storage/implementation/common/PersistedBatch.js +5 -0
  42. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  43. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +1 -1
  44. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
  45. package/dist/storage/implementation/createMongoSyncBucketStorage.d.ts +2 -2
  46. package/dist/storage/implementation/createMongoSyncBucketStorage.js +4 -4
  47. package/dist/storage/implementation/createMongoSyncBucketStorage.js.map +1 -1
  48. package/dist/storage/implementation/db.js +9 -14
  49. package/dist/storage/implementation/db.js.map +1 -1
  50. package/dist/storage/implementation/models.d.ts +4 -13
  51. package/dist/storage/implementation/models.js.map +1 -1
  52. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +9 -1
  53. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +42 -26
  54. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  55. package/dist/storage/implementation/v1/MongoChecksumsV1.js +114 -3
  56. package/dist/storage/implementation/v1/MongoChecksumsV1.js.map +1 -1
  57. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +15 -17
  58. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +47 -45
  59. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  60. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.d.ts +2 -1
  61. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js +3 -0
  62. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js.map +1 -1
  63. package/dist/storage/implementation/v1/models.d.ts +10 -1
  64. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +8 -4
  65. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +286 -259
  66. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  67. package/dist/storage/implementation/v3/MongoChecksumsV3.d.ts +27 -7
  68. package/dist/storage/implementation/v3/MongoChecksumsV3.js +202 -18
  69. package/dist/storage/implementation/v3/MongoChecksumsV3.js.map +1 -1
  70. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +19 -2
  71. package/dist/storage/implementation/v3/MongoCompactorV3.js +511 -22
  72. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  73. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +62 -0
  74. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +350 -0
  75. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -0
  76. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +40 -28
  77. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +232 -116
  78. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  79. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +1 -1
  80. package/dist/storage/implementation/v3/PersistedBatchV3.js +43 -20
  81. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  82. package/dist/storage/implementation/v3/SingleBucketStoreV3.js +52 -16
  83. package/dist/storage/implementation/v3/SingleBucketStoreV3.js.map +1 -1
  84. package/dist/storage/implementation/v3/SourceRecordStoreV3.d.ts +3 -3
  85. package/dist/storage/implementation/v3/SourceRecordStoreV3.js +13 -11
  86. package/dist/storage/implementation/v3/SourceRecordStoreV3.js.map +1 -1
  87. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +13 -12
  88. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +29 -27
  89. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  90. package/dist/storage/implementation/v3/bucket-format.d.ts +4 -0
  91. package/dist/storage/implementation/v3/bucket-format.js +58 -0
  92. package/dist/storage/implementation/v3/bucket-format.js.map +1 -0
  93. package/dist/storage/implementation/v3/chunking.d.ts +10 -0
  94. package/dist/storage/implementation/v3/chunking.js +28 -0
  95. package/dist/storage/implementation/v3/chunking.js.map +1 -0
  96. package/dist/storage/implementation/v3/models.d.ts +64 -21
  97. package/dist/storage/implementation/v3/models.js +11 -28
  98. package/dist/storage/implementation/v3/models.js.map +1 -1
  99. package/dist/storage/implementation/v3/source-table-utils.d.ts +55 -0
  100. package/dist/storage/implementation/v3/source-table-utils.js +214 -0
  101. package/dist/storage/implementation/v3/source-table-utils.js.map +1 -0
  102. package/dist/storage/storage-index.d.ts +4 -4
  103. package/dist/storage/storage-index.js +4 -4
  104. package/dist/storage/storage-index.js.map +1 -1
  105. package/dist/types/types.d.ts +2 -2
  106. package/dist/utils/test-utils.d.ts +1 -2
  107. package/dist/utils/test-utils.js +5 -1
  108. package/dist/utils/test-utils.js.map +1 -1
  109. package/dist/utils/util.d.ts +1 -1
  110. package/dist/utils/util.js +3 -2
  111. package/dist/utils/util.js.map +1 -1
  112. package/package.json +9 -19
  113. package/src/storage/MongoBucketStorage.ts +334 -87
  114. package/src/storage/implementation/MongoBucketBatch.ts +86 -58
  115. package/src/storage/implementation/MongoChecksums.ts +6 -138
  116. package/src/storage/implementation/MongoCompactor.ts +19 -9
  117. package/src/storage/implementation/MongoHydrationState.ts +61 -0
  118. package/src/storage/implementation/MongoParameterCompactor.ts +24 -5
  119. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +88 -0
  120. package/src/storage/implementation/MongoPersistedReplicationStream.ts +118 -0
  121. package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +154 -0
  122. package/src/storage/implementation/MongoStorageProvider.ts +5 -2
  123. package/src/storage/implementation/MongoSyncBucketStorage.ts +70 -56
  124. package/src/storage/implementation/MongoSyncRulesLock.ts +7 -4
  125. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +7 -0
  126. package/src/storage/implementation/common/PersistedBatch.ts +7 -3
  127. package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +1 -1
  128. package/src/storage/implementation/createMongoSyncBucketStorage.ts +21 -7
  129. package/src/storage/implementation/db.ts +11 -19
  130. package/src/storage/implementation/models.ts +4 -14
  131. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +55 -34
  132. package/src/storage/implementation/v1/MongoChecksumsV1.ts +137 -4
  133. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +70 -64
  134. package/src/storage/implementation/v1/VersionedPowerSyncMongoV1.ts +11 -1
  135. package/src/storage/implementation/v1/models.ts +11 -1
  136. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +358 -314
  137. package/src/storage/implementation/v3/MongoChecksumsV3.ts +262 -26
  138. package/src/storage/implementation/v3/MongoCompactorV3.ts +646 -29
  139. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +480 -0
  140. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +315 -157
  141. package/src/storage/implementation/v3/PersistedBatchV3.ts +62 -36
  142. package/src/storage/implementation/v3/SingleBucketStoreV3.ts +59 -19
  143. package/src/storage/implementation/v3/SourceRecordStoreV3.ts +12 -12
  144. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +41 -36
  145. package/src/storage/implementation/v3/bucket-format.ts +70 -0
  146. package/src/storage/implementation/v3/chunking.ts +38 -0
  147. package/src/storage/implementation/v3/models.ts +93 -55
  148. package/src/storage/implementation/v3/source-table-utils.ts +392 -0
  149. package/src/storage/storage-index.ts +12 -4
  150. package/src/utils/test-utils.ts +6 -3
  151. package/src/utils/util.ts +3 -2
  152. package/test/src/__snapshots__/storage.test.ts.snap +0 -75
  153. package/test/src/__snapshots__/storage_sync.test.ts.snap +48 -48
  154. package/test/src/cleanup-stopped-sync-configs.test.ts +648 -0
  155. package/test/src/storage.test.ts +11 -11
  156. package/test/src/storage_compacting.test.ts +1956 -6
  157. package/test/src/storage_sync.test.ts +1077 -68
  158. package/test/src/storeCurrentData.test.ts +17 -7
  159. package/test/src/util.ts +2 -1
  160. package/tsconfig.json +1 -4
  161. package/tsconfig.tsbuildinfo +1 -1
  162. package/dist/storage/implementation/BucketDefinitionMapping.d.ts +0 -15
  163. package/dist/storage/implementation/BucketDefinitionMapping.js +0 -58
  164. package/dist/storage/implementation/BucketDefinitionMapping.js.map +0 -1
  165. package/dist/storage/implementation/MongoPersistedSyncRules.d.ts +0 -14
  166. package/dist/storage/implementation/MongoPersistedSyncRules.js +0 -67
  167. package/dist/storage/implementation/MongoPersistedSyncRules.js.map +0 -1
  168. package/dist/storage/implementation/MongoPersistedSyncRulesContent.d.ts +0 -29
  169. package/dist/storage/implementation/MongoPersistedSyncRulesContent.js +0 -76
  170. package/dist/storage/implementation/MongoPersistedSyncRulesContent.js.map +0 -1
  171. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.d.ts +0 -13
  172. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.js +0 -2
  173. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.js.map +0 -1
  174. package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +0 -9
  175. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +0 -18
  176. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +0 -1
  177. package/dist/storage/implementation/v3/MongoParameterLookupV3.d.ts +0 -4
  178. package/dist/storage/implementation/v3/MongoParameterLookupV3.js +0 -9
  179. package/dist/storage/implementation/v3/MongoParameterLookupV3.js.map +0 -1
  180. package/src/storage/implementation/BucketDefinitionMapping.ts +0 -75
  181. package/src/storage/implementation/MongoPersistedSyncRules.ts +0 -82
  182. package/src/storage/implementation/MongoPersistedSyncRulesContent.ts +0 -101
  183. package/src/storage/implementation/common/MongoSyncBucketStorageContext.ts +0 -15
  184. package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +0 -24
  185. package/src/storage/implementation/v3/MongoParameterLookupV3.ts +0 -11
@@ -1,11 +1,16 @@
1
1
  import { mongo } from '@powersync/lib-service-mongodb';
2
- import { ReplicationAssertionError, ServiceAssertionError } from '@powersync/lib-services-framework';
3
- import { storage } from '@powersync/service-core';
2
+ import { logger, ReplicationAssertionError, ServiceAssertionError } from '@powersync/lib-services-framework';
3
+ import { addChecksums, storage, utils } from '@powersync/service-core';
4
4
  import { BucketDefinitionId } from '@powersync/service-sync-rules';
5
- import { SingleBucketStore } from '../common/SingleBucketStore.js';
6
- import { BucketStateDocumentBase } from '../models.js';
5
+ import { BucketDataDoc } from '../common/BucketDataDoc.js';
6
+ import { BucketDataDocumentGeneric } from '../common/SingleBucketStore.js';
7
+ import { BucketDataKey, BucketStateDocumentBase } from '../models.js';
7
8
  import { DirtyBucket, MongoCompactor } from '../MongoCompactor.js';
8
- import { BucketStateDocumentV3 } from './models.js';
9
+ import { cacheKey } from '../OperationBatch.js';
10
+ import { loadBucketDataDocument, serializeBucketData } from './bucket-format.js';
11
+ import { chunkBucketData } from './chunking.js';
12
+ import { BucketDataDocumentV3, BucketStateDocumentV3 } from './models.js';
13
+ import { DefinitionChecksumOperations, MongoChecksumsV3 } from './MongoChecksumsV3.js';
9
14
  import type { MongoSyncBucketStorageV3 } from './MongoSyncBucketStorageV3.js';
10
15
  import { SingleBucketStoreV3 } from './SingleBucketStoreV3.js';
11
16
  import { VersionedPowerSyncMongoV3 } from './VersionedPowerSyncMongoV3.js';
@@ -21,11 +26,11 @@ export class MongoCompactorV3 extends MongoCompactor {
21
26
  if (options.minBucketChanges <= 0) {
22
27
  throw new ReplicationAssertionError('minBucketChanges must be >= 1');
23
28
  }
24
- // Same scan strategy as V1, but with the V3 bucket_state key shape.
29
+ const collection = this.db.bucketState(this.group_id) as unknown as mongo.Collection<BucketStateDocumentBase>;
25
30
  yield* this.dirtyBucketBatchesForCollection(
26
- this.db.bucketStateV3(this.group_id),
27
- { d: new mongo.MinKey() as any, b: new mongo.MinKey() as any },
28
- { d: new mongo.MaxKey() as any, b: new mongo.MaxKey() as any },
31
+ collection,
32
+ { d: new mongo.MinKey(), b: new mongo.MinKey() } as unknown as BucketStateDocumentV3['_id'],
33
+ { d: new mongo.MaxKey(), b: new mongo.MaxKey() } as unknown as BucketStateDocumentV3['_id'],
29
34
  options,
30
35
  (bucketState) => (bucketState as BucketStateDocumentV3)._id.d
31
36
  );
@@ -36,27 +41,37 @@ export class MongoCompactorV3 extends MongoCompactor {
36
41
  throw new ReplicationAssertionError('minBucketChanges must be >= 1');
37
42
  }
38
43
  return this.dirtyBucketBatchForChecksumsForCollection(
39
- this.db.bucketStateV3(this.group_id),
44
+ this.db.bucketState(this.group_id) as unknown as mongo.Collection<BucketStateDocumentBase>,
40
45
  {
41
46
  'estimate_since_compact.count': { $gte: options.minBucketChanges }
42
- },
47
+ } as unknown as mongo.Filter<BucketStateDocumentBase>,
43
48
  (bucketState) => (bucketState as BucketStateDocumentV3)._id.d
44
49
  );
45
50
  }
46
51
 
47
52
  protected async writeBucketStateUpdates(): Promise<void> {
48
53
  await this.db
49
- .bucketStateV3(this.group_id)
50
- .bulkWrite(this.bucketStateUpdates as mongo.AnyBulkWriteOperation<BucketStateDocumentV3>[], { ordered: false });
54
+ .bucketState(this.group_id)
55
+ .bulkWrite(this.bucketStateUpdates as mongo.AnyBulkWriteOperation<BucketStateDocumentV3>[], {
56
+ ordered: false
57
+ });
58
+ }
59
+
60
+ /**
61
+ * The compactor operates on persisted definition ids only - never on parsed sources.
62
+ * This narrowed view makes the source-resolving checksum methods unreachable here.
63
+ */
64
+ private get definitionChecksums(): DefinitionChecksumOperations {
65
+ return this.storage.checksums as MongoChecksumsV3;
51
66
  }
52
67
 
53
68
  protected async computeChecksumsForBuckets(
54
69
  buckets: Pick<DirtyBucket, 'bucket' | 'definitionId'>[]
55
70
  ): Promise<storage.PartialChecksumMap> {
56
- return this.storage.checksums.computePartialChecksumsDirectByDefinition(
71
+ return this.definitionChecksums.computePartialChecksumsDirectByDefinition(
57
72
  buckets.map(({ bucket, definitionId }) => {
58
73
  if (definitionId == null) {
59
- throw new ServiceAssertionError(`Missing definitionId for V3 bucket checksum update on bucket ${bucket}`);
74
+ throw new ServiceAssertionError(`Missing definitionId for bucket checksum update on bucket ${bucket}`);
60
75
  }
61
76
  return {
62
77
  bucket,
@@ -85,23 +100,625 @@ export class MongoCompactorV3 extends MongoCompactor {
85
100
  protected async getBucketDataContext(
86
101
  bucket: string,
87
102
  definitionId: BucketDefinitionId | null
88
- ): Promise<SingleBucketStore | null> {
89
- if (definitionId == null) {
90
- // Not the _most_ efficient approach, but this is not used often
91
- const allDefinitionIds = this.storage.mapping.allBucketDefinitionIds();
92
- if (allDefinitionIds.length == 0) {
93
- return null;
103
+ ): Promise<SingleBucketStoreV3 | null> {
104
+ let resolvedDefinitionId = definitionId;
105
+
106
+ if (resolvedDefinitionId == null) {
107
+ const allDefinitionIds = this.storage.storageIds.bucketDefinitionIds;
108
+ if (allDefinitionIds.length > 0) {
109
+ const potentialIds = allDefinitionIds.map((id) => ({ d: id, b: bucket }));
110
+ const bucketState = await this.db.bucketState(this.group_id).findOne({
111
+ _id: { $in: potentialIds }
112
+ });
113
+ if (bucketState != null) {
114
+ resolvedDefinitionId = bucketState._id.d;
115
+ }
94
116
  }
95
- const potentialIds = allDefinitionIds.map((definitionId) => ({ d: definitionId, b: bucket }));
96
- const bucketState = await this.db.bucketStateV3(this.group_id).findOne({
97
- _id: { $in: potentialIds }
98
- });
99
- if (bucketState == null) {
100
- return null;
117
+ }
118
+
119
+ if (resolvedDefinitionId == null) {
120
+ return null;
121
+ }
122
+
123
+ return new SingleBucketStoreV3(this.db, {
124
+ bucket,
125
+ definitionId: resolvedDefinitionId,
126
+ replicationStreamId: this.group_id
127
+ });
128
+ }
129
+
130
+ protected override async compactSingleBucket(bucket: string, definitionId: BucketDefinitionId | null = null) {
131
+ const bucketContext = await this.getBucketDataContext(bucket, definitionId);
132
+ if (bucketContext == null) {
133
+ return;
134
+ }
135
+
136
+ const resolvedDefinitionId = bucketContext.key.definitionId;
137
+ const collection = this.db.bucketData(this.group_id, resolvedDefinitionId);
138
+ const context = { replicationStreamId: this.group_id, definitionId: resolvedDefinitionId };
139
+
140
+ const lowerBound = bucketContext.minId;
141
+ let upperBound = bucketContext.maxId;
142
+
143
+ let totalChecksum = 0;
144
+ let totalOpCount = 0;
145
+ let totalOpBytes = 0;
146
+
147
+ let lastNotPut: bigint | null = null;
148
+ let opsSincePut = 0;
149
+ let clearBoundaryDocId: BucketDataKey | null = null;
150
+
151
+ const seen = new Map<string, bigint>();
152
+ let trackingSize = 0;
153
+
154
+ // --- Read batch from MongoDB ---
155
+ while (true) {
156
+ this.signal?.throwIfAborted();
157
+
158
+ const pipeline: mongo.Document[] = [
159
+ {
160
+ $match: {
161
+ '_id.b': bucket,
162
+ _id: {
163
+ $gte: lowerBound,
164
+ $lt: upperBound
165
+ }
166
+ }
167
+ },
168
+ { $sort: { _id: -1 } },
169
+ { $limit: this.moveBatchQueryLimit },
170
+ {
171
+ $project: {
172
+ _id: 1,
173
+ min_op: 1,
174
+ checksum: 1,
175
+ count: 1,
176
+ size: 1,
177
+ target_op: 1,
178
+ ops: 1,
179
+ bsonSize: { $bsonSize: '$$ROOT' }
180
+ }
181
+ }
182
+ ];
183
+
184
+ const rawBatch = await collection
185
+ .aggregate<BucketDataDocumentV3 & { bsonSize: number | bigint }>(pipeline, {
186
+ batchSize: this.moveBatchQueryLimit + 1
187
+ })
188
+ .toArray();
189
+
190
+ if (rawBatch.length == 0) {
191
+ // No more documents in this bucket — compaction complete.
192
+ break;
193
+ }
194
+
195
+ // --- Cut batch to byte limit ---
196
+ let cumulativeBytes = 0;
197
+ let batchCutIndex = rawBatch.length;
198
+
199
+ for (let i = 0; i < rawBatch.length; i++) {
200
+ cumulativeBytes += Number(rawBatch[i].bsonSize);
201
+ if (cumulativeBytes > this.moveBatchByteLimit && i > 0) {
202
+ // Byte limit exceeded; cut batch at current index. Always include
203
+ // at least one document (i > 0 guard) to guarantee forward progress.
204
+ batchCutIndex = i;
205
+ break;
206
+ }
207
+ }
208
+
209
+ const batchDocs = rawBatch.slice(0, batchCutIndex);
210
+
211
+ // --- Decode documents into individual ops ---
212
+ // Processable: document has at least one op <= maxOpId.
213
+ // Only processable docs are deleted and recreated; the rest survive untouched.
214
+ const batchOps: BucketDataDoc[] = [];
215
+ const processableDocs: (BucketDataDocumentV3 & { bsonSize: number | bigint })[] = [];
216
+
217
+ for (const doc of batchDocs) {
218
+ let hasRelevantOp = false;
219
+ const candidateOps: BucketDataDoc[] = [];
220
+ for (const op of loadBucketDataDocument(context, doc as unknown as BucketDataDocumentV3)) {
221
+ candidateOps.push(op);
222
+ if (op.o <= this.maxOpId) {
223
+ hasRelevantOp = true;
224
+ }
225
+ }
226
+ if (hasRelevantOp) {
227
+ processableDocs.push(doc);
228
+ batchOps.push(...candidateOps);
229
+ } // else: candidateOps discarded — document has no ops <= maxOpId
230
+ }
231
+
232
+ if (processableDocs.length == 0) {
233
+ // No documents with relevant ops in this batch; paginate to next batch
234
+ // without performing any writes. This handles batches where all documents
235
+ // contain only ops above maxOpId.
236
+ upperBound = batchDocs[batchDocs.length - 1]._id as typeof upperBound;
237
+ if (batchCutIndex >= rawBatch.length && rawBatch.length < this.moveBatchQueryLimit) {
238
+ // Entire remaining bucket is non-processable — compaction complete.
239
+ break;
240
+ }
241
+ // Skip dedup, rechunking, and transaction for this batch.
242
+ continue;
243
+ }
244
+
245
+ // Scoped replace in a bounded transaction.
246
+ // Delete by individual _id values instead of a continuous range.
247
+ // A continuous range could catch non-processable documents (all ops > maxOpId)
248
+ // that happen to fall between processable documents in _id.o sort order.
249
+ const idsToDelete = processableDocs.map((d) => d._id);
250
+ const expectedDocCount = processableDocs.length;
251
+ const expectedChecksum = processableDocs.reduce((sum, doc) => sum + doc.checksum, 0n);
252
+ const expectedOpCount = processableDocs.reduce((sum, doc) => sum + doc.count, 0);
253
+
254
+ // Sort ops by o descending for newest-first dedup
255
+ batchOps.sort((a, b) => (b.o > a.o ? 1 : b.o < a.o ? -1 : 0));
256
+
257
+ // --- Dedup: newest-first, superseded → MOVE ---
258
+ const surviving: BucketDataDoc[] = [];
259
+
260
+ for (const op of batchOps) {
261
+ if (op.op == 'PUT' || op.op == 'REMOVE') {
262
+ if (op.o > this.maxOpId) {
263
+ surviving.push(op);
264
+ continue; // Do not dedup ops above compaction horizon
265
+ }
266
+ const key = `${op.table}/${op.row_id}/${cacheKey(op.source_table!, op.source_key!)}`;
267
+ const targetOp = seen.get(key);
268
+ if (targetOp != null) {
269
+ surviving.push({
270
+ ...op,
271
+ op: 'MOVE',
272
+ target_op: targetOp,
273
+ table: undefined,
274
+ row_id: undefined,
275
+ source_table: undefined,
276
+ source_key: undefined,
277
+ data: null
278
+ });
279
+ if (lastNotPut == null) {
280
+ lastNotPut = op.o;
281
+ }
282
+ opsSincePut += 1;
283
+ } else {
284
+ if (trackingSize < this.idLimitBytes) {
285
+ seen.set(utils.flatstr(key), op.o);
286
+ trackingSize += key.length + 140;
287
+ }
288
+ surviving.push(op);
289
+ if (op.op == 'PUT') {
290
+ lastNotPut = null;
291
+ opsSincePut = 0;
292
+ } else {
293
+ if (lastNotPut == null) {
294
+ lastNotPut = op.o;
295
+ }
296
+ opsSincePut += 1;
297
+ }
298
+ }
299
+ } else {
300
+ surviving.push(op);
301
+ if (op.op != 'CLEAR') {
302
+ if (lastNotPut == null) {
303
+ lastNotPut = op.o;
304
+ }
305
+ opsSincePut += 1;
306
+ }
307
+ }
308
+ }
309
+
310
+ // Reverse back to ascending order for rechunking
311
+ surviving.reverse();
312
+
313
+ // --- Rechunk survivors into new V3 documents ---
314
+ const chunks = chunkBucketData(surviving);
315
+ const newDocs = chunks.map((chunk) => serializeBucketData(bucket, chunk));
316
+
317
+ if (lastNotPut == null) {
318
+ clearBoundaryDocId = null;
319
+ } else {
320
+ const boundaryOp = lastNotPut;
321
+ const boundaryDoc = newDocs.find((doc) => doc.min_op <= boundaryOp && doc._id.o >= boundaryOp);
322
+ if (boundaryDoc != null) {
323
+ clearBoundaryDocId = boundaryDoc._id;
324
+ }
325
+ }
326
+
327
+ // --- Commit: scoped delete + insert in transaction ---
328
+ const session = this.db.client.startSession();
329
+ try {
330
+ await session.withTransaction(
331
+ async () => {
332
+ // Verify documents haven't been modified since we read them.
333
+ // This aggregate anchors the transaction snapshot and catches
334
+ // concurrent compaction jobs that modified the same documents.
335
+ const verification = await bucketContext.collection
336
+ .aggregate<{ docCount: number; checksumSum: bigint | null; opCountSum: number | null }>(
337
+ [
338
+ { $match: { _id: { $in: idsToDelete } } },
339
+ {
340
+ $group: {
341
+ _id: null,
342
+ docCount: { $sum: 1 },
343
+ checksumSum: { $sum: '$checksum' },
344
+ opCountSum: { $sum: '$count' }
345
+ }
346
+ }
347
+ ],
348
+ { session }
349
+ )
350
+ .next();
351
+
352
+ if (
353
+ verification == null || // all docs deleted
354
+ verification.docCount !== expectedDocCount || // some docs deleted
355
+ verification.checksumSum !== expectedChecksum || // docs modified in-place
356
+ verification.opCountSum !== expectedOpCount // ops added/removed within docs
357
+ ) {
358
+ throw new Error(
359
+ `Concurrent modification detected in bucket ${bucket}. Aborting compaction for this batch.`
360
+ );
361
+ }
362
+
363
+ await bucketContext.collection.deleteMany(
364
+ {
365
+ _id: { $in: idsToDelete }
366
+ } as any,
367
+ { session }
368
+ );
369
+ if (newDocs.length > 0) {
370
+ await bucketContext.collection.insertMany(newDocs as unknown as BucketDataDocumentGeneric[], { session });
371
+ }
372
+ },
373
+ {
374
+ writeConcern: { w: 'majority' },
375
+ readConcern: { level: 'snapshot' }
376
+ }
377
+ );
378
+ } finally {
379
+ await session.endSession();
380
+ }
381
+
382
+ // --- Accumulate bucket state ---
383
+ for (const chunk of chunks) {
384
+ for (const op of chunk) {
385
+ if (op.o <= this.maxOpId) {
386
+ totalChecksum = addChecksums(totalChecksum, Number(op.checksum));
387
+ totalOpBytes += op.data?.length ?? 0;
388
+ }
389
+ }
101
390
  }
102
- definitionId = bucketState._id.d;
391
+ totalOpCount += surviving.filter((op) => op.o <= this.maxOpId).length;
392
+
393
+ // --- Advance to next batch ---
394
+ upperBound = (newDocs.length > 0 ? newDocs[0]._id : rawBatch[batchCutIndex - 1]._id) as typeof upperBound;
395
+
396
+ if (batchCutIndex < rawBatch.length) {
397
+ // We cut the batch short due to byte limit — don't advance past cut point
398
+ // The upperBound is already set to the last doc we processed
399
+ } else {
400
+ // Processed all docs in the raw batch. If we got fewer than the query
401
+ // limit, there are no more documents in this bucket — compaction complete.
402
+ if (rawBatch.length < this.moveBatchQueryLimit) {
403
+ break;
404
+ }
405
+ }
406
+
407
+ this.logger.info(`Compacted batch of ${batchDocs.length} documents for bucket ${bucket}`);
408
+ }
409
+
410
+ // --- Clear: collapse leading MOVE/REMOVE/CLEAR sequence ---
411
+ if (lastNotPut != null && opsSincePut >= 2) {
412
+ if (clearBoundaryDocId == null) {
413
+ throw new ReplicationAssertionError(`Missing CLEAR boundary document for bucket ${bucket}`);
414
+ }
415
+
416
+ totalOpCount += await this.clearBucketLeading(lastNotPut, clearBoundaryDocId, bucketContext, collection, context);
417
+ }
418
+
419
+ // --- Finalize: update bucket checksums and state ---
420
+ this.updateBucketChecksums({
421
+ bucket,
422
+ definitionId: resolvedDefinitionId,
423
+ seen: new Map(),
424
+ trackingSize: 0,
425
+ lastNotPut: lastNotPut,
426
+ opsSincePut: opsSincePut,
427
+ checksum: totalChecksum,
428
+ opCount: totalOpCount,
429
+ opBytes: totalOpBytes
430
+ });
431
+ if (this.bucketStateUpdates.length > 0) {
432
+ await this.writeBucketStateUpdates();
433
+ this.bucketStateUpdates = [];
434
+ }
435
+
436
+ logger.info(`Compacted bucket ${bucket}: ${totalOpCount} surviving ops`);
437
+ }
438
+
439
+ /**
440
+ * Collapse the leading sequence of MOVE/REMOVE/CLEAR ops at the start
441
+ * of the bucket into a single CLEAR op. Reads whole clearable documents
442
+ * before the known boundary document, then splits that boundary document
443
+ * if it contains ops on both sides of lastNotPut.
444
+ *
445
+ * Returns the op count diff after replacing cleared ops with CLEAR ops.
446
+ */
447
+ private async clearBucketLeading(
448
+ lastNotPut: bigint,
449
+ boundaryDocId: BucketDataKey,
450
+ bucketContext: SingleBucketStoreV3,
451
+ collection: mongo.Collection<BucketDataDocumentV3 & { bsonSize?: number | bigint }>,
452
+ context: { replicationStreamId: number; definitionId: string }
453
+ ): Promise<number> {
454
+ let opCountDiff = 0;
455
+ const session = this.db.client.startSession();
456
+ try {
457
+ let done = false;
458
+ // First step is to clear full chunks that contain only CLEAR/MOVE/REMOVE operations.
459
+ // There can be many of them, so we do one batch at a time.
460
+ while (!done) {
461
+ const batch = await this.clearLeadingFullDocuments(
462
+ session,
463
+ lastNotPut,
464
+ boundaryDocId,
465
+ bucketContext,
466
+ collection,
467
+ context
468
+ );
469
+ done = batch.done;
470
+ opCountDiff += batch.opCountDiff;
471
+ }
472
+
473
+ // The final step is to process the "boundary" document: It may contain some CLEAR/MOVE/REMOVE operations,
474
+ // potentially followed by PUT operations. This is only a single document, so no need for batching.
475
+ opCountDiff += await this.clearBoundaryDocument(
476
+ session,
477
+ lastNotPut,
478
+ boundaryDocId,
479
+ bucketContext,
480
+ collection,
481
+ context
482
+ );
483
+ } finally {
484
+ await session.endSession();
103
485
  }
104
486
 
105
- return new SingleBucketStoreV3(this.db, { bucket, definitionId, replicationStreamId: this.group_id });
487
+ return opCountDiff;
488
+ }
489
+
490
+ private async clearLeadingFullDocuments(
491
+ session: mongo.ClientSession,
492
+ lastNotPut: bigint,
493
+ boundaryDocId: BucketDataKey,
494
+ bucketContext: SingleBucketStoreV3,
495
+ collection: mongo.Collection<BucketDataDocumentV3 & { bsonSize?: number | bigint }>,
496
+ context: { replicationStreamId: number; definitionId: string }
497
+ ): Promise<{ done: boolean; opCountDiff: number }> {
498
+ const bucket = bucketContext.key.bucket;
499
+ let done = false;
500
+ let opCountDiff = 0;
501
+
502
+ this.signal?.throwIfAborted();
503
+ await session.withTransaction(
504
+ async () => {
505
+ const query = collection.find(
506
+ {
507
+ _id: {
508
+ $gte: bucketContext.minId,
509
+ $lt: boundaryDocId
510
+ }
511
+ },
512
+ {
513
+ session,
514
+ sort: { _id: 1 },
515
+ projection: {
516
+ _id: 1,
517
+ min_op: 1,
518
+ checksum: 1,
519
+ count: 1,
520
+ target_op: 1,
521
+ ops: 1
522
+ },
523
+ limit: this.clearBatchLimit
524
+ }
525
+ );
526
+
527
+ let combinedChecksum = 0;
528
+ let clearedOpCount = 0;
529
+ let maxTargetOp: bigint | null = null;
530
+ let lastDocId: BucketDataKey | null = null;
531
+ let clearOpCount = 0;
532
+ let gotNonClearOp = false;
533
+
534
+ for await (const doc of query.stream()) {
535
+ if (doc.min_op > lastNotPut) {
536
+ throw new ReplicationAssertionError(
537
+ `Unexpected document before CLEAR boundary with min_op ${doc.min_op} > ${lastNotPut} in bucket ${bucket}`
538
+ );
539
+ }
540
+
541
+ lastDocId = doc._id;
542
+ for (const op of loadBucketDataDocument(context, doc)) {
543
+ if (op.o > lastNotPut) {
544
+ throw new ReplicationAssertionError(
545
+ `Unexpected op ${op.o} after CLEAR boundary ${lastNotPut} in bucket ${bucket}`
546
+ );
547
+ }
548
+ if (op.op == 'PUT') {
549
+ throw new ReplicationAssertionError(`Unexpected PUT at op ${op.o} in CLEAR region for bucket ${bucket}`);
550
+ }
551
+
552
+ if (op.op == 'CLEAR') {
553
+ clearOpCount++;
554
+ if (clearOpCount > 1) {
555
+ throw new ReplicationAssertionError(`Unexpected multiple CLEAR operations in bucket ${bucket}`);
556
+ }
557
+ } else {
558
+ gotNonClearOp = true;
559
+ }
560
+ combinedChecksum = addChecksums(combinedChecksum, Number(op.checksum));
561
+ clearedOpCount++;
562
+ if (op.target_op != null && (maxTargetOp == null || op.target_op > maxTargetOp)) {
563
+ maxTargetOp = op.target_op;
564
+ }
565
+ }
566
+ }
567
+
568
+ if (!gotNonClearOp) {
569
+ done = true;
570
+ return;
571
+ }
572
+
573
+ this.logger.info(`Flushing CLEAR for ${clearedOpCount} ops at ${lastDocId?.o}`);
574
+ await collection.deleteMany(
575
+ {
576
+ _id: {
577
+ $gte: bucketContext.minId,
578
+ $lte: lastDocId!
579
+ }
580
+ },
581
+ { session }
582
+ );
583
+
584
+ const clearOp = {
585
+ bucketKey: { ...context, bucket },
586
+ o: lastDocId!.o,
587
+ op: 'CLEAR' as const,
588
+ checksum: BigInt(combinedChecksum),
589
+ data: null,
590
+ target_op: maxTargetOp
591
+ } satisfies BucketDataDoc;
592
+ await collection.insertOne(serializeBucketData(bucket, [clearOp]), { session });
593
+
594
+ opCountDiff = -clearedOpCount + 1;
595
+ },
596
+ {
597
+ writeConcern: { w: 'majority' },
598
+ readConcern: { level: 'snapshot' }
599
+ }
600
+ );
601
+
602
+ return { done, opCountDiff };
603
+ }
604
+
605
+ private async clearBoundaryDocument(
606
+ session: mongo.ClientSession,
607
+ lastNotPut: bigint,
608
+ boundaryDocId: BucketDataKey,
609
+ bucketContext: SingleBucketStoreV3,
610
+ collection: mongo.Collection<BucketDataDocumentV3 & { bsonSize?: number | bigint }>,
611
+ context: { replicationStreamId: number; definitionId: string }
612
+ ): Promise<number> {
613
+ const bucket = bucketContext.key.bucket;
614
+ let opCountDiff = 0;
615
+
616
+ await session.withTransaction(
617
+ async () => {
618
+ const query = collection.find(
619
+ {
620
+ // This is a range query, but should only ever return two documents:
621
+ // 1. The CLEAR op from the previous clearLeadingFullDocuments.
622
+ // 2. The boundary document.
623
+ _id: {
624
+ $gte: bucketContext.minId,
625
+ $lte: boundaryDocId
626
+ }
627
+ },
628
+ {
629
+ session,
630
+ sort: { _id: 1 },
631
+ projection: {
632
+ _id: 1,
633
+ min_op: 1,
634
+ checksum: 1,
635
+ count: 1,
636
+ target_op: 1,
637
+ ops: 1
638
+ },
639
+ limit: 3
640
+ }
641
+ );
642
+
643
+ let docsRead = 0;
644
+ let combinedChecksum = 0;
645
+ let clearedOpCount = 0;
646
+ let maxTargetOp: bigint | null = null;
647
+ const boundarySurvivors: BucketDataDoc[] = [];
648
+
649
+ for await (const doc of query.stream()) {
650
+ docsRead++;
651
+ if (docsRead > 2) {
652
+ throw new ReplicationAssertionError(`Unexpected extra document before CLEAR boundary in bucket ${bucket}`);
653
+ }
654
+
655
+ const isBoundaryDoc = doc._id.o == boundaryDocId.o;
656
+ for (const op of loadBucketDataDocument(context, doc)) {
657
+ if (!isBoundaryDoc && op.op != 'CLEAR') {
658
+ throw new ReplicationAssertionError(
659
+ `Unexpected ${op.op} operation before CLEAR boundary in bucket ${bucket}`
660
+ );
661
+ }
662
+
663
+ if (op.o <= lastNotPut) {
664
+ if (op.op == 'PUT') {
665
+ throw new ReplicationAssertionError(
666
+ `Unexpected PUT at op ${op.o} in CLEAR region for bucket ${bucket}`
667
+ );
668
+ }
669
+ combinedChecksum = addChecksums(combinedChecksum, Number(op.checksum));
670
+ clearedOpCount++;
671
+ if (op.target_op != null && (maxTargetOp == null || op.target_op > maxTargetOp)) {
672
+ maxTargetOp = op.target_op;
673
+ }
674
+ } else if (isBoundaryDoc) {
675
+ boundarySurvivors.push(op);
676
+ } else {
677
+ throw new ReplicationAssertionError(
678
+ `Unexpected op ${op.o} after CLEAR boundary ${lastNotPut} in bucket ${bucket}`
679
+ );
680
+ }
681
+ }
682
+ }
683
+
684
+ if (clearedOpCount == 0) {
685
+ throw new Error(`CLEAR boundary document not found for bucket ${bucket}`);
686
+ }
687
+
688
+ this.logger.info(`Flushing CLEAR for ${clearedOpCount} ops at ${lastNotPut}`);
689
+ await collection.deleteMany(
690
+ {
691
+ _id: {
692
+ $gte: bucketContext.minId,
693
+ $lte: boundaryDocId
694
+ }
695
+ },
696
+ { session }
697
+ );
698
+
699
+ const clearOp = {
700
+ bucketKey: { ...context, bucket },
701
+ o: lastNotPut,
702
+ op: 'CLEAR' as const,
703
+ checksum: BigInt(combinedChecksum),
704
+ data: null,
705
+ target_op: maxTargetOp
706
+ } satisfies BucketDataDoc;
707
+ await collection.insertOne(serializeBucketData(bucket, [clearOp]), { session });
708
+
709
+ if (boundarySurvivors.length > 0) {
710
+ const survivingDocs = chunkBucketData(boundarySurvivors).map((chunk) => serializeBucketData(bucket, chunk));
711
+ await collection.insertMany(survivingDocs, { session });
712
+ }
713
+
714
+ opCountDiff = -clearedOpCount + 1;
715
+ },
716
+ {
717
+ writeConcern: { w: 'majority' },
718
+ readConcern: { level: 'snapshot' }
719
+ }
720
+ );
721
+
722
+ return opCountDiff;
106
723
  }
107
724
  }