@ultimat3/storage 29.4.0 → 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.
Files changed (47) hide show
  1. package/CLAUDE.md +25 -45
  2. package/README.md +102 -12
  3. package/package.json +12 -2
  4. package/src/accept-part.ts +30 -0
  5. package/src/accept.ts +7 -0
  6. package/src/attachment.ts +99 -6
  7. package/src/byte-pump.ts +46 -0
  8. package/src/driver-local-list.ts +75 -0
  9. package/src/driver-local-multipart.ts +133 -0
  10. package/src/driver-local-remove.ts +39 -0
  11. package/src/driver-local-stream.ts +104 -0
  12. package/src/driver-local-write.ts +28 -5
  13. package/src/driver-local.ts +250 -295
  14. package/src/driver-memory-multipart.ts +41 -0
  15. package/src/driver-memory.ts +205 -143
  16. package/src/driver-s3-client.ts +9 -0
  17. package/src/driver-s3-copy.ts +35 -0
  18. package/src/driver-s3-lock.ts +9 -2
  19. package/src/driver-s3-multipart-session.ts +87 -0
  20. package/src/driver-s3-multipart.ts +177 -0
  21. package/src/driver-s3-presign.ts +52 -0
  22. package/src/driver-s3-put-stream.ts +97 -0
  23. package/src/driver-s3-settle.ts +150 -0
  24. package/src/driver-s3-signed.ts +68 -3
  25. package/src/driver-s3-sse.ts +29 -0
  26. package/src/driver-s3.ts +89 -65
  27. package/src/driver.ts +86 -36
  28. package/src/errors-multipart.ts +35 -0
  29. package/src/errors-promote.ts +14 -0
  30. package/src/errors-sniff.ts +35 -0
  31. package/src/errors-stream.ts +62 -0
  32. package/src/errors.ts +13 -0
  33. package/src/expire.ts +169 -0
  34. package/src/grant-multipart.ts +196 -0
  35. package/src/grant.ts +42 -25
  36. package/src/image-warm.ts +84 -0
  37. package/src/image.ts +57 -0
  38. package/src/index.ts +75 -3
  39. package/src/multipart-staged.ts +127 -0
  40. package/src/multipart.ts +119 -0
  41. package/src/put-stream.ts +52 -0
  42. package/src/range.ts +35 -4
  43. package/src/signed-url.ts +59 -2
  44. package/src/subject-target.ts +82 -0
  45. package/src/upload-client-multipart.ts +156 -0
  46. package/src/upload-client.ts +21 -0
  47. package/src/upload.ts +107 -21
@@ -6,17 +6,14 @@
6
6
  import {
7
7
  type Clock,
8
8
  finiteCount,
9
- NotImplementedError,
10
9
  type Page,
11
- pageOf,
12
10
  type ResolveEnvironmentOptions,
13
- renderFixShellArg,
14
- stringField,
15
11
  systemClock,
16
12
  } from '@ultimat3/core';
17
13
  import {
18
14
  assertListOptions,
19
15
  assertPutOptions,
16
+ type CopyOptions,
20
17
  DEFAULT_CONTENT_TYPE,
21
18
  etagOf,
22
19
  type ListOptions,
@@ -29,8 +26,11 @@ import {
29
26
  type StorageObject,
30
27
  type StorageRead,
31
28
  sha256Base64,
29
+ signedByteBound,
32
30
  toBytes,
33
31
  } from './driver';
32
+ import { listLocalPage } from './driver-local-list';
33
+ import { localPartStore } from './driver-local-multipart';
34
34
  import {
35
35
  etagOfFile,
36
36
  headObject,
@@ -38,16 +38,21 @@ import {
38
38
  readObjectRange,
39
39
  readSidecar,
40
40
  } from './driver-local-read';
41
+ import { removeIfPresent } from './driver-local-remove';
41
42
  import { lockOfSidecar, type Sidecar, sidecarLock } from './driver-local-sidecar';
43
+ import { putLocalStream } from './driver-local-stream';
42
44
  import {
43
45
  commitObject,
44
46
  inTurnOfBoth,
45
47
  type KeyedQueue,
46
48
  keyedQueue,
47
49
  pendingPathOf,
50
+ refuseUnsupportedPut,
48
51
  sidecarPathOf,
49
52
  } from './driver-local-write';
50
- import { checksumMismatch, deleteFailed, getTooLarge, listFailed, objectNotFound } from './errors';
53
+ import { checksumMismatch, getTooLarge, objectNotFound } from './errors';
54
+ import { objectChanged } from './errors-promote';
55
+ import { withStagedMultipart } from './multipart-staged';
51
56
  import {
52
57
  assertObjectLockOptions,
53
58
  isLocked,
@@ -55,8 +60,16 @@ import {
55
60
  ObjectLockedError,
56
61
  requestedLock,
57
62
  } from './object-lock';
58
- import { assertSafeKey, META_DIR } from './path';
59
- import { assertGetOptions, type GetOptions, servedRange } from './range';
63
+ import { assertSafeKey } from './path';
64
+ import { assertPutStream, type PutStreamOptions } from './put-stream';
65
+ import {
66
+ assertGetOptions,
67
+ assertStreamRange,
68
+ type ByteRange,
69
+ type GetOptions,
70
+ servedRange,
71
+ streamedRange,
72
+ } from './range';
60
73
  import type { SignedUrlVerification } from './signed-url';
61
74
  import { buildSignedUrl, signedUrlBaseFor, verifySignedUrl } from './signed-url';
62
75
  import { resolveSigningSecret } from './signing-secret';
@@ -106,38 +119,6 @@ export interface LocalDriverOptions {
106
119
  readonly queue?: KeyedQueue | undefined;
107
120
  }
108
121
 
109
- /**
110
- * A POSIX file is not encrypted at rest by this driver, and recording the request in the sidecar
111
- * would answer a security review with a field the disk never honoured. Refused on the DEV disk
112
- * too, and deliberately: a `put()` that succeeds locally and throws in production is a gap an app
113
- * meets on the worst day, and both drivers refusing is one rule instead of two.
114
- */
115
- function refuseUnsupportedPut(putOptions?: PutOptions): void {
116
- if (putOptions?.serverSideEncryption === undefined) return;
117
- throw new NotImplementedError({
118
- cause:
119
- 'server-side encryption on the local driver (it writes plain files under one root) 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, or a LUKS/FileVault volume under root',
120
- fix: 'put(key, body)',
121
- });
122
- }
123
-
124
- /** `ENOENT` is the one delete failure that means "already in the desired state". */
125
- const isMissingFile = (error: unknown): boolean => stringField(error, 'code') === 'ENOENT';
126
-
127
- /**
128
- * A directory at an object's path is OTHER keys' parent (`c` beside `c/d`), never an object: no
129
- * bytes are stored at the key, so deleting it is deleting an absent key. Asked only after an
130
- * unlink failed, because the errno for "that is a directory" is `EISDIR` on Linux and `EPERM` on
131
- * macOS — the stat is the portable answer.
132
- */
133
- const isDirectory = async (path: string): Promise<boolean> =>
134
- Bun.file(path)
135
- .stat()
136
- .then(
137
- (stats) => stats.isDirectory(),
138
- () => false,
139
- );
140
-
141
122
  export function localDriver(options: LocalDriverOptions): StorageDriver {
142
123
  const root = options.root.replace(/\/+$/, '');
143
124
  // `=== undefined`, never `??`: `??` coalesces on `null` too, so an explicitly blanked key in a
@@ -193,278 +174,252 @@ export function localDriver(options: LocalDriverOptions): StorageDriver {
193
174
  }
194
175
  };
195
176
 
196
- /** Removes one path, or reports WHY it could not — a swallowed refusal is a false erasure. */
197
- const removeIfPresent = async (path: string, key: string): Promise<void> => {
198
- try {
199
- await Bun.file(path).delete();
200
- } catch (error) {
201
- if (isMissingFile(error) || (await isDirectory(path))) return;
202
- throw deleteFailed(
203
- DRIVER_NAME,
204
- key,
205
- error,
206
- // Screened: a key may carry `$(…)` and a root is configuration, and a fix: is pasted.
207
- `make the disk root writable by this process, then retry: ls -ld ${renderFixShellArg(root, "'<the disk root>'")} && rm -f ${renderFixShellArg(path, "'<the object file>'")}`,
208
- );
209
- }
210
- };
177
+ return withStagedMultipart(
178
+ {
179
+ name: DRIVER_NAME,
211
180
 
212
- return {
213
- name: DRIVER_NAME,
181
+ /** A getter, not a captured string: `registerAs` runs after the driver is constructed. */
182
+ get signedUrlBase(): string {
183
+ return baseUrl;
184
+ },
214
185
 
215
- /** A getter, not a captured string: `registerAs` runs after the driver is constructed. */
216
- get signedUrlBase(): string {
217
- return baseUrl;
218
- },
186
+ registerAs(diskName: string): void {
187
+ registered = diskName;
188
+ if (options.baseUrl === undefined) baseUrl = signedUrlBaseFor(diskName);
189
+ },
219
190
 
220
- registerAs(diskName: string): void {
221
- registered = diskName;
222
- if (options.baseUrl === undefined) baseUrl = signedUrlBaseFor(diskName);
223
- },
224
-
225
- async put(key: string, body: StorageBody, putOptions?: PutOptions): Promise<StorageObject> {
226
- const safe = assertSafeKey(key);
227
- refuseUnsupportedPut(putOptions);
228
- assertPutOptions(DRIVER_NAME, putOptions);
229
- assertObjectLockOptions(DRIVER_NAME, putOptions, clock);
230
- const bytes = await toBytes(body, { driver: DRIVER_NAME, key: safe, maxBytes: maxPutBytes });
231
- const claimed = putOptions?.checksum;
232
- if (claimed !== undefined) {
233
- const actual = sha256Base64(bytes);
234
- if (claimed !== actual) throw checksumMismatch(safe, claimed, actual);
235
- }
236
- const lastModified = clock.now();
237
- const sidecar: Sidecar = {
238
- contentType: putOptions?.contentType ?? DEFAULT_CONTENT_TYPE,
239
- etag: etagOf(bytes),
240
- cacheControl: putOptions?.cacheControl,
241
- metadata: putOptions?.metadata,
242
- // The write's instant, on the injected clock, recorded for every later read to report.
243
- lastModified: lastModified.toISOString(),
244
- ...sidecarLock(requestedLock(putOptions)),
245
- };
246
- await oneAtATime(safe, async () => {
247
- await refuseOverwrite(safe, 'overwrite');
248
- await commitObject({ root, key: safe, disk: registered, body: bytes, sidecar });
249
- });
250
- return {
251
- key: safe,
252
- size: bytes.byteLength,
253
- contentType: sidecar.contentType,
254
- etag: sidecar.etag,
255
- lastModified,
256
- ...(sidecar.cacheControl === undefined ? {} : { cacheControl: sidecar.cacheControl }),
257
- ...(sidecar.metadata === undefined ? {} : { metadata: sidecar.metadata }),
258
- };
259
- },
260
-
261
- async stat(key: string): Promise<StorageObject | undefined> {
262
- const entry = await measured(assertSafeKey(key));
263
- return entry && { ...entry, contentType: entry.contentType ?? DEFAULT_CONTENT_TYPE };
264
- },
265
-
266
- async get(key: string, getOptions?: GetOptions): Promise<StorageRead> {
267
- const safe = assertSafeKey(key);
268
- assertGetOptions(DRIVER_NAME, getOptions);
269
- const range = getOptions?.range;
270
- // Queued behind this key's writers: an object is two files, and a read between a put()'s
271
- // renames is a pair in doubt this process has no need to meet.
272
- return oneAtATime(safe, async () => {
273
- const file = Bun.file(filePath(safe));
274
- if (!(await file.exists())) throw objectNotFound(registered, safe);
275
- if (range !== undefined) {
276
- const served = servedRange(DRIVER_NAME, safe, range, file.size, maxGetBytes);
277
- const bytes = await readObjectRange(root, safe, registered, served);
278
- // A doubt is settled by streaming the WHOLE file through the hash, never by buffering it:
279
- // the range is the only part of this object the read may hold.
280
- const entry = await headObject(root, safe, () => etagOfFile(root, safe, registered));
281
- if (entry === undefined) throw objectNotFound(registered, safe);
282
- const object = { ...entry, contentType: entry.contentType ?? DEFAULT_CONTENT_TYPE };
283
- return { object, bytes, range: served };
191
+ async put(key: string, body: StorageBody, putOptions?: PutOptions): Promise<StorageObject> {
192
+ const safe = assertSafeKey(key);
193
+ refuseUnsupportedPut(putOptions);
194
+ assertPutOptions(DRIVER_NAME, putOptions);
195
+ assertObjectLockOptions(DRIVER_NAME, putOptions, clock);
196
+ const bytes = await toBytes(body, {
197
+ driver: DRIVER_NAME,
198
+ key: safe,
199
+ maxBytes: maxPutBytes,
200
+ });
201
+ const claimed = putOptions?.checksum;
202
+ if (claimed !== undefined) {
203
+ const actual = sha256Base64(bytes);
204
+ if (claimed !== actual) throw checksumMismatch(safe, claimed, actual);
284
205
  }
285
- if (file.size > maxGetBytes) throw getTooLarge(DRIVER_NAME, safe, file.size, maxGetBytes);
286
- const bytes = await readObjectBytes(root, safe, registered);
287
- // Settled against the bytes this read ALREADY holds: hashed only for a sidecar-less object
288
- // or a pair in doubt, and then exactly once.
289
- const entry = await headObject(root, safe, () => etagOf(bytes));
290
- if (entry === undefined) throw objectNotFound(registered, safe);
291
- return {
292
- object: { ...entry, contentType: entry.contentType ?? DEFAULT_CONTENT_TYPE },
293
- bytes,
294
- };
295
- });
296
- },
297
-
298
- async stream(key: string): Promise<ReadableStream<Uint8Array>> {
299
- const safe = assertSafeKey(key);
300
- const file = Bun.file(filePath(safe));
301
- if (!(await file.exists())) throw objectNotFound(registered, safe);
302
- return file.stream();
303
- },
304
-
305
- /** A real file copy — `Bun.write` from a `BunFile` never routes the bytes through the heap. */
306
- async copy(from: string, to: string): Promise<StorageObject> {
307
- const source = assertSafeKey(from);
308
- const destination = assertSafeKey(to);
309
- // Measured AND read in one turn on both keys. Measured in the source's turn and read in the
310
- // destination's, a put() of the source in between committed its NEW bytes under the OLD
311
- // etag and type — a pair no marker flags, so every later `get()` of the copy trusted it.
312
- return inTurnOfBoth(oneAtATime, source, destination, async () => {
313
- // Measured, not merely read: a sidecar carrying `etag: ''` or a torn source's borrowed
314
- // type is the same durable lie. The hash is streamed, and only for a pair in doubt.
315
- const entry = await headObject(root, source, () => etagOfFile(root, source, registered));
316
- if (entry === undefined) throw objectNotFound(registered, source);
317
206
  const lastModified = clock.now();
318
207
  const sidecar: Sidecar = {
319
- contentType: entry.contentType ?? DEFAULT_CONTENT_TYPE,
320
- etag: entry.etag,
321
- cacheControl: entry.cacheControl,
322
- metadata: entry.metadata,
208
+ contentType: putOptions?.contentType ?? DEFAULT_CONTENT_TYPE,
209
+ etag: etagOf(bytes),
210
+ cacheControl: putOptions?.cacheControl,
211
+ metadata: putOptions?.metadata,
212
+ // The write's instant, on the injected clock, recorded for every later read to report.
323
213
  lastModified: lastModified.toISOString(),
214
+ ...sidecarLock(requestedLock(putOptions)),
324
215
  };
325
- await refuseOverwrite(destination, 'copy onto');
326
- await commitObject({
327
- root,
328
- key: destination,
329
- disk: registered,
330
- body: Bun.file(filePath(source)),
331
- sidecar,
332
- }).catch(async (error: unknown) => {
333
- // The queue is this process's: another process can still take the source mid-copy.
334
- if (!(await Bun.file(filePath(source)).exists()))
335
- throw objectNotFound(registered, source);
336
- throw error;
216
+ await oneAtATime(safe, async () => {
217
+ await refuseOverwrite(safe, 'overwrite');
218
+ await commitObject({ root, key: safe, disk: registered, body: bytes, sidecar });
337
219
  });
338
- return { ...entry, key: destination, contentType: sidecar.contentType, lastModified };
339
- });
340
- },
220
+ return {
221
+ key: safe,
222
+ size: bytes.byteLength,
223
+ contentType: sidecar.contentType,
224
+ etag: sidecar.etag,
225
+ lastModified,
226
+ ...(sidecar.cacheControl === undefined ? {} : { cacheControl: sidecar.cacheControl }),
227
+ ...(sidecar.metadata === undefined ? {} : { metadata: sidecar.metadata }),
228
+ };
229
+ },
230
+
231
+ async putStream(
232
+ key: string,
233
+ body: ReadableStream<Uint8Array>,
234
+ streamOptions: PutStreamOptions,
235
+ ): Promise<StorageObject> {
236
+ const safe = assertSafeKey(key);
237
+ const maxBytes = assertPutStream(DRIVER_NAME, body, streamOptions);
238
+ const input = { root, key: safe, disk: registered, body, maxBytes, clock };
239
+ return putLocalStream(input, streamOptions, (then) =>
240
+ oneAtATime(safe, async () => {
241
+ await refuseOverwrite(safe, 'overwrite');
242
+ await then();
243
+ }),
244
+ );
245
+ },
246
+
247
+ async stat(key: string): Promise<StorageObject | undefined> {
248
+ const entry = await measured(assertSafeKey(key));
249
+ return entry && { ...entry, contentType: entry.contentType ?? DEFAULT_CONTENT_TYPE };
250
+ },
251
+
252
+ async get(key: string, getOptions?: GetOptions): Promise<StorageRead> {
253
+ const safe = assertSafeKey(key);
254
+ assertGetOptions(DRIVER_NAME, getOptions);
255
+ const range = getOptions?.range;
256
+ // Queued behind this key's writers: an object is two files, and a read between a put()'s
257
+ // renames is a pair in doubt this process has no need to meet.
258
+ return oneAtATime(safe, async () => {
259
+ const file = Bun.file(filePath(safe));
260
+ if (!(await file.exists())) throw objectNotFound(registered, safe);
261
+ if (range !== undefined) {
262
+ const served = servedRange(DRIVER_NAME, safe, range, file.size, maxGetBytes);
263
+ const bytes = await readObjectRange(root, safe, registered, served);
264
+ // A doubt is settled by streaming the WHOLE file through the hash, never by buffering it:
265
+ // the range is the only part of this object the read may hold.
266
+ const entry = await headObject(root, safe, () => etagOfFile(root, safe, registered));
267
+ if (entry === undefined) throw objectNotFound(registered, safe);
268
+ const object = { ...entry, contentType: entry.contentType ?? DEFAULT_CONTENT_TYPE };
269
+ return { object, bytes, range: served };
270
+ }
271
+ if (file.size > maxGetBytes) throw getTooLarge(DRIVER_NAME, safe, file.size, maxGetBytes);
272
+ const bytes = await readObjectBytes(root, safe, registered);
273
+ // Settled against the bytes this read ALREADY holds: hashed only for a sidecar-less object
274
+ // or a pair in doubt, and then exactly once.
275
+ const entry = await headObject(root, safe, () => etagOf(bytes));
276
+ if (entry === undefined) throw objectNotFound(registered, safe);
277
+ return {
278
+ object: { ...entry, contentType: entry.contentType ?? DEFAULT_CONTENT_TYPE },
279
+ bytes,
280
+ };
281
+ });
282
+ },
283
+
284
+ async stream(key: string): Promise<ReadableStream<Uint8Array>> {
285
+ const safe = assertSafeKey(key);
286
+ const file = Bun.file(filePath(safe));
287
+ if (!(await file.exists())) throw objectNotFound(registered, safe);
288
+ return file.stream();
289
+ },
341
290
 
342
- async delete(key: string): Promise<void> {
343
- const safe = assertSafeKey(key);
344
- // Idempotent by contract: a missing key is already in the desired state. A REFUSED unlink
345
- // is not — a read-only mount or a root this process cannot write reports the bytes gone
346
- // when they are still on disk, which is the one lie an erasure sweep must never repeat.
347
- await oneAtATime(safe, async () => {
348
- const lock = await lockAt(safe);
349
- if (lock !== undefined) {
350
- throw new ObjectLockedError({
291
+ async streamRange(key: string, range: ByteRange): Promise<ReadableStream<Uint8Array>> {
292
+ const safe = assertSafeKey(key);
293
+ assertStreamRange(DRIVER_NAME, range);
294
+ const file = Bun.file(filePath(safe));
295
+ if (!(await file.exists())) throw objectNotFound(registered, safe);
296
+ const [first, last] = streamedRange(DRIVER_NAME, safe, range, file.size);
297
+ return file.slice(first, last + 1).stream();
298
+ },
299
+
300
+ /** A real file copy — `Bun.write` from a `BunFile` never routes the bytes through the heap. */
301
+ async copy(from: string, to: string, options?: CopyOptions): Promise<StorageObject> {
302
+ const source = assertSafeKey(from);
303
+ const destination = assertSafeKey(to);
304
+ // Measured AND read in one turn on both keys. Measured in the source's turn and read in the
305
+ // destination's, a put() of the source in between committed its NEW bytes under the OLD
306
+ // etag and type — a pair no marker flags, so every later `get()` of the copy trusted it.
307
+ return inTurnOfBoth(oneAtATime, source, destination, async () => {
308
+ // Measured, not merely read: a sidecar carrying `etag: ''` or a torn source's borrowed
309
+ // type is the same durable lie. The hash is streamed, and only for a pair in doubt.
310
+ const entry = await headObject(root, source, () => etagOfFile(root, source, registered));
311
+ if (entry === undefined) throw objectNotFound(registered, source);
312
+ if (options?.ifMatch !== undefined && entry.etag !== options.ifMatch) {
313
+ throw objectChanged(source, options.ifMatch, entry.etag);
314
+ }
315
+ const lastModified = clock.now();
316
+ const sidecar: Sidecar = {
317
+ contentType: entry.contentType ?? DEFAULT_CONTENT_TYPE,
318
+ etag: entry.etag,
319
+ cacheControl: entry.cacheControl,
320
+ metadata: entry.metadata,
321
+ lastModified: lastModified.toISOString(),
322
+ };
323
+ await refuseOverwrite(destination, 'copy onto');
324
+ await commitObject({
325
+ root,
326
+ key: destination,
351
327
  disk: registered,
352
- key: safe,
353
- action: 'delete',
354
- lock,
355
- sidecar: metaPath(safe),
328
+ body: Bun.file(filePath(source)),
329
+ sidecar,
330
+ }).catch(async (error: unknown) => {
331
+ // The queue is this process's: another process can still take the source mid-copy.
332
+ if (!(await Bun.file(filePath(source)).exists()))
333
+ throw objectNotFound(registered, source);
334
+ throw error;
356
335
  });
357
- }
358
- await removeIfPresent(filePath(safe), safe);
359
- await removeIfPresent(metaPath(safe), safe);
360
- await removeIfPresent(`${root}/${pendingPathOf(safe)}`, safe);
361
- });
362
- },
336
+ return { ...entry, key: destination, contentType: sidecar.contentType, lastModified };
337
+ });
338
+ },
363
339
 
364
- async exists(key: string): Promise<boolean> {
365
- return Bun.file(filePath(assertSafeKey(key))).exists();
366
- },
340
+ async delete(key: string): Promise<void> {
341
+ const safe = assertSafeKey(key);
342
+ // Idempotent by contract: a missing key is already in the desired state. A REFUSED unlink
343
+ // is not — a read-only mount or a root this process cannot write reports the bytes gone
344
+ // when they are still on disk, which is the one lie an erasure sweep must never repeat.
345
+ await oneAtATime(safe, async () => {
346
+ const lock = await lockAt(safe);
347
+ if (lock !== undefined) {
348
+ throw new ObjectLockedError({
349
+ disk: registered,
350
+ key: safe,
351
+ action: 'delete',
352
+ lock,
353
+ sidecar: metaPath(safe),
354
+ });
355
+ }
356
+ await removeIfPresent(root, filePath(safe), safe);
357
+ await removeIfPresent(root, metaPath(safe), safe);
358
+ await removeIfPresent(root, `${root}/${pendingPathOf(safe)}`, safe);
359
+ });
360
+ },
367
361
 
368
- /** From the sidecar, as recorded — a lapsed retention is still reported, as S3 reports it. */
369
- async retentionOf(key: string): Promise<ObjectLock> {
370
- const safe = assertSafeKey(key);
371
- return oneAtATime(safe, async () => {
372
- if (!(await Bun.file(filePath(safe)).exists())) throw objectNotFound(registered, safe);
373
- return lockOfSidecar(await readSidecar(root, safe));
374
- });
375
- },
362
+ async exists(key: string): Promise<boolean> {
363
+ return Bun.file(filePath(assertSafeKey(key))).exists();
364
+ },
376
365
 
377
- async list(listOptions?: ListOptions): Promise<Page<StorageListEntry>> {
378
- const prefix = listOptions?.prefix ?? '';
379
- assertListOptions(listOptions);
380
- const limit = resolveListLimit(listOptions?.limit);
381
- const cursor = listOptions?.cursor ?? undefined;
382
- const keys: string[] = [];
383
- try {
384
- // `dot: true`, and it is load-bearing: without it a glob matches no dot-prefixed entry, so
385
- // every object whose key has one — `.hidden.txt`, `org/o1/pending/.x.png`, the
386
- // `.metadata/a.json` `path.test.ts` pins as legal — was absent from the listing while
387
- // `put`/`get`/`exists` handled it normally and the s3 listing returned it. `sweepOrphans`
388
- // pages through `list()`, so those objects were swept as if they did not exist: a false
389
- // erasure report by omission, which is what the classification below exists to prevent.
390
- for await (const entry of new Bun.Glob('**/*').scan({
391
- cwd: root,
392
- onlyFiles: true,
393
- dot: true,
394
- })) {
395
- const key = entry.replaceAll('\\', '/');
396
- // The real filter now, not a second line of defence: the glob above yields the sidecar
397
- // tree, and this is the one thing keeping `.meta/<key>.json` out of the object
398
- // namespace. Folded like `assertSafeKey`'s reservation — `.META/` and `.meta/` are one
399
- // directory on APFS and NTFS, and listing a key `get()` would refuse is the worse half.
400
- if (key.toLowerCase().startsWith(`${META_DIR}/`)) continue;
401
- if (!key.startsWith(prefix)) continue;
402
- // The cursor IS the last key of the previous page — lexicographic order keeps it stable.
403
- if (cursor !== undefined && key <= cursor) continue;
404
- keys.push(key);
405
- }
406
- } catch (error) {
407
- // A disk nobody has written to yet has no directory: an empty listing, not an error.
408
- if (isMissingFile(error)) return pageOf([], null);
409
- // Everything else is a refusal, and a bare `catch` reported all of them as "this disk is
410
- // empty" — `EACCES` on the root, `ENOTDIR` on a root that is a file, an I/O error on the
411
- // mount. `sweepOrphans` walks `list()`, so that swallow certified an unreadable prefix as
412
- // having no orphans: the same false report `delete()`'s `.catch(() => undefined)` used to
413
- // make, one call to the left.
414
- throw listFailed(
415
- DRIVER_NAME,
416
- prefix,
417
- error,
418
- `make the disk root readable by this process, then retry: ls -ld ${renderFixShellArg(root, "'<the disk root>'")}`,
419
- );
420
- }
421
- keys.sort();
422
- const page = keys.slice(0, limit);
423
- const objects: StorageListEntry[] = [];
424
- for (const key of page) {
425
- const object = await head(key);
426
- if (object !== undefined) objects.push(object);
427
- }
428
- // The cursor is minted only when a key PAST the page exists — `keys` is every match, so a
429
- // full last page answers no cursor. `limit` is a positive integer, so a page with more after
430
- // it always HAS a last key: the `?? null` is the type's, not a second condition. It used to
431
- // be one, and `limit: 0` fell through it — an empty page reported as complete.
432
- return pageOf(objects, keys.length > page.length ? (page.at(-1) ?? null) : null);
433
- },
366
+ /** From the sidecar, as recorded — a lapsed retention is still reported, as S3 reports it. */
367
+ async retentionOf(key: string): Promise<ObjectLock> {
368
+ const safe = assertSafeKey(key);
369
+ return oneAtATime(safe, async () => {
370
+ if (!(await Bun.file(filePath(safe)).exists())) throw objectNotFound(registered, safe);
371
+ return lockOfSidecar(await readSidecar(root, safe));
372
+ });
373
+ },
434
374
 
435
- async signedUrl(key: string, urlOptions?: SignedUrlOptions): Promise<string> {
436
- return buildSignedUrl({
437
- secret,
438
- key: assertSafeKey(key),
439
- method: urlOptions?.method,
440
- expiresInMs: urlOptions?.expiresInMs,
441
- maxBytes: urlOptions?.maxBytes,
442
- contentType: urlOptions?.contentType,
443
- baseUrl,
444
- clock,
445
- });
446
- },
375
+ async list(listOptions?: ListOptions): Promise<Page<StorageListEntry>> {
376
+ assertListOptions(listOptions);
377
+ return listLocalPage({
378
+ root,
379
+ prefix: listOptions?.prefix ?? '',
380
+ cursor: listOptions?.cursor ?? undefined,
381
+ limit: resolveListLimit(listOptions?.limit),
382
+ head,
383
+ });
384
+ },
385
+
386
+ async signedUrl(key: string, urlOptions?: SignedUrlOptions): Promise<string> {
387
+ return buildSignedUrl({
388
+ secret,
389
+ key: assertSafeKey(key),
390
+ method: urlOptions?.method,
391
+ expiresInMs: urlOptions?.expiresInMs,
392
+ maxBytes: signedByteBound(DRIVER_NAME, urlOptions),
393
+ contentType: urlOptions?.contentType,
394
+ part: urlOptions?.part,
395
+ baseUrl,
396
+ clock,
397
+ });
398
+ },
447
399
 
448
- /**
449
- * The mint above, run backwards, on the same three values from the same closure: `secret`,
450
- * `baseUrl` and `clock`. That is the whole reason it belongs here rather than in a caller — a
451
- * route holding this disk can now accept an upload without ever being handed the key, and
452
- * before this member existed there was no way for one to verify at all, which is why the
453
- * shipped `PUT` half of `/_storage` was never mounted.
454
- *
455
- * Never throws: `verifySignedUrl` returns a reason, and `accept.ts` owns which reason becomes
456
- * which error. A driver that decided that here would be a second error taxonomy.
457
- */
458
- async verifySigned(input: {
459
- readonly url: string;
460
- readonly clock?: Clock | undefined;
461
- }): Promise<SignedUrlVerification> {
462
- return verifySignedUrl({
463
- url: input.url,
464
- secret,
465
- baseUrl,
466
- clock: input.clock ?? clock,
467
- });
400
+ /**
401
+ * The mint above, run backwards, on the same three values from the same closure: `secret`,
402
+ * `baseUrl` and `clock`. That is the whole reason it belongs here rather than in a caller — a
403
+ * route holding this disk can now accept an upload without ever being handed the key, and
404
+ * before this member existed there was no way for one to verify at all, which is why the
405
+ * shipped `PUT` half of `/_storage` was never mounted.
406
+ *
407
+ * Never throws: `verifySignedUrl` returns a reason, and `accept.ts` owns which reason becomes
408
+ * which error. A driver that decided that here would be a second error taxonomy.
409
+ */
410
+ async verifySigned(input: {
411
+ readonly url: string;
412
+ readonly clock?: Clock | undefined;
413
+ }): Promise<SignedUrlVerification> {
414
+ return verifySignedUrl({
415
+ url: input.url,
416
+ secret,
417
+ baseUrl,
418
+ clock: input.clock ?? clock,
419
+ });
420
+ },
468
421
  },
469
- };
422
+ localPartStore(root),
423
+ clock,
424
+ );
470
425
  }
@@ -0,0 +1,41 @@
1
+ // Single responsibility: where the memory disk keeps an open upload in parts — a `Map` beside its
2
+ // objects, holding copies, so a test's disk answers the local disk's multipart contract with
3
+ // nothing on a filesystem.
4
+
5
+ import type { UploadedPart } from './multipart';
6
+ import type { PartStore, StagedSession } from './multipart-staged';
7
+
8
+ interface Held {
9
+ readonly session: StagedSession;
10
+ readonly parts: Map<number, { readonly part: UploadedPart; readonly bytes: Uint8Array }>;
11
+ }
12
+
13
+ export function memoryPartStore(): PartStore {
14
+ const held = new Map<string, Held>();
15
+ return {
16
+ async open(uploadId, session): Promise<void> {
17
+ held.set(uploadId, { session, parts: new Map() });
18
+ },
19
+ async session(uploadId): Promise<StagedSession | undefined> {
20
+ return held.get(uploadId)?.session;
21
+ },
22
+ async writePart(uploadId, part, bytes): Promise<void> {
23
+ held.get(uploadId)?.parts.set(part.partNumber, { part, bytes: bytes.slice() });
24
+ },
25
+ async parts(uploadId): Promise<readonly UploadedPart[]> {
26
+ const parts = [...(held.get(uploadId)?.parts.values() ?? [])].map(({ part }) => part);
27
+ return parts.sort((a, b) => a.partNumber - b.partNumber);
28
+ },
29
+ read(uploadId, parts): ReadableStream<Uint8Array> {
30
+ const stored = held.get(uploadId)?.parts;
31
+ const chunks = parts.map(({ partNumber }) => stored?.get(partNumber)?.bytes.slice());
32
+ return new Blob(chunks.filter((chunk) => chunk !== undefined)).stream();
33
+ },
34
+ async drop(uploadId): Promise<void> {
35
+ held.delete(uploadId);
36
+ },
37
+ async sessions(): Promise<readonly (readonly [string, StagedSession])[]> {
38
+ return [...held].map(([uploadId, { session }]) => [uploadId, session] as const);
39
+ },
40
+ };
41
+ }