@ultimat3/storage 29.3.2 → 30.0.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/CLAUDE.md +27 -47
- package/README.md +130 -12
- package/package.json +12 -2
- package/src/accept-part.ts +30 -0
- package/src/accept.ts +7 -0
- package/src/attachment.ts +99 -6
- package/src/byte-pump.ts +46 -0
- package/src/driver-local-list.ts +75 -0
- package/src/driver-local-multipart.ts +133 -0
- package/src/driver-local-read.ts +13 -0
- package/src/driver-local-remove.ts +39 -0
- package/src/driver-local-stream.ts +104 -0
- package/src/driver-local-write.ts +28 -5
- package/src/driver-local.ts +257 -283
- package/src/driver-memory-multipart.ts +41 -0
- package/src/driver-memory.ts +205 -135
- package/src/driver-s3-client.ts +9 -0
- package/src/driver-s3-copy.ts +35 -0
- package/src/driver-s3-lock.ts +9 -2
- package/src/driver-s3-multipart-session.ts +87 -0
- package/src/driver-s3-multipart.ts +177 -0
- package/src/driver-s3-presign.ts +52 -0
- package/src/driver-s3-put-stream.ts +97 -0
- package/src/driver-s3-range.ts +99 -0
- package/src/driver-s3-settle.ts +150 -0
- package/src/driver-s3-signed.ts +69 -4
- package/src/driver-s3-sse.ts +29 -0
- package/src/driver-s3.ts +113 -65
- package/src/driver.ts +98 -38
- package/src/errors-multipart.ts +35 -0
- package/src/errors-promote.ts +14 -0
- package/src/errors-range.ts +47 -0
- package/src/errors-sniff.ts +35 -0
- package/src/errors-stream.ts +62 -0
- package/src/errors.ts +16 -1
- package/src/expire.ts +169 -0
- package/src/grant-multipart.ts +196 -0
- package/src/grant.ts +42 -25
- package/src/image-warm.ts +84 -0
- package/src/image.ts +57 -0
- package/src/index.ts +81 -2
- package/src/multipart-staged.ts +127 -0
- package/src/multipart.ts +119 -0
- package/src/put-stream.ts +52 -0
- package/src/range.ts +102 -0
- package/src/signed-url.ts +59 -2
- package/src/subject-target.ts +82 -0
- package/src/upload-client-multipart.ts +156 -0
- package/src/upload-client.ts +21 -0
- package/src/upload.ts +107 -21
package/src/driver-memory.ts
CHANGED
|
@@ -9,14 +9,17 @@
|
|
|
9
9
|
import {
|
|
10
10
|
type Clock,
|
|
11
11
|
finiteCount,
|
|
12
|
+
isUltimateError,
|
|
12
13
|
NotImplementedError,
|
|
13
14
|
type Page,
|
|
14
15
|
pageOf,
|
|
15
16
|
systemClock,
|
|
16
17
|
} from '@ultimat3/core';
|
|
18
|
+
import { pumpBounded } from './byte-pump';
|
|
17
19
|
import {
|
|
18
20
|
assertListOptions,
|
|
19
21
|
assertPutOptions,
|
|
22
|
+
type CopyOptions,
|
|
20
23
|
DEFAULT_CONTENT_TYPE,
|
|
21
24
|
etagOf,
|
|
22
25
|
type ListOptions,
|
|
@@ -29,11 +32,15 @@ import {
|
|
|
29
32
|
type StorageObject,
|
|
30
33
|
type StorageRead,
|
|
31
34
|
sha256Base64,
|
|
35
|
+
signedByteBound,
|
|
32
36
|
toBytes,
|
|
33
37
|
} from './driver';
|
|
34
38
|
import type { LocalDriverOptions } from './driver-local';
|
|
35
39
|
import { keyInTheWay } from './driver-memory-conflict';
|
|
36
|
-
import {
|
|
40
|
+
import { memoryPartStore } from './driver-memory-multipart';
|
|
41
|
+
import { checksumMismatch, getTooLarge, keyConflict, objectNotFound, putFailed } from './errors';
|
|
42
|
+
import { objectChanged } from './errors-promote';
|
|
43
|
+
import { withStagedMultipart } from './multipart-staged';
|
|
37
44
|
import {
|
|
38
45
|
assertObjectLockOptions,
|
|
39
46
|
isLocked,
|
|
@@ -42,6 +49,15 @@ import {
|
|
|
42
49
|
requestedLock,
|
|
43
50
|
} from './object-lock';
|
|
44
51
|
import { assertSafeKey } from './path';
|
|
52
|
+
import { assertPutStream, type PutStreamOptions, streamLimit } from './put-stream';
|
|
53
|
+
import {
|
|
54
|
+
assertGetOptions,
|
|
55
|
+
assertStreamRange,
|
|
56
|
+
type ByteRange,
|
|
57
|
+
type GetOptions,
|
|
58
|
+
servedRange,
|
|
59
|
+
streamedRange,
|
|
60
|
+
} from './range';
|
|
45
61
|
import type { SignedUrlVerification } from './signed-url';
|
|
46
62
|
import { buildSignedUrl, signedUrlBaseFor, verifySignedUrl } from './signed-url';
|
|
47
63
|
import { resolveSigningSecret } from './signing-secret';
|
|
@@ -120,160 +136,214 @@ export function memoryStorageDriver(options: MemoryStorageDriverOptions = {}): M
|
|
|
120
136
|
if (blocking !== undefined) throw keyConflict(registered, key, blocking);
|
|
121
137
|
};
|
|
122
138
|
|
|
139
|
+
/** Both writes end here, with no await between the checks and the write. */
|
|
140
|
+
const commit = (safe: string, bytes: Uint8Array, putOptions?: PutOptions): StorageObject => {
|
|
141
|
+
const object: StorageObject = {
|
|
142
|
+
key: safe,
|
|
143
|
+
size: bytes.byteLength,
|
|
144
|
+
contentType: putOptions?.contentType ?? DEFAULT_CONTENT_TYPE,
|
|
145
|
+
etag: etagOf(bytes),
|
|
146
|
+
lastModified: clock.now(),
|
|
147
|
+
...(putOptions?.cacheControl === undefined ? {} : { cacheControl: putOptions.cacheControl }),
|
|
148
|
+
...(putOptions?.metadata === undefined ? {} : { metadata: putOptions.metadata }),
|
|
149
|
+
};
|
|
150
|
+
// Checked AFTER the body is read: the map is the queue the local disk needs a `keyedQueue` for.
|
|
151
|
+
refuseConflict(safe);
|
|
152
|
+
refuseOverwrite(safe, 'overwrite');
|
|
153
|
+
// A copy in, as a file write is: the caller's buffer and metadata are the caller's to reuse.
|
|
154
|
+
stored.set(safe, {
|
|
155
|
+
bytes: bytes.slice(),
|
|
156
|
+
object: snapshot(object),
|
|
157
|
+
lock: requestedLock(putOptions),
|
|
158
|
+
});
|
|
159
|
+
return snapshot(object);
|
|
160
|
+
};
|
|
161
|
+
|
|
123
162
|
const found = (key: string): Stored => {
|
|
124
163
|
const hit = stored.get(key);
|
|
125
164
|
if (hit === undefined) throw objectNotFound(registered, key);
|
|
126
165
|
return hit;
|
|
127
166
|
};
|
|
128
167
|
|
|
129
|
-
return
|
|
130
|
-
|
|
168
|
+
return withStagedMultipart(
|
|
169
|
+
{
|
|
170
|
+
name: DRIVER_NAME,
|
|
131
171
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
172
|
+
get signedUrlBase(): string {
|
|
173
|
+
return baseUrl;
|
|
174
|
+
},
|
|
135
175
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
176
|
+
registerAs(diskName: string): void {
|
|
177
|
+
registered = diskName;
|
|
178
|
+
if (options.baseUrl === undefined) baseUrl = signedUrlBaseFor(diskName);
|
|
179
|
+
},
|
|
140
180
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
181
|
+
objects(): ReadonlyMap<string, Uint8Array> {
|
|
182
|
+
return new Map([...stored].map(([key, value]) => [key, value.bytes.slice()]));
|
|
183
|
+
},
|
|
144
184
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
185
|
+
async put(key: string, body: StorageBody, putOptions?: PutOptions): Promise<StorageObject> {
|
|
186
|
+
const safe = assertSafeKey(key);
|
|
187
|
+
// The same refusal both shipped disks make: a `put()` that succeeds in a test and throws in
|
|
188
|
+
// production is a gap an app meets on the worst day.
|
|
189
|
+
if (putOptions?.serverSideEncryption !== undefined) {
|
|
190
|
+
throw new NotImplementedError({
|
|
191
|
+
cause:
|
|
192
|
+
'server-side encryption on the memory driver (it holds bytes in a Map) is not implemented by this driver — drop serverSideEncryption from put() and encrypt the disk itself: an s3Driver over a bucket with a default KMS rule',
|
|
193
|
+
fix: 'put(key, body)',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
assertPutOptions(DRIVER_NAME, putOptions);
|
|
197
|
+
assertObjectLockOptions(DRIVER_NAME, putOptions, clock);
|
|
198
|
+
const bytes = await toBytes(body, {
|
|
199
|
+
driver: DRIVER_NAME,
|
|
200
|
+
key: safe,
|
|
201
|
+
maxBytes: maxPutBytes,
|
|
154
202
|
});
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (claimed !== actual) throw checksumMismatch(safe, claimed, actual);
|
|
163
|
-
}
|
|
164
|
-
const object: StorageObject = {
|
|
165
|
-
key: safe,
|
|
166
|
-
size: bytes.byteLength,
|
|
167
|
-
contentType: putOptions?.contentType ?? DEFAULT_CONTENT_TYPE,
|
|
168
|
-
etag: etagOf(bytes),
|
|
169
|
-
lastModified: clock.now(),
|
|
170
|
-
...(putOptions?.cacheControl === undefined
|
|
171
|
-
? {}
|
|
172
|
-
: { cacheControl: putOptions.cacheControl }),
|
|
173
|
-
...(putOptions?.metadata === undefined ? {} : { metadata: putOptions.metadata }),
|
|
174
|
-
};
|
|
175
|
-
// Checked AFTER the body is read, with no await between check and write: the map is the
|
|
176
|
-
// queue the local disk needs a `keyedQueue` for.
|
|
177
|
-
refuseConflict(safe);
|
|
178
|
-
refuseOverwrite(safe, 'overwrite');
|
|
179
|
-
// A copy in, as a file write is: the caller's buffer and metadata are the caller's to reuse.
|
|
180
|
-
stored.set(safe, {
|
|
181
|
-
bytes: bytes.slice(),
|
|
182
|
-
object: snapshot(object),
|
|
183
|
-
lock: requestedLock(putOptions),
|
|
184
|
-
});
|
|
185
|
-
return snapshot(object);
|
|
186
|
-
},
|
|
203
|
+
const claimed = putOptions?.checksum;
|
|
204
|
+
if (claimed !== undefined) {
|
|
205
|
+
const actual = sha256Base64(bytes);
|
|
206
|
+
if (claimed !== actual) throw checksumMismatch(safe, claimed, actual);
|
|
207
|
+
}
|
|
208
|
+
return commit(safe, bytes, putOptions);
|
|
209
|
+
},
|
|
187
210
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
211
|
+
/** A `Map` holds bytes, so "unbuffered" has no meaning here. The contract is the rest of it. */
|
|
212
|
+
async putStream(
|
|
213
|
+
key: string,
|
|
214
|
+
body: ReadableStream<Uint8Array>,
|
|
215
|
+
streamOptions: PutStreamOptions,
|
|
216
|
+
): Promise<StorageObject> {
|
|
217
|
+
const safe = assertSafeKey(key);
|
|
218
|
+
const maxBytes = assertPutStream(DRIVER_NAME, body, streamOptions);
|
|
219
|
+
refuseOverwrite(safe, 'overwrite');
|
|
220
|
+
const chunks: Uint8Array[] = [];
|
|
221
|
+
const limit = streamLimit(DRIVER_NAME, safe, maxBytes);
|
|
222
|
+
await pumpBounded(body, limit, (chunk) => {
|
|
223
|
+
chunks.push(chunk);
|
|
224
|
+
}).catch((error: unknown) => {
|
|
225
|
+
if (isUltimateError(error)) throw error;
|
|
226
|
+
throw putFailed(
|
|
227
|
+
registered,
|
|
228
|
+
safe,
|
|
229
|
+
error,
|
|
230
|
+
'await disk.putStream(key, stream, { maxBytes }) # with a fresh stream',
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
return commit(safe, new Uint8Array(Bun.concatArrayBuffers(chunks)), streamOptions);
|
|
234
|
+
},
|
|
192
235
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
if (hit.bytes.byteLength > maxGetBytes) {
|
|
198
|
-
throw getTooLarge(DRIVER_NAME, safe, hit.bytes.byteLength, maxGetBytes);
|
|
199
|
-
}
|
|
200
|
-
return { object: snapshot(hit.object), bytes: hit.bytes.slice() };
|
|
201
|
-
},
|
|
236
|
+
async stat(key: string): Promise<StorageObject | undefined> {
|
|
237
|
+
const hit = stored.get(assertSafeKey(key));
|
|
238
|
+
return hit && snapshot(hit.object);
|
|
239
|
+
},
|
|
202
240
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
241
|
+
async get(key: string, getOptions?: GetOptions): Promise<StorageRead> {
|
|
242
|
+
const safe = assertSafeKey(key);
|
|
243
|
+
assertGetOptions(DRIVER_NAME, getOptions);
|
|
244
|
+
const hit = found(safe);
|
|
245
|
+
const range = getOptions?.range;
|
|
246
|
+
if (range !== undefined) {
|
|
247
|
+
const served = servedRange(DRIVER_NAME, safe, range, hit.bytes.byteLength, maxGetBytes);
|
|
248
|
+
const bytes = hit.bytes.slice(served[0], served[1] + 1);
|
|
249
|
+
return { object: snapshot(hit.object), bytes, range: served };
|
|
250
|
+
}
|
|
251
|
+
// The local disk's refusal, so a suite on this disk meets the ceiling production has.
|
|
252
|
+
if (hit.bytes.byteLength > maxGetBytes) {
|
|
253
|
+
throw getTooLarge(DRIVER_NAME, safe, hit.bytes.byteLength, maxGetBytes);
|
|
254
|
+
}
|
|
255
|
+
return { object: snapshot(hit.object), bytes: hit.bytes.slice() };
|
|
256
|
+
},
|
|
207
257
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const source = found(from_);
|
|
213
|
-
refuseConflict(destination);
|
|
214
|
-
refuseOverwrite(destination, 'copy onto');
|
|
215
|
-
const object: StorageObject = {
|
|
216
|
-
...source.object,
|
|
217
|
-
key: destination,
|
|
218
|
-
lastModified: clock.now(),
|
|
219
|
-
};
|
|
220
|
-
stored.set(destination, { bytes: source.bytes.slice(), object: snapshot(object) });
|
|
221
|
-
return snapshot(object);
|
|
222
|
-
},
|
|
258
|
+
async stream(key: string): Promise<ReadableStream<Uint8Array>> {
|
|
259
|
+
const hit = found(assertSafeKey(key));
|
|
260
|
+
return new Blob([hit.bytes.slice()]).stream();
|
|
261
|
+
},
|
|
223
262
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
},
|
|
263
|
+
async streamRange(key: string, range: ByteRange): Promise<ReadableStream<Uint8Array>> {
|
|
264
|
+
const safe = assertSafeKey(key);
|
|
265
|
+
assertStreamRange(DRIVER_NAME, range);
|
|
266
|
+
const hit = found(safe);
|
|
267
|
+
const [first, last] = streamedRange(DRIVER_NAME, safe, range, hit.bytes.byteLength);
|
|
268
|
+
return new Blob([hit.bytes.slice(first, last + 1)]).stream();
|
|
269
|
+
},
|
|
232
270
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
271
|
+
async copy(from: string, to: string, options?: CopyOptions): Promise<StorageObject> {
|
|
272
|
+
// Both keys before either lookup, as the other two disks do: an unsafe destination is the
|
|
273
|
+
// refusal whether or not the source exists.
|
|
274
|
+
const [from_, destination] = [assertSafeKey(from), assertSafeKey(to)];
|
|
275
|
+
const source = found(from_);
|
|
276
|
+
if (options?.ifMatch !== undefined && source.object.etag !== options.ifMatch) {
|
|
277
|
+
throw objectChanged(from_, options.ifMatch, source.object.etag);
|
|
278
|
+
}
|
|
279
|
+
refuseConflict(destination);
|
|
280
|
+
refuseOverwrite(destination, 'copy onto');
|
|
281
|
+
const object: StorageObject = {
|
|
282
|
+
...source.object,
|
|
283
|
+
key: destination,
|
|
284
|
+
lastModified: clock.now(),
|
|
285
|
+
};
|
|
286
|
+
stored.set(destination, { bytes: source.bytes.slice(), object: snapshot(object) });
|
|
287
|
+
return snapshot(object);
|
|
288
|
+
},
|
|
237
289
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
290
|
+
async delete(key: string): Promise<void> {
|
|
291
|
+
const safe = assertSafeKey(key);
|
|
292
|
+
const lock = lockAt(safe);
|
|
293
|
+
if (lock !== undefined) {
|
|
294
|
+
throw new ObjectLockedError({ disk: registered, key: safe, action: 'delete', lock });
|
|
295
|
+
}
|
|
296
|
+
stored.delete(safe);
|
|
297
|
+
},
|
|
241
298
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const cursor = listOptions?.cursor ?? undefined;
|
|
247
|
-
// Code-unit order and "the cursor is the last key of the page before" — the local disk's own.
|
|
248
|
-
const keys = [...stored.keys()]
|
|
249
|
-
.filter((key) => key.startsWith(prefix) && (cursor === undefined || key > cursor))
|
|
250
|
-
.sort();
|
|
251
|
-
const page = keys.slice(0, limit);
|
|
252
|
-
const objects = page.flatMap((key) => {
|
|
253
|
-
const hit = stored.get(key);
|
|
254
|
-
return hit === undefined ? [] : [snapshot(hit.object)];
|
|
255
|
-
});
|
|
256
|
-
return pageOf(objects, keys.length > page.length ? (page.at(-1) ?? null) : null);
|
|
257
|
-
},
|
|
299
|
+
async retentionOf(key: string): Promise<ObjectLock> {
|
|
300
|
+
const lock = found(assertSafeKey(key)).lock;
|
|
301
|
+
return lock === undefined ? { legalHold: false } : structuredClone(lock);
|
|
302
|
+
},
|
|
258
303
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
key: assertSafeKey(key),
|
|
263
|
-
method: urlOptions?.method,
|
|
264
|
-
expiresInMs: urlOptions?.expiresInMs,
|
|
265
|
-
maxBytes: urlOptions?.maxBytes,
|
|
266
|
-
contentType: urlOptions?.contentType,
|
|
267
|
-
baseUrl,
|
|
268
|
-
clock,
|
|
269
|
-
});
|
|
270
|
-
},
|
|
304
|
+
async exists(key: string): Promise<boolean> {
|
|
305
|
+
return stored.has(assertSafeKey(key));
|
|
306
|
+
},
|
|
271
307
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
308
|
+
async list(listOptions?: ListOptions): Promise<Page<StorageListEntry>> {
|
|
309
|
+
const prefix = listOptions?.prefix ?? '';
|
|
310
|
+
assertListOptions(listOptions);
|
|
311
|
+
const limit = resolveListLimit(listOptions?.limit);
|
|
312
|
+
const cursor = listOptions?.cursor ?? undefined;
|
|
313
|
+
// Code-unit order and "the cursor is the last key of the page before" — the local disk's own.
|
|
314
|
+
const keys = [...stored.keys()]
|
|
315
|
+
.filter((key) => key.startsWith(prefix) && (cursor === undefined || key > cursor))
|
|
316
|
+
.sort();
|
|
317
|
+
const page = keys.slice(0, limit);
|
|
318
|
+
const objects = page.flatMap((key) => {
|
|
319
|
+
const hit = stored.get(key);
|
|
320
|
+
return hit === undefined ? [] : [snapshot(hit.object)];
|
|
321
|
+
});
|
|
322
|
+
return pageOf(objects, keys.length > page.length ? (page.at(-1) ?? null) : null);
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
async signedUrl(key: string, urlOptions?: SignedUrlOptions): Promise<string> {
|
|
326
|
+
return buildSignedUrl({
|
|
327
|
+
secret,
|
|
328
|
+
key: assertSafeKey(key),
|
|
329
|
+
method: urlOptions?.method,
|
|
330
|
+
expiresInMs: urlOptions?.expiresInMs,
|
|
331
|
+
maxBytes: signedByteBound(DRIVER_NAME, urlOptions),
|
|
332
|
+
contentType: urlOptions?.contentType,
|
|
333
|
+
part: urlOptions?.part,
|
|
334
|
+
baseUrl,
|
|
335
|
+
clock,
|
|
336
|
+
});
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
async verifySigned(input: {
|
|
340
|
+
readonly url: string;
|
|
341
|
+
readonly clock?: Clock | undefined;
|
|
342
|
+
}): Promise<SignedUrlVerification> {
|
|
343
|
+
return verifySignedUrl({ url: input.url, secret, baseUrl, clock: input.clock ?? clock });
|
|
344
|
+
},
|
|
277
345
|
},
|
|
278
|
-
|
|
346
|
+
memoryPartStore(),
|
|
347
|
+
clock,
|
|
348
|
+
);
|
|
279
349
|
}
|
package/src/driver-s3-client.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface S3FileLike {
|
|
|
14
14
|
exists(): Promise<boolean>;
|
|
15
15
|
delete(): Promise<void>;
|
|
16
16
|
stream(): ReadableStream<Uint8Array>;
|
|
17
|
+
/** Bun's `S3File.slice`: `end` exclusive. Its `stream()` sends one `Range:`, measured on 1.4.4. */
|
|
18
|
+
slice(begin: number, end: number): { stream(): ReadableStream<Uint8Array> };
|
|
17
19
|
stat(): Promise<S3StatLike>;
|
|
18
20
|
presign(options: { method?: string; expiresIn?: number; type?: string }): string;
|
|
19
21
|
}
|
|
@@ -92,3 +94,10 @@ export function buildClient(options: S3DriverOptions): S3ClientLike {
|
|
|
92
94
|
...clientTarget(options),
|
|
93
95
|
});
|
|
94
96
|
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A missing `LastModified` is ABSENT, never `new Date(0)`: epoch 0 is older than every window, so
|
|
100
|
+
* `sweepOrphans` deleted an upload on the strength of a field the provider never sent.
|
|
101
|
+
*/
|
|
102
|
+
export const dated = (value: string | Date | undefined): { readonly lastModified?: Date } =>
|
|
103
|
+
value === undefined ? {} : { lastModified: value instanceof Date ? value : new Date(value) };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Single responsibility: the s3 copy that names a revision. `Bun.S3Client`'s `write` takes no
|
|
2
|
+
// condition, and `x-amz-copy-source-if-match` is what lets the provider refuse an overwrite in the
|
|
3
|
+
// one moment this process cannot see, so a revision-bound copy is a signed CopyObject.
|
|
4
|
+
|
|
5
|
+
import { type S3Refusal, type S3WireOptions, sendSigned, xmlField } from './driver-s3-signed';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The provider's refusal, or `undefined` when the copy landed. A 200 is not yet a verdict: S3 sends
|
|
9
|
+
* its headers before a long copy ends, so a failure can arrive as an `<Error>` inside a 200's body.
|
|
10
|
+
*/
|
|
11
|
+
export async function signedCopy(
|
|
12
|
+
options: S3WireOptions,
|
|
13
|
+
source: string,
|
|
14
|
+
destination: string,
|
|
15
|
+
ifMatch: string,
|
|
16
|
+
): Promise<S3Refusal | undefined> {
|
|
17
|
+
const path = source.split('/').map(encodeURIComponent).join('/');
|
|
18
|
+
const result = await sendSigned(options, {
|
|
19
|
+
method: 'PUT',
|
|
20
|
+
key: destination,
|
|
21
|
+
headers: {
|
|
22
|
+
'x-amz-copy-source': `/${encodeURIComponent(options.bucket)}/${path}`,
|
|
23
|
+
'x-amz-copy-source-if-match': ifMatch,
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
if (!result.ok) return result.refusal;
|
|
27
|
+
const body = await result.response.text();
|
|
28
|
+
if (!body.includes('<Error>')) return undefined;
|
|
29
|
+
return {
|
|
30
|
+
name: 'S3Error',
|
|
31
|
+
code: xmlField(body, 'Code') ?? 'InternalError',
|
|
32
|
+
message: xmlField(body, 'Message') ?? 'the copy failed after it was accepted',
|
|
33
|
+
statusCode: 500,
|
|
34
|
+
};
|
|
35
|
+
}
|
package/src/driver-s3-lock.ts
CHANGED
|
@@ -25,12 +25,19 @@ export function needsSignedPut(options: PutOptions | undefined): boolean {
|
|
|
25
25
|
|
|
26
26
|
/** Every header the signed PUT sends beyond the signer's own, screened before a byte moves. */
|
|
27
27
|
export function putHeaders(bytes: Uint8Array, options: PutOptions): Record<string, string> {
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
return {
|
|
29
|
+
...objectHeaders(options),
|
|
30
30
|
// Required by S3 on any PUT that sets a retention, and an integrity check on every other one:
|
|
31
31
|
// a body that changed in flight is `BadDigest`, never a stored object.
|
|
32
32
|
'content-md5': new Bun.CryptoHasher('md5').update(bytes).digest('base64'),
|
|
33
33
|
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What describes the object rather than one body: a PUT's headers, and a multipart Create's. */
|
|
37
|
+
export function objectHeaders(options: PutOptions): Record<string, string> {
|
|
38
|
+
const headers: Record<string, string> = {
|
|
39
|
+
'content-type': options.contentType ?? DEFAULT_CONTENT_TYPE,
|
|
40
|
+
};
|
|
34
41
|
if (options.cacheControl !== undefined) {
|
|
35
42
|
assert(
|
|
36
43
|
HEADER_VALUE.test(options.cacheControl),
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Single responsibility: the s3 disk's `MultipartCapability` — the browser's parts go straight to
|
|
2
|
+
// the bucket on URLs core's signer presigns, and every other step is one signed call. No bytes
|
|
3
|
+
// pass through this process, so the web role never carries them.
|
|
4
|
+
|
|
5
|
+
import type { StorageDriver, StorageObject } from './driver';
|
|
6
|
+
import {
|
|
7
|
+
abortS3Upload,
|
|
8
|
+
completeS3Upload,
|
|
9
|
+
createS3Upload,
|
|
10
|
+
listS3Parts,
|
|
11
|
+
listS3Uploads,
|
|
12
|
+
} from './driver-s3-multipart';
|
|
13
|
+
import type { S3Refusal, S3WireOptions } from './driver-s3-signed';
|
|
14
|
+
import { objectNotFound } from './errors';
|
|
15
|
+
import { partsInvalid, uploadUnknown } from './errors-multipart';
|
|
16
|
+
import type { MultipartCapability } from './multipart';
|
|
17
|
+
import { assertSafeKey } from './path';
|
|
18
|
+
|
|
19
|
+
export interface S3MultipartInput {
|
|
20
|
+
readonly wire: S3WireOptions;
|
|
21
|
+
/** The disk: it presigns the part URLs and stats the completed object. */
|
|
22
|
+
readonly driver: StorageDriver;
|
|
23
|
+
/** The disk's coded write refusal for a key. */
|
|
24
|
+
readonly refusedWrite: (key: string) => (refusal: unknown) => never;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** S3's answer for an id that is not an open upload: never opened, completed or aborted. */
|
|
28
|
+
const isUnknownUpload = (refusal: S3Refusal): boolean => refusal.code === 'NoSuchUpload';
|
|
29
|
+
/** S3's refusals of a Complete whose parts do not match what it holds. */
|
|
30
|
+
const PART_REFUSALS: ReadonlySet<string> = new Set([
|
|
31
|
+
'InvalidPart',
|
|
32
|
+
'InvalidPartOrder',
|
|
33
|
+
'EntityTooSmall',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
export function s3Multipart(input: S3MultipartInput): MultipartCapability {
|
|
37
|
+
const { wire, driver } = input;
|
|
38
|
+
const target = (key: string, uploadId?: string) => ({
|
|
39
|
+
wire,
|
|
40
|
+
key,
|
|
41
|
+
refused: (refusal: S3Refusal): never => {
|
|
42
|
+
if (uploadId !== undefined && isUnknownUpload(refusal)) {
|
|
43
|
+
throw uploadUnknown(driver.name, key, uploadId);
|
|
44
|
+
}
|
|
45
|
+
if (uploadId !== undefined && PART_REFUSALS.has(refusal.code)) {
|
|
46
|
+
throw partsInvalid(key, uploadId, refusal.message);
|
|
47
|
+
}
|
|
48
|
+
return input.refusedWrite(key)(refusal);
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
create: (key, { contentType }) =>
|
|
54
|
+
createS3Upload(target(assertSafeKey(key)), { 'content-type': contentType }),
|
|
55
|
+
|
|
56
|
+
signPart: (key, uploadId, partNumber, { contentLength, expiresInMs }) =>
|
|
57
|
+
driver.signedUrl(key, {
|
|
58
|
+
method: 'PUT',
|
|
59
|
+
contentLength,
|
|
60
|
+
expiresInMs,
|
|
61
|
+
part: { uploadId, partNumber },
|
|
62
|
+
}),
|
|
63
|
+
|
|
64
|
+
listParts: (key, uploadId) => listS3Parts(target(assertSafeKey(key), uploadId), uploadId),
|
|
65
|
+
|
|
66
|
+
async complete(key, uploadId, parts): Promise<StorageObject> {
|
|
67
|
+
const safe = assertSafeKey(key);
|
|
68
|
+
await completeS3Upload(target(safe, uploadId), uploadId, parts);
|
|
69
|
+
const object = await driver.stat(safe);
|
|
70
|
+
if (object === undefined) throw objectNotFound(driver.name, safe);
|
|
71
|
+
return object;
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async abort(key, uploadId): Promise<void> {
|
|
75
|
+
const safe = assertSafeKey(key);
|
|
76
|
+
const refusal = await abortS3Upload(target(safe), uploadId);
|
|
77
|
+
// Gone already is the state asked for; anything else is a refusal the caller must hear.
|
|
78
|
+
if (refusal !== undefined && !isUnknownUpload(refusal)) input.refusedWrite(safe)(refusal);
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
listUploads: (prefix) =>
|
|
82
|
+
listS3Uploads(
|
|
83
|
+
{ wire, refused: (refusal) => input.refusedWrite(prefix ?? '')(refusal) },
|
|
84
|
+
prefix,
|
|
85
|
+
),
|
|
86
|
+
};
|
|
87
|
+
}
|