@powersync/service-module-mongodb-storage 0.18.2 → 0.19.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 (143) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.d.ts +3 -0
  3. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js +41 -0
  4. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js.map +1 -0
  5. package/dist/storage/MongoBucketStorage.d.ts +5 -1
  6. package/dist/storage/MongoBucketStorage.js +5 -1
  7. package/dist/storage/MongoBucketStorage.js.map +1 -1
  8. package/dist/storage/implementation/MongoBucketBatch.d.ts +3 -0
  9. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  10. package/dist/storage/implementation/MongoChecksums.d.ts +2 -0
  11. package/dist/storage/implementation/MongoChecksums.js +3 -2
  12. package/dist/storage/implementation/MongoChecksums.js.map +1 -1
  13. package/dist/storage/implementation/MongoCompactor.d.ts +15 -19
  14. package/dist/storage/implementation/MongoCompactor.js +68 -260
  15. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  16. package/dist/storage/implementation/MongoStorageProvider.js +26 -3
  17. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  18. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +12 -5
  19. package/dist/storage/implementation/MongoSyncBucketStorage.js +25 -14
  20. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  21. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +2 -1
  22. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +146 -34
  23. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  24. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +2 -0
  25. package/dist/storage/implementation/common/PersistedBatch.d.ts +6 -0
  26. package/dist/storage/implementation/common/PersistedBatch.js +15 -8
  27. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  28. package/dist/storage/implementation/models.d.ts +16 -0
  29. package/dist/storage/implementation/models.js.map +1 -1
  30. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +11 -2
  31. package/dist/storage/implementation/v1/MongoCompactorV1.js +250 -1
  32. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  33. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +20 -11
  34. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  35. package/dist/storage/implementation/v1/SingleBucketStoreV1.d.ts +9 -10
  36. package/dist/storage/implementation/v1/SingleBucketStoreV1.js +2 -8
  37. package/dist/storage/implementation/v1/SingleBucketStoreV1.js.map +1 -1
  38. package/dist/storage/implementation/v3/BucketDataContextV3.d.ts +13 -0
  39. package/dist/storage/implementation/v3/BucketDataContextV3.js +23 -0
  40. package/dist/storage/implementation/v3/BucketDataContextV3.js.map +1 -0
  41. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +3 -1
  42. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  43. package/dist/storage/implementation/v3/MongoChecksumsV3.d.ts +1 -0
  44. package/dist/storage/implementation/v3/MongoChecksumsV3.js +59 -103
  45. package/dist/storage/implementation/v3/MongoChecksumsV3.js.map +1 -1
  46. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +23 -2
  47. package/dist/storage/implementation/v3/MongoCompactorV3.js +354 -219
  48. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  49. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +5 -0
  50. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +12 -11
  51. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
  52. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +5 -3
  53. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +109 -106
  54. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  55. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +5 -2
  56. package/dist/storage/implementation/v3/PersistedBatchV3.js +53 -15
  57. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  58. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +2 -1
  59. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +3 -0
  60. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  61. package/dist/storage/implementation/v3/bucket-format.d.ts +10 -1
  62. package/dist/storage/implementation/v3/bucket-format.js +28 -9
  63. package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
  64. package/dist/storage/implementation/v3/chunking.js +3 -1
  65. package/dist/storage/implementation/v3/chunking.js.map +1 -1
  66. package/dist/storage/implementation/v3/models.d.ts +38 -1
  67. package/dist/storage/implementation/v3/models.js.map +1 -1
  68. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +21 -0
  69. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +44 -0
  70. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -0
  71. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +30 -0
  72. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js +12 -0
  73. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js.map +1 -0
  74. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +70 -0
  75. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +146 -0
  76. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -0
  77. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +42 -0
  78. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +285 -0
  79. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -0
  80. package/dist/types/types.d.ts +35 -0
  81. package/dist/types/types.js +35 -1
  82. package/dist/types/types.js.map +1 -1
  83. package/dist/utils/test-utils.js +3 -1
  84. package/dist/utils/test-utils.js.map +1 -1
  85. package/dist/utils/util.d.ts +8 -1
  86. package/dist/utils/util.js +125 -2
  87. package/dist/utils/util.js.map +1 -1
  88. package/package.json +10 -7
  89. package/src/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.ts +55 -0
  90. package/src/storage/MongoBucketStorage.ts +10 -2
  91. package/src/storage/implementation/MongoBucketBatch.ts +4 -0
  92. package/src/storage/implementation/MongoChecksums.ts +5 -2
  93. package/src/storage/implementation/MongoCompactor.ts +90 -314
  94. package/src/storage/implementation/MongoStorageProvider.ts +32 -3
  95. package/src/storage/implementation/MongoSyncBucketStorage.ts +34 -24
  96. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +164 -37
  97. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +2 -0
  98. package/src/storage/implementation/common/PersistedBatch.ts +22 -10
  99. package/src/storage/implementation/models.ts +16 -0
  100. package/src/storage/implementation/v1/MongoCompactorV1.ts +292 -8
  101. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +54 -40
  102. package/src/storage/implementation/v1/SingleBucketStoreV1.ts +18 -27
  103. package/src/storage/implementation/v3/BucketDataContextV3.ts +32 -0
  104. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +3 -1
  105. package/src/storage/implementation/v3/MongoChecksumsV3.ts +75 -105
  106. package/src/storage/implementation/v3/MongoCompactorV3.ts +456 -258
  107. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +21 -19
  108. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +123 -126
  109. package/src/storage/implementation/v3/PersistedBatchV3.ts +69 -16
  110. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +5 -0
  111. package/src/storage/implementation/v3/bucket-format.ts +36 -9
  112. package/src/storage/implementation/v3/chunking.ts +3 -1
  113. package/src/storage/implementation/v3/models.ts +40 -1
  114. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +54 -0
  115. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +29 -0
  116. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +171 -0
  117. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +343 -0
  118. package/src/types/types.ts +42 -1
  119. package/src/utils/test-utils.ts +3 -1
  120. package/src/utils/util.ts +182 -4
  121. package/test/src/__snapshots__/storage_sync.test.ts.snap +1 -583
  122. package/test/src/cleanup-stopped-sync-configs.test.ts +24 -1
  123. package/test/src/helpers/MemoryObjectStorage.ts +53 -0
  124. package/test/src/helpers/s3TestFactory.ts +69 -0
  125. package/test/src/object_storage_lifecycle.test.ts +59 -0
  126. package/test/src/setup.ts +6 -1
  127. package/test/src/storage.test.ts +216 -2
  128. package/test/src/storage_compacting.test.ts +176 -187
  129. package/test/src/storage_object_storage_inline_threshold.test.ts +116 -0
  130. package/test/src/storage_s3_checksums.test.ts +191 -0
  131. package/test/src/storage_s3_compaction_lifecycle.test.ts +417 -0
  132. package/test/src/storage_s3_reading.test.ts +526 -0
  133. package/test/src/storage_s3_writing.test.ts +135 -0
  134. package/test/src/storage_sync.test.ts +95 -5
  135. package/tsconfig.tsbuildinfo +1 -1
  136. package/dist/storage/implementation/common/SingleBucketStore.d.ts +0 -54
  137. package/dist/storage/implementation/common/SingleBucketStore.js +0 -3
  138. package/dist/storage/implementation/common/SingleBucketStore.js.map +0 -1
  139. package/dist/storage/implementation/v3/SingleBucketStoreV3.d.ts +0 -18
  140. package/dist/storage/implementation/v3/SingleBucketStoreV3.js +0 -84
  141. package/dist/storage/implementation/v3/SingleBucketStoreV3.js.map +0 -1
  142. package/src/storage/implementation/common/SingleBucketStore.ts +0 -63
  143. package/src/storage/implementation/v3/SingleBucketStoreV3.ts +0 -108
@@ -0,0 +1,417 @@
1
+ import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
2
+ import { bucketRequest, test_utils } from '@powersync/service-core-tests';
3
+ import { describe, expect, test } from 'vitest';
4
+ import { MongoSyncBucketStorage } from '../../src/storage/implementation/createMongoSyncBucketStorage.js';
5
+ import { VersionedPowerSyncMongoV3 } from '../../src/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
6
+ import { ObjectStorageError } from '../../src/storage/implementation/v3/object-storage/ObjectStorage.js';
7
+ import { env } from './env.js';
8
+ import { createMemoryS3TestStorageSuite, createS3TestStorageSuite } from './helpers/s3TestFactory.js';
9
+
10
+ const SYNC_RULES_YAML = `
11
+ bucket_definitions:
12
+ global:
13
+ data:
14
+ - SELECT id, description FROM items
15
+ `;
16
+
17
+ function s3Factory() {
18
+ const { objectStorage, factoryGen } = createS3TestStorageSuite({ url: env.MONGO_TEST_URL, isCI: env.CI });
19
+ return { memoryStorage: objectStorage, factory: factoryGen };
20
+ }
21
+
22
+ function memoryS3Factory(options: { inlineThresholdBytes?: number } = {}) {
23
+ const { objectStorage, factoryGen } = createMemoryS3TestStorageSuite({
24
+ url: env.MONGO_TEST_URL,
25
+ isCI: env.CI,
26
+ inlineThresholdBytes: options.inlineThresholdBytes ?? 0
27
+ });
28
+ return { memoryStorage: objectStorage, factory: factoryGen };
29
+ }
30
+
31
+ describe('S3 compaction storage lifecycle', () => {
32
+ test('retries transient object storage failures', async () => {
33
+ const { memoryStorage, factory: factoryGen } = memoryS3Factory();
34
+ await using factory = await factoryGen.factory();
35
+ const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_RULES_YAML, { storageVersion: 3 }));
36
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
37
+
38
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
39
+ const sourceTable = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, 1);
40
+ await writer.markAllSnapshotDone('1/1');
41
+ for (const [index, description] of ['old', 'new'].entries()) {
42
+ await writer.save({
43
+ sourceTable,
44
+ tag: index === 0 ? storage.SaveOperationTag.INSERT : storage.SaveOperationTag.UPDATE,
45
+ after: { id: 'A', description },
46
+ afterReplicaId: test_utils.rid('A')
47
+ });
48
+ await writer.commit(`${index + 1}/1`);
49
+ }
50
+
51
+ const originalGet = memoryStorage.get.bind(memoryStorage);
52
+ let injectedFailure = false;
53
+ memoryStorage.get = async (...args) => {
54
+ if (!injectedFailure) {
55
+ injectedFailure = true;
56
+ throw new ObjectStorageError('temporary object storage failure', {
57
+ cause: new Error('socket reset'),
58
+ retryable: true
59
+ });
60
+ }
61
+ return originalGet(...args);
62
+ };
63
+
64
+ const checkpoint = await bucketStorage.getCheckpoint();
65
+ const request = bucketRequest(syncRules.syncConfigContent[0], 'global[]', 0n);
66
+ await bucketStorage.compact({
67
+ maxOpId: checkpoint.checkpoint,
68
+ compactBuckets: [request.bucket],
69
+ minBucketChanges: 1,
70
+ minChangeRatio: 0
71
+ });
72
+
73
+ expect(injectedFailure).toBe(true);
74
+ const batch = await test_utils.getBatchArray(bucketStorage.getBucketDataBatch(checkpoint, [request]));
75
+ const data = batch.flatMap((chunk) => chunk.chunkData.data);
76
+ expect(data).toHaveLength(2);
77
+ });
78
+
79
+ test('small inline updates merge into an inline replacement', async () => {
80
+ const { memoryStorage, factory: factoryGen } = memoryS3Factory({ inlineThresholdBytes: 10_000 });
81
+ await using factory = await factoryGen.factory();
82
+ const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_RULES_YAML, { storageVersion: 3 }));
83
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
84
+
85
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
86
+ const sourceTable = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, 10);
87
+ await writer.markAllSnapshotDone('1/1');
88
+ await writer.save({
89
+ sourceTable,
90
+ tag: storage.SaveOperationTag.INSERT,
91
+ after: { id: 'A', description: 'old' },
92
+ afterReplicaId: test_utils.rid('A')
93
+ });
94
+ await writer.commit('1/1');
95
+ await writer.save({
96
+ sourceTable,
97
+ tag: storage.SaveOperationTag.UPDATE,
98
+ after: { id: 'A', description: 'new' },
99
+ afterReplicaId: test_utils.rid('A')
100
+ });
101
+ await writer.commit('2/1');
102
+
103
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
104
+ const definitionId = syncRules.syncConfigContent[0].mapping.allBucketDefinitionIds()[0];
105
+ const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
106
+ expect(await collection.countDocuments({ storage_ref: { $exists: true } })).toBe(0);
107
+
108
+ const checkpoint = await bucketStorage.getCheckpoint();
109
+ const request = bucketRequest(syncRules.syncConfigContent[0], 'global[]', 0n);
110
+ await bucketStorage.compact({
111
+ maxOpId: checkpoint.checkpoint,
112
+ compactBuckets: [request.bucket],
113
+ minBucketChanges: 1,
114
+ minChangeRatio: 0
115
+ });
116
+
117
+ const docs = await collection.find({}).toArray();
118
+ expect(docs).toHaveLength(1);
119
+ expect(docs[0].storage_ref).toBeUndefined();
120
+ expect(docs[0].ops).toBeDefined();
121
+ expect(memoryStorage.store.size).toBe(0);
122
+ });
123
+
124
+ test('MOVE compaction merges adjacent objects using their compacted size in one write', async () => {
125
+ const { memoryStorage, factory: factoryGen } = memoryS3Factory();
126
+ await using factory = await factoryGen.factory();
127
+ const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_RULES_YAML, { storageVersion: 3 }));
128
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
129
+
130
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
131
+ const sourceTable = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, 1);
132
+ await writer.markAllSnapshotDone('1/1');
133
+
134
+ await writer.save({
135
+ sourceTable,
136
+ tag: storage.SaveOperationTag.INSERT,
137
+ after: { id: 'A', description: 'old'.repeat(120_000) },
138
+ afterReplicaId: test_utils.rid('A')
139
+ });
140
+ await writer.commit('1/1');
141
+
142
+ await writer.save({
143
+ sourceTable,
144
+ tag: storage.SaveOperationTag.INSERT,
145
+ after: { id: 'B', description: 'middle'.repeat(60_000) },
146
+ afterReplicaId: test_utils.rid('B')
147
+ });
148
+ await writer.commit('2/1');
149
+
150
+ await writer.save({
151
+ sourceTable,
152
+ tag: storage.SaveOperationTag.UPDATE,
153
+ after: { id: 'A', description: 'new'.repeat(120_000) },
154
+ afterReplicaId: test_utils.rid('A')
155
+ });
156
+ await writer.commit('3/1');
157
+
158
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
159
+ const definitionId = syncRules.syncConfigContent[0].mapping.allBucketDefinitionIds()[0];
160
+ const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
161
+ const docsBefore = await collection.find({}).sort({ _id: 1 }).toArray();
162
+ expect(docsBefore).toHaveLength(3);
163
+
164
+ const oldPaths = new Set(docsBefore.map((doc) => doc.storage_ref!.path));
165
+ const objectStore = memoryStorage.store;
166
+ expect(objectStore.size).toBe(3);
167
+
168
+ const checkpoint = await bucketStorage.getCheckpoint();
169
+ const request = bucketRequest(syncRules.syncConfigContent[0], 'global[]', 0n);
170
+ await bucketStorage.compact({
171
+ maxOpId: checkpoint.checkpoint,
172
+ compactBuckets: [request.bucket],
173
+ clearBatchLimit: 200,
174
+ moveBatchLimit: 10,
175
+ // Force each input object into a different MongoDB query batch. The
176
+ // pending merge group must survive across those batches.
177
+ moveBatchQueryLimit: 1,
178
+ moveBatchByteLimit: 16 * 1024 * 1024,
179
+ minBucketChanges: 1,
180
+ minChangeRatio: 0
181
+ });
182
+
183
+ const docsAfter = await collection.find({}).toArray();
184
+ expect(docsAfter).toHaveLength(1);
185
+ expect(docsAfter[0]).toMatchObject({
186
+ min_op: docsBefore[0].min_op,
187
+ _id: { o: docsBefore[2]._id.o },
188
+ count: 3
189
+ });
190
+ expect(oldPaths.has(docsAfter[0].storage_ref!.path)).toBe(false);
191
+
192
+ // The three old objects remain during their grace period and exactly one
193
+ // final replacement was uploaded. There was no intermediate MOVE-only
194
+ // object before the merge.
195
+ expect(objectStore.size).toBe(4);
196
+ });
197
+
198
+ test('compaction only retires S3 objects at or below the maxOpId horizon', async () => {
199
+ const { memoryStorage, factory: factoryGen } = memoryS3Factory();
200
+ await using factory = await factoryGen.factory();
201
+ const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_RULES_YAML, { storageVersion: 3 }));
202
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
203
+
204
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
205
+ const sourceTable = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, 1);
206
+ await writer.markAllSnapshotDone('1/1');
207
+
208
+ // Commit each operation separately to create objects on both sides of the
209
+ // compaction horizon.
210
+ for (let i = 1; i <= 12; i++) {
211
+ const letter = String.fromCharCode(64 + i); // A..L
212
+ await writer.save({
213
+ sourceTable,
214
+ tag: storage.SaveOperationTag.INSERT,
215
+ after: { id: letter, description: `val${i}`.repeat(50) },
216
+ afterReplicaId: test_utils.rid(letter)
217
+ });
218
+ await writer.commit(`${i}/1`);
219
+ }
220
+
221
+ const request = bucketRequest(syncRules.syncConfigContent[0], 'global[]', 0n);
222
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
223
+ const definitionId = syncRules.syncConfigContent[0].mapping.allBucketDefinitionIds()[0];
224
+ const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
225
+ const docsBefore = await collection.find({}).sort({ '_id.o': 1 }).toArray();
226
+ expect(docsBefore).toHaveLength(12);
227
+
228
+ const maxOpId = docsBefore[5]._id.o;
229
+ const readCheckpoint = test_utils.testCheckpoint(docsBefore[docsBefore.length - 1]._id.o);
230
+ const lowerPaths = new Set(docsBefore.slice(0, 6).map((doc) => doc.storage_ref!.path));
231
+ const upperPaths = new Set(docsBefore.slice(6).map((doc) => doc.storage_ref!.path));
232
+
233
+ await bucketStorage.compact({
234
+ maxOpId,
235
+ compactBuckets: [request.bucket],
236
+ clearBatchLimit: 200,
237
+ moveBatchLimit: 10,
238
+ moveBatchQueryLimit: 10,
239
+ moveBatchByteLimit: 1024,
240
+ minBucketChanges: 1,
241
+ minChangeRatio: 0
242
+ });
243
+
244
+ const docsAfter = await collection.find({}).sort({ '_id.o': 1 }).toArray();
245
+ const referencedPathsAfter = new Set(docsAfter.map((doc) => doc.storage_ref!.path));
246
+
247
+ // The lower documents are replaced and queued for delayed deletion.
248
+ expect([...lowerPaths].some((path) => referencedPathsAfter.has(path))).toBe(false);
249
+ const pendingDeletes = await db.pendingObjectStorageDeletes(bucketStorage.replicationStreamId).find({}).toArray();
250
+ const retiredPaths = new Set(pendingDeletes.map((marker) => marker.path));
251
+ expect([...lowerPaths].every((path) => retiredPaths.has(path))).toBe(true);
252
+
253
+ // Objects above the horizon remain referenced and must not be retired.
254
+ expect([...upperPaths].every((path) => referencedPathsAfter.has(path))).toBe(true);
255
+ expect([...upperPaths].some((path) => retiredPaths.has(path))).toBe(false);
256
+ expect([...upperPaths].every((path) => memoryStorage.store.has(path))).toBe(true);
257
+
258
+ // Retired objects remain readable during the reference grace period.
259
+ expect([...lowerPaths].every((path) => memoryStorage.store.has(path))).toBe(true);
260
+
261
+ const batch = await test_utils.getBatchArray(bucketStorage.getBucketDataBatch(readCheckpoint, [request]));
262
+ const data = batch.flatMap((chunk) => chunk.chunkData.data);
263
+ expect(data).toHaveLength(12);
264
+ for (let i = 7; i <= 12; i++) {
265
+ const letter = String.fromCharCode(64 + i);
266
+ expect(data.find((op: any) => op.object_id === letter)).toMatchObject({ op: 'PUT' });
267
+ }
268
+ });
269
+
270
+ test('compaction round-trip hydrates and replaces S3-backed data', async () => {
271
+ const { memoryStorage, factory: factoryGen } = memoryS3Factory();
272
+ await using factory = await factoryGen.factory();
273
+ const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_RULES_YAML, { storageVersion: 3 }));
274
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
275
+
276
+ // Write several operations, including a repeated object id, to exercise
277
+ // the compaction round-trip.
278
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
279
+ const sourceTable = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, 1);
280
+ await writer.markAllSnapshotDone('1/1');
281
+
282
+ // Op 1: A@first
283
+ await writer.save({
284
+ sourceTable,
285
+ tag: storage.SaveOperationTag.INSERT,
286
+ after: { id: 'A', description: 'first version' },
287
+ afterReplicaId: test_utils.rid('A')
288
+ });
289
+
290
+ // Op 2: B
291
+ await writer.save({
292
+ sourceTable,
293
+ tag: storage.SaveOperationTag.INSERT,
294
+ after: { id: 'B', description: 'second item' },
295
+ afterReplicaId: test_utils.rid('B')
296
+ });
297
+
298
+ // Op 3: A@updated (supersedes Op 1)
299
+ await writer.save({
300
+ sourceTable,
301
+ tag: storage.SaveOperationTag.UPDATE,
302
+ after: { id: 'A', description: 'updated version' },
303
+ afterReplicaId: test_utils.rid('A')
304
+ });
305
+
306
+ // Op 4: C
307
+ await writer.save({
308
+ sourceTable,
309
+ tag: storage.SaveOperationTag.INSERT,
310
+ after: { id: 'C', description: 'third item' },
311
+ afterReplicaId: test_utils.rid('C')
312
+ });
313
+
314
+ await writer.commit('1/1');
315
+
316
+ // Verify S3 objects were created (write path works).
317
+ const storedPaths = memoryStorage.store;
318
+ expect(storedPaths.size).toBeGreaterThan(0);
319
+
320
+ // Verify MongoDB documents have storage_ref (no ops[]).
321
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
322
+ const definitionId = syncRules.syncConfigContent[0].mapping.allBucketDefinitionIds()[0];
323
+ const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
324
+ const bucketStateCollection = db.bucketState(bucketStorage.replicationStreamId);
325
+
326
+ const docsBefore = await collection.find({}).toArray();
327
+ expect(docsBefore).toHaveLength(1);
328
+ for (const doc of docsBefore) {
329
+ expect(doc.storage_ref).toBeDefined();
330
+ expect(doc.ops).toBeUndefined();
331
+ }
332
+
333
+ // Get checkpoint and the bucket name.
334
+ const checkpoint = await bucketStorage.getCheckpoint();
335
+ expect(checkpoint.checkpoint).toBeGreaterThan(0n);
336
+
337
+ const request = bucketRequest(syncRules.syncConfigContent[0], 'global[]', 0n);
338
+ const bucket = request.bucket;
339
+
340
+ // Read bucket_state before compaction to confirm it exists and has
341
+ // estimate_since_compact populated by the writer.
342
+ const bucketStateBefore = await bucketStateCollection.findOne({
343
+ _id: { d: definitionId, b: bucket }
344
+ });
345
+ expect(bucketStateBefore).toBeDefined();
346
+ expect(bucketStateBefore!.estimate_since_compact).toBeDefined();
347
+ expect(bucketStateBefore!.estimate_since_compact!.count).toBeGreaterThan(0);
348
+
349
+ // Record the input operations and object path.
350
+ const batchBefore = await test_utils.getBatchArray(bucketStorage.getBucketDataBatch(checkpoint, [request]));
351
+ const dataBefore = test_utils.getBatchData(batchBefore);
352
+ expect(dataBefore).toHaveLength(4);
353
+ const oldS3Paths = new Set(docsBefore.map((doc) => doc.storage_ref!.path));
354
+ const expectedCompactedOpId = docsBefore
355
+ .filter((doc) => doc._id.b === bucket && doc._id.o <= checkpoint.checkpoint)
356
+ .reduce<bigint | null>((highest, doc) => (highest == null || doc._id.o > highest ? doc._id.o : highest), null);
357
+ expect(expectedCompactedOpId).not.toBeNull();
358
+
359
+ // Compact the bucket.
360
+ await bucketStorage.compact({
361
+ maxOpId: checkpoint.checkpoint,
362
+ compactBuckets: [bucket],
363
+ clearBatchLimit: 200,
364
+ moveBatchLimit: 10,
365
+ moveBatchQueryLimit: 10,
366
+ moveBatchByteLimit: 1024,
367
+ minBucketChanges: 1,
368
+ minChangeRatio: 0
369
+ });
370
+
371
+ // The compacted state reflects the hydrated object contents.
372
+ const bucketStateAfter = await bucketStateCollection.findOne({
373
+ _id: { d: definitionId, b: bucket }
374
+ });
375
+ expect(bucketStateAfter).toBeDefined();
376
+ expect(bucketStateAfter!.compacted_state).toBeDefined();
377
+
378
+ expect(bucketStateAfter!.compacted_state!.checksum).not.toBe(0n);
379
+ expect(bucketStateAfter!.compacted_state!.count).toBe(4);
380
+
381
+ // Record the highest persisted document that was actually included in the
382
+ // compaction scan, rather than the requested upper bound.
383
+ expect(bucketStateAfter!.compacted_state!.op_id).toBe(expectedCompactedOpId);
384
+
385
+ // Compacted MongoDB documents remain metadata shells for object storage.
386
+ const docsAfter = await collection.find({}).toArray();
387
+ expect(docsAfter).toHaveLength(1);
388
+ for (const doc of docsAfter) {
389
+ expect(doc.storage_ref).toBeDefined();
390
+ expect(doc.ops).toBeUndefined();
391
+ }
392
+
393
+ // Paths are globally unique, and compaction records delayed deletion markers
394
+ // instead of deleting objects while old readers may still be downloading them.
395
+ const afterS3Paths = new Set(docsAfter.map((doc) => doc.storage_ref!.path));
396
+ expect([...afterS3Paths].some((path) => oldS3Paths.has(path))).toBe(false);
397
+ const pendingDeletes = await db.pendingObjectStorageDeletes(bucketStorage.replicationStreamId).find({}).toArray();
398
+ expect(pendingDeletes.map((marker) => marker.path)).toEqual(expect.arrayContaining([...oldS3Paths]));
399
+ for (const path of oldS3Paths) {
400
+ expect(storedPaths.has(path)).toBe(true);
401
+ }
402
+ for (const path of afterS3Paths) {
403
+ expect(storedPaths.has(path)).toBe(true);
404
+ }
405
+
406
+ // The replacement object is readable and contains the expected compacted
407
+ // operation sequence.
408
+ const batchAfter = await test_utils.getBatchArray(bucketStorage.getBucketDataBatch(checkpoint, [request]));
409
+ const dataAfter = batchAfter.flatMap((chunk) => chunk.chunkData.data);
410
+ expect(dataAfter).toMatchObject([
411
+ { op: 'MOVE' },
412
+ { object_id: 'B', op: 'PUT' },
413
+ { object_id: 'A', op: 'PUT', data: '{"id":"A","description":"updated version"}' },
414
+ { object_id: 'C', op: 'PUT' }
415
+ ]);
416
+ });
417
+ });