@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.
- package/CHANGELOG.md +48 -0
- 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 +5 -1
- package/dist/storage/MongoBucketStorage.js +5 -1
- package/dist/storage/MongoBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoBucketBatch.d.ts +3 -0
- package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
- package/dist/storage/implementation/MongoChecksums.d.ts +2 -0
- package/dist/storage/implementation/MongoChecksums.js +3 -2
- package/dist/storage/implementation/MongoChecksums.js.map +1 -1
- package/dist/storage/implementation/MongoCompactor.d.ts +15 -19
- package/dist/storage/implementation/MongoCompactor.js +68 -260
- package/dist/storage/implementation/MongoCompactor.js.map +1 -1
- package/dist/storage/implementation/MongoStorageProvider.js +26 -3
- package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
- package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +12 -5
- package/dist/storage/implementation/MongoSyncBucketStorage.js +25 -14
- package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +2 -1
- package/dist/storage/implementation/MongoWriteCheckpointAPI.js +146 -34
- package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
- package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +2 -0
- package/dist/storage/implementation/common/PersistedBatch.d.ts +6 -0
- package/dist/storage/implementation/common/PersistedBatch.js +15 -8
- package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
- package/dist/storage/implementation/models.d.ts +16 -0
- package/dist/storage/implementation/models.js.map +1 -1
- package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +11 -2
- package/dist/storage/implementation/v1/MongoCompactorV1.js +250 -1
- package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
- package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +20 -11
- 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/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/MongoBucketBatchV3.js +3 -1
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoChecksumsV3.d.ts +1 -0
- package/dist/storage/implementation/v3/MongoChecksumsV3.js +59 -103
- package/dist/storage/implementation/v3/MongoChecksumsV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +23 -2
- package/dist/storage/implementation/v3/MongoCompactorV3.js +354 -219
- package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +5 -0
- package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +12 -11
- package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +5 -3
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +109 -106
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
- package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +5 -2
- package/dist/storage/implementation/v3/PersistedBatchV3.js +53 -15
- package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +2 -1
- package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +3 -0
- package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
- package/dist/storage/implementation/v3/bucket-format.d.ts +10 -1
- package/dist/storage/implementation/v3/bucket-format.js +28 -9
- package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
- package/dist/storage/implementation/v3/chunking.js +3 -1
- package/dist/storage/implementation/v3/chunking.js.map +1 -1
- package/dist/storage/implementation/v3/models.d.ts +38 -1
- package/dist/storage/implementation/v3/models.js.map +1 -1
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +21 -0
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +44 -0
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +30 -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 +70 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +146 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -0
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +42 -0
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +285 -0
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -0
- package/dist/types/types.d.ts +35 -0
- package/dist/types/types.js +35 -1
- package/dist/types/types.js.map +1 -1
- package/dist/utils/test-utils.js +3 -1
- package/dist/utils/test-utils.js.map +1 -1
- package/dist/utils/util.d.ts +8 -1
- package/dist/utils/util.js +125 -2
- package/dist/utils/util.js.map +1 -1
- package/package.json +10 -7
- package/src/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.ts +55 -0
- package/src/storage/MongoBucketStorage.ts +10 -2
- package/src/storage/implementation/MongoBucketBatch.ts +4 -0
- package/src/storage/implementation/MongoChecksums.ts +5 -2
- package/src/storage/implementation/MongoCompactor.ts +90 -314
- package/src/storage/implementation/MongoStorageProvider.ts +32 -3
- package/src/storage/implementation/MongoSyncBucketStorage.ts +34 -24
- package/src/storage/implementation/MongoWriteCheckpointAPI.ts +164 -37
- package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +2 -0
- package/src/storage/implementation/common/PersistedBatch.ts +22 -10
- package/src/storage/implementation/models.ts +16 -0
- package/src/storage/implementation/v1/MongoCompactorV1.ts +292 -8
- package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +54 -40
- package/src/storage/implementation/v1/SingleBucketStoreV1.ts +18 -27
- package/src/storage/implementation/v3/BucketDataContextV3.ts +32 -0
- package/src/storage/implementation/v3/MongoBucketBatchV3.ts +3 -1
- package/src/storage/implementation/v3/MongoChecksumsV3.ts +75 -105
- package/src/storage/implementation/v3/MongoCompactorV3.ts +456 -258
- package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +21 -19
- package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +123 -126
- package/src/storage/implementation/v3/PersistedBatchV3.ts +69 -16
- package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +5 -0
- package/src/storage/implementation/v3/bucket-format.ts +36 -9
- package/src/storage/implementation/v3/chunking.ts +3 -1
- package/src/storage/implementation/v3/models.ts +40 -1
- package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +54 -0
- package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +29 -0
- package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +171 -0
- package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +343 -0
- package/src/types/types.ts +42 -1
- package/src/utils/test-utils.ts +3 -1
- package/src/utils/util.ts +182 -4
- package/test/src/__snapshots__/storage_sync.test.ts.snap +1 -583
- package/test/src/cleanup-stopped-sync-configs.test.ts +24 -1
- package/test/src/helpers/MemoryObjectStorage.ts +53 -0
- package/test/src/helpers/s3TestFactory.ts +69 -0
- package/test/src/object_storage_lifecycle.test.ts +59 -0
- package/test/src/setup.ts +6 -1
- package/test/src/storage.test.ts +216 -2
- package/test/src/storage_compacting.test.ts +176 -187
- package/test/src/storage_object_storage_inline_threshold.test.ts +116 -0
- package/test/src/storage_s3_checksums.test.ts +191 -0
- package/test/src/storage_s3_compaction_lifecycle.test.ts +417 -0
- package/test/src/storage_s3_reading.test.ts +526 -0
- package/test/src/storage_s3_writing.test.ts +135 -0
- package/test/src/storage_sync.test.ts +95 -5
- package/tsconfig.tsbuildinfo +1 -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/SingleBucketStoreV3.d.ts +0 -18
- package/dist/storage/implementation/v3/SingleBucketStoreV3.js +0 -84
- package/dist/storage/implementation/v3/SingleBucketStoreV3.js.map +0 -1
- package/src/storage/implementation/common/SingleBucketStore.ts +0 -63
- 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
|
+
});
|