@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,526 @@
1
+ import { DeleteObjectsCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
2
+ import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
3
+ import { bucketRequest, test_utils } from '@powersync/service-core-tests';
4
+ import * as bson from 'bson';
5
+ import { describe, expect, test } from 'vitest';
6
+ import { MongoSyncBucketStorage } from '../../src/storage/implementation/createMongoSyncBucketStorage.js';
7
+ import { VersionedPowerSyncMongoV3 } from '../../src/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
8
+ import { hydrateBucketDataDocuments } from '../../src/storage/implementation/v3/object-storage/BucketDataObjectStorage.js';
9
+ import { ObjectStorageError } from '../../src/storage/implementation/v3/object-storage/ObjectStorage.js';
10
+ import { S3ObjectStorage } from '../../src/storage/implementation/v3/object-storage/S3ObjectStorage.js';
11
+ import { env } from './env.js';
12
+ import { MemoryObjectStorage } from './helpers/MemoryObjectStorage.js';
13
+ import { createMemoryS3TestStorageSuite, createS3TestStorageSuite } from './helpers/s3TestFactory.js';
14
+
15
+ const SYNC_RULES_YAML = `
16
+ bucket_definitions:
17
+ global:
18
+ data:
19
+ - SELECT id, description FROM items
20
+ `;
21
+
22
+ function s3Factory() {
23
+ const { objectStorage, factoryGen } = createS3TestStorageSuite({ url: env.MONGO_TEST_URL, isCI: env.CI });
24
+ return { memoryStorage: objectStorage, factory: factoryGen };
25
+ }
26
+
27
+ function memoryS3Factory(options: { inlineThresholdBytes?: number } = {}) {
28
+ const { objectStorage, factoryGen } = createMemoryS3TestStorageSuite({
29
+ url: env.MONGO_TEST_URL,
30
+ isCI: env.CI,
31
+ inlineThresholdBytes: options.inlineThresholdBytes ?? 0
32
+ });
33
+ return { memoryStorage: objectStorage, factory: factoryGen };
34
+ }
35
+
36
+ describe('S3 object storage reads', () => {
37
+ test('configures S3 credentials and validates operation options', async () => {
38
+ const objectStorage = new S3ObjectStorage({
39
+ bucket: 'test',
40
+ region: 'test',
41
+ accessKeyId: 'access-key',
42
+ secretAccessKey: 'secret-key',
43
+ forcePathStyle: true,
44
+ concurrencyLimit: 4
45
+ });
46
+
47
+ await expect(objectStorage.client.config.credentials()).resolves.toMatchObject({
48
+ accessKeyId: 'access-key',
49
+ secretAccessKey: 'secret-key'
50
+ });
51
+ expect(() => new S3ObjectStorage({ bucket: 'test', region: 'test', accessKeyId: 'access-key' })).toThrowError(
52
+ /configured together/
53
+ );
54
+ expect(() => new S3ObjectStorage({ bucket: 'test', region: 'test', concurrencyLimit: 0 })).toThrowError(
55
+ /positive integer/
56
+ );
57
+ expect(() => new S3ObjectStorage({ bucket: 'test', region: 'test', prefix: '💾'.repeat(65) })).toThrowError(
58
+ /at most 256 UTF-8 bytes/
59
+ );
60
+ expect(() => new S3ObjectStorage({ bucket: 'test', region: 'test', prefix: 'prefix/' })).toThrowError(
61
+ /must not end with/
62
+ );
63
+ });
64
+
65
+ test('rejects complete S3 keys over the safe byte limit', async () => {
66
+ const objectStorage = new S3ObjectStorage({
67
+ bucket: 'test',
68
+ region: 'test',
69
+ prefix: 'p'.repeat(256)
70
+ });
71
+
72
+ await expect(objectStorage.get('x'.repeat(640))).rejects.toThrowError(/exceeding the safe limit of 896 bytes/);
73
+ });
74
+
75
+ test('preallocates downloads and validates ContentLength', async () => {
76
+ const objectStorage = new S3ObjectStorage({ bucket: 'test', region: 'test' });
77
+ const mockDownload = (contentLength: number | undefined, chunks: number[][]) => {
78
+ objectStorage.client.send = async () => ({
79
+ ContentLength: contentLength,
80
+ Body: (async function* () {
81
+ for (const chunk of chunks) {
82
+ yield new Uint8Array(chunk);
83
+ }
84
+ })(),
85
+ ContentType: 'application/bson'
86
+ });
87
+ };
88
+
89
+ mockDownload(5, [
90
+ [1, 2],
91
+ [3, 4, 5]
92
+ ]);
93
+ await expect(objectStorage.get('valid-object')).resolves.toEqual({
94
+ data: new Uint8Array([1, 2, 3, 4, 5]),
95
+ metadata: {
96
+ contentType: 'application/bson',
97
+ contentEncoding: null
98
+ }
99
+ });
100
+
101
+ mockDownload(undefined, [[1]]);
102
+ await expect(objectStorage.get('missing-length')).rejects.toThrowError(/missing ContentLength/);
103
+
104
+ mockDownload(5, [[1, 2, 3]]);
105
+ await expect(objectStorage.get('short-object')).rejects.toThrowError(/expected 5 bytes, received 3/);
106
+
107
+ mockDownload(3, [
108
+ [1, 2],
109
+ [3, 4]
110
+ ]);
111
+ await expect(objectStorage.get('long-object')).rejects.toThrowError(/expected 3 bytes, received at least 4/);
112
+ });
113
+
114
+ test('classifies S3 failures for compaction retries', async () => {
115
+ const objectStorage = new S3ObjectStorage({ bucket: 'test', region: 'test' });
116
+ const transientCause = Object.assign(new Error('socket reset'), { code: 'ECONNRESET' });
117
+ objectStorage.client.send = async () => {
118
+ throw transientCause;
119
+ };
120
+
121
+ await expect(objectStorage.get('transient-object')).rejects.toMatchObject({
122
+ name: 'ObjectStorageError',
123
+ retryable: true,
124
+ cause: transientCause,
125
+ message: expect.stringContaining('S3 download failed for transient-object')
126
+ } satisfies Partial<ObjectStorageError>);
127
+
128
+ const permanentCause = Object.assign(new Error('access denied'), {
129
+ name: 'AccessDenied',
130
+ $metadata: { httpStatusCode: 403 }
131
+ });
132
+ objectStorage.client.send = async () => {
133
+ throw permanentCause;
134
+ };
135
+ await expect(objectStorage.get('permanent-object')).rejects.toMatchObject({
136
+ name: 'ObjectStorageError',
137
+ retryable: false,
138
+ cause: permanentCause,
139
+ message: expect.stringContaining('S3 download failed for permanent-object')
140
+ } satisfies Partial<ObjectStorageError>);
141
+
142
+ const controller = new AbortController();
143
+ controller.abort();
144
+ objectStorage.client.send = async () => {
145
+ throw controller.signal.reason;
146
+ };
147
+ await expect(objectStorage.get('aborted-object')).rejects.toBe(controller.signal.reason);
148
+ });
149
+
150
+ test('paginates listings and strips the configured prefix', async () => {
151
+ const objectStorage = new S3ObjectStorage({
152
+ bucket: 'test',
153
+ region: 'test',
154
+ prefix: 'test-run'
155
+ });
156
+ const requests: any[] = [];
157
+ objectStorage.client.send = async (command: any) => {
158
+ requests.push(command.input);
159
+ if (command.input.ContinuationToken == null) {
160
+ return {
161
+ Contents: [{ Key: 'test-run/bucket-data/1/definition/object-1.bson' }],
162
+ IsTruncated: true,
163
+ NextContinuationToken: 'next-page'
164
+ };
165
+ }
166
+ return {
167
+ Contents: [{ Key: 'test-run/bucket-data/1/definition/object-2.bson' }],
168
+ IsTruncated: false
169
+ };
170
+ };
171
+
172
+ const paths = await test_utils.fromAsync(objectStorage.list('bucket-data/1/'));
173
+
174
+ expect(paths).toEqual(['bucket-data/1/definition/object-1.bson', 'bucket-data/1/definition/object-2.bson']);
175
+ expect(requests).toEqual([
176
+ expect.objectContaining({
177
+ Prefix: 'test-run/bucket-data/1/',
178
+ ContinuationToken: undefined
179
+ }),
180
+ expect.objectContaining({
181
+ Prefix: 'test-run/bucket-data/1/',
182
+ ContinuationToken: 'next-page'
183
+ })
184
+ ]);
185
+ });
186
+
187
+ test('deletes each prefix listing page directly', async () => {
188
+ const objectStorage = new S3ObjectStorage({
189
+ bucket: 'test',
190
+ region: 'test',
191
+ prefix: 'test-run'
192
+ });
193
+ const listRequests: any[] = [];
194
+ const deleteRequests: any[] = [];
195
+ objectStorage.client.send = async (command: any) => {
196
+ if (command instanceof ListObjectsV2Command) {
197
+ listRequests.push(command.input);
198
+ if (command.input.ContinuationToken == null) {
199
+ return {
200
+ Contents: [
201
+ { Key: 'test-run/bucket-data/1/object-1.bson' },
202
+ { Key: 'test-run/bucket-data/1/object-2.bson' }
203
+ ],
204
+ IsTruncated: true,
205
+ NextContinuationToken: 'next-page'
206
+ };
207
+ }
208
+ return {
209
+ Contents: [{ Key: 'test-run/bucket-data/1/object-3.bson' }],
210
+ IsTruncated: false
211
+ };
212
+ }
213
+ expect(command).toBeInstanceOf(DeleteObjectsCommand);
214
+ deleteRequests.push(command.input);
215
+ return {};
216
+ };
217
+
218
+ await expect(objectStorage.deletePrefix('bucket-data/1/')).resolves.toEqual({ objectCount: 3 });
219
+ expect(listRequests).toEqual([
220
+ expect.objectContaining({
221
+ Prefix: 'test-run/bucket-data/1/',
222
+ ContinuationToken: undefined,
223
+ MaxKeys: 1000
224
+ }),
225
+ expect.objectContaining({
226
+ Prefix: 'test-run/bucket-data/1/',
227
+ ContinuationToken: 'next-page',
228
+ MaxKeys: 1000
229
+ })
230
+ ]);
231
+ expect(deleteRequests).toEqual([
232
+ expect.objectContaining({
233
+ Delete: {
234
+ Objects: [{ Key: 'test-run/bucket-data/1/object-1.bson' }, { Key: 'test-run/bucket-data/1/object-2.bson' }],
235
+ Quiet: true
236
+ }
237
+ }),
238
+ expect.objectContaining({
239
+ Delete: {
240
+ Objects: [{ Key: 'test-run/bucket-data/1/object-3.bson' }],
241
+ Quiet: true
242
+ }
243
+ })
244
+ ]);
245
+ });
246
+
247
+ test('keeps up to 12 S3 prefix deletes in flight', async () => {
248
+ const objectStorage = new S3ObjectStorage({ bucket: 'test', region: 'test', concurrencyLimit: 32 });
249
+ const deleteResolvers: (() => void)[] = [];
250
+ let activeDeletes = 0;
251
+ let maxActiveDeletes = 0;
252
+ let reached12!: () => void;
253
+ let reached13!: () => void;
254
+ const first12Started = new Promise<void>((resolve) => (reached12 = resolve));
255
+ const replacementStarted = new Promise<void>((resolve) => (reached13 = resolve));
256
+ objectStorage.client.send = async (command: any) => {
257
+ if (command instanceof ListObjectsV2Command) {
258
+ const page = Number(command.input.ContinuationToken ?? 0);
259
+ return {
260
+ Contents: [{ Key: `object-${page}` }],
261
+ IsTruncated: page < 12,
262
+ NextContinuationToken: page < 12 ? String(page + 1) : undefined
263
+ };
264
+ }
265
+ expect(command).toBeInstanceOf(DeleteObjectsCommand);
266
+ activeDeletes++;
267
+ maxActiveDeletes = Math.max(maxActiveDeletes, activeDeletes);
268
+ if (deleteResolvers.length === 11) {
269
+ reached12();
270
+ } else if (deleteResolvers.length === 12) {
271
+ reached13();
272
+ }
273
+ await new Promise<void>((resolve) => deleteResolvers.push(resolve));
274
+ activeDeletes--;
275
+ return {};
276
+ };
277
+
278
+ const deleting = objectStorage.deletePrefix('object-');
279
+ await first12Started;
280
+ expect(activeDeletes).toBe(12);
281
+
282
+ deleteResolvers[0]();
283
+ await replacementStarted;
284
+ expect(activeDeletes).toBe(12);
285
+ for (const resolve of deleteResolvers.slice(1)) {
286
+ resolve();
287
+ }
288
+
289
+ await expect(deleting).resolves.toEqual({ objectCount: 13 });
290
+ expect(maxActiveDeletes).toBe(12);
291
+ });
292
+
293
+ test('shares the concurrency limit across S3 operations', async () => {
294
+ const objectStorage = new S3ObjectStorage({ bucket: 'test', region: 'test', concurrencyLimit: 4 });
295
+ let activeOperations = 0;
296
+ let maxActiveOperations = 0;
297
+ objectStorage.client.send = async (command: any) => {
298
+ activeOperations++;
299
+ maxActiveOperations = Math.max(maxActiveOperations, activeOperations);
300
+ await new Promise<void>((resolve) => setImmediate(resolve));
301
+
302
+ if (command.input.Body != null) {
303
+ // Upload concurrency ends when the SDK request resolves.
304
+ activeOperations--;
305
+ return {};
306
+ }
307
+
308
+ // Download concurrency includes consuming the response body.
309
+ const data = bson.serialize({ ops: [] });
310
+ return {
311
+ Body: (async function* () {
312
+ try {
313
+ await new Promise<void>((resolve) => setImmediate(resolve));
314
+ yield data;
315
+ } finally {
316
+ activeOperations--;
317
+ }
318
+ })(),
319
+ ContentLength: data.byteLength,
320
+ ContentType: 'application/bson'
321
+ };
322
+ };
323
+
324
+ await Promise.all(
325
+ Array.from({ length: 32 }, (_, index) =>
326
+ index % 2 === 0
327
+ ? objectStorage.get(`object-${index}`)
328
+ : objectStorage.put(`object-${index}`, new Uint8Array(), {
329
+ contentType: 'application/bson',
330
+ contentEncoding: null
331
+ })
332
+ )
333
+ );
334
+ expect(maxActiveOperations).toBe(4);
335
+ });
336
+
337
+ test('aborts active object downloads', async () => {
338
+ const objectStorage = new MemoryObjectStorage();
339
+ const controller = new AbortController();
340
+ let downloadStarted!: () => void;
341
+ const started = new Promise<void>((resolve) => (downloadStarted = resolve));
342
+ objectStorage.get = async (_path, options) => {
343
+ const signal = options?.signal;
344
+ expect(signal).toBe(controller.signal);
345
+ downloadStarted();
346
+ await new Promise<void>((_resolve, reject) => {
347
+ signal!.addEventListener('abort', () => reject(signal!.reason), { once: true });
348
+ });
349
+ throw new Error('unreachable');
350
+ };
351
+
352
+ const hydrating = hydrateBucketDataDocuments(
353
+ [
354
+ {
355
+ _id: { b: 'bucket', o: 1n },
356
+ min_op: 1n,
357
+ checksum: 0n,
358
+ count: 0,
359
+ size: 1,
360
+ storage_ref: { path: 'object', file_size: 1 }
361
+ }
362
+ ],
363
+ objectStorage,
364
+ { signal: controller.signal }
365
+ );
366
+ const expectation = expect(hydrating).rejects.toMatchObject({ name: 'AbortError' });
367
+
368
+ await started;
369
+ controller.abort();
370
+ await expectation;
371
+ });
372
+
373
+ test('1. Round-trip write → read through S3', async () => {
374
+ const { memoryStorage, factory: factoryGen } = memoryS3Factory();
375
+ await using factory = await factoryGen.factory();
376
+ const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_RULES_YAML, { storageVersion: 3 }));
377
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
378
+
379
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
380
+ const sourceTable = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, 1);
381
+ await writer.markAllSnapshotDone('1/1');
382
+
383
+ // Write two ops
384
+ await writer.save({
385
+ sourceTable,
386
+ tag: storage.SaveOperationTag.INSERT,
387
+ after: { id: 'item1', description: 'hello' },
388
+ afterReplicaId: test_utils.rid('item1')
389
+ });
390
+ await writer.save({
391
+ sourceTable,
392
+ tag: storage.SaveOperationTag.INSERT,
393
+ after: { id: 'item2', description: 'world' },
394
+ afterReplicaId: test_utils.rid('item2')
395
+ });
396
+
397
+ const flushResult = await writer.flush();
398
+ const checkpoint = flushResult!.flushed_op;
399
+
400
+ // Confirm S3 objects were uploaded (baseline)
401
+ const storedPaths = memoryStorage.store;
402
+ expect(storedPaths.size).toBeGreaterThan(0);
403
+
404
+ // Read back via getBucketDataBatch.
405
+ const batch = await test_utils.fromAsync(
406
+ bucketStorage.getBucketDataBatch(test_utils.testCheckpoint(checkpoint), [
407
+ bucketRequest(syncRules.syncConfigContent[0], 'global[]', 0n)
408
+ ])
409
+ );
410
+ const data = test_utils.getBatchData(batch);
411
+
412
+ expect(data.length).toBe(2);
413
+ expect(data).toEqual(
414
+ expect.arrayContaining([
415
+ expect.objectContaining({ op: 'PUT', object_id: 'item1' }),
416
+ expect.objectContaining({ op: 'PUT', object_id: 'item2' })
417
+ ])
418
+ );
419
+ });
420
+
421
+ test('2. Missing S3 object is a hard error', async () => {
422
+ const { factory: factoryGen } = s3Factory();
423
+ await using factory = await factoryGen.factory();
424
+ const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_RULES_YAML, { storageVersion: 3 }));
425
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
426
+
427
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
428
+ const sourceTable = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, 1);
429
+ await writer.markAllSnapshotDone('1/1');
430
+
431
+ await writer.save({
432
+ sourceTable,
433
+ tag: storage.SaveOperationTag.INSERT,
434
+ after: { id: 'real1', description: 'real data' },
435
+ afterReplicaId: test_utils.rid('real1')
436
+ });
437
+ await writer.save({
438
+ sourceTable,
439
+ tag: storage.SaveOperationTag.INSERT,
440
+ after: { id: 'real2', description: 'real data' },
441
+ afterReplicaId: test_utils.rid('real2')
442
+ });
443
+
444
+ const flushResult = await writer.flush();
445
+ const checkpoint = flushResult!.flushed_op;
446
+
447
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
448
+ const definitionId = syncRules.syncConfigContent[0].mapping.allBucketDefinitionIds()[0];
449
+ const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
450
+ const document = await collection.findOne({});
451
+ expect(document?.storage_ref).toBeDefined();
452
+ await collection.updateOne(
453
+ { _id: document!._id },
454
+ {
455
+ $set: {
456
+ storage_ref: {
457
+ path: 'nonexistent/missing-object/path',
458
+ file_size: document!.storage_ref!.file_size
459
+ }
460
+ }
461
+ }
462
+ );
463
+
464
+ // A missing S3 object should be a hard error, not silently skipped.
465
+ await expect(
466
+ test_utils.fromAsync(
467
+ bucketStorage.getBucketDataBatch(test_utils.testCheckpoint(checkpoint), [
468
+ bucketRequest(syncRules.syncConfigContent[0], 'global[]', 0n)
469
+ ])
470
+ )
471
+ ).rejects.toThrow('nonexistent/missing-object/path');
472
+ });
473
+
474
+ test('3. Read with mixed inline + S3 docs', async () => {
475
+ const { memoryStorage, factory: factoryGen } = memoryS3Factory({ inlineThresholdBytes: 1_000 });
476
+ await using factory = await factoryGen.factory();
477
+ const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_RULES_YAML, { storageVersion: 3 }));
478
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
479
+
480
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
481
+ const sourceTable = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, 1);
482
+ await writer.markAllSnapshotDone('1/1');
483
+
484
+ // The first commit stays inline.
485
+ await writer.save({
486
+ sourceTable,
487
+ tag: storage.SaveOperationTag.INSERT,
488
+ after: { id: 'inline_item', description: 'small' },
489
+ afterReplicaId: test_utils.rid('inline_item')
490
+ });
491
+ await writer.commit('1/1');
492
+
493
+ // The second commit exceeds the threshold and is offloaded.
494
+ await writer.save({
495
+ sourceTable,
496
+ tag: storage.SaveOperationTag.INSERT,
497
+ after: { id: 's3_item', description: 'large'.repeat(500) },
498
+ afterReplicaId: test_utils.rid('s3_item')
499
+ });
500
+ await writer.commit('2/1');
501
+
502
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
503
+ const definitionId = syncRules.syncConfigContent[0].mapping.allBucketDefinitionIds()[0];
504
+ const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
505
+ const documents = await collection.find({}).sort({ '_id.o': 1 }).toArray();
506
+ expect(documents).toHaveLength(2);
507
+ expect(documents[0].ops).toBeDefined();
508
+ expect(documents[0].storage_ref).toBeUndefined();
509
+ expect(documents[1].ops).toBeUndefined();
510
+ expect(documents[1].storage_ref).toBeDefined();
511
+ expect(new Set(memoryStorage.store.keys())).toEqual(new Set([documents[1].storage_ref!.path]));
512
+
513
+ // Read back. Both S3-backed and inline ops should be returned.
514
+ const batch = await test_utils.getBatchArray(
515
+ bucketStorage.getBucketDataBatch(test_utils.testCheckpoint(documents[1]._id.o), [
516
+ bucketRequest(syncRules.syncConfigContent[0], 'global[]', 0n)
517
+ ])
518
+ );
519
+ const data = batch.flatMap((chunk) => chunk.chunkData.data);
520
+
521
+ expect(data).toMatchObject([
522
+ { op: 'PUT', object_id: 'inline_item' },
523
+ { op: 'PUT', object_id: 's3_item' }
524
+ ]);
525
+ });
526
+ });
@@ -0,0 +1,135 @@
1
+ import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
2
+ import { test_utils } from '@powersync/service-core-tests';
3
+ import * as bson from 'bson';
4
+ import { describe, expect, test } from 'vitest';
5
+ import { MongoSyncBucketStorage } from '../../src/storage/implementation/createMongoSyncBucketStorage.js';
6
+ import { VersionedPowerSyncMongoV3 } from '../../src/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
7
+ import { env } from './env.js';
8
+ import { createMemoryS3TestStorageSuite } 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 memoryS3Factory(options: { inlineThresholdBytes?: number } = {}) {
18
+ const { objectStorage, factoryGen } = createMemoryS3TestStorageSuite({
19
+ url: env.MONGO_TEST_URL,
20
+ isCI: env.CI,
21
+ inlineThresholdBytes: options.inlineThresholdBytes ?? 0
22
+ });
23
+ return { memoryStorage: objectStorage, factory: factoryGen };
24
+ }
25
+
26
+ describe('S3 object storage writes', () => {
27
+ test('writes operation payload and MongoDB metadata shell', async () => {
28
+ const { memoryStorage, factory: factoryGen } = memoryS3Factory();
29
+ await using factory = await factoryGen.factory();
30
+ const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_RULES_YAML, { storageVersion: 3 }));
31
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
32
+
33
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
34
+ const sourceTable = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, 1);
35
+ await writer.markAllSnapshotDone('1/1');
36
+
37
+ await writer.save({
38
+ sourceTable,
39
+ tag: storage.SaveOperationTag.INSERT,
40
+ after: { id: 'item1', description: 'hello' },
41
+ afterReplicaId: test_utils.rid('item1')
42
+ });
43
+
44
+ await writer.save({
45
+ sourceTable,
46
+ tag: storage.SaveOperationTag.INSERT,
47
+ after: { id: 'item2', description: 'world' },
48
+ afterReplicaId: test_utils.rid('item2')
49
+ });
50
+
51
+ await writer.commit('1/1');
52
+
53
+ // Verify the S3 payload.
54
+ const storedPaths = memoryStorage.store;
55
+ expect(storedPaths.size).toBe(1);
56
+
57
+ // Find and deserialize the stored payload.
58
+ const [path, entry] = [...storedPaths.entries()][0];
59
+ const wrapper = bson.deserialize(entry.data, { promoteValues: false });
60
+ const ops = wrapper.ops as any[];
61
+ expect(ops).toHaveLength(2);
62
+
63
+ expect(ops).toMatchObject([
64
+ { op: 'PUT', row_id: 'item1' },
65
+ { op: 'PUT', row_id: 'item2' }
66
+ ]);
67
+ expect(ops.every((op) => op.data != null)).toBe(true);
68
+
69
+ // Verify the corresponding MongoDB metadata shell.
70
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
71
+ const definitionId = syncRules.syncConfigContent[0].mapping.allBucketDefinitionIds()[0];
72
+ const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
73
+ const docs = await collection.find({}).toArray();
74
+ expect(docs).toHaveLength(1);
75
+
76
+ const doc = docs[0];
77
+ expect(doc).toMatchObject({
78
+ count: 2,
79
+ storage_ref: {
80
+ path,
81
+ file_size: entry.data.byteLength
82
+ }
83
+ });
84
+ expect(doc._id.b).toBeTypeOf('string');
85
+ expect(doc._id.o).toBeTypeOf('bigint');
86
+ expect(doc.min_op).toBeTypeOf('bigint');
87
+ expect(doc.checksum).toBeTypeOf('bigint');
88
+ expect(doc.target_op === null || typeof doc.target_op === 'bigint').toBe(true);
89
+ expect(doc.size).toBe(bson.calculateObjectSize(ops));
90
+ expect(doc.ops).toBeUndefined();
91
+ });
92
+
93
+ test('clearing storage deletes only the replication stream object prefix', async () => {
94
+ const { memoryStorage, factory: factoryGen } = memoryS3Factory();
95
+ await using factory = await factoryGen.factory();
96
+ const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_RULES_YAML, { storageVersion: 3 }));
97
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
98
+
99
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
100
+ const sourceTable = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, 4);
101
+ await writer.markAllSnapshotDone('1/1');
102
+ await writer.save({
103
+ sourceTable,
104
+ tag: storage.SaveOperationTag.INSERT,
105
+ after: { id: 'clear-prefix', description: 'x'.repeat(1_000) },
106
+ afterReplicaId: test_utils.rid('clear-prefix')
107
+ });
108
+ await writer.commit('1/1');
109
+
110
+ const streamPrefix = `bucket-data/${bucketStorage.replicationStreamId}/`;
111
+ expect(Array.from(memoryStorage.store.keys()).some((path) => path.startsWith(streamPrefix))).toBe(true);
112
+
113
+ const unrelatedPath = `bucket-data/${bucketStorage.replicationStreamId + 1}/unrelated/object.bson`;
114
+ await memoryStorage.put(unrelatedPath, new Uint8Array(), {
115
+ contentType: 'application/bson',
116
+ contentEncoding: null
117
+ });
118
+
119
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
120
+ const deletionMarkers = db.pendingObjectStorageDeletes(bucketStorage.replicationStreamId);
121
+ await deletionMarkers.insertOne({
122
+ _id: new bson.ObjectId(),
123
+ path: `${streamPrefix}pending-object.bson`,
124
+ delete_after: new Date()
125
+ });
126
+
127
+ await bucketStorage.clear();
128
+
129
+ expect(Array.from(memoryStorage.store.keys()).some((path) => path.startsWith(streamPrefix))).toBe(false);
130
+ expect(memoryStorage.store.has(unrelatedPath)).toBe(true);
131
+ expect(await db.db.listCollections({ name: deletionMarkers.collectionName }, { nameOnly: true }).hasNext()).toBe(
132
+ false
133
+ );
134
+ });
135
+ });