@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
@@ -4,7 +4,7 @@ import {
4
4
  storage,
5
5
  updateSyncRulesFromYaml
6
6
  } from '@powersync/service-core';
7
- import { bucketRequest, test_utils } from '@powersync/service-core-tests';
7
+ import { bucketRequest, compactActive, test_utils } from '@powersync/service-core-tests';
8
8
  import { describe, expect, test } from 'vitest';
9
9
  import { MongoSyncBucketStorage } from '../../src/storage/implementation/createMongoSyncBucketStorage.js';
10
10
  import { VersionedPowerSyncMongoV3 } from '../../src/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
@@ -60,8 +60,7 @@ describe('V3 checksums with S3 object storage', () => {
60
60
  {
61
61
  $set: {
62
62
  last_op: 3n,
63
- compacted_state: { op_id: 3n, count: 0, checksum: 0n, bytes: null },
64
- estimate_since_compact: { count: 3, bytes: 100 }
63
+ compacted_state: { op_id: 3n, count: 0, checksum: 0n, bytes: 0n, chunks: 0, at: new Date() }
65
64
  }
66
65
  },
67
66
  { upsert: true }
@@ -154,7 +153,7 @@ describe('V3 checksums with S3 object storage', () => {
154
153
  const checkpoint = await bucketStorage.getCheckpoint();
155
154
 
156
155
  // Compact — produces CLEAR doc from collapsed MOVEs
157
- await bucketStorage.compact({
156
+ await compactActive(factory, {
158
157
  maxOpId: checkpoint.checkpoint,
159
158
  compactBuckets: [request.bucket],
160
159
  clearBatchLimit: 200,
@@ -1,7 +1,9 @@
1
+ import * as lib_mongo from '@powersync/lib-service-mongodb';
1
2
  import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
2
- import { bucketRequest, test_utils } from '@powersync/service-core-tests';
3
+ import { bucketRequest, compactActive, test_utils } from '@powersync/service-core-tests';
3
4
  import { describe, expect, test } from 'vitest';
4
5
  import { MongoSyncBucketStorage } from '../../src/storage/implementation/createMongoSyncBucketStorage.js';
6
+ import { MongoSyncBucketStorageV3 } from '../../src/storage/implementation/v3/MongoSyncBucketStorageV3.js';
5
7
  import { VersionedPowerSyncMongoV3 } from '../../src/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
6
8
  import { ObjectStorageError } from '../../src/storage/implementation/v3/object-storage/ObjectStorage.js';
7
9
  import { env } from './env.js';
@@ -28,8 +30,30 @@ function memoryS3Factory(options: { inlineThresholdBytes?: number } = {}) {
28
30
  return { memoryStorage: objectStorage, factory: factoryGen };
29
31
  }
30
32
 
33
+ async function expectUsageMatchesBucketData(bucketStorage: MongoSyncBucketStorage, definitionId: string) {
34
+ const v3BucketStorage = bucketStorage as MongoSyncBucketStorageV3;
35
+ const db = v3BucketStorage.db as VersionedPowerSyncMongoV3;
36
+ const documents = await db.bucketData(v3BucketStorage.replicationStreamId, definitionId).find({}).toArray();
37
+ const expectedBytes = documents.reduce((sum, document) => sum + BigInt(document.storage_ref?.file_size ?? 0), 0n);
38
+ const entries = await db.objectStorageUsage
39
+ .aggregate<{ active_bytes: bigint }>([
40
+ { $match: { '_id.g': v3BucketStorage.replicationStreamId } },
41
+ { $project: { definitions: { $objectToArray: '$definitions' } } },
42
+ { $unwind: '$definitions' },
43
+ { $group: { _id: null, active_bytes: { $sum: '$definitions.v' } } }
44
+ ])
45
+ .toArray()
46
+ .catch((error) => {
47
+ if (lib_mongo.isMongoNamespaceNotFoundError(error)) {
48
+ return [];
49
+ }
50
+ throw error;
51
+ });
52
+ expect(BigInt(entries[0]?.active_bytes ?? 0)).toBe(expectedBytes);
53
+ }
54
+
31
55
  describe('S3 compaction storage lifecycle', () => {
32
- test('retries transient object storage failures', async () => {
56
+ test('a later compaction recovers from a transient object storage failure', async () => {
33
57
  const { memoryStorage, factory: factoryGen } = memoryS3Factory();
34
58
  await using factory = await factoryGen.factory();
35
59
  const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_RULES_YAML, { storageVersion: 3 }));
@@ -63,12 +87,14 @@ describe('S3 compaction storage lifecycle', () => {
63
87
 
64
88
  const checkpoint = await bucketStorage.getCheckpoint();
65
89
  const request = bucketRequest(syncRules.syncConfigContent[0], 'global[]', 0n);
66
- await bucketStorage.compact({
90
+ const compactOptions = {
67
91
  maxOpId: checkpoint.checkpoint,
68
92
  compactBuckets: [request.bucket],
69
93
  minBucketChanges: 1,
70
94
  minChangeRatio: 0
71
- });
95
+ };
96
+ await expect(compactActive(factory, compactOptions)).rejects.toThrow('temporary object storage failure');
97
+ await compactActive(factory, compactOptions);
72
98
 
73
99
  expect(injectedFailure).toBe(true);
74
100
  const batch = await test_utils.getBatchArray(bucketStorage.getBucketDataBatch(checkpoint, [request]));
@@ -107,7 +133,7 @@ describe('S3 compaction storage lifecycle', () => {
107
133
 
108
134
  const checkpoint = await bucketStorage.getCheckpoint();
109
135
  const request = bucketRequest(syncRules.syncConfigContent[0], 'global[]', 0n);
110
- await bucketStorage.compact({
136
+ await compactActive(factory, {
111
137
  maxOpId: checkpoint.checkpoint,
112
138
  compactBuckets: [request.bucket],
113
139
  minBucketChanges: 1,
@@ -119,6 +145,103 @@ describe('S3 compaction storage lifecycle', () => {
119
145
  expect(docs[0].storage_ref).toBeUndefined();
120
146
  expect(docs[0].ops).toBeDefined();
121
147
  expect(memoryStorage.store.size).toBe(0);
148
+ await expectUsageMatchesBucketData(bucketStorage, definitionId);
149
+ });
150
+
151
+ test('boundary CLEAR replacement updates active object-storage usage', async () => {
152
+ const { factory: factoryGen } = memoryS3Factory();
153
+ await using factory = await factoryGen.factory();
154
+ const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_RULES_YAML, { storageVersion: 3 }));
155
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
156
+
157
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
158
+ const sourceTable = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, 11);
159
+ await writer.markAllSnapshotDone('1/1');
160
+ for (const [index, description] of ['first', 'second', 'latest'].entries()) {
161
+ await writer.save({
162
+ sourceTable,
163
+ tag: index === 0 ? storage.SaveOperationTag.INSERT : storage.SaveOperationTag.UPDATE,
164
+ after: { id: 'A', description: description.repeat(100) },
165
+ afterReplicaId: test_utils.rid('A')
166
+ });
167
+ }
168
+ await writer.commit('1/1');
169
+
170
+ const definitionId = syncRules.syncConfigContent[0].mapping.allBucketDefinitionIds()[0];
171
+ const request = bucketRequest(syncRules.syncConfigContent[0], 'global[]', 0n);
172
+ const checkpoint = await bucketStorage.getCheckpoint();
173
+ await compactActive(factory, {
174
+ maxOpId: checkpoint.checkpoint,
175
+ compactBuckets: [request.bucket],
176
+ minBucketChanges: 1,
177
+ minChangeRatio: 0
178
+ });
179
+
180
+ const batch = await test_utils.getBatchArray(bucketStorage.getBucketDataBatch(checkpoint, [request]));
181
+ const data = batch.flatMap((chunk) => chunk.chunkData.data);
182
+ expect(data.some((op: any) => op.op === 'CLEAR')).toBe(true);
183
+ await expectUsageMatchesBucketData(bucketStorage, definitionId);
184
+ });
185
+
186
+ test('leading and boundary CLEAR replacements update active object-storage usage', async () => {
187
+ const { factory: factoryGen } = memoryS3Factory();
188
+ await using factory = await factoryGen.factory();
189
+ const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_RULES_YAML, { storageVersion: 3 }));
190
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
191
+
192
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
193
+ const sourceTable = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, 12);
194
+ await writer.markAllSnapshotDone('1/1');
195
+
196
+ // The first and last A versions force a leading MOVE document. The
197
+ // middle document also contains a large surviving PUT, keeping it as the
198
+ // CLEAR boundary document rather than merging it with the leading group.
199
+ await writer.save({
200
+ sourceTable,
201
+ tag: storage.SaveOperationTag.INSERT,
202
+ after: { id: 'A', description: 'old' },
203
+ afterReplicaId: test_utils.rid('A')
204
+ });
205
+ await writer.commit('1/1');
206
+
207
+ await writer.save({
208
+ sourceTable,
209
+ tag: storage.SaveOperationTag.UPDATE,
210
+ after: { id: 'A', description: 'middle' },
211
+ afterReplicaId: test_utils.rid('A')
212
+ });
213
+ await writer.save({
214
+ sourceTable,
215
+ tag: storage.SaveOperationTag.INSERT,
216
+ after: { id: 'B', description: 'b'.repeat(990_000) },
217
+ afterReplicaId: test_utils.rid('B')
218
+ });
219
+ await writer.commit('2/1');
220
+
221
+ await writer.save({
222
+ sourceTable,
223
+ tag: storage.SaveOperationTag.UPDATE,
224
+ after: { id: 'A', description: 'latest'.repeat(10_000) },
225
+ afterReplicaId: test_utils.rid('A')
226
+ });
227
+ await writer.commit('3/1');
228
+
229
+ const definitionId = syncRules.syncConfigContent[0].mapping.allBucketDefinitionIds()[0];
230
+ const request = bucketRequest(syncRules.syncConfigContent[0], 'global[]', 0n);
231
+ const checkpoint = await bucketStorage.getCheckpoint();
232
+ await compactActive(factory, {
233
+ maxOpId: checkpoint.checkpoint,
234
+ compactBuckets: [request.bucket],
235
+ clearBatchLimit: 2,
236
+ moveBatchQueryLimit: 1,
237
+ minBucketChanges: 1,
238
+ minChangeRatio: 0
239
+ });
240
+
241
+ const batch = await test_utils.getBatchArray(bucketStorage.getBucketDataBatch(checkpoint, [request]));
242
+ const data = batch.flatMap((chunk) => chunk.chunkData.data);
243
+ expect(data.some((op: any) => op.op === 'CLEAR')).toBe(true);
244
+ await expectUsageMatchesBucketData(bucketStorage, definitionId);
122
245
  });
123
246
 
124
247
  test('MOVE compaction merges adjacent objects using their compacted size in one write', async () => {
@@ -167,7 +290,7 @@ describe('S3 compaction storage lifecycle', () => {
167
290
 
168
291
  const checkpoint = await bucketStorage.getCheckpoint();
169
292
  const request = bucketRequest(syncRules.syncConfigContent[0], 'global[]', 0n);
170
- await bucketStorage.compact({
293
+ await compactActive(factory, {
171
294
  maxOpId: checkpoint.checkpoint,
172
295
  compactBuckets: [request.bucket],
173
296
  clearBatchLimit: 200,
@@ -193,6 +316,7 @@ describe('S3 compaction storage lifecycle', () => {
193
316
  // final replacement was uploaded. There was no intermediate MOVE-only
194
317
  // object before the merge.
195
318
  expect(objectStore.size).toBe(4);
319
+ await expectUsageMatchesBucketData(bucketStorage, definitionId);
196
320
  });
197
321
 
198
322
  test('compaction only retires S3 objects at or below the maxOpId horizon', async () => {
@@ -230,7 +354,7 @@ describe('S3 compaction storage lifecycle', () => {
230
354
  const lowerPaths = new Set(docsBefore.slice(0, 6).map((doc) => doc.storage_ref!.path));
231
355
  const upperPaths = new Set(docsBefore.slice(6).map((doc) => doc.storage_ref!.path));
232
356
 
233
- await bucketStorage.compact({
357
+ await compactActive(factory, {
234
358
  maxOpId,
235
359
  compactBuckets: [request.bucket],
236
360
  clearBatchLimit: 200,
@@ -257,6 +381,7 @@ describe('S3 compaction storage lifecycle', () => {
257
381
 
258
382
  // Retired objects remain readable during the reference grace period.
259
383
  expect([...lowerPaths].every((path) => memoryStorage.store.has(path))).toBe(true);
384
+ await expectUsageMatchesBucketData(bucketStorage, definitionId);
260
385
 
261
386
  const batch = await test_utils.getBatchArray(bucketStorage.getBucketDataBatch(readCheckpoint, [request]));
262
387
  const data = batch.flatMap((chunk) => chunk.chunkData.data);
@@ -337,14 +462,14 @@ describe('S3 compaction storage lifecycle', () => {
337
462
  const request = bucketRequest(syncRules.syncConfigContent[0], 'global[]', 0n);
338
463
  const bucket = request.bucket;
339
464
 
340
- // Read bucket_state before compaction to confirm it exists and has
341
- // estimate_since_compact populated by the writer.
465
+ // Read bucket_state before compaction to confirm the writer recorded its
466
+ // aggregate statistics and scheduled a compact check.
342
467
  const bucketStateBefore = await bucketStateCollection.findOne({
343
468
  _id: { d: definitionId, b: bucket }
344
469
  });
345
470
  expect(bucketStateBefore).toBeDefined();
346
- expect(bucketStateBefore!.estimate_since_compact).toBeDefined();
347
- expect(bucketStateBefore!.estimate_since_compact!.count).toBeGreaterThan(0);
471
+ expect(bucketStateBefore!.bucket_stats.count).toBeGreaterThan(0);
472
+ expect(bucketStateBefore!.next_compact_check).toBeDefined();
348
473
 
349
474
  // Record the input operations and object path.
350
475
  const batchBefore = await test_utils.getBatchArray(bucketStorage.getBucketDataBatch(checkpoint, [request]));
@@ -357,7 +482,7 @@ describe('S3 compaction storage lifecycle', () => {
357
482
  expect(expectedCompactedOpId).not.toBeNull();
358
483
 
359
484
  // Compact the bucket.
360
- await bucketStorage.compact({
485
+ await compactActive(factory, {
361
486
  maxOpId: checkpoint.checkpoint,
362
487
  compactBuckets: [bucket],
363
488
  clearBatchLimit: 200,
@@ -402,6 +527,7 @@ describe('S3 compaction storage lifecycle', () => {
402
527
  for (const path of afterS3Paths) {
403
528
  expect(storedPaths.has(path)).toBe(true);
404
529
  }
530
+ await expectUsageMatchesBucketData(bucketStorage, definitionId);
405
531
 
406
532
  // The replacement object is readable and contains the expected compacted
407
533
  // operation sequence.
@@ -2,12 +2,16 @@ import { DeleteObjectsCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
2
2
  import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
3
3
  import { bucketRequest, test_utils } from '@powersync/service-core-tests';
4
4
  import * as bson from 'bson';
5
- import { describe, expect, test } from 'vitest';
5
+ import { describe, expect, test, vi } from 'vitest';
6
6
  import { MongoSyncBucketStorage } from '../../src/storage/implementation/createMongoSyncBucketStorage.js';
7
7
  import { VersionedPowerSyncMongoV3 } from '../../src/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
8
8
  import { hydrateBucketDataDocuments } from '../../src/storage/implementation/v3/object-storage/BucketDataObjectStorage.js';
9
9
  import { ObjectStorageError } from '../../src/storage/implementation/v3/object-storage/ObjectStorage.js';
10
- import { S3ObjectStorage } from '../../src/storage/implementation/v3/object-storage/S3ObjectStorage.js';
10
+ import {
11
+ resolveTimeoutProfile,
12
+ S3ObjectStorage,
13
+ type S3TimeoutProfile
14
+ } from '../../src/storage/implementation/v3/object-storage/S3ObjectStorage.js';
11
15
  import { env } from './env.js';
12
16
  import { MemoryObjectStorage } from './helpers/MemoryObjectStorage.js';
13
17
  import { createMemoryS3TestStorageSuite, createS3TestStorageSuite } from './helpers/s3TestFactory.js';
@@ -62,6 +66,234 @@ describe('S3 object storage reads', () => {
62
66
  );
63
67
  });
64
68
 
69
+ test('derives request timeouts from the AWS defaults mode', () => {
70
+ // legacy - the mode used when nothing is configured - defines no timeouts of its own,
71
+ // and uses the same baseline as standard.
72
+ expect(resolveTimeoutProfile('legacy')).toEqual({
73
+ connectionTimeoutMs: 3_100,
74
+ requestTimeoutMs: 15_500,
75
+ operationTimeoutMs: 31_000,
76
+ queueTimeoutMs: 124_000
77
+ });
78
+ expect(resolveTimeoutProfile('standard')).toEqual(resolveTimeoutProfile('legacy'));
79
+ expect(resolveTimeoutProfile('cross-region')).toEqual(resolveTimeoutProfile('legacy'));
80
+ expect(resolveTimeoutProfile('in-region')).toEqual({
81
+ connectionTimeoutMs: 1_100,
82
+ requestTimeoutMs: 5_500,
83
+ operationTimeoutMs: 11_000,
84
+ queueTimeoutMs: 44_000
85
+ });
86
+ expect(resolveTimeoutProfile('mobile')).toEqual({
87
+ connectionTimeoutMs: 30_000,
88
+ requestTimeoutMs: 150_000,
89
+ operationTimeoutMs: 300_000,
90
+ queueTimeoutMs: 1_200_000
91
+ });
92
+ });
93
+
94
+ test('resolves explicit and environment defaults modes without waiting on configuration', async () => {
95
+ // The timeouts are resolved up front, so operations never wait on configuration. The client
96
+ // wraps the mode in a provider of its own, which resolves to the same value.
97
+ const modeOf = async (objectStorage: S3ObjectStorage) => {
98
+ const configured = objectStorage.client.config.defaultsMode;
99
+ return typeof configured === 'function' ? await configured() : configured;
100
+ };
101
+
102
+ const previousMode = process.env.AWS_DEFAULTS_MODE;
103
+ try {
104
+ process.env.AWS_DEFAULTS_MODE = 'cross-region';
105
+ await expect(modeOf(new S3ObjectStorage({ bucket: 'test', region: 'test' }))).resolves.toEqual('cross-region');
106
+
107
+ // The storage option takes precedence over the environment.
108
+ await expect(
109
+ modeOf(new S3ObjectStorage({ bucket: 'test', region: 'test', defaultsMode: 'in-region' }))
110
+ ).resolves.toEqual('in-region');
111
+
112
+ // 'auto' would query the EC2 instance metadata service, and requires a resolvable region.
113
+ await expect(modeOf(new S3ObjectStorage({ bucket: 'test', defaultsMode: 'auto' }))).resolves.toEqual('standard');
114
+
115
+ delete process.env.AWS_DEFAULTS_MODE;
116
+ await expect(modeOf(new S3ObjectStorage({ bucket: 'test', region: 'test' }))).resolves.toEqual('legacy');
117
+
118
+ // An invalid mode fails on startup, rather than on every operation.
119
+ expect(
120
+ () => new S3ObjectStorage({ bucket: 'test', region: 'test', defaultsMode: 'nonsense' as any })
121
+ ).toThrowError(/Invalid AWS defaults mode "nonsense"/);
122
+ } finally {
123
+ if (previousMode == null) {
124
+ delete process.env.AWS_DEFAULTS_MODE;
125
+ } else {
126
+ process.env.AWS_DEFAULTS_MODE = previousMode;
127
+ }
128
+ }
129
+ });
130
+
131
+ test('cancels active uploads and deletes', async () => {
132
+ const operations = [
133
+ (objectStorage: S3ObjectStorage, signal: AbortSignal) =>
134
+ objectStorage.put(
135
+ 'object',
136
+ new Uint8Array(),
137
+ { contentType: 'application/bson', contentEncoding: null },
138
+ { signal }
139
+ ),
140
+ (objectStorage: S3ObjectStorage, signal: AbortSignal) => objectStorage.delete(['object'], { signal })
141
+ ];
142
+
143
+ for (const startOperation of operations) {
144
+ const objectStorage = new S3ObjectStorage({ bucket: 'test', region: 'test' });
145
+ const controller = new AbortController();
146
+ let requestStarted!: () => void;
147
+ const started = new Promise<void>((resolve) => (requestStarted = resolve));
148
+ objectStorage.client.send = async (_command: any, options: any) => {
149
+ requestStarted();
150
+ await new Promise<void>((_resolve, reject) => {
151
+ const abort = () => reject(options.abortSignal.reason);
152
+ if (options.abortSignal.aborted) {
153
+ abort();
154
+ } else {
155
+ options.abortSignal.addEventListener('abort', abort, { once: true });
156
+ }
157
+ });
158
+ throw new Error('unreachable');
159
+ };
160
+
161
+ const running = startOperation(objectStorage, controller.signal);
162
+ const expectation = expect(running).rejects.toMatchObject({ name: 'AbortError' });
163
+ await started;
164
+ controller.abort();
165
+ await expectation;
166
+ }
167
+ });
168
+
169
+ test('reports why a streaming operation was cancelled', async () => {
170
+ // Aborting mid-stream destroys the socket, and the stream fails with a generic 'aborted'
171
+ // error. The operation reports the actual cause instead.
172
+ const stalledDownload = (objectStorage: S3ObjectStorage) => {
173
+ objectStorage.client.send = async (_command: any, options: any) => ({
174
+ ContentLength: 10,
175
+ ContentType: 'application/bson',
176
+ Body: (async function* () {
177
+ yield new Uint8Array([1, 2, 3]);
178
+ await new Promise<void>((_resolve, reject) => {
179
+ const abort = () => reject(new Error('stream aborted'));
180
+ if (options.abortSignal.aborted) {
181
+ abort();
182
+ } else {
183
+ options.abortSignal.addEventListener('abort', abort, { once: true });
184
+ }
185
+ });
186
+ })()
187
+ });
188
+ };
189
+
190
+ const timingOut = new S3ObjectStorage({ bucket: 'test', region: 'test' });
191
+ (timingOut as any).timeouts = {
192
+ ...resolveTimeoutProfile('standard'),
193
+ operationTimeoutMs: 50
194
+ } satisfies S3TimeoutProfile;
195
+ stalledDownload(timingOut);
196
+ await expect(timingOut.get('stalled-object')).rejects.toMatchObject({
197
+ name: 'ObjectStorageError',
198
+ retryable: true,
199
+ message: expect.stringContaining('did not complete within 50 ms')
200
+ } satisfies Partial<ObjectStorageError>);
201
+
202
+ // A caller abort is a cancellation, not a retryable failure.
203
+ const cancelled = new S3ObjectStorage({ bucket: 'test', region: 'test' });
204
+ stalledDownload(cancelled);
205
+ const controller = new AbortController();
206
+ const reading = cancelled.get('stalled-object', { signal: controller.signal });
207
+ const expectation = expect(reading).rejects.toMatchObject({ name: 'AbortError' });
208
+ controller.abort();
209
+ await expectation;
210
+ });
211
+
212
+ test('reports the caller abort reason unchanged', async () => {
213
+ // Callers abort with the error that stopped them, and upstream error handling matches on that
214
+ // type. Replacing it with an ObjectStorageError would hide it - for MongoDB replication, that
215
+ // loses the invalidation error that triggers a replication restart.
216
+ class StreamInvalidatedError extends Error {}
217
+
218
+ const objectStorage = new S3ObjectStorage({ bucket: 'test', region: 'test' });
219
+ const reason = new StreamInvalidatedError('stream invalidated');
220
+ const controller = new AbortController();
221
+ objectStorage.client.send = async (_command: any, options: any) => {
222
+ controller.abort(reason);
223
+ throw options.abortSignal.reason;
224
+ };
225
+
226
+ await expect(objectStorage.get('object', { signal: controller.signal })).rejects.toBe(reason);
227
+ await expect(
228
+ objectStorage.put(
229
+ 'object',
230
+ new Uint8Array(),
231
+ { contentType: 'application/bson', contentEncoding: null },
232
+ { signal: controller.signal }
233
+ )
234
+ ).rejects.toBe(reason);
235
+ await expect(objectStorage.delete(['object'], { signal: controller.signal })).rejects.toBe(reason);
236
+ });
237
+
238
+ test('times out waiting for an operation slot', async () => {
239
+ const objectStorage = new S3ObjectStorage({ bucket: 'test', region: 'test', concurrencyLimit: 1 });
240
+ (objectStorage as any).timeouts = {
241
+ connectionTimeoutMs: 10,
242
+ requestTimeoutMs: 20,
243
+ operationTimeoutMs: 10_000,
244
+ queueTimeoutMs: 50
245
+ } satisfies S3TimeoutProfile;
246
+ let releaseBlocker!: () => void;
247
+ const blocked = new Promise<void>((resolve) => (releaseBlocker = resolve));
248
+ objectStorage.client.send = async () => {
249
+ await blocked;
250
+ return { ContentLength: 0, Body: (async function* () {})(), ContentType: 'application/bson' };
251
+ };
252
+
253
+ // The single slot is held by the first upload, which has no signal of its own.
254
+ const holding = objectStorage.put('held-object', new Uint8Array(), {
255
+ contentType: 'application/bson',
256
+ contentEncoding: null
257
+ });
258
+ await expect(objectStorage.get('queued-object')).rejects.toMatchObject({
259
+ name: 'ObjectStorageError',
260
+ retryable: true,
261
+ message: expect.stringContaining('waiting for a concurrency slot')
262
+ } satisfies Partial<ObjectStorageError>);
263
+
264
+ releaseBlocker();
265
+ await holding;
266
+ // The slot is usable again once the blocking operation completes.
267
+ await expect(objectStorage.get('later-object')).resolves.toMatchObject({ data: new Uint8Array(0) });
268
+ });
269
+
270
+ test('reports a caller abort while queued as a cancellation', async () => {
271
+ const objectStorage = new S3ObjectStorage({ bucket: 'test', region: 'test', concurrencyLimit: 1 });
272
+ const controller = new AbortController();
273
+ let holderStarted!: () => void;
274
+ const started = new Promise<void>((resolve) => (holderStarted = resolve));
275
+ let releaseHolder!: () => void;
276
+ const blocked = new Promise<void>((resolve) => (releaseHolder = resolve));
277
+ objectStorage.client.send = async () => {
278
+ holderStarted();
279
+ await blocked;
280
+ return {};
281
+ };
282
+
283
+ const holding = objectStorage.put('held-object', new Uint8Array(), {
284
+ contentType: 'application/bson',
285
+ contentEncoding: null
286
+ });
287
+ await started;
288
+
289
+ const queuedRead = objectStorage.get('queued-object', { signal: controller.signal });
290
+ const expectation = expect(queuedRead).rejects.toMatchObject({ name: 'AbortError' });
291
+ controller.abort();
292
+ await expectation;
293
+ releaseHolder();
294
+ await holding;
295
+ });
296
+
65
297
  test('rejects complete S3 keys over the safe byte limit', async () => {
66
298
  const objectStorage = new S3ObjectStorage({
67
299
  bucket: 'test',
@@ -125,6 +357,16 @@ describe('S3 object storage reads', () => {
125
357
  message: expect.stringContaining('S3 download failed for transient-object')
126
358
  } satisfies Partial<ObjectStorageError>);
127
359
 
360
+ const timeoutCause = new DOMException('The operation was aborted due to timeout', 'TimeoutError');
361
+ objectStorage.client.send = async () => {
362
+ throw timeoutCause;
363
+ };
364
+ await expect(objectStorage.get('timed-out-object')).rejects.toMatchObject({
365
+ name: 'ObjectStorageError',
366
+ retryable: true,
367
+ cause: timeoutCause
368
+ } satisfies Partial<ObjectStorageError>);
369
+
128
370
  const permanentCause = Object.assign(new Error('access denied'), {
129
371
  name: 'AccessDenied',
130
372
  $metadata: { httpStatusCode: 403 }
@@ -334,6 +576,52 @@ describe('S3 object storage reads', () => {
334
576
  expect(maxActiveOperations).toBe(4);
335
577
  });
336
578
 
579
+ test('drains sibling downloads before reporting a hydration failure', async () => {
580
+ const objectStorage = new MemoryObjectStorage();
581
+ const gate = Promise.withResolvers<void>();
582
+ const failure = new Error('download failed');
583
+ const documents = ['failed', 'pending'].map((path, index) => ({
584
+ _id: { b: 'bucket', o: BigInt(index + 1) },
585
+ min_op: BigInt(index + 1),
586
+ checksum: 0n,
587
+ count: 0,
588
+ size: 1,
589
+ storage_ref: { path, file_size: 1 }
590
+ }));
591
+ const get = vi.spyOn(objectStorage, 'get').mockImplementation(async (path) => {
592
+ if (path === 'failed') {
593
+ throw failure;
594
+ }
595
+ await gate.promise;
596
+ return {
597
+ data: bson.serialize({ ops: [] }),
598
+ metadata: { contentType: 'application/bson', contentEncoding: null }
599
+ };
600
+ });
601
+ let settled = false;
602
+ const result = hydrateBucketDataDocuments(documents, objectStorage, {}).then(
603
+ () => {
604
+ settled = true;
605
+ return undefined;
606
+ },
607
+ (error) => {
608
+ settled = true;
609
+ return error;
610
+ }
611
+ );
612
+ try {
613
+ await vi.waitFor(() => expect(get).toHaveBeenCalledTimes(2));
614
+ await new Promise<void>((resolve) => setImmediate(resolve));
615
+ expect(settled).toBe(false);
616
+ } finally {
617
+ gate.resolve();
618
+ await result;
619
+ get.mockRestore();
620
+ }
621
+ expect(await result).toBe(failure);
622
+ expect(documents[1]).toHaveProperty('ops', []);
623
+ });
624
+
337
625
  test('aborts active object downloads', async () => {
338
626
  const objectStorage = new MemoryObjectStorage();
339
627
  const controller = new AbortController();
@@ -65,6 +65,7 @@ describe('S3 object storage writes', () => {
65
65
  { op: 'PUT', row_id: 'item2' }
66
66
  ]);
67
67
  expect(ops.every((op) => op.data != null)).toBe(true);
68
+ expect(ops.every((op) => typeof op.subkey == 'string')).toBe(true);
68
69
 
69
70
  // Verify the corresponding MongoDB metadata shell.
70
71
  const db = bucketStorage.db as VersionedPowerSyncMongoV3;