@powersync/service-module-mongodb-storage 0.0.0-dev-20260602073133 → 0.0.0-dev-20260909133214
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.
- package/CHANGELOG.md +177 -5
- package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.d.ts +3 -0
- package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js +41 -0
- package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js.map +1 -0
- package/dist/storage/MongoBucketStorage.d.ts +71 -18
- package/dist/storage/MongoBucketStorage.js +516 -202
- package/dist/storage/MongoBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoBucketBatch.d.ts +71 -29
- package/dist/storage/implementation/MongoBucketBatch.js +140 -66
- package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
- package/dist/storage/implementation/MongoChecksums.d.ts +41 -13
- package/dist/storage/implementation/MongoChecksums.js +36 -121
- package/dist/storage/implementation/MongoChecksums.js.map +1 -1
- package/dist/storage/implementation/MongoCompactor.d.ts +28 -90
- package/dist/storage/implementation/MongoCompactor.js +87 -481
- package/dist/storage/implementation/MongoCompactor.js.map +1 -1
- package/dist/storage/implementation/MongoHydrationState.d.ts +11 -0
- package/dist/storage/implementation/MongoHydrationState.js +49 -0
- package/dist/storage/implementation/MongoHydrationState.js.map +1 -0
- package/dist/storage/implementation/MongoParameterCompactor.d.ts +82 -8
- package/dist/storage/implementation/MongoParameterCompactor.js +312 -76
- package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
- package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +21 -0
- package/dist/storage/implementation/MongoParsedSyncConfigSet.js +93 -0
- package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -0
- package/dist/storage/implementation/MongoPersistedReplicationStream.d.ts +36 -0
- package/dist/storage/implementation/MongoPersistedReplicationStream.js +90 -0
- package/dist/storage/implementation/MongoPersistedReplicationStream.js.map +1 -0
- package/dist/storage/implementation/MongoPersistedSyncConfigContent.d.ts +25 -0
- package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +125 -0
- package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -0
- package/dist/storage/implementation/MongoStorageProvider.js +41 -4
- package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
- package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +81 -37
- package/dist/storage/implementation/MongoSyncBucketStorage.js +159 -86
- package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoSyncRulesLock.d.ts +1 -1
- package/dist/storage/implementation/MongoSyncRulesLock.js +23 -11
- package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
- package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +11 -8
- package/dist/storage/implementation/MongoWriteCheckpointAPI.js +159 -33
- package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
- package/dist/storage/implementation/SyncRuleStateUpdate.d.ts +11 -5
- package/dist/storage/implementation/SyncRuleStateUpdate.js +14 -6
- package/dist/storage/implementation/SyncRuleStateUpdate.js.map +1 -1
- package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +28 -0
- package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.js +2 -0
- package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.js.map +1 -0
- package/dist/storage/implementation/common/PersistedBatch.d.ts +34 -2
- package/dist/storage/implementation/common/PersistedBatch.js +63 -10
- package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
- package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +1 -1
- package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
- package/dist/storage/implementation/createMongoSyncBucketStorage.d.ts +2 -2
- package/dist/storage/implementation/createMongoSyncBucketStorage.js +4 -4
- package/dist/storage/implementation/createMongoSyncBucketStorage.js.map +1 -1
- package/dist/storage/implementation/db.d.ts +4 -1
- package/dist/storage/implementation/db.js +31 -16
- package/dist/storage/implementation/db.js.map +1 -1
- package/dist/storage/implementation/models.d.ts +55 -13
- package/dist/storage/implementation/models.js.map +1 -1
- package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +15 -1
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js +135 -107
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
- package/dist/storage/implementation/v1/MongoChecksumsV1.d.ts +4 -4
- package/dist/storage/implementation/v1/MongoChecksumsV1.js +119 -7
- package/dist/storage/implementation/v1/MongoChecksumsV1.js.map +1 -1
- package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +47 -7
- package/dist/storage/implementation/v1/MongoCompactorV1.js +478 -8
- package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
- package/dist/storage/implementation/v1/MongoParameterCompactorV1.d.ts +16 -2
- package/dist/storage/implementation/v1/MongoParameterCompactorV1.js +20 -9
- package/dist/storage/implementation/v1/MongoParameterCompactorV1.js.map +1 -1
- package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +33 -27
- package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +146 -73
- package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
- package/dist/storage/implementation/v1/SingleBucketStoreV1.d.ts +9 -10
- package/dist/storage/implementation/v1/SingleBucketStoreV1.js +2 -8
- package/dist/storage/implementation/v1/SingleBucketStoreV1.js.map +1 -1
- package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.d.ts +2 -1
- package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js +3 -0
- package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js.map +1 -1
- package/dist/storage/implementation/v1/models.d.ts +10 -1
- package/dist/storage/implementation/v1/models.js +1 -0
- package/dist/storage/implementation/v1/models.js.map +1 -1
- package/dist/storage/implementation/v3/BucketDataContextV3.d.ts +13 -0
- package/dist/storage/implementation/v3/BucketDataContextV3.js +23 -0
- package/dist/storage/implementation/v3/BucketDataContextV3.js.map +1 -0
- package/dist/storage/implementation/v3/CompactionLease.d.ts +50 -0
- package/dist/storage/implementation/v3/CompactionLease.js +131 -0
- package/dist/storage/implementation/v3/CompactionLease.js.map +1 -0
- package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +16 -5
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js +393 -262
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoChecksumsV3.d.ts +30 -9
- package/dist/storage/implementation/v3/MongoChecksumsV3.js +161 -20
- package/dist/storage/implementation/v3/MongoChecksumsV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +108 -18
- package/dist/storage/implementation/v3/MongoCompactorV3.js +1115 -47
- package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +12 -3
- package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +13 -8
- package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +54 -0
- package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +319 -0
- package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -0
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +61 -36
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +353 -161
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.d.ts +26 -0
- package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js +82 -0
- package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js.map +1 -0
- package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +8 -3
- package/dist/storage/implementation/v3/PersistedBatchV3.js +131 -29
- package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/SourceRecordStoreV3.d.ts +3 -3
- package/dist/storage/implementation/v3/SourceRecordStoreV3.js +13 -11
- package/dist/storage/implementation/v3/SourceRecordStoreV3.js.map +1 -1
- package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +26 -14
- package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +73 -29
- package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
- package/dist/storage/implementation/v3/bucket-format.d.ts +13 -0
- package/dist/storage/implementation/v3/bucket-format.js +79 -0
- package/dist/storage/implementation/v3/bucket-format.js.map +1 -0
- package/dist/storage/implementation/v3/chunking.d.ts +10 -0
- package/dist/storage/implementation/v3/chunking.js +30 -0
- package/dist/storage/implementation/v3/chunking.js.map +1 -0
- package/dist/storage/implementation/v3/compact-utils.d.ts +107 -0
- package/dist/storage/implementation/v3/compact-utils.js +216 -0
- package/dist/storage/implementation/v3/compact-utils.js.map +1 -0
- package/dist/storage/implementation/v3/compaction-constants.d.ts +5 -0
- package/dist/storage/implementation/v3/compaction-constants.js +6 -0
- package/dist/storage/implementation/v3/compaction-constants.js.map +1 -0
- package/dist/storage/implementation/v3/models.d.ts +187 -23
- package/dist/storage/implementation/v3/models.js +11 -28
- package/dist/storage/implementation/v3/models.js.map +1 -1
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +20 -0
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +48 -0
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +33 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorage.js +12 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorage.js.map +1 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +68 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +154 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +37 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +167 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -0
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +67 -0
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +439 -0
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -0
- package/dist/storage/implementation/v3/source-table-utils.d.ts +63 -0
- package/dist/storage/implementation/v3/source-table-utils.js +206 -0
- package/dist/storage/implementation/v3/source-table-utils.js.map +1 -0
- package/dist/storage/storage-index.d.ts +5 -4
- package/dist/storage/storage-index.js +5 -4
- package/dist/storage/storage-index.js.map +1 -1
- package/dist/types/types.d.ts +71 -3
- package/dist/types/types.js +58 -2
- package/dist/types/types.js.map +1 -1
- package/dist/utils/test-utils.d.ts +1 -2
- package/dist/utils/test-utils.js +7 -1
- package/dist/utils/test-utils.js.map +1 -1
- package/dist/utils/util.d.ts +9 -2
- package/dist/utils/util.js +131 -5
- package/dist/utils/util.js.map +1 -1
- package/package.json +13 -19
- package/src/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.ts +55 -0
- package/src/storage/MongoBucketStorage.ts +728 -242
- package/src/storage/implementation/MongoBucketBatch.ts +196 -91
- package/src/storage/implementation/MongoChecksums.ts +93 -153
- package/src/storage/implementation/MongoCompactor.ts +106 -631
- package/src/storage/implementation/MongoHydrationState.ts +61 -0
- package/src/storage/implementation/MongoParameterCompactor.ts +392 -87
- package/src/storage/implementation/MongoParsedSyncConfigSet.ts +122 -0
- package/src/storage/implementation/MongoPersistedReplicationStream.ts +118 -0
- package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +156 -0
- package/src/storage/implementation/MongoStorageProvider.ts +48 -4
- package/src/storage/implementation/MongoSyncBucketStorage.ts +226 -118
- package/src/storage/implementation/MongoSyncRulesLock.ts +28 -12
- package/src/storage/implementation/MongoWriteCheckpointAPI.ts +194 -52
- package/src/storage/implementation/SyncRuleStateUpdate.ts +13 -6
- package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +31 -0
- package/src/storage/implementation/common/PersistedBatch.ts +83 -15
- package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +1 -1
- package/src/storage/implementation/createMongoSyncBucketStorage.ts +21 -7
- package/src/storage/implementation/db.ts +40 -22
- package/src/storage/implementation/models.ts +58 -14
- package/src/storage/implementation/v1/MongoBucketBatchV1.ts +174 -125
- package/src/storage/implementation/v1/MongoChecksumsV1.ts +154 -12
- package/src/storage/implementation/v1/MongoCompactorV1.ts +594 -25
- package/src/storage/implementation/v1/MongoParameterCompactorV1.ts +25 -9
- package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +226 -129
- package/src/storage/implementation/v1/SingleBucketStoreV1.ts +18 -27
- package/src/storage/implementation/v1/VersionedPowerSyncMongoV1.ts +11 -1
- package/src/storage/implementation/v1/models.ts +12 -1
- package/src/storage/implementation/v3/BucketDataContextV3.ts +32 -0
- package/src/storage/implementation/v3/CompactionLease.ts +152 -0
- package/src/storage/implementation/v3/MongoBucketBatchV3.ts +502 -324
- package/src/storage/implementation/v3/MongoChecksumsV3.ts +250 -32
- package/src/storage/implementation/v3/MongoCompactorV3.ts +1439 -70
- package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +19 -9
- package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +473 -0
- package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +453 -211
- package/src/storage/implementation/v3/MongoWriteCheckpointAPIV3.ts +111 -0
- package/src/storage/implementation/v3/PersistedBatchV3.ts +169 -45
- package/src/storage/implementation/v3/SourceRecordStoreV3.ts +12 -12
- package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +99 -38
- package/src/storage/implementation/v3/bucket-format.ts +99 -0
- package/src/storage/implementation/v3/chunking.ts +40 -0
- package/src/storage/implementation/v3/compact-utils.ts +320 -0
- package/src/storage/implementation/v3/compaction-constants.ts +5 -0
- package/src/storage/implementation/v3/models.ts +228 -58
- package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +62 -0
- package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +44 -0
- package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +182 -0
- package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +230 -0
- package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +573 -0
- package/src/storage/implementation/v3/source-table-utils.ts +398 -0
- package/src/storage/storage-index.ts +14 -4
- package/src/types/types.ts +76 -2
- package/src/utils/test-utils.ts +8 -3
- package/src/utils/util.ts +188 -7
- package/test/src/__snapshots__/storage.test.ts.snap +4 -54
- package/test/src/__snapshots__/storage_sync.test.ts.snap +652 -645
- package/test/src/cleanup-stopped-sync-configs.test.ts +698 -0
- package/test/src/compact-utils.test.ts +226 -0
- package/test/src/helpers/MemoryObjectStorage.ts +61 -0
- package/test/src/helpers/s3TestFactory.ts +69 -0
- package/test/src/object_storage_lifecycle.test.ts +59 -0
- package/test/src/object_storage_usage.test.ts +255 -0
- package/test/src/parameter_compacting_v1.test.ts +120 -0
- package/test/src/parameter_compacting_v3.test.ts +192 -0
- package/test/src/parameter_compaction_fence.test.ts +276 -0
- package/test/src/setup.ts +6 -1
- package/test/src/storage.test.ts +478 -13
- package/test/src/storage_compacting.test.ts +2516 -107
- package/test/src/storage_object_storage_inline_threshold.test.ts +116 -0
- package/test/src/storage_s3_checksums.test.ts +190 -0
- package/test/src/storage_s3_compaction_lifecycle.test.ts +543 -0
- package/test/src/storage_s3_reading.test.ts +768 -0
- package/test/src/storage_s3_writing.test.ts +136 -0
- package/test/src/storage_sync.test.ts +1507 -76
- package/test/src/storeCurrentData.test.ts +227 -0
- package/test/src/util.ts +3 -2
- package/tsconfig.json +1 -4
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/storage/implementation/BucketDefinitionMapping.d.ts +0 -15
- package/dist/storage/implementation/BucketDefinitionMapping.js +0 -58
- package/dist/storage/implementation/BucketDefinitionMapping.js.map +0 -1
- package/dist/storage/implementation/MongoPersistedSyncRules.d.ts +0 -14
- package/dist/storage/implementation/MongoPersistedSyncRules.js +0 -67
- package/dist/storage/implementation/MongoPersistedSyncRules.js.map +0 -1
- package/dist/storage/implementation/MongoPersistedSyncRulesContent.d.ts +0 -29
- package/dist/storage/implementation/MongoPersistedSyncRulesContent.js +0 -76
- package/dist/storage/implementation/MongoPersistedSyncRulesContent.js.map +0 -1
- package/dist/storage/implementation/common/MongoSyncBucketStorageContext.d.ts +0 -13
- package/dist/storage/implementation/common/MongoSyncBucketStorageContext.js +0 -2
- package/dist/storage/implementation/common/MongoSyncBucketStorageContext.js.map +0 -1
- package/dist/storage/implementation/common/SingleBucketStore.d.ts +0 -54
- package/dist/storage/implementation/common/SingleBucketStore.js +0 -3
- package/dist/storage/implementation/common/SingleBucketStore.js.map +0 -1
- package/dist/storage/implementation/v3/MongoParameterLookupV3.d.ts +0 -4
- package/dist/storage/implementation/v3/MongoParameterLookupV3.js +0 -9
- package/dist/storage/implementation/v3/MongoParameterLookupV3.js.map +0 -1
- package/dist/storage/implementation/v3/SingleBucketStoreV3.d.ts +0 -18
- package/dist/storage/implementation/v3/SingleBucketStoreV3.js +0 -48
- package/dist/storage/implementation/v3/SingleBucketStoreV3.js.map +0 -1
- package/src/storage/implementation/BucketDefinitionMapping.ts +0 -75
- package/src/storage/implementation/MongoPersistedSyncRules.ts +0 -82
- package/src/storage/implementation/MongoPersistedSyncRulesContent.ts +0 -101
- package/src/storage/implementation/common/MongoSyncBucketStorageContext.ts +0 -15
- package/src/storage/implementation/common/SingleBucketStore.ts +0 -63
- package/src/storage/implementation/v3/MongoParameterLookupV3.ts +0 -11
- package/src/storage/implementation/v3/SingleBucketStoreV3.ts +0 -68
|
@@ -0,0 +1,768 @@
|
|
|
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 {
|
|
11
|
+
resolveTimeoutProfile,
|
|
12
|
+
S3ObjectStorage,
|
|
13
|
+
type S3TimeoutProfile
|
|
14
|
+
} from '../../src/storage/implementation/v3/object-storage/S3ObjectStorage.js';
|
|
15
|
+
import { env } from './env.js';
|
|
16
|
+
import { MemoryObjectStorage } from './helpers/MemoryObjectStorage.js';
|
|
17
|
+
import { createMemoryS3TestStorageSuite, createS3TestStorageSuite } from './helpers/s3TestFactory.js';
|
|
18
|
+
|
|
19
|
+
const SYNC_RULES_YAML = `
|
|
20
|
+
bucket_definitions:
|
|
21
|
+
global:
|
|
22
|
+
data:
|
|
23
|
+
- SELECT id, description FROM items
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
function s3Factory() {
|
|
27
|
+
const { objectStorage, factoryGen } = createS3TestStorageSuite({ url: env.MONGO_TEST_URL, isCI: env.CI });
|
|
28
|
+
return { memoryStorage: objectStorage, factory: factoryGen };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function memoryS3Factory(options: { inlineThresholdBytes?: number } = {}) {
|
|
32
|
+
const { objectStorage, factoryGen } = createMemoryS3TestStorageSuite({
|
|
33
|
+
url: env.MONGO_TEST_URL,
|
|
34
|
+
isCI: env.CI,
|
|
35
|
+
inlineThresholdBytes: options.inlineThresholdBytes ?? 0
|
|
36
|
+
});
|
|
37
|
+
return { memoryStorage: objectStorage, factory: factoryGen };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('S3 object storage reads', () => {
|
|
41
|
+
test('configures S3 credentials and validates operation options', async () => {
|
|
42
|
+
const objectStorage = new S3ObjectStorage({
|
|
43
|
+
bucket: 'test',
|
|
44
|
+
region: 'test',
|
|
45
|
+
accessKeyId: 'access-key',
|
|
46
|
+
secretAccessKey: 'secret-key',
|
|
47
|
+
forcePathStyle: true,
|
|
48
|
+
concurrencyLimit: 4
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
await expect(objectStorage.client.config.credentials()).resolves.toMatchObject({
|
|
52
|
+
accessKeyId: 'access-key',
|
|
53
|
+
secretAccessKey: 'secret-key'
|
|
54
|
+
});
|
|
55
|
+
expect(() => new S3ObjectStorage({ bucket: 'test', region: 'test', accessKeyId: 'access-key' })).toThrowError(
|
|
56
|
+
/configured together/
|
|
57
|
+
);
|
|
58
|
+
expect(() => new S3ObjectStorage({ bucket: 'test', region: 'test', concurrencyLimit: 0 })).toThrowError(
|
|
59
|
+
/positive integer/
|
|
60
|
+
);
|
|
61
|
+
expect(() => new S3ObjectStorage({ bucket: 'test', region: 'test', prefix: '💾'.repeat(65) })).toThrowError(
|
|
62
|
+
/at most 256 UTF-8 bytes/
|
|
63
|
+
);
|
|
64
|
+
expect(() => new S3ObjectStorage({ bucket: 'test', region: 'test', prefix: 'prefix/' })).toThrowError(
|
|
65
|
+
/must not end with/
|
|
66
|
+
);
|
|
67
|
+
});
|
|
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
|
+
|
|
297
|
+
test('rejects complete S3 keys over the safe byte limit', async () => {
|
|
298
|
+
const objectStorage = new S3ObjectStorage({
|
|
299
|
+
bucket: 'test',
|
|
300
|
+
region: 'test',
|
|
301
|
+
prefix: 'p'.repeat(256)
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
await expect(objectStorage.get('x'.repeat(640))).rejects.toThrowError(/exceeding the safe limit of 896 bytes/);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('preallocates downloads and validates ContentLength', async () => {
|
|
308
|
+
const objectStorage = new S3ObjectStorage({ bucket: 'test', region: 'test' });
|
|
309
|
+
const mockDownload = (contentLength: number | undefined, chunks: number[][]) => {
|
|
310
|
+
objectStorage.client.send = async () => ({
|
|
311
|
+
ContentLength: contentLength,
|
|
312
|
+
Body: (async function* () {
|
|
313
|
+
for (const chunk of chunks) {
|
|
314
|
+
yield new Uint8Array(chunk);
|
|
315
|
+
}
|
|
316
|
+
})(),
|
|
317
|
+
ContentType: 'application/bson'
|
|
318
|
+
});
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
mockDownload(5, [
|
|
322
|
+
[1, 2],
|
|
323
|
+
[3, 4, 5]
|
|
324
|
+
]);
|
|
325
|
+
await expect(objectStorage.get('valid-object')).resolves.toEqual({
|
|
326
|
+
data: new Uint8Array([1, 2, 3, 4, 5]),
|
|
327
|
+
metadata: {
|
|
328
|
+
contentType: 'application/bson',
|
|
329
|
+
contentEncoding: null
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
mockDownload(undefined, [[1]]);
|
|
334
|
+
await expect(objectStorage.get('missing-length')).rejects.toThrowError(/missing ContentLength/);
|
|
335
|
+
|
|
336
|
+
mockDownload(5, [[1, 2, 3]]);
|
|
337
|
+
await expect(objectStorage.get('short-object')).rejects.toThrowError(/expected 5 bytes, received 3/);
|
|
338
|
+
|
|
339
|
+
mockDownload(3, [
|
|
340
|
+
[1, 2],
|
|
341
|
+
[3, 4]
|
|
342
|
+
]);
|
|
343
|
+
await expect(objectStorage.get('long-object')).rejects.toThrowError(/expected 3 bytes, received at least 4/);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test('classifies S3 failures for compaction retries', async () => {
|
|
347
|
+
const objectStorage = new S3ObjectStorage({ bucket: 'test', region: 'test' });
|
|
348
|
+
const transientCause = Object.assign(new Error('socket reset'), { code: 'ECONNRESET' });
|
|
349
|
+
objectStorage.client.send = async () => {
|
|
350
|
+
throw transientCause;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
await expect(objectStorage.get('transient-object')).rejects.toMatchObject({
|
|
354
|
+
name: 'ObjectStorageError',
|
|
355
|
+
retryable: true,
|
|
356
|
+
cause: transientCause,
|
|
357
|
+
message: expect.stringContaining('S3 download failed for transient-object')
|
|
358
|
+
} satisfies Partial<ObjectStorageError>);
|
|
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
|
+
|
|
370
|
+
const permanentCause = Object.assign(new Error('access denied'), {
|
|
371
|
+
name: 'AccessDenied',
|
|
372
|
+
$metadata: { httpStatusCode: 403 }
|
|
373
|
+
});
|
|
374
|
+
objectStorage.client.send = async () => {
|
|
375
|
+
throw permanentCause;
|
|
376
|
+
};
|
|
377
|
+
await expect(objectStorage.get('permanent-object')).rejects.toMatchObject({
|
|
378
|
+
name: 'ObjectStorageError',
|
|
379
|
+
retryable: false,
|
|
380
|
+
cause: permanentCause,
|
|
381
|
+
message: expect.stringContaining('S3 download failed for permanent-object')
|
|
382
|
+
} satisfies Partial<ObjectStorageError>);
|
|
383
|
+
|
|
384
|
+
const controller = new AbortController();
|
|
385
|
+
controller.abort();
|
|
386
|
+
objectStorage.client.send = async () => {
|
|
387
|
+
throw controller.signal.reason;
|
|
388
|
+
};
|
|
389
|
+
await expect(objectStorage.get('aborted-object')).rejects.toBe(controller.signal.reason);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test('paginates listings and strips the configured prefix', async () => {
|
|
393
|
+
const objectStorage = new S3ObjectStorage({
|
|
394
|
+
bucket: 'test',
|
|
395
|
+
region: 'test',
|
|
396
|
+
prefix: 'test-run'
|
|
397
|
+
});
|
|
398
|
+
const requests: any[] = [];
|
|
399
|
+
objectStorage.client.send = async (command: any) => {
|
|
400
|
+
requests.push(command.input);
|
|
401
|
+
if (command.input.ContinuationToken == null) {
|
|
402
|
+
return {
|
|
403
|
+
Contents: [{ Key: 'test-run/bucket-data/1/definition/object-1.bson' }],
|
|
404
|
+
IsTruncated: true,
|
|
405
|
+
NextContinuationToken: 'next-page'
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
return {
|
|
409
|
+
Contents: [{ Key: 'test-run/bucket-data/1/definition/object-2.bson' }],
|
|
410
|
+
IsTruncated: false
|
|
411
|
+
};
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const paths = await test_utils.fromAsync(objectStorage.list('bucket-data/1/'));
|
|
415
|
+
|
|
416
|
+
expect(paths).toEqual(['bucket-data/1/definition/object-1.bson', 'bucket-data/1/definition/object-2.bson']);
|
|
417
|
+
expect(requests).toEqual([
|
|
418
|
+
expect.objectContaining({
|
|
419
|
+
Prefix: 'test-run/bucket-data/1/',
|
|
420
|
+
ContinuationToken: undefined
|
|
421
|
+
}),
|
|
422
|
+
expect.objectContaining({
|
|
423
|
+
Prefix: 'test-run/bucket-data/1/',
|
|
424
|
+
ContinuationToken: 'next-page'
|
|
425
|
+
})
|
|
426
|
+
]);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test('deletes each prefix listing page directly', async () => {
|
|
430
|
+
const objectStorage = new S3ObjectStorage({
|
|
431
|
+
bucket: 'test',
|
|
432
|
+
region: 'test',
|
|
433
|
+
prefix: 'test-run'
|
|
434
|
+
});
|
|
435
|
+
const listRequests: any[] = [];
|
|
436
|
+
const deleteRequests: any[] = [];
|
|
437
|
+
objectStorage.client.send = async (command: any) => {
|
|
438
|
+
if (command instanceof ListObjectsV2Command) {
|
|
439
|
+
listRequests.push(command.input);
|
|
440
|
+
if (command.input.ContinuationToken == null) {
|
|
441
|
+
return {
|
|
442
|
+
Contents: [
|
|
443
|
+
{ Key: 'test-run/bucket-data/1/object-1.bson' },
|
|
444
|
+
{ Key: 'test-run/bucket-data/1/object-2.bson' }
|
|
445
|
+
],
|
|
446
|
+
IsTruncated: true,
|
|
447
|
+
NextContinuationToken: 'next-page'
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
return {
|
|
451
|
+
Contents: [{ Key: 'test-run/bucket-data/1/object-3.bson' }],
|
|
452
|
+
IsTruncated: false
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
expect(command).toBeInstanceOf(DeleteObjectsCommand);
|
|
456
|
+
deleteRequests.push(command.input);
|
|
457
|
+
return {};
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
await expect(objectStorage.deletePrefix('bucket-data/1/')).resolves.toEqual({ objectCount: 3 });
|
|
461
|
+
expect(listRequests).toEqual([
|
|
462
|
+
expect.objectContaining({
|
|
463
|
+
Prefix: 'test-run/bucket-data/1/',
|
|
464
|
+
ContinuationToken: undefined,
|
|
465
|
+
MaxKeys: 1000
|
|
466
|
+
}),
|
|
467
|
+
expect.objectContaining({
|
|
468
|
+
Prefix: 'test-run/bucket-data/1/',
|
|
469
|
+
ContinuationToken: 'next-page',
|
|
470
|
+
MaxKeys: 1000
|
|
471
|
+
})
|
|
472
|
+
]);
|
|
473
|
+
expect(deleteRequests).toEqual([
|
|
474
|
+
expect.objectContaining({
|
|
475
|
+
Delete: {
|
|
476
|
+
Objects: [{ Key: 'test-run/bucket-data/1/object-1.bson' }, { Key: 'test-run/bucket-data/1/object-2.bson' }],
|
|
477
|
+
Quiet: true
|
|
478
|
+
}
|
|
479
|
+
}),
|
|
480
|
+
expect.objectContaining({
|
|
481
|
+
Delete: {
|
|
482
|
+
Objects: [{ Key: 'test-run/bucket-data/1/object-3.bson' }],
|
|
483
|
+
Quiet: true
|
|
484
|
+
}
|
|
485
|
+
})
|
|
486
|
+
]);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test('keeps up to 12 S3 prefix deletes in flight', async () => {
|
|
490
|
+
const objectStorage = new S3ObjectStorage({ bucket: 'test', region: 'test', concurrencyLimit: 32 });
|
|
491
|
+
const deleteResolvers: (() => void)[] = [];
|
|
492
|
+
let activeDeletes = 0;
|
|
493
|
+
let maxActiveDeletes = 0;
|
|
494
|
+
let reached12!: () => void;
|
|
495
|
+
let reached13!: () => void;
|
|
496
|
+
const first12Started = new Promise<void>((resolve) => (reached12 = resolve));
|
|
497
|
+
const replacementStarted = new Promise<void>((resolve) => (reached13 = resolve));
|
|
498
|
+
objectStorage.client.send = async (command: any) => {
|
|
499
|
+
if (command instanceof ListObjectsV2Command) {
|
|
500
|
+
const page = Number(command.input.ContinuationToken ?? 0);
|
|
501
|
+
return {
|
|
502
|
+
Contents: [{ Key: `object-${page}` }],
|
|
503
|
+
IsTruncated: page < 12,
|
|
504
|
+
NextContinuationToken: page < 12 ? String(page + 1) : undefined
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
expect(command).toBeInstanceOf(DeleteObjectsCommand);
|
|
508
|
+
activeDeletes++;
|
|
509
|
+
maxActiveDeletes = Math.max(maxActiveDeletes, activeDeletes);
|
|
510
|
+
if (deleteResolvers.length === 11) {
|
|
511
|
+
reached12();
|
|
512
|
+
} else if (deleteResolvers.length === 12) {
|
|
513
|
+
reached13();
|
|
514
|
+
}
|
|
515
|
+
await new Promise<void>((resolve) => deleteResolvers.push(resolve));
|
|
516
|
+
activeDeletes--;
|
|
517
|
+
return {};
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const deleting = objectStorage.deletePrefix('object-');
|
|
521
|
+
await first12Started;
|
|
522
|
+
expect(activeDeletes).toBe(12);
|
|
523
|
+
|
|
524
|
+
deleteResolvers[0]();
|
|
525
|
+
await replacementStarted;
|
|
526
|
+
expect(activeDeletes).toBe(12);
|
|
527
|
+
for (const resolve of deleteResolvers.slice(1)) {
|
|
528
|
+
resolve();
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
await expect(deleting).resolves.toEqual({ objectCount: 13 });
|
|
532
|
+
expect(maxActiveDeletes).toBe(12);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test('shares the concurrency limit across S3 operations', async () => {
|
|
536
|
+
const objectStorage = new S3ObjectStorage({ bucket: 'test', region: 'test', concurrencyLimit: 4 });
|
|
537
|
+
let activeOperations = 0;
|
|
538
|
+
let maxActiveOperations = 0;
|
|
539
|
+
objectStorage.client.send = async (command: any) => {
|
|
540
|
+
activeOperations++;
|
|
541
|
+
maxActiveOperations = Math.max(maxActiveOperations, activeOperations);
|
|
542
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
543
|
+
|
|
544
|
+
if (command.input.Body != null) {
|
|
545
|
+
// Upload concurrency ends when the SDK request resolves.
|
|
546
|
+
activeOperations--;
|
|
547
|
+
return {};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Download concurrency includes consuming the response body.
|
|
551
|
+
const data = bson.serialize({ ops: [] });
|
|
552
|
+
return {
|
|
553
|
+
Body: (async function* () {
|
|
554
|
+
try {
|
|
555
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
556
|
+
yield data;
|
|
557
|
+
} finally {
|
|
558
|
+
activeOperations--;
|
|
559
|
+
}
|
|
560
|
+
})(),
|
|
561
|
+
ContentLength: data.byteLength,
|
|
562
|
+
ContentType: 'application/bson'
|
|
563
|
+
};
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
await Promise.all(
|
|
567
|
+
Array.from({ length: 32 }, (_, index) =>
|
|
568
|
+
index % 2 === 0
|
|
569
|
+
? objectStorage.get(`object-${index}`)
|
|
570
|
+
: objectStorage.put(`object-${index}`, new Uint8Array(), {
|
|
571
|
+
contentType: 'application/bson',
|
|
572
|
+
contentEncoding: null
|
|
573
|
+
})
|
|
574
|
+
)
|
|
575
|
+
);
|
|
576
|
+
expect(maxActiveOperations).toBe(4);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test('aborts active object downloads', async () => {
|
|
580
|
+
const objectStorage = new MemoryObjectStorage();
|
|
581
|
+
const controller = new AbortController();
|
|
582
|
+
let downloadStarted!: () => void;
|
|
583
|
+
const started = new Promise<void>((resolve) => (downloadStarted = resolve));
|
|
584
|
+
objectStorage.get = async (_path, options) => {
|
|
585
|
+
const signal = options?.signal;
|
|
586
|
+
expect(signal).toBe(controller.signal);
|
|
587
|
+
downloadStarted();
|
|
588
|
+
await new Promise<void>((_resolve, reject) => {
|
|
589
|
+
signal!.addEventListener('abort', () => reject(signal!.reason), { once: true });
|
|
590
|
+
});
|
|
591
|
+
throw new Error('unreachable');
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
const hydrating = hydrateBucketDataDocuments(
|
|
595
|
+
[
|
|
596
|
+
{
|
|
597
|
+
_id: { b: 'bucket', o: 1n },
|
|
598
|
+
min_op: 1n,
|
|
599
|
+
checksum: 0n,
|
|
600
|
+
count: 0,
|
|
601
|
+
size: 1,
|
|
602
|
+
storage_ref: { path: 'object', file_size: 1 }
|
|
603
|
+
}
|
|
604
|
+
],
|
|
605
|
+
objectStorage,
|
|
606
|
+
{ signal: controller.signal }
|
|
607
|
+
);
|
|
608
|
+
const expectation = expect(hydrating).rejects.toMatchObject({ name: 'AbortError' });
|
|
609
|
+
|
|
610
|
+
await started;
|
|
611
|
+
controller.abort();
|
|
612
|
+
await expectation;
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test('1. Round-trip write → read through S3', async () => {
|
|
616
|
+
const { memoryStorage, factory: factoryGen } = memoryS3Factory();
|
|
617
|
+
await using factory = await factoryGen.factory();
|
|
618
|
+
const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_RULES_YAML, { storageVersion: 3 }));
|
|
619
|
+
const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
|
|
620
|
+
|
|
621
|
+
await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
|
|
622
|
+
const sourceTable = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, 1);
|
|
623
|
+
await writer.markAllSnapshotDone('1/1');
|
|
624
|
+
|
|
625
|
+
// Write two ops
|
|
626
|
+
await writer.save({
|
|
627
|
+
sourceTable,
|
|
628
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
629
|
+
after: { id: 'item1', description: 'hello' },
|
|
630
|
+
afterReplicaId: test_utils.rid('item1')
|
|
631
|
+
});
|
|
632
|
+
await writer.save({
|
|
633
|
+
sourceTable,
|
|
634
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
635
|
+
after: { id: 'item2', description: 'world' },
|
|
636
|
+
afterReplicaId: test_utils.rid('item2')
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
const flushResult = await writer.flush();
|
|
640
|
+
const checkpoint = flushResult!.flushed_op;
|
|
641
|
+
|
|
642
|
+
// Confirm S3 objects were uploaded (baseline)
|
|
643
|
+
const storedPaths = memoryStorage.store;
|
|
644
|
+
expect(storedPaths.size).toBeGreaterThan(0);
|
|
645
|
+
|
|
646
|
+
// Read back via getBucketDataBatch.
|
|
647
|
+
const batch = await test_utils.fromAsync(
|
|
648
|
+
bucketStorage.getBucketDataBatch(test_utils.testCheckpoint(checkpoint), [
|
|
649
|
+
bucketRequest(syncRules.syncConfigContent[0], 'global[]', 0n)
|
|
650
|
+
])
|
|
651
|
+
);
|
|
652
|
+
const data = test_utils.getBatchData(batch);
|
|
653
|
+
|
|
654
|
+
expect(data.length).toBe(2);
|
|
655
|
+
expect(data).toEqual(
|
|
656
|
+
expect.arrayContaining([
|
|
657
|
+
expect.objectContaining({ op: 'PUT', object_id: 'item1' }),
|
|
658
|
+
expect.objectContaining({ op: 'PUT', object_id: 'item2' })
|
|
659
|
+
])
|
|
660
|
+
);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test('2. Missing S3 object is a hard error', async () => {
|
|
664
|
+
const { factory: factoryGen } = s3Factory();
|
|
665
|
+
await using factory = await factoryGen.factory();
|
|
666
|
+
const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_RULES_YAML, { storageVersion: 3 }));
|
|
667
|
+
const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
|
|
668
|
+
|
|
669
|
+
await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
|
|
670
|
+
const sourceTable = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, 1);
|
|
671
|
+
await writer.markAllSnapshotDone('1/1');
|
|
672
|
+
|
|
673
|
+
await writer.save({
|
|
674
|
+
sourceTable,
|
|
675
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
676
|
+
after: { id: 'real1', description: 'real data' },
|
|
677
|
+
afterReplicaId: test_utils.rid('real1')
|
|
678
|
+
});
|
|
679
|
+
await writer.save({
|
|
680
|
+
sourceTable,
|
|
681
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
682
|
+
after: { id: 'real2', description: 'real data' },
|
|
683
|
+
afterReplicaId: test_utils.rid('real2')
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
const flushResult = await writer.flush();
|
|
687
|
+
const checkpoint = flushResult!.flushed_op;
|
|
688
|
+
|
|
689
|
+
const db = bucketStorage.db as VersionedPowerSyncMongoV3;
|
|
690
|
+
const definitionId = syncRules.syncConfigContent[0].mapping.allBucketDefinitionIds()[0];
|
|
691
|
+
const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
|
|
692
|
+
const document = await collection.findOne({});
|
|
693
|
+
expect(document?.storage_ref).toBeDefined();
|
|
694
|
+
await collection.updateOne(
|
|
695
|
+
{ _id: document!._id },
|
|
696
|
+
{
|
|
697
|
+
$set: {
|
|
698
|
+
storage_ref: {
|
|
699
|
+
path: 'nonexistent/missing-object/path',
|
|
700
|
+
file_size: document!.storage_ref!.file_size
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
);
|
|
705
|
+
|
|
706
|
+
// A missing S3 object should be a hard error, not silently skipped.
|
|
707
|
+
await expect(
|
|
708
|
+
test_utils.fromAsync(
|
|
709
|
+
bucketStorage.getBucketDataBatch(test_utils.testCheckpoint(checkpoint), [
|
|
710
|
+
bucketRequest(syncRules.syncConfigContent[0], 'global[]', 0n)
|
|
711
|
+
])
|
|
712
|
+
)
|
|
713
|
+
).rejects.toThrow('nonexistent/missing-object/path');
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
test('3. Read with mixed inline + S3 docs', async () => {
|
|
717
|
+
const { memoryStorage, factory: factoryGen } = memoryS3Factory({ inlineThresholdBytes: 1_000 });
|
|
718
|
+
await using factory = await factoryGen.factory();
|
|
719
|
+
const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_RULES_YAML, { storageVersion: 3 }));
|
|
720
|
+
const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
|
|
721
|
+
|
|
722
|
+
await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
|
|
723
|
+
const sourceTable = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, 1);
|
|
724
|
+
await writer.markAllSnapshotDone('1/1');
|
|
725
|
+
|
|
726
|
+
// The first commit stays inline.
|
|
727
|
+
await writer.save({
|
|
728
|
+
sourceTable,
|
|
729
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
730
|
+
after: { id: 'inline_item', description: 'small' },
|
|
731
|
+
afterReplicaId: test_utils.rid('inline_item')
|
|
732
|
+
});
|
|
733
|
+
await writer.commit('1/1');
|
|
734
|
+
|
|
735
|
+
// The second commit exceeds the threshold and is offloaded.
|
|
736
|
+
await writer.save({
|
|
737
|
+
sourceTable,
|
|
738
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
739
|
+
after: { id: 's3_item', description: 'large'.repeat(500) },
|
|
740
|
+
afterReplicaId: test_utils.rid('s3_item')
|
|
741
|
+
});
|
|
742
|
+
await writer.commit('2/1');
|
|
743
|
+
|
|
744
|
+
const db = bucketStorage.db as VersionedPowerSyncMongoV3;
|
|
745
|
+
const definitionId = syncRules.syncConfigContent[0].mapping.allBucketDefinitionIds()[0];
|
|
746
|
+
const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
|
|
747
|
+
const documents = await collection.find({}).sort({ '_id.o': 1 }).toArray();
|
|
748
|
+
expect(documents).toHaveLength(2);
|
|
749
|
+
expect(documents[0].ops).toBeDefined();
|
|
750
|
+
expect(documents[0].storage_ref).toBeUndefined();
|
|
751
|
+
expect(documents[1].ops).toBeUndefined();
|
|
752
|
+
expect(documents[1].storage_ref).toBeDefined();
|
|
753
|
+
expect(new Set(memoryStorage.store.keys())).toEqual(new Set([documents[1].storage_ref!.path]));
|
|
754
|
+
|
|
755
|
+
// Read back. Both S3-backed and inline ops should be returned.
|
|
756
|
+
const batch = await test_utils.getBatchArray(
|
|
757
|
+
bucketStorage.getBucketDataBatch(test_utils.testCheckpoint(documents[1]._id.o), [
|
|
758
|
+
bucketRequest(syncRules.syncConfigContent[0], 'global[]', 0n)
|
|
759
|
+
])
|
|
760
|
+
);
|
|
761
|
+
const data = batch.flatMap((chunk) => chunk.chunkData.data);
|
|
762
|
+
|
|
763
|
+
expect(data).toMatchObject([
|
|
764
|
+
{ op: 'PUT', object_id: 'inline_item' },
|
|
765
|
+
{ op: 'PUT', object_id: 's3_item' }
|
|
766
|
+
]);
|
|
767
|
+
});
|
|
768
|
+
});
|