@syncular/server 0.0.1-110 → 0.0.1-112
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/dist/blobs/adapters/filesystem.d.ts +31 -0
- package/dist/blobs/adapters/filesystem.d.ts.map +1 -0
- package/dist/blobs/adapters/filesystem.js +140 -0
- package/dist/blobs/adapters/filesystem.js.map +1 -0
- package/dist/blobs/adapters/s3.d.ts +3 -2
- package/dist/blobs/adapters/s3.d.ts.map +1 -1
- package/dist/blobs/adapters/s3.js +49 -0
- package/dist/blobs/adapters/s3.js.map +1 -1
- package/dist/blobs/index.d.ts +1 -0
- package/dist/blobs/index.d.ts.map +1 -1
- package/dist/blobs/index.js +1 -0
- package/dist/blobs/index.js.map +1 -1
- package/dist/snapshot-chunks/index.d.ts +0 -1
- package/dist/snapshot-chunks/index.d.ts.map +1 -1
- package/dist/snapshot-chunks/index.js +0 -1
- package/dist/snapshot-chunks/index.js.map +1 -1
- package/package.json +2 -2
- package/src/blobs/adapters/filesystem.test.ts +132 -0
- package/src/blobs/adapters/filesystem.ts +189 -0
- package/src/blobs/adapters/s3.test.ts +522 -0
- package/src/blobs/adapters/s3.ts +55 -2
- package/src/blobs/index.ts +1 -0
- package/src/snapshot-chunks/index.ts +0 -1
- package/dist/snapshot-chunks/adapters/s3.d.ts +0 -74
- package/dist/snapshot-chunks/adapters/s3.d.ts.map +0 -1
- package/dist/snapshot-chunks/adapters/s3.js +0 -50
- package/dist/snapshot-chunks/adapters/s3.js.map +0 -1
- package/src/snapshot-chunks/adapters/s3.ts +0 -68
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem blob storage adapter.
|
|
3
|
+
*
|
|
4
|
+
* Stores blobs as files on disk with 2-level hash-based subdirectories.
|
|
5
|
+
* Uploads/downloads go through the server's blob routes using signed tokens
|
|
6
|
+
* (same pattern as the database adapter).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
mkdir,
|
|
11
|
+
open,
|
|
12
|
+
readFile,
|
|
13
|
+
rename,
|
|
14
|
+
stat,
|
|
15
|
+
unlink,
|
|
16
|
+
writeFile,
|
|
17
|
+
} from 'node:fs/promises';
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
19
|
+
import type {
|
|
20
|
+
BlobSignDownloadOptions,
|
|
21
|
+
BlobSignedUpload,
|
|
22
|
+
BlobSignUploadOptions,
|
|
23
|
+
BlobStorageAdapter,
|
|
24
|
+
} from '@syncular/core';
|
|
25
|
+
import type { BlobTokenSigner } from './database';
|
|
26
|
+
|
|
27
|
+
export interface FilesystemBlobStorageAdapterOptions {
|
|
28
|
+
/** Directory root for blob files */
|
|
29
|
+
basePath: string;
|
|
30
|
+
/** Server base URL for upload/download routes (e.g. "/api/sync") */
|
|
31
|
+
baseUrl: string;
|
|
32
|
+
/** Token signer for authorization */
|
|
33
|
+
tokenSigner: BlobTokenSigner;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve hash to a 2-level subdirectory path:
|
|
38
|
+
* `{basePath}/{hex[0..2]}/{hex[2..4]}/{hex}`
|
|
39
|
+
*/
|
|
40
|
+
function hashToFilePath(basePath: string, hash: string): string {
|
|
41
|
+
const hex = hash.startsWith('sha256:') ? hash.slice(7) : hash;
|
|
42
|
+
return join(basePath, hex.slice(0, 2), hex.slice(2, 4), hex);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function tmpPath(filePath: string): string {
|
|
46
|
+
return `${filePath}.${Date.now()}.tmp`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Create a filesystem blob storage adapter.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```typescript
|
|
54
|
+
* const adapter = createFilesystemBlobStorageAdapter({
|
|
55
|
+
* basePath: '/data/blobs',
|
|
56
|
+
* baseUrl: 'https://api.example.com/api/sync',
|
|
57
|
+
* tokenSigner: createHmacTokenSigner(process.env.BLOB_SECRET!),
|
|
58
|
+
* });
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export function createFilesystemBlobStorageAdapter(
|
|
62
|
+
options: FilesystemBlobStorageAdapterOptions
|
|
63
|
+
): BlobStorageAdapter {
|
|
64
|
+
const { basePath, tokenSigner } = options;
|
|
65
|
+
const normalizedBaseUrl = options.baseUrl.replace(/\/$/, '');
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
name: 'filesystem',
|
|
69
|
+
|
|
70
|
+
async signUpload(opts: BlobSignUploadOptions): Promise<BlobSignedUpload> {
|
|
71
|
+
const expiresAt = Date.now() + opts.expiresIn * 1000;
|
|
72
|
+
const token = await tokenSigner.sign(
|
|
73
|
+
{ hash: opts.hash, action: 'upload', expiresAt },
|
|
74
|
+
opts.expiresIn
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const url = `${normalizedBaseUrl}/blobs/${encodeURIComponent(opts.hash)}/upload?token=${encodeURIComponent(token)}`;
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
url,
|
|
81
|
+
method: 'PUT',
|
|
82
|
+
headers: {
|
|
83
|
+
'Content-Type': opts.mimeType,
|
|
84
|
+
'Content-Length': String(opts.size),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
async signDownload(opts: BlobSignDownloadOptions): Promise<string> {
|
|
90
|
+
const expiresAt = Date.now() + opts.expiresIn * 1000;
|
|
91
|
+
const token = await tokenSigner.sign(
|
|
92
|
+
{ hash: opts.hash, action: 'download', expiresAt },
|
|
93
|
+
opts.expiresIn
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
return `${normalizedBaseUrl}/blobs/${encodeURIComponent(opts.hash)}/download?token=${encodeURIComponent(token)}`;
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
async exists(hash: string): Promise<boolean> {
|
|
100
|
+
try {
|
|
101
|
+
await stat(hashToFilePath(basePath, hash));
|
|
102
|
+
return true;
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
async delete(hash: string): Promise<void> {
|
|
109
|
+
try {
|
|
110
|
+
await unlink(hashToFilePath(basePath, hash));
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
async getMetadata(
|
|
117
|
+
hash: string
|
|
118
|
+
): Promise<{ size: number; mimeType?: string } | null> {
|
|
119
|
+
try {
|
|
120
|
+
const s = await stat(hashToFilePath(basePath, hash));
|
|
121
|
+
return { size: s.size };
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async put(hash: string, data: Uint8Array): Promise<void> {
|
|
128
|
+
const filePath = hashToFilePath(basePath, hash);
|
|
129
|
+
const tmp = tmpPath(filePath);
|
|
130
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
131
|
+
await writeFile(tmp, data);
|
|
132
|
+
await rename(tmp, filePath);
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
async putStream(
|
|
136
|
+
hash: string,
|
|
137
|
+
stream: ReadableStream<Uint8Array>
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
const filePath = hashToFilePath(basePath, hash);
|
|
140
|
+
const tmp = tmpPath(filePath);
|
|
141
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
142
|
+
|
|
143
|
+
const fh = await open(tmp, 'w');
|
|
144
|
+
try {
|
|
145
|
+
const reader = stream.getReader();
|
|
146
|
+
while (true) {
|
|
147
|
+
const { done, value } = await reader.read();
|
|
148
|
+
if (done) break;
|
|
149
|
+
await fh.write(value);
|
|
150
|
+
}
|
|
151
|
+
} finally {
|
|
152
|
+
await fh.close();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
await rename(tmp, filePath);
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
async get(hash: string): Promise<Uint8Array | null> {
|
|
159
|
+
try {
|
|
160
|
+
const buf = await readFile(hashToFilePath(basePath, hash));
|
|
161
|
+
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
|
164
|
+
throw err;
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
async getStream(hash: string): Promise<ReadableStream<Uint8Array> | null> {
|
|
169
|
+
let data: Buffer;
|
|
170
|
+
try {
|
|
171
|
+
data = await readFile(hashToFilePath(basePath, hash));
|
|
172
|
+
} catch (err) {
|
|
173
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
const bytes = new Uint8Array(
|
|
177
|
+
data.buffer,
|
|
178
|
+
data.byteOffset,
|
|
179
|
+
data.byteLength
|
|
180
|
+
);
|
|
181
|
+
return new ReadableStream<Uint8Array>({
|
|
182
|
+
start(controller) {
|
|
183
|
+
controller.enqueue(bytes);
|
|
184
|
+
controller.close();
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
createS3BlobStorageAdapter,
|
|
4
|
+
type GetSignedUrlFn,
|
|
5
|
+
type S3ClientLike,
|
|
6
|
+
type S3Commands,
|
|
7
|
+
} from './s3';
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Helpers
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
const TEST_BUCKET = 'test-bucket';
|
|
14
|
+
const TEST_HASH =
|
|
15
|
+
'sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890';
|
|
16
|
+
const TEST_HEX =
|
|
17
|
+
'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890';
|
|
18
|
+
|
|
19
|
+
/** Compute the expected base64 of a hex hash (used in checksum headers). */
|
|
20
|
+
function hexToBase64(hex: string): string {
|
|
21
|
+
return Buffer.from(hex, 'hex').toString('base64');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Tag types so the mock client can identify which command was sent
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const PUT_TAG = Symbol('PutObjectCommand');
|
|
29
|
+
const GET_TAG = Symbol('GetObjectCommand');
|
|
30
|
+
const HEAD_TAG = Symbol('HeadObjectCommand');
|
|
31
|
+
const DELETE_TAG = Symbol('DeleteObjectCommand');
|
|
32
|
+
|
|
33
|
+
interface MockCommand {
|
|
34
|
+
__tag: symbol;
|
|
35
|
+
input: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function createMockCommands(): S3Commands {
|
|
39
|
+
return {
|
|
40
|
+
PutObjectCommand: class {
|
|
41
|
+
__tag = PUT_TAG;
|
|
42
|
+
input: Record<string, unknown>;
|
|
43
|
+
constructor(input: Record<string, unknown>) {
|
|
44
|
+
this.input = input;
|
|
45
|
+
}
|
|
46
|
+
} as unknown as S3Commands['PutObjectCommand'],
|
|
47
|
+
|
|
48
|
+
GetObjectCommand: class {
|
|
49
|
+
__tag = GET_TAG;
|
|
50
|
+
input: Record<string, unknown>;
|
|
51
|
+
constructor(input: Record<string, unknown>) {
|
|
52
|
+
this.input = input;
|
|
53
|
+
}
|
|
54
|
+
} as unknown as S3Commands['GetObjectCommand'],
|
|
55
|
+
|
|
56
|
+
HeadObjectCommand: class {
|
|
57
|
+
__tag = HEAD_TAG;
|
|
58
|
+
input: Record<string, unknown>;
|
|
59
|
+
constructor(input: Record<string, unknown>) {
|
|
60
|
+
this.input = input;
|
|
61
|
+
}
|
|
62
|
+
} as unknown as S3Commands['HeadObjectCommand'],
|
|
63
|
+
|
|
64
|
+
DeleteObjectCommand: class {
|
|
65
|
+
__tag = DELETE_TAG;
|
|
66
|
+
input: Record<string, unknown>;
|
|
67
|
+
constructor(input: Record<string, unknown>) {
|
|
68
|
+
this.input = input;
|
|
69
|
+
}
|
|
70
|
+
} as unknown as S3Commands['DeleteObjectCommand'],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Mock S3 client
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
interface SendCall {
|
|
79
|
+
tag: symbol;
|
|
80
|
+
input: Record<string, unknown>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function createMockS3Client(options?: {
|
|
84
|
+
/** Value returned by send(). Can be a function of the command tag. */
|
|
85
|
+
response?:
|
|
86
|
+
| Record<string, unknown>
|
|
87
|
+
| ((tag: symbol) => Record<string, unknown>);
|
|
88
|
+
/** When true, send() rejects with a NotFound-style error. */
|
|
89
|
+
notFound?: boolean;
|
|
90
|
+
}) {
|
|
91
|
+
const calls: SendCall[] = [];
|
|
92
|
+
|
|
93
|
+
const client: S3ClientLike = {
|
|
94
|
+
async send(command: unknown) {
|
|
95
|
+
const cmd = command as MockCommand;
|
|
96
|
+
calls.push({ tag: cmd.__tag, input: cmd.input });
|
|
97
|
+
|
|
98
|
+
if (options?.notFound) {
|
|
99
|
+
const err = new Error('NotFound') as Error & {
|
|
100
|
+
name: string;
|
|
101
|
+
$metadata: { httpStatusCode: number };
|
|
102
|
+
};
|
|
103
|
+
err.name = 'NotFound';
|
|
104
|
+
err.$metadata = { httpStatusCode: 404 };
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (typeof options?.response === 'function') {
|
|
109
|
+
return options.response(cmd.__tag);
|
|
110
|
+
}
|
|
111
|
+
return options?.response ?? {};
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return { client, calls };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Mock getSignedUrl
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
interface SignedUrlCall {
|
|
123
|
+
command: MockCommand;
|
|
124
|
+
options: { expiresIn: number };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function createMockGetSignedUrl(): {
|
|
128
|
+
fn: GetSignedUrlFn;
|
|
129
|
+
calls: SignedUrlCall[];
|
|
130
|
+
} {
|
|
131
|
+
const calls: SignedUrlCall[] = [];
|
|
132
|
+
const fn: GetSignedUrlFn = async (_client, command, options) => {
|
|
133
|
+
const cmd = command as MockCommand;
|
|
134
|
+
calls.push({ command: cmd, options });
|
|
135
|
+
return `https://s3.example.com/presigned/${cmd.input.Key as string}`;
|
|
136
|
+
};
|
|
137
|
+
return { fn, calls };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Tests
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
describe('createS3BlobStorageAdapter', () => {
|
|
145
|
+
// ---- signUpload ----
|
|
146
|
+
describe('signUpload', () => {
|
|
147
|
+
test('returns presigned URL with correct method and headers', async () => {
|
|
148
|
+
const commands = createMockCommands();
|
|
149
|
+
const { client } = createMockS3Client();
|
|
150
|
+
const { fn: getSignedUrl, calls: signCalls } = createMockGetSignedUrl();
|
|
151
|
+
|
|
152
|
+
const adapter = createS3BlobStorageAdapter({
|
|
153
|
+
client,
|
|
154
|
+
bucket: TEST_BUCKET,
|
|
155
|
+
commands,
|
|
156
|
+
getSignedUrl,
|
|
157
|
+
requireChecksum: false,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const result = await adapter.signUpload({
|
|
161
|
+
hash: TEST_HASH,
|
|
162
|
+
size: 1024,
|
|
163
|
+
mimeType: 'image/png',
|
|
164
|
+
expiresIn: 300,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(result.method).toBe('PUT');
|
|
168
|
+
expect(result.url).toContain(TEST_HEX);
|
|
169
|
+
expect(result.headers).toBeDefined();
|
|
170
|
+
expect(result.headers!['Content-Type']).toBe('image/png');
|
|
171
|
+
expect(result.headers!['Content-Length']).toBe('1024');
|
|
172
|
+
// No checksum header when requireChecksum=false
|
|
173
|
+
expect(result.headers!['x-amz-checksum-sha256']).toBeUndefined();
|
|
174
|
+
|
|
175
|
+
// Verify the presigner was called with the right expiresIn
|
|
176
|
+
expect(signCalls).toHaveLength(1);
|
|
177
|
+
expect(signCalls[0]!.options.expiresIn).toBe(300);
|
|
178
|
+
|
|
179
|
+
// Verify PutObjectCommand was constructed with correct bucket/key
|
|
180
|
+
const cmdInput = signCalls[0]!.command.input;
|
|
181
|
+
expect(cmdInput.Bucket).toBe(TEST_BUCKET);
|
|
182
|
+
expect(cmdInput.Key).toBe(TEST_HEX);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('includes checksum header when requireChecksum=true', async () => {
|
|
186
|
+
const commands = createMockCommands();
|
|
187
|
+
const { client } = createMockS3Client();
|
|
188
|
+
const { fn: getSignedUrl } = createMockGetSignedUrl();
|
|
189
|
+
|
|
190
|
+
const adapter = createS3BlobStorageAdapter({
|
|
191
|
+
client,
|
|
192
|
+
bucket: TEST_BUCKET,
|
|
193
|
+
commands,
|
|
194
|
+
getSignedUrl,
|
|
195
|
+
requireChecksum: true,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const result = await adapter.signUpload({
|
|
199
|
+
hash: TEST_HASH,
|
|
200
|
+
size: 512,
|
|
201
|
+
mimeType: 'application/octet-stream',
|
|
202
|
+
expiresIn: 60,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const expectedBase64 = hexToBase64(TEST_HEX);
|
|
206
|
+
expect(result.headers!['x-amz-checksum-sha256']).toBe(expectedBase64);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ---- signDownload ----
|
|
211
|
+
describe('signDownload', () => {
|
|
212
|
+
test('returns presigned URL', async () => {
|
|
213
|
+
const commands = createMockCommands();
|
|
214
|
+
const { client } = createMockS3Client();
|
|
215
|
+
const { fn: getSignedUrl, calls: signCalls } = createMockGetSignedUrl();
|
|
216
|
+
|
|
217
|
+
const adapter = createS3BlobStorageAdapter({
|
|
218
|
+
client,
|
|
219
|
+
bucket: TEST_BUCKET,
|
|
220
|
+
commands,
|
|
221
|
+
getSignedUrl,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const url = await adapter.signDownload({
|
|
225
|
+
hash: TEST_HASH,
|
|
226
|
+
expiresIn: 120,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
expect(url).toContain(TEST_HEX);
|
|
230
|
+
expect(signCalls).toHaveLength(1);
|
|
231
|
+
expect(signCalls[0]!.options.expiresIn).toBe(120);
|
|
232
|
+
|
|
233
|
+
// Verify GetObjectCommand was used
|
|
234
|
+
expect(signCalls[0]!.command.__tag).toBe(GET_TAG);
|
|
235
|
+
expect(signCalls[0]!.command.input.Bucket).toBe(TEST_BUCKET);
|
|
236
|
+
expect(signCalls[0]!.command.input.Key).toBe(TEST_HEX);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ---- exists ----
|
|
241
|
+
describe('exists', () => {
|
|
242
|
+
test('returns true when HeadObject succeeds', async () => {
|
|
243
|
+
const commands = createMockCommands();
|
|
244
|
+
const { client } = createMockS3Client();
|
|
245
|
+
const { fn: getSignedUrl } = createMockGetSignedUrl();
|
|
246
|
+
|
|
247
|
+
const adapter = createS3BlobStorageAdapter({
|
|
248
|
+
client,
|
|
249
|
+
bucket: TEST_BUCKET,
|
|
250
|
+
commands,
|
|
251
|
+
getSignedUrl,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
expect(await adapter.exists(TEST_HASH)).toBe(true);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('returns false on NotFound error', async () => {
|
|
258
|
+
const commands = createMockCommands();
|
|
259
|
+
const { client } = createMockS3Client({ notFound: true });
|
|
260
|
+
const { fn: getSignedUrl } = createMockGetSignedUrl();
|
|
261
|
+
|
|
262
|
+
const adapter = createS3BlobStorageAdapter({
|
|
263
|
+
client,
|
|
264
|
+
bucket: TEST_BUCKET,
|
|
265
|
+
commands,
|
|
266
|
+
getSignedUrl,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
expect(await adapter.exists(TEST_HASH)).toBe(false);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// ---- delete ----
|
|
274
|
+
describe('delete', () => {
|
|
275
|
+
test('calls DeleteObjectCommand with correct bucket and key', async () => {
|
|
276
|
+
const commands = createMockCommands();
|
|
277
|
+
const { client, calls } = createMockS3Client();
|
|
278
|
+
const { fn: getSignedUrl } = createMockGetSignedUrl();
|
|
279
|
+
|
|
280
|
+
const adapter = createS3BlobStorageAdapter({
|
|
281
|
+
client,
|
|
282
|
+
bucket: TEST_BUCKET,
|
|
283
|
+
commands,
|
|
284
|
+
getSignedUrl,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
await adapter.delete(TEST_HASH);
|
|
288
|
+
|
|
289
|
+
expect(calls).toHaveLength(1);
|
|
290
|
+
expect(calls[0]!.tag).toBe(DELETE_TAG);
|
|
291
|
+
expect(calls[0]!.input.Bucket).toBe(TEST_BUCKET);
|
|
292
|
+
expect(calls[0]!.input.Key).toBe(TEST_HEX);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// ---- getMetadata ----
|
|
297
|
+
describe('getMetadata', () => {
|
|
298
|
+
test('returns size and mimeType from HeadObject', async () => {
|
|
299
|
+
const commands = createMockCommands();
|
|
300
|
+
const { client } = createMockS3Client({
|
|
301
|
+
response: { ContentLength: 2048, ContentType: 'image/jpeg' },
|
|
302
|
+
});
|
|
303
|
+
const { fn: getSignedUrl } = createMockGetSignedUrl();
|
|
304
|
+
|
|
305
|
+
const adapter = createS3BlobStorageAdapter({
|
|
306
|
+
client,
|
|
307
|
+
bucket: TEST_BUCKET,
|
|
308
|
+
commands,
|
|
309
|
+
getSignedUrl,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const meta = await adapter.getMetadata!(TEST_HASH);
|
|
313
|
+
expect(meta).toEqual({ size: 2048, mimeType: 'image/jpeg' });
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('returns null on NotFound', async () => {
|
|
317
|
+
const commands = createMockCommands();
|
|
318
|
+
const { client } = createMockS3Client({ notFound: true });
|
|
319
|
+
const { fn: getSignedUrl } = createMockGetSignedUrl();
|
|
320
|
+
|
|
321
|
+
const adapter = createS3BlobStorageAdapter({
|
|
322
|
+
client,
|
|
323
|
+
bucket: TEST_BUCKET,
|
|
324
|
+
commands,
|
|
325
|
+
getSignedUrl,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const meta = await adapter.getMetadata!(TEST_HASH);
|
|
329
|
+
expect(meta).toBeNull();
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// ---- put ----
|
|
334
|
+
describe('put', () => {
|
|
335
|
+
test('calls PutObjectCommand with Body and correct key', async () => {
|
|
336
|
+
const commands = createMockCommands();
|
|
337
|
+
const { client, calls } = createMockS3Client();
|
|
338
|
+
const { fn: getSignedUrl } = createMockGetSignedUrl();
|
|
339
|
+
|
|
340
|
+
const adapter = createS3BlobStorageAdapter({
|
|
341
|
+
client,
|
|
342
|
+
bucket: TEST_BUCKET,
|
|
343
|
+
commands,
|
|
344
|
+
getSignedUrl,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const data = new Uint8Array([1, 2, 3, 4]);
|
|
348
|
+
await adapter.put!(TEST_HASH, data);
|
|
349
|
+
|
|
350
|
+
expect(calls).toHaveLength(1);
|
|
351
|
+
expect(calls[0]!.tag).toBe(PUT_TAG);
|
|
352
|
+
expect(calls[0]!.input.Bucket).toBe(TEST_BUCKET);
|
|
353
|
+
expect(calls[0]!.input.Key).toBe(TEST_HEX);
|
|
354
|
+
expect(calls[0]!.input.Body).toBe(data);
|
|
355
|
+
expect(calls[0]!.input.ContentLength).toBe(4);
|
|
356
|
+
expect(calls[0]!.input.ContentType).toBe('application/octet-stream');
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// ---- get ----
|
|
361
|
+
describe('get', () => {
|
|
362
|
+
test('returns Uint8Array from transformToByteArray', async () => {
|
|
363
|
+
const expectedBytes = new Uint8Array([10, 20, 30]);
|
|
364
|
+
const commands = createMockCommands();
|
|
365
|
+
const { client } = createMockS3Client({
|
|
366
|
+
response: {
|
|
367
|
+
Body: {
|
|
368
|
+
transformToByteArray: async () => expectedBytes,
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
const { fn: getSignedUrl } = createMockGetSignedUrl();
|
|
373
|
+
|
|
374
|
+
const adapter = createS3BlobStorageAdapter({
|
|
375
|
+
client,
|
|
376
|
+
bucket: TEST_BUCKET,
|
|
377
|
+
commands,
|
|
378
|
+
getSignedUrl,
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const result = await adapter.get!(TEST_HASH);
|
|
382
|
+
expect(result).toBe(expectedBytes);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test('returns null on NotFound', async () => {
|
|
386
|
+
const commands = createMockCommands();
|
|
387
|
+
const { client } = createMockS3Client({ notFound: true });
|
|
388
|
+
const { fn: getSignedUrl } = createMockGetSignedUrl();
|
|
389
|
+
|
|
390
|
+
const adapter = createS3BlobStorageAdapter({
|
|
391
|
+
client,
|
|
392
|
+
bucket: TEST_BUCKET,
|
|
393
|
+
commands,
|
|
394
|
+
getSignedUrl,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
const result = await adapter.get!(TEST_HASH);
|
|
398
|
+
expect(result).toBeNull();
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// ---- getStream ----
|
|
403
|
+
describe('getStream', () => {
|
|
404
|
+
test('returns ReadableStream from transformToWebStream', async () => {
|
|
405
|
+
const mockStream = new ReadableStream<Uint8Array>({
|
|
406
|
+
start(controller) {
|
|
407
|
+
controller.enqueue(new Uint8Array([5, 6, 7]));
|
|
408
|
+
controller.close();
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
const commands = createMockCommands();
|
|
413
|
+
const { client } = createMockS3Client({
|
|
414
|
+
response: {
|
|
415
|
+
Body: {
|
|
416
|
+
transformToWebStream: () => mockStream,
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
const { fn: getSignedUrl } = createMockGetSignedUrl();
|
|
421
|
+
|
|
422
|
+
const adapter = createS3BlobStorageAdapter({
|
|
423
|
+
client,
|
|
424
|
+
bucket: TEST_BUCKET,
|
|
425
|
+
commands,
|
|
426
|
+
getSignedUrl,
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const result = await adapter.getStream!(TEST_HASH);
|
|
430
|
+
expect(result).toBe(mockStream);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test('returns null on NotFound', async () => {
|
|
434
|
+
const commands = createMockCommands();
|
|
435
|
+
const { client } = createMockS3Client({ notFound: true });
|
|
436
|
+
const { fn: getSignedUrl } = createMockGetSignedUrl();
|
|
437
|
+
|
|
438
|
+
const adapter = createS3BlobStorageAdapter({
|
|
439
|
+
client,
|
|
440
|
+
bucket: TEST_BUCKET,
|
|
441
|
+
commands,
|
|
442
|
+
getSignedUrl,
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const result = await adapter.getStream!(TEST_HASH);
|
|
446
|
+
expect(result).toBeNull();
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// ---- key prefix ----
|
|
451
|
+
describe('key prefix', () => {
|
|
452
|
+
test('prepends keyPrefix to all keys', async () => {
|
|
453
|
+
const commands = createMockCommands();
|
|
454
|
+
const { client, calls } = createMockS3Client();
|
|
455
|
+
const { fn: getSignedUrl, calls: signCalls } = createMockGetSignedUrl();
|
|
456
|
+
|
|
457
|
+
const adapter = createS3BlobStorageAdapter({
|
|
458
|
+
client,
|
|
459
|
+
bucket: TEST_BUCKET,
|
|
460
|
+
keyPrefix: 'blobs/',
|
|
461
|
+
commands,
|
|
462
|
+
getSignedUrl,
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// exists -> HeadObjectCommand
|
|
466
|
+
await adapter.exists(TEST_HASH);
|
|
467
|
+
expect(calls[0]!.input.Key).toBe(`blobs/${TEST_HEX}`);
|
|
468
|
+
|
|
469
|
+
// delete -> DeleteObjectCommand
|
|
470
|
+
await adapter.delete(TEST_HASH);
|
|
471
|
+
expect(calls[1]!.input.Key).toBe(`blobs/${TEST_HEX}`);
|
|
472
|
+
|
|
473
|
+
// signUpload -> PutObjectCommand via presigner
|
|
474
|
+
await adapter.signUpload({
|
|
475
|
+
hash: TEST_HASH,
|
|
476
|
+
size: 100,
|
|
477
|
+
mimeType: 'text/plain',
|
|
478
|
+
expiresIn: 60,
|
|
479
|
+
});
|
|
480
|
+
expect(signCalls[0]!.command.input.Key).toBe(`blobs/${TEST_HEX}`);
|
|
481
|
+
|
|
482
|
+
// signDownload -> GetObjectCommand via presigner
|
|
483
|
+
await adapter.signDownload({ hash: TEST_HASH, expiresIn: 60 });
|
|
484
|
+
expect(signCalls[1]!.command.input.Key).toBe(`blobs/${TEST_HEX}`);
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// ---- hash stripping ----
|
|
489
|
+
describe('hash stripping', () => {
|
|
490
|
+
test('strips "sha256:" prefix from hash to form the key', async () => {
|
|
491
|
+
const commands = createMockCommands();
|
|
492
|
+
const { client, calls } = createMockS3Client();
|
|
493
|
+
const { fn: getSignedUrl } = createMockGetSignedUrl();
|
|
494
|
+
|
|
495
|
+
const adapter = createS3BlobStorageAdapter({
|
|
496
|
+
client,
|
|
497
|
+
bucket: TEST_BUCKET,
|
|
498
|
+
commands,
|
|
499
|
+
getSignedUrl,
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
await adapter.exists('sha256:deadbeef');
|
|
503
|
+
expect(calls[0]!.input.Key).toBe('deadbeef');
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test('leaves hashes without "sha256:" prefix unchanged', async () => {
|
|
507
|
+
const commands = createMockCommands();
|
|
508
|
+
const { client, calls } = createMockS3Client();
|
|
509
|
+
const { fn: getSignedUrl } = createMockGetSignedUrl();
|
|
510
|
+
|
|
511
|
+
const adapter = createS3BlobStorageAdapter({
|
|
512
|
+
client,
|
|
513
|
+
bucket: TEST_BUCKET,
|
|
514
|
+
commands,
|
|
515
|
+
getSignedUrl,
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
await adapter.exists('deadbeef');
|
|
519
|
+
expect(calls[0]!.input.Key).toBe('deadbeef');
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
});
|