@powersync/service-module-mongodb-storage 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/storage/MongoBucketStorage.d.ts +36 -3
  3. package/dist/storage/MongoBucketStorage.js +372 -207
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +17 -5
  6. package/dist/storage/implementation/MongoBucketBatch.js +44 -17
  7. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  8. package/dist/storage/implementation/MongoCompactor.d.ts +19 -79
  9. package/dist/storage/implementation/MongoCompactor.js +27 -238
  10. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  11. package/dist/storage/implementation/MongoParameterCompactor.d.ts +83 -10
  12. package/dist/storage/implementation/MongoParameterCompactor.js +309 -96
  13. package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
  14. package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +9 -1
  15. package/dist/storage/implementation/MongoParsedSyncConfigSet.js +29 -0
  16. package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -1
  17. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +4 -2
  18. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -1
  19. package/dist/storage/implementation/MongoStorageProvider.js +7 -3
  20. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  21. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +27 -11
  22. package/dist/storage/implementation/MongoSyncBucketStorage.js +78 -31
  23. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  24. package/dist/storage/implementation/MongoSyncRulesLock.js +21 -9
  25. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  26. package/dist/storage/implementation/MongoWriteBatch.d.ts +49 -0
  27. package/dist/storage/implementation/MongoWriteBatch.js +149 -0
  28. package/dist/storage/implementation/MongoWriteBatch.js.map +1 -0
  29. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +9 -7
  30. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +9 -30
  31. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  32. package/dist/storage/implementation/SyncRuleStateUpdate.d.ts +11 -5
  33. package/dist/storage/implementation/SyncRuleStateUpdate.js +14 -6
  34. package/dist/storage/implementation/SyncRuleStateUpdate.js.map +1 -1
  35. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +22 -2
  36. package/dist/storage/implementation/common/PersistedBatch.d.ts +32 -4
  37. package/dist/storage/implementation/common/PersistedBatch.js +49 -6
  38. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  39. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.d.ts +3 -0
  40. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +3 -0
  41. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
  42. package/dist/storage/implementation/db.d.ts +9 -1
  43. package/dist/storage/implementation/db.js +27 -1
  44. package/dist/storage/implementation/db.js.map +1 -1
  45. package/dist/storage/implementation/models.d.ts +31 -0
  46. package/dist/storage/implementation/models.js.map +1 -1
  47. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +2 -0
  48. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +33 -4
  49. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  50. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +38 -6
  51. package/dist/storage/implementation/v1/MongoCompactorV1.js +247 -20
  52. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  53. package/dist/storage/implementation/v1/MongoParameterCompactorV1.d.ts +16 -2
  54. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js +20 -9
  55. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js.map +1 -1
  56. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +18 -11
  57. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +65 -16
  58. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  59. package/dist/storage/implementation/v1/PersistedBatchV1.d.ts +7 -5
  60. package/dist/storage/implementation/v1/PersistedBatchV1.js +22 -28
  61. package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -1
  62. package/dist/storage/implementation/v1/models.js +1 -0
  63. package/dist/storage/implementation/v1/models.js.map +1 -1
  64. package/dist/storage/implementation/v3/CompactionLease.d.ts +50 -0
  65. package/dist/storage/implementation/v3/CompactionLease.js +131 -0
  66. package/dist/storage/implementation/v3/CompactionLease.js.map +1 -0
  67. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +10 -2
  68. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +100 -26
  69. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  70. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +82 -25
  71. package/dist/storage/implementation/v3/MongoCompactorV3.js +698 -159
  72. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  73. package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +18 -0
  74. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +23 -0
  75. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +1 -0
  76. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +2 -15
  77. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +60 -92
  78. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
  79. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +19 -9
  80. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +91 -31
  81. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  82. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.d.ts +26 -0
  83. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js +82 -0
  84. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js.map +1 -0
  85. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +14 -8
  86. package/dist/storage/implementation/v3/PersistedBatchV3.js +76 -42
  87. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  88. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +12 -2
  89. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +42 -3
  90. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  91. package/dist/storage/implementation/v3/bucket-format.js +2 -0
  92. package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
  93. package/dist/storage/implementation/v3/compact-utils.d.ts +107 -0
  94. package/dist/storage/implementation/v3/compact-utils.js +216 -0
  95. package/dist/storage/implementation/v3/compact-utils.js.map +1 -0
  96. package/dist/storage/implementation/v3/compaction-constants.d.ts +5 -0
  97. package/dist/storage/implementation/v3/compaction-constants.js +6 -0
  98. package/dist/storage/implementation/v3/compaction-constants.js.map +1 -0
  99. package/dist/storage/implementation/v3/models.d.ts +84 -4
  100. package/dist/storage/implementation/v3/models.js.map +1 -1
  101. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +7 -8
  102. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +16 -5
  103. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
  104. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +14 -11
  105. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +6 -8
  106. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +17 -11
  107. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
  108. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +38 -0
  109. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +164 -0
  110. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -0
  111. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +37 -12
  112. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +197 -25
  113. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -1
  114. package/dist/storage/implementation/v3/source-table-utils.d.ts +6 -6
  115. package/dist/storage/implementation/v3/source-table-utils.js +30 -37
  116. package/dist/storage/implementation/v3/source-table-utils.js.map +1 -1
  117. package/dist/storage/storage-index.d.ts +2 -1
  118. package/dist/storage/storage-index.js +1 -0
  119. package/dist/storage/storage-index.js.map +1 -1
  120. package/dist/types/types.d.ts +28 -1
  121. package/dist/types/types.js +28 -7
  122. package/dist/types/types.js.map +1 -1
  123. package/dist/utils/test-utils.js +1 -0
  124. package/dist/utils/test-utils.js.map +1 -1
  125. package/dist/utils/util.js +3 -1
  126. package/dist/utils/util.js.map +1 -1
  127. package/package.json +10 -9
  128. package/src/storage/MongoBucketStorage.ts +507 -269
  129. package/src/storage/implementation/MongoBucketBatch.ts +67 -24
  130. package/src/storage/implementation/MongoCompactor.ts +35 -346
  131. package/src/storage/implementation/MongoParameterCompactor.ts +388 -102
  132. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +34 -0
  133. package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +4 -2
  134. package/src/storage/implementation/MongoStorageProvider.ts +15 -3
  135. package/src/storage/implementation/MongoSyncBucketStorage.ts +114 -40
  136. package/src/storage/implementation/MongoSyncRulesLock.ts +22 -9
  137. package/src/storage/implementation/MongoWriteBatch.ts +172 -0
  138. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +16 -46
  139. package/src/storage/implementation/SyncRuleStateUpdate.ts +13 -6
  140. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +24 -2
  141. package/src/storage/implementation/common/PersistedBatch.ts +66 -10
  142. package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +4 -0
  143. package/src/storage/implementation/db.ts +35 -2
  144. package/src/storage/implementation/models.ts +34 -0
  145. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +46 -6
  146. package/src/storage/implementation/v1/MongoCompactorV1.ts +327 -40
  147. package/src/storage/implementation/v1/MongoParameterCompactorV1.ts +25 -9
  148. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +87 -27
  149. package/src/storage/implementation/v1/PersistedBatchV1.ts +25 -30
  150. package/src/storage/implementation/v1/models.ts +1 -0
  151. package/src/storage/implementation/v3/CompactionLease.ts +152 -0
  152. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +138 -34
  153. package/src/storage/implementation/v3/MongoCompactorV3.ts +958 -225
  154. package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +34 -0
  155. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +100 -109
  156. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +111 -41
  157. package/src/storage/implementation/v3/MongoWriteCheckpointAPIV3.ts +111 -0
  158. package/src/storage/implementation/v3/PersistedBatchV3.ts +93 -48
  159. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +55 -4
  160. package/src/storage/implementation/v3/bucket-format.ts +2 -0
  161. package/src/storage/implementation/v3/compact-utils.ts +320 -0
  162. package/src/storage/implementation/v3/compaction-constants.ts +5 -0
  163. package/src/storage/implementation/v3/models.ts +91 -3
  164. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +24 -9
  165. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +20 -5
  166. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +26 -14
  167. package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +229 -0
  168. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +279 -29
  169. package/src/storage/implementation/v3/source-table-utils.ts +39 -41
  170. package/src/storage/storage-index.ts +2 -0
  171. package/src/types/types.ts +42 -7
  172. package/src/utils/test-utils.ts +1 -0
  173. package/src/utils/util.ts +3 -1
  174. package/test/src/__snapshots__/storage.test.ts.snap +25 -0
  175. package/test/src/__snapshots__/storage_sync.test.ts.snap +605 -16
  176. package/test/src/chunk_compaction_config.test.ts +38 -0
  177. package/test/src/cleanup-stopped-sync-configs.test.ts +40 -13
  178. package/test/src/compact-utils.test.ts +226 -0
  179. package/test/src/helpers/MemoryObjectStorage.ts +14 -6
  180. package/test/src/object_storage_usage.test.ts +267 -0
  181. package/test/src/parameter_compacting_v1.test.ts +120 -0
  182. package/test/src/parameter_compacting_v3.test.ts +192 -0
  183. package/test/src/parameter_compaction_fence.test.ts +276 -0
  184. package/test/src/storage.test.ts +273 -22
  185. package/test/src/storage_compacting.test.ts +968 -246
  186. package/test/src/storage_s3_checksums.test.ts +3 -4
  187. package/test/src/storage_s3_compaction_lifecycle.test.ts +138 -12
  188. package/test/src/storage_s3_reading.test.ts +290 -2
  189. package/test/src/storage_s3_writing.test.ts +1 -0
  190. package/test/src/storage_sync.test.ts +336 -41
  191. package/test/src/util.ts +1 -1
  192. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,320 @@
1
+ import { mongo } from '@powersync/lib-service-mongodb';
2
+ import { ReplicationAssertionError } from '@powersync/lib-services-framework';
3
+ import { addChecksums, InternalOpId } from '@powersync/service-core';
4
+ import { BucketDataDoc } from '../common/BucketDataDoc.js';
5
+ import { AVAILABLE_LEASE_EXPR, CompactionLease } from './CompactionLease.js';
6
+ import { BucketDataDocumentV3, BucketStateDocumentV3 } from './models.js';
7
+
8
+ /**
9
+ * Perform chunk compaction if at least this many chunks have been added since
10
+ * the latest compaction.
11
+ *
12
+ * A lower value increases compaction frequency and can increase chunk rewrites
13
+ * and object-storage operations. A higher value leaves more small chunks for
14
+ * longer, which can hurt sync performance.
15
+ */
16
+ const MERGE_CHUNKS_THRESHOLD = 8;
17
+
18
+ export const FULL_COMPACT_RESCHEDULE_MARGIN_MS = 60 * 1000;
19
+
20
+ export interface PendingCompactionGroup {
21
+ /**
22
+ * Input documents are ordered from oldest to newest, matching `ops`.
23
+ * Keeping the inputs intact lets unchanged singletons retain their object.
24
+ */
25
+ inputs: BucketDataDocumentV3[];
26
+ ops: BucketDataDoc[];
27
+ changed: boolean;
28
+ targetOp: InternalOpId | null;
29
+ }
30
+
31
+ export interface CompactIntervalConfig {
32
+ readonly minCompactChunkIntervalMs: number;
33
+ readonly minCompactFullIntervalMs: number;
34
+ readonly maxCompactFullIntervalMs: number;
35
+ }
36
+ export interface CompactTargetConfig {
37
+ readonly maxOpIdCap: InternalOpId | undefined;
38
+ }
39
+
40
+ export enum CompactionKind {
41
+ Full = 'full',
42
+ Chunks = 'chunks'
43
+ }
44
+
45
+ export interface CompactionDecision {
46
+ kind: CompactionKind | null;
47
+ nextCompactCheck: mongo.Document;
48
+ }
49
+
50
+ export interface ScheduledCompactionOptions {
51
+ /** Process checks scheduled this far after the captured job start. */
52
+ dueAheadMs?: number;
53
+ /** Used by initial replication, which must not run a full compact. */
54
+ forceKind?: CompactionKind;
55
+ }
56
+
57
+ export class CompactionContext {
58
+ constructor(
59
+ readonly lease: CompactionLease,
60
+ readonly kind: CompactionKind,
61
+ readonly decision: CompactionDecision,
62
+ readonly rescheduleNotBefore: Date | undefined,
63
+ readonly targetOp: InternalOpId
64
+ ) {}
65
+
66
+ get state() {
67
+ return this.lease.state;
68
+ }
69
+
70
+ get startedAt() {
71
+ return this.lease.startedAt;
72
+ }
73
+
74
+ get lastOp() {
75
+ return this.lease.lastOp;
76
+ }
77
+ }
78
+
79
+ export interface BucketStats {
80
+ count: number;
81
+ bytes: bigint;
82
+ chunks: number;
83
+ }
84
+
85
+ /** Bucket stats read from bucket-data documents, including their checksum. */
86
+ export interface BucketStatsWithChecksum extends BucketStats {
87
+ checksum: number;
88
+ }
89
+
90
+ export interface CompactionResult {
91
+ /** Metadata cached at compacted_state.op_id. */
92
+ compactedState: BucketStatsWithChecksum;
93
+ /** Complete bucket metadata through the op head captured at claim time. */
94
+ bucketStats: BucketStats;
95
+ }
96
+
97
+ export function bucketStats(state: BucketStateDocumentV3): BucketStats {
98
+ return {
99
+ count: state.bucket_stats.count,
100
+ bytes: state.bucket_stats.bytes,
101
+ chunks: state.bucket_stats.chunks
102
+ };
103
+ }
104
+
105
+ export function emptyBucketStats(): BucketStatsWithChecksum {
106
+ return { count: 0, bytes: 0n, chunks: 0, checksum: 0 };
107
+ }
108
+
109
+ export function statsForDocument(
110
+ document: Pick<BucketDataDocumentV3, 'count' | 'size' | 'checksum'>
111
+ ): BucketStatsWithChecksum {
112
+ return {
113
+ count: document.count,
114
+ bytes: BigInt(document.size),
115
+ chunks: 1,
116
+ checksum: addChecksums(0, Number(document.checksum))
117
+ };
118
+ }
119
+
120
+ export function statsForDocuments(
121
+ documents: Iterable<Pick<BucketDataDocumentV3, 'count' | 'size' | 'checksum'>>
122
+ ): BucketStatsWithChecksum {
123
+ let result = emptyBucketStats();
124
+ for (const document of documents) {
125
+ result = combineAdjacentStats(result, statsForDocument(document));
126
+ }
127
+ return result;
128
+ }
129
+
130
+ /** A scheduled bucket always has writes awaiting a full compact. */
131
+ export function firstUncompactedWrite(state: BucketStateDocumentV3): Date {
132
+ if (state.first_uncompacted_write == null) {
133
+ throw new ReplicationAssertionError(`Scheduled V3 bucket ${state._id.b} has no first uncompacted write`);
134
+ }
135
+ return state.first_uncompacted_write;
136
+ }
137
+
138
+ export function combineChunkStats(
139
+ previous: NonNullable<BucketStateDocumentV3['compacted_state']>,
140
+ compactedTail: BucketStatsWithChecksum,
141
+ overlappingCompactedChunk: BucketStatsWithChecksum
142
+ ): BucketStatsWithChecksum {
143
+ return {
144
+ count: previous.count - overlappingCompactedChunk.count + compactedTail.count,
145
+ bytes: previous.bytes - overlappingCompactedChunk.bytes + compactedTail.bytes,
146
+ chunks: previous.chunks - 1 + compactedTail.chunks,
147
+ checksum: addChecksums(
148
+ addChecksums(Number(previous.checksum), -overlappingCompactedChunk.checksum),
149
+ compactedTail.checksum
150
+ )
151
+ };
152
+ }
153
+
154
+ export function combineAdjacentStats(
155
+ first: BucketStatsWithChecksum,
156
+ second: BucketStatsWithChecksum
157
+ ): BucketStatsWithChecksum {
158
+ return {
159
+ count: first.count + second.count,
160
+ bytes: first.bytes + second.bytes,
161
+ chunks: first.chunks + second.chunks,
162
+ checksum: addChecksums(first.checksum, second.checksum)
163
+ };
164
+ }
165
+
166
+ export function applyCompactionDelta(
167
+ total: BucketStats,
168
+ before: BucketStatsWithChecksum,
169
+ after: BucketStatsWithChecksum
170
+ ): BucketStats {
171
+ return {
172
+ count: total.count - before.count + after.count,
173
+ bytes: total.bytes - before.bytes + after.bytes,
174
+ chunks: total.chunks - before.chunks + after.chunks
175
+ };
176
+ }
177
+
178
+ /** Replace one stored range in an accumulated statistics snapshot. */
179
+ export function applyStatsReplacement(
180
+ total: BucketStatsWithChecksum,
181
+ before: BucketStatsWithChecksum,
182
+ after: BucketStatsWithChecksum
183
+ ): BucketStatsWithChecksum {
184
+ return {
185
+ ...applyCompactionDelta(total, before, after),
186
+ checksum: addChecksums(addChecksums(total.checksum, -before.checksum), after.checksum)
187
+ };
188
+ }
189
+
190
+ /**
191
+ * This checks that the bucket state hasn't changed by a concurrent write since we checked.
192
+ *
193
+ * If it has changed, we'll re-check in the next batch.
194
+ */
195
+ export function unclaimedSnapshotFilter(state: BucketStateDocumentV3): mongo.Filter<BucketStateDocumentV3> {
196
+ return {
197
+ _id: state._id,
198
+ last_op: state.last_op,
199
+ next_compact_check: state.next_compact_check,
200
+ first_uncompacted_write: state.first_uncompacted_write ?? { $exists: false },
201
+ bucket_stats: state.bucket_stats,
202
+ compacted_state: state.compacted_state ?? { $exists: false },
203
+ last_full_compact: state.last_full_compact ?? { $exists: false },
204
+ ...AVAILABLE_LEASE_EXPR
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Calculate the earliest full compaction time from the first uncompacted
210
+ * write, bounded by the maximum retention interval.
211
+ */
212
+ export function fullCompactionCheckAt(state: BucketStateDocumentV3, config: CompactIntervalConfig): Date {
213
+ const firstWrite = firstUncompactedWrite(state);
214
+ const stats = bucketStats(state);
215
+ const lastFull = state.last_full_compact;
216
+
217
+ // The number of operations since the last full compact.
218
+ // We may make this more specific in the future, to track new updates and deletes only, ignoring
219
+ // full new inserts, but that requires more granular tracking when replicating.
220
+ const uncompactedCount = lastFull == null ? stats.count : Math.max(0, stats.count - lastFull.count);
221
+ const compactedRows = lastFull?.puts ?? 0;
222
+
223
+ // If no full compact has ever been performed: ratio = 1, compact after minCompactFullIntervalMs.
224
+ // If every row has been updated or deleted exactly once since the last full compact: ratio = 0.5, compact after minCompactFullIntervalMs * 2.
225
+ // If 10% of rows has been updated since the last full compact, compact after minCompactFullIntervalMs * 11.
226
+ // If every row has been updated multiple times, the ratio tends closer to 1 again.
227
+ const ratio = uncompactedCount == 0 ? 0 : uncompactedCount / (compactedRows + uncompactedCount);
228
+ const fullIntervalMs = ratio == 0 ? config.maxCompactFullIntervalMs : config.minCompactFullIntervalMs / ratio;
229
+ return new Date(firstWrite.getTime() + Math.min(fullIntervalMs, config.maxCompactFullIntervalMs));
230
+ }
231
+
232
+ export function chooseCompactionKind(
233
+ state: BucketStateDocumentV3,
234
+ now: Date,
235
+ config: CompactIntervalConfig
236
+ ): CompactionDecision {
237
+ // For chunk compaction, we consider the number of chunks added.
238
+ // Right now, we trigger a compact if the interval has passed and at least a threshold of chunks were added.
239
+ // A future policy could also use bytes per chunk or records per chunk to
240
+ // decide when to compact.
241
+ const fullCheckAt = fullCompactionCheckAt(state, config);
242
+ // Schedule a little late so a worker using a slightly earlier clock does
243
+ // not wake before the exact full-compaction condition is true.
244
+ const fullCheckWithMargin = new Date(fullCheckAt.getTime() + FULL_COMPACT_RESCHEDULE_MARGIN_MS);
245
+ const compacted = state.compacted_state;
246
+ const chunksSinceCompact = Math.max(0, state.bucket_stats.chunks - (compacted?.chunks ?? 0));
247
+ const shouldCompactChunks = chunksSinceCompact >= MERGE_CHUNKS_THRESHOLD;
248
+ const canCheckChunks =
249
+ compacted == null || now.getTime() - compacted.at.getTime() >= config.minCompactChunkIntervalMs;
250
+ // Too few new chunks cannot make chunk compaction eligible. Do not poll this
251
+ // bucket at the chunk-compaction interval; only wake it for its full-compact check.
252
+ const nextCompactCheck: mongo.Document = !shouldCompactChunks
253
+ ? fullCheckWithMargin
254
+ : {
255
+ $min: [
256
+ fullCheckWithMargin,
257
+ { $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: config.minCompactChunkIntervalMs } }
258
+ ]
259
+ };
260
+ let kind: CompactionKind | null = null;
261
+ if (now >= fullCheckAt) {
262
+ kind = CompactionKind.Full;
263
+ } else if (canCheckChunks && shouldCompactChunks) {
264
+ kind = CompactionKind.Chunks;
265
+ }
266
+ return {
267
+ kind,
268
+ nextCompactCheck
269
+ };
270
+ }
271
+
272
+ export function forcedCompactionKind(
273
+ state: BucketStateDocumentV3,
274
+ forceKind: CompactionKind | undefined,
275
+ config: CompactTargetConfig
276
+ ): CompactionKind | null {
277
+ if (forceKind == null) {
278
+ return null;
279
+ }
280
+ const maxOpId = config.maxOpIdCap == null || state.last_op < config.maxOpIdCap ? state.last_op : config.maxOpIdCap;
281
+ return state.compacted_state == null || state.compacted_state.op_id < maxOpId ? forceKind : null;
282
+ }
283
+
284
+ /**
285
+ * Read one bounded prefix from a compaction cursor.
286
+ *
287
+ * The document that would cross the byte limit is deliberately not returned:
288
+ * pagination resumes past the last returned `_id`, so that document remains
289
+ * eligible for the next query. The first document is always accepted to
290
+ * ensure progress when a single document exceeds the configured byte limit.
291
+ *
292
+ * `hasMore` is conservative when the document limit is reached. An extra empty
293
+ * query is preferable to exhausting the cursor just to determine whether the
294
+ * limited MongoDB query contained another document.
295
+ */
296
+ export async function readCompactionBatch(
297
+ cursor: mongo.AggregationCursor<BucketDataDocumentV3>,
298
+ options: { byteLimit: number; documentLimit: number }
299
+ ): Promise<{ documents: BucketDataDocumentV3[]; hasMore: boolean }> {
300
+ const documents: BucketDataDocumentV3[] = [];
301
+ let cumulativeBytes = 0;
302
+
303
+ try {
304
+ for await (const document of cursor) {
305
+ if (documents.length > 0 && cumulativeBytes + document.size > options.byteLimit) {
306
+ return { documents, hasMore: true };
307
+ }
308
+
309
+ documents.push(document);
310
+ cumulativeBytes += document.size;
311
+
312
+ if (documents.length >= options.documentLimit) {
313
+ return { documents, hasMore: true };
314
+ }
315
+ }
316
+ return { documents, hasMore: false };
317
+ } finally {
318
+ await cursor.close();
319
+ }
320
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Writers use this delay before scheduling their first chunk compaction.
3
+ * Initial replication includes the same delay when it picks up that work.
4
+ */
5
+ export const DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS = 5 * 60 * 1000;
@@ -8,6 +8,7 @@ import {
8
8
  } from '@powersync/service-core';
9
9
  import {
10
10
  BucketDefinitionId,
11
+ EventDefinitionId,
11
12
  ParameterIndexId,
12
13
  ScopedParameterLookup,
13
14
  SqliteJsonValue
@@ -16,7 +17,6 @@ import * as bson from 'bson';
16
17
  import {
17
18
  BucketDataKey,
18
19
  BucketParameterDocumentBase,
19
- BucketStateDocumentBase,
20
20
  CurrentBucket,
21
21
  OpType,
22
22
  ReplicaId,
@@ -104,6 +104,8 @@ export interface SyncConfigDefinition {
104
104
  replication_stream_id: number;
105
105
 
106
106
  content: string;
107
+ /** Optional operator-supplied label identifying this sync config version. */
108
+ version_label?: string;
107
109
  serialized_plan?: SerializedSyncPlan | null;
108
110
 
109
111
  rule_mapping: PersistedDefinitionMapping;
@@ -157,6 +159,10 @@ export interface SourceTableDocumentV3 {
157
159
  snapshot_status: SourceTableDocumentSnapshotStatus | undefined;
158
160
  bucket_data_source_ids: BucketDefinitionId[];
159
161
  parameter_lookup_source_ids: ParameterIndexId[];
162
+ /**
163
+ * Stream-local ids of the compiled event definitions evaluated by this source table.
164
+ */
165
+ event_definition_ids: EventDefinitionId[];
160
166
  latest_pending_delete?: InternalOpId | undefined;
161
167
  /**
162
168
  * Source-specific metadata. Absent for legacy records.
@@ -164,10 +170,57 @@ export interface SourceTableDocumentV3 {
164
170
  source_metadata?: JsonValue;
165
171
  }
166
172
 
167
- export interface BucketStateDocumentV3 extends BucketStateDocumentBase {
168
- _id: BucketStateDocumentBase['_id'] & {
173
+ export interface BucketStateDocumentV3 {
174
+ _id: {
175
+ b: string;
169
176
  d: BucketDefinitionId;
170
177
  };
178
+
179
+ /** Must always identify an actual operation in this logical stream. */
180
+ last_op: bigint;
181
+
182
+ /** The next time a compact worker should inspect this bucket. */
183
+ next_compact_check: Date | undefined;
184
+ /**
185
+ * Scheduling epoch for work not covered by a full compact. Writers set the
186
+ * first actual write time; a partial full compact advances it to completion.
187
+ */
188
+ first_uncompacted_write: Date | undefined;
189
+
190
+ /**
191
+ * A checksum cache and the statistics captured by the latest compact (full
192
+ * or chunk). Keeping these separate from bucket_stats lets writers only
193
+ * update one set of counters.
194
+ */
195
+ compacted_state?: {
196
+ op_id: InternalOpId;
197
+ checksum: bigint;
198
+ count: number;
199
+ bytes: bigint;
200
+ at: Date;
201
+ chunks: number;
202
+ };
203
+
204
+ /** Statistics for the prefix covered by the most recent full compact. */
205
+ last_full_compact?: {
206
+ op_id: InternalOpId;
207
+ count: number;
208
+ at: Date;
209
+ puts: number;
210
+ };
211
+
212
+ /** Current aggregate bucket statistics, maintained by writers and compactors. */
213
+ bucket_stats: {
214
+ count: number;
215
+ bytes: bigint;
216
+ chunks: number;
217
+ };
218
+
219
+ /** A short-lived ownership marker used to distribute bucket compaction. */
220
+ compact_lease?: {
221
+ expires_at: Date;
222
+ id: unknown;
223
+ };
171
224
  }
172
225
 
173
226
  export interface BucketOperation {
@@ -175,6 +228,7 @@ export interface BucketOperation {
175
228
  op: OpType;
176
229
  source_table?: bson.ObjectId;
177
230
  source_key?: ReplicaId;
231
+ subkey?: string;
178
232
  table?: string;
179
233
  row_id?: string;
180
234
  checksum: bigint;
@@ -186,6 +240,15 @@ export interface StorageRef {
186
240
  file_size: number;
187
241
  }
188
242
 
243
+ export interface ObjectStorageUsageDocument {
244
+ _id: {
245
+ g: number;
246
+ w: string;
247
+ };
248
+ updated_at: Date;
249
+ definitions: Record<BucketDefinitionId, bigint>;
250
+ }
251
+
189
252
  /** An S3 object that may be deleted once its grace period has elapsed. */
190
253
  export interface ObjectStorageDeletionMarker {
191
254
  _id: bson.ObjectId;
@@ -230,6 +293,31 @@ export interface BucketDataDocumentV3 {
230
293
  has_clear_op?: true;
231
294
  }
232
295
 
296
+ /**
297
+ * v3 Custom checkpoints (new name for legacy write checkpoints) are related to
298
+ * a replication stream and a specific event (in a sync config) which created them.
299
+ * Unchanged events, between sync configs, can share the documents.
300
+ * For v3, we store each replication stream's custom checkpoint requests in their own collection:
301
+ * `custom_checkpoint_requests_${replicationStreamId}_${eventId}`
302
+ */
303
+ export interface CustomCheckpointRequestDocumentV3 {
304
+ _id: bson.ObjectId;
305
+ user_id: string;
306
+ checkpoint: bigint;
307
+ /**
308
+ * Unlike managed write checkpoints, custom write checkpoints are flushed together with
309
+ * normal ops. This means we can assign an op_id for ordering / correlating with read checkpoints.
310
+ *
311
+ * This is not unique - multiple write checkpoints can have the same op_id.
312
+ */
313
+ op_id?: InternalOpId;
314
+ /**
315
+ * Set when this checkpoint was created from a client-supplied checkpoint
316
+ * request rather than a persistent custom write checkpoint.
317
+ */
318
+ checkpoint_requested_at?: Date | null;
319
+ }
320
+
233
321
  export function serializeParameterLookup(lookup: ScopedParameterLookup): bson.Binary {
234
322
  return new bson.Binary(bson.serialize({ l: lookup.values.slice(2) }));
235
323
  }
@@ -1,18 +1,22 @@
1
- import { storage } from '@powersync/service-core';
1
+ import { PerformanceTracer, storage } from '@powersync/service-core';
2
2
  import * as bson from 'bson';
3
3
  import { BucketDataDocumentV3, BucketOperation } from '../models.js';
4
- import { ObjectStorage } from './ObjectStorage.js';
4
+ import { ObjectStorage, ObjectStorageOperationOptions } from './ObjectStorage.js';
5
5
 
6
6
  export class BucketDataObjectStorage {
7
7
  constructor(private readonly storage: ObjectStorage) {}
8
8
 
9
- async store(path: string, ops: BucketOperation[]): Promise<{ fileSize: number }> {
9
+ async store(
10
+ path: string,
11
+ ops: BucketOperation[],
12
+ options?: ObjectStorageOperationOptions
13
+ ): Promise<{ fileSize: number }> {
10
14
  const bsonBuffer = bson.serialize({ ops });
11
- await this.storage.put(path, bsonBuffer, { contentType: 'application/bson', contentEncoding: null });
15
+ await this.storage.put(path, bsonBuffer, { contentType: 'application/bson', contentEncoding: null }, options);
12
16
  return { fileSize: bsonBuffer.byteLength };
13
17
  }
14
18
 
15
- async retrieve(path: string, options: { signal?: AbortSignal }): Promise<BucketOperation[]> {
19
+ async retrieve(path: string, options: ObjectStorageOperationOptions): Promise<BucketOperation[]> {
16
20
  const { data, metadata } = await this.storage.get(path, { signal: options.signal });
17
21
  if (metadata.contentEncoding != null) {
18
22
  throw new Error(`Unexpected content encoding: ${metadata.contentEncoding}`);
@@ -24,8 +28,8 @@ export class BucketDataObjectStorage {
24
28
  return wrapper.ops;
25
29
  }
26
30
 
27
- async delete(paths: string[]): Promise<void> {
28
- return this.storage.delete(paths);
31
+ async delete(paths: string[], options?: ObjectStorageOperationOptions): Promise<void> {
32
+ return this.storage.delete(paths, options);
29
33
  }
30
34
  }
31
35
 
@@ -37,7 +41,7 @@ export class BucketDataObjectStorage {
37
41
  export async function hydrateBucketDataDocuments(
38
42
  documents: BucketDataDocumentV3[],
39
43
  objectStorage: ObjectStorage | undefined,
40
- options: { signal?: AbortSignal }
44
+ options: ObjectStorageOperationOptions & { tracer?: PerformanceTracer<string> }
41
45
  ): Promise<void> {
42
46
  if (!objectStorage) {
43
47
  return;
@@ -46,9 +50,20 @@ export async function hydrateBucketDataDocuments(
46
50
  options.signal?.throwIfAborted();
47
51
  const store = new BucketDataObjectStorage(objectStorage);
48
52
  const storedDocuments = documents.filter((document) => document.storage_ref);
49
- await Promise.all(
53
+ if (storedDocuments.length == 0) {
54
+ return;
55
+ }
56
+ using _ = options?.tracer?.span('s3', 'read');
57
+ const results = await Promise.allSettled(
50
58
  storedDocuments.map(async (document) => {
51
59
  document.ops = await store.retrieve(document.storage_ref!.path, { signal: options.signal });
52
60
  })
53
61
  );
62
+ // Drain every download before propagating failure so callers can release their
63
+ // worker slot and payloads without a sibling download hydrating a document later.
64
+ for (const result of results) {
65
+ if (result.status === 'rejected') {
66
+ throw result.reason;
67
+ }
68
+ }
54
69
  }
@@ -17,13 +17,28 @@ export function isRetryableObjectStorageError(error: unknown): error is ObjectSt
17
17
  return error instanceof ObjectStorageError && error.retryable;
18
18
  }
19
19
 
20
+ export interface ObjectStorageOperationOptions {
21
+ /**
22
+ * Cancels the operation, including while it waits for a concurrency slot.
23
+ *
24
+ * Operations are bounded by their own timeouts regardless, but a signal releases the slot as
25
+ * soon as the caller no longer needs the result.
26
+ */
27
+ signal?: AbortSignal;
28
+ }
29
+
20
30
  export interface ObjectStorage {
21
- put(path: string, data: Uint8Array, metadata: ObjectStoragePutMetadata): Promise<void>;
31
+ put(
32
+ path: string,
33
+ data: Uint8Array,
34
+ metadata: ObjectStoragePutMetadata,
35
+ options?: ObjectStorageOperationOptions
36
+ ): Promise<void>;
22
37
  get(
23
38
  path: string,
24
- options?: { signal?: AbortSignal }
39
+ options?: ObjectStorageOperationOptions
25
40
  ): Promise<{ data: Uint8Array; metadata: ObjectStoragePutMetadata }>;
26
- list(prefix: string, options?: { signal?: AbortSignal }): AsyncIterable<string>;
27
- delete(paths: string[]): Promise<void>;
28
- deletePrefix(prefix: string, options?: { signal?: AbortSignal }): Promise<{ objectCount: number }>;
41
+ list(prefix: string, options?: ObjectStorageOperationOptions): AsyncIterable<string>;
42
+ delete(paths: string[], options?: ObjectStorageOperationOptions): Promise<void>;
43
+ deletePrefix(prefix: string, options?: ObjectStorageOperationOptions): Promise<{ objectCount: number }>;
29
44
  }
@@ -1,11 +1,11 @@
1
- import { mongo } from '@powersync/lib-service-mongodb';
2
1
  import { Logger } from '@powersync/lib-services-framework';
3
2
  import * as bson from 'bson';
4
3
  import { createHash, randomUUID } from 'node:crypto';
4
+ import { MongoWriteBatch } from '../../MongoWriteBatch.js';
5
5
  import { ObjectStorageDeletionMarker } from '../models.js';
6
6
  import { VersionedPowerSyncMongoV3 } from '../VersionedPowerSyncMongoV3.js';
7
7
  import { BucketDataObjectStorage } from './BucketDataObjectStorage.js';
8
- import { ObjectStorage } from './ObjectStorage.js';
8
+ import { ObjectStorage, ObjectStorageOperationOptions } from './ObjectStorage.js';
9
9
 
10
10
  export const OBJECT_STORAGE_UPLOAD_LEASE_MS = 60 * 60 * 1000;
11
11
  export const OBJECT_STORAGE_PUBLICATION_SAFETY_MARGIN_MS = 2 * 60 * 1000;
@@ -93,7 +93,7 @@ export class ObjectStorageLifecycle {
93
93
  *
94
94
  * Call this in the same transaction as the one creating the references.
95
95
  */
96
- async publishUploads(uploads: PreparedObjectStorageUpload[], session: mongo.ClientSession): Promise<void> {
96
+ publishUploads(uploads: PreparedObjectStorageUpload[], writes: MongoWriteBatch): void {
97
97
  for (const upload of uploads) {
98
98
  if (!this.canPublish(upload)) {
99
99
  throw new Error(`Object storage publication lease expired for ${upload.path}`);
@@ -103,12 +103,13 @@ export class ObjectStorageLifecycle {
103
103
  return;
104
104
  }
105
105
 
106
- const result = await this.db
107
- .pendingObjectStorageDeletes(this.replicationStreamId)
108
- .deleteMany({ _id: { $in: uploads.map((upload) => upload.markerId) } }, { session });
109
- if (result.deletedCount !== uploads.length) {
110
- throw new Error(`Missing object storage publication markers`);
111
- }
106
+ writes.deleteMany(
107
+ this.db.pendingObjectStorageDeletes(this.replicationStreamId),
108
+ { _id: { $in: uploads.map((upload) => upload.markerId) } },
109
+ (deletedCount) => {
110
+ if (deletedCount !== uploads.length) throw new Error('Missing object storage publication markers');
111
+ }
112
+ );
112
113
  }
113
114
 
114
115
  /**
@@ -122,14 +123,14 @@ export class ObjectStorageLifecycle {
122
123
  *
123
124
  * Call this in the same transaction as the one removing the references to these files.
124
125
  */
125
- async retire(paths: Iterable<string>, session: mongo.ClientSession, now = new Date()): Promise<void> {
126
+ retire(paths: Iterable<string>, writes: MongoWriteBatch, now = new Date()): void {
126
127
  const markers: ObjectStorageDeletionMarker[] = Array.from(paths, (path) => ({
127
128
  _id: new bson.ObjectId(),
128
129
  path,
129
130
  delete_after: new Date(now.getTime() + OBJECT_STORAGE_REFERENCE_GRACE_MS)
130
131
  }));
131
132
  if (markers.length) {
132
- await this.db.pendingObjectStorageDeletes(this.replicationStreamId).insertMany(markers, { session });
133
+ writes.insertMany(this.db.pendingObjectStorageDeletes(this.replicationStreamId), markers);
133
134
  }
134
135
  }
135
136
 
@@ -137,11 +138,12 @@ export class ObjectStorageLifecycle {
137
138
  * Discover and delete objects by their storage prefix
138
139
  * without touching the database.
139
140
  */
140
- async deletePrefix(prefix: string, options?: { signal?: AbortSignal }): Promise<{ objectCount: number }> {
141
+ async deletePrefix(prefix: string, options?: ObjectStorageOperationOptions): Promise<{ objectCount: number }> {
141
142
  return this.objectStorage.deletePrefix(prefix, { signal: options?.signal });
142
143
  }
143
144
 
144
- async cleanup(logger: Logger): Promise<void> {
145
+ async cleanup(logger: Logger, options?: ObjectStorageOperationOptions): Promise<void> {
146
+ const signal = options?.signal;
145
147
  const markers = this.db.pendingObjectStorageDeletes(this.replicationStreamId);
146
148
  const cursor = markers.find({ delete_after: { $lte: new Date() } });
147
149
  let batch: ObjectStorageDeletionMarker[] = [];
@@ -153,14 +155,24 @@ export class ObjectStorageLifecycle {
153
155
  const deleting = batch;
154
156
  batch = [];
155
157
  try {
156
- await this.bucketData.delete(deleting.map((marker) => marker.path));
158
+ await this.bucketData.delete(
159
+ deleting.map((marker) => marker.path),
160
+ { signal }
161
+ );
157
162
  await markers.deleteMany({ _id: { $in: deleting.map((marker) => marker._id) } });
158
163
  } catch (error) {
164
+ if (signal?.aborted) {
165
+ // Shutting down: stop instead of working through the remaining markers. They stay
166
+ // pending, and are cleaned up during the next compaction. Checked on the signal rather
167
+ // than the error, since a caller may abort with any reason.
168
+ throw error;
169
+ }
159
170
  logger.warn('Failed to clean up object storage deletion markers; will retry during the next compaction', error);
160
171
  }
161
172
  };
162
173
 
163
174
  for await (const marker of cursor) {
175
+ signal?.throwIfAborted();
164
176
  batch.push(marker);
165
177
  if (batch.length === 500) {
166
178
  await deleteBatch();