@ultimat3/storage 23.0.0 → 24.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/src/driver-s3.ts CHANGED
@@ -4,8 +4,17 @@
4
4
  // The client is built lazily on first use so importing this module never opens a socket, and
5
5
  // credentials arrive as env var NAMES: a literal key in app.config.ts is a key in git.
6
6
 
7
- import { ConfigInvalidError, EnvMissingError, finiteCount, stringField } from '@ultimat3/core';
8
7
  import {
8
+ ConfigInvalidError,
9
+ EnvMissingError,
10
+ finiteCount,
11
+ NotImplementedError,
12
+ renderFixShellArg,
13
+ stringField,
14
+ } from '@ultimat3/core';
15
+ import {
16
+ assertListOptions,
17
+ assertPutOptions,
9
18
  DEFAULT_CONTENT_TYPE,
10
19
  type ListOptions,
11
20
  type ListPage,
@@ -24,9 +33,12 @@ import { regionMismatch } from './driver-s3-region';
24
33
  import {
25
34
  checksumMismatch,
26
35
  deleteFailed,
36
+ getTooLarge,
37
+ isStorageError,
27
38
  listFailed,
28
39
  objectNotFound,
29
- storageNotImplemented,
40
+ putFailed,
41
+ readFailed,
30
42
  } from './errors';
31
43
  import { assertSafeKey } from './path';
32
44
  import { DEFAULT_SIGNED_URL_TTL_MS } from './signed-url';
@@ -102,6 +114,12 @@ export interface S3DriverOptions {
102
114
  * and S3's single-PUT limit is 5GB regardless of what this says.
103
115
  */
104
116
  readonly maxPutBytes?: number | undefined;
117
+ /**
118
+ * Ceiling on ONE `get()`, because `get()` buffers the whole object — and on this disk nothing
119
+ * bounds what a presigned PUT stored (`SignedUrlOptions.maxBytes`). Defaults to `maxPutBytes`;
120
+ * an object past it is read with `stream()`.
121
+ */
122
+ readonly maxGetBytes?: number | undefined;
105
123
  }
106
124
 
107
125
  interface S3ClientConstructor {
@@ -157,8 +175,12 @@ function buildClient(options: S3DriverOptions): S3ClientLike {
157
175
  });
158
176
  }
159
177
 
160
- const toDate = (value: string | Date | undefined): Date =>
161
- value === undefined ? new Date(0) : value instanceof Date ? value : new Date(value);
178
+ /**
179
+ * A missing `LastModified` is ABSENT, never `new Date(0)`: epoch 0 is older than every window, so
180
+ * `sweepOrphans` deleted an upload on the strength of a field the provider never sent.
181
+ */
182
+ const dated = (value: string | Date | undefined): { readonly lastModified?: Date } =>
183
+ value === undefined ? {} : { lastModified: value instanceof Date ? value : new Date(value) };
162
184
 
163
185
  /** One numeric field off a value that may fight being read — `stringField`'s missing twin. */
164
186
  function numberField(value: unknown, key: string): number | undefined {
@@ -196,12 +218,12 @@ function isAbsentObject(error: unknown): boolean {
196
218
  */
197
219
  function refuseUnsupportedPut(bucket: string, key: string, putOptions?: PutOptions): void {
198
220
  if (putOptions?.metadata !== undefined || putOptions?.cacheControl !== undefined) {
199
- const uri = `s3://${bucket}/${key}`;
200
- throw storageNotImplemented(
201
- 'user metadata and cache-control on the s3 driver (Bun exposes no header hook yet)',
202
- `drop metadata/cacheControl from put(), or set them out of band: ` +
203
- `aws s3 cp ${uri} ${uri} --metadata-directive REPLACE`,
204
- );
221
+ const uri = renderFixShellArg(`s3://${bucket}/${key}`, '<s3-uri>');
222
+ throw new NotImplementedError({
223
+ cause:
224
+ 'user metadata and cache-control on the s3 driver (Bun exposes no header hook yet) is not implemented by this driver — drop metadata/cacheControl from put(), or set them out of band with the command in fix',
225
+ fix: `aws s3 cp ${uri} ${uri} --metadata-directive REPLACE`,
226
+ });
205
227
  }
206
228
  const sse = putOptions?.serverSideEncryption;
207
229
  if (sse === undefined) return;
@@ -209,10 +231,11 @@ function refuseUnsupportedPut(bucket: string, key: string, putOptions?: PutOptio
209
231
  sse.algorithm === 'aws:kms'
210
232
  ? `{"SSEAlgorithm":"aws:kms","KMSMasterKeyID":"${sse.kmsKeyId ?? '<key-arn>'}"}`
211
233
  : '{"SSEAlgorithm":"AES256"}';
212
- throw storageNotImplemented(
213
- 'per-object server-side encryption on the s3 driver (Bun.S3Client exposes acl, storageClass and type, and nothing for x-amz-server-side-encryption)',
214
- `set it bucket-wide, then drop serverSideEncryption from put(): aws s3api put-bucket-encryption --bucket ${bucket} --server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":${rule},"BucketKeyEnabled":true}]}'`,
215
- );
234
+ throw new NotImplementedError({
235
+ cause:
236
+ 'per-object server-side encryption on the s3 driver (Bun.S3Client exposes acl, storageClass and type, and nothing for x-amz-server-side-encryption) is not implemented by this driver — set it bucket-wide with the command in fix, then drop serverSideEncryption from put()',
237
+ fix: `aws s3api put-bucket-encryption --bucket ${renderFixShellArg(bucket, '<bucket>')} --server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":${rule},"BucketKeyEnabled":true}]}'`,
238
+ });
216
239
  }
217
240
 
218
241
  export function s3Driver(options: S3DriverOptions): StorageDriver {
@@ -224,6 +247,12 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
224
247
  options.maxPutBytes === undefined ? DEFAULT_MAX_UPLOAD_BYTES : options.maxPutBytes,
225
248
  1,
226
249
  );
250
+ const maxGetBytes = finiteCount(
251
+ 'the s3 driver',
252
+ 'maxGetBytes',
253
+ options.maxGetBytes === undefined ? maxPutBytes : options.maxGetBytes,
254
+ 1,
255
+ );
227
256
  let client: S3ClientLike | undefined;
228
257
  const conn = (): S3ClientLike => {
229
258
  client ??= buildClient(options);
@@ -240,19 +269,51 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
240
269
  const mismatch = regionMismatch(options.bucket, error);
241
270
  if (mismatch !== undefined) throw mismatch;
242
271
  };
243
- const misconfigured = (error: unknown): never => {
244
- throwIfMisconfigured(error);
245
- throw error;
246
- };
272
+ /**
273
+ * A write the provider refused, coded: a denied `s3:PutObject`, a throttle, an expired
274
+ * credential. The bare `S3Error` used to escape `put()` and `copy()` exactly as it once escaped
275
+ * `list()` — no code, no fix, an anonymous 500.
276
+ */
277
+ const refusedWrite =
278
+ (key: string) =>
279
+ (error: unknown): never => {
280
+ throwIfMisconfigured(error);
281
+ throw putFailed(
282
+ DRIVER_NAME,
283
+ key,
284
+ error,
285
+ `aws s3api get-bucket-policy --bucket ${renderFixShellArg(options.bucket, '<bucket>')}`,
286
+ );
287
+ };
288
+
289
+ /**
290
+ * A read the provider refused, coded — `refusedWrite`'s twin. A 404 is "not found" wherever it
291
+ * arrives: an object deleted between `exists()` and the read is absent, not unreadable.
292
+ */
293
+ const refusedRead =
294
+ (key: string) =>
295
+ (error: unknown): never => {
296
+ if (isStorageError(error)) throw error;
297
+ if (isAbsentObject(error)) throw objectNotFound(DRIVER_NAME, key);
298
+ throwIfMisconfigured(error);
299
+ throw readFailed(
300
+ DRIVER_NAME,
301
+ key,
302
+ error,
303
+ `aws s3api head-object --bucket ${renderFixShellArg(options.bucket, '<bucket>')} --key ${renderFixShellArg(key, '<key>')}`,
304
+ );
305
+ };
306
+ const present = (key: string): Promise<boolean> =>
307
+ conn().file(key).exists().catch(refusedRead(key));
247
308
 
248
309
  const statObject = async (key: string): Promise<StorageObject> => {
249
- const stat = await conn().file(key).stat();
310
+ const stat = await conn().file(key).stat().catch(refusedRead(key));
250
311
  return {
251
312
  key,
252
313
  size: stat.size,
253
314
  contentType: stat.type ?? DEFAULT_CONTENT_TYPE,
254
315
  etag: stat.etag ?? '',
255
- lastModified: toDate(stat.lastModified),
316
+ ...dated(stat.lastModified),
256
317
  };
257
318
  };
258
319
 
@@ -262,6 +323,7 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
262
323
  async put(key: string, body: StorageBody, putOptions?: PutOptions): Promise<StorageObject> {
263
324
  const safe = assertSafeKey(key);
264
325
  refuseUnsupportedPut(options.bucket, safe, putOptions);
326
+ assertPutOptions(DRIVER_NAME, putOptions);
265
327
  // Buffered on purpose: size and checksum must be known before the object exists — so this
266
328
  // path is for objects that FIT IN MEMORY, and `maxPutBytes` is what makes that a contract
267
329
  // rather than a hope. User uploads never come through here: they go direct to the bucket
@@ -275,22 +337,32 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
275
337
  await conn()
276
338
  .file(safe)
277
339
  .write(bytes, { type: putOptions?.contentType ?? DEFAULT_CONTENT_TYPE })
278
- .catch(misconfigured);
340
+ .catch(refusedWrite(safe));
279
341
  return statObject(safe);
280
342
  },
281
343
 
344
+ async stat(key: string): Promise<StorageObject | undefined> {
345
+ const safe = assertSafeKey(key);
346
+ return (await present(safe)) ? statObject(safe) : undefined;
347
+ },
348
+
282
349
  async get(key: string): Promise<StorageRead> {
283
350
  const safe = assertSafeKey(key);
284
351
  const file = conn().file(safe);
285
- if (!(await file.exists())) throw objectNotFound(DRIVER_NAME, safe);
286
- const bytes = new Uint8Array(await file.arrayBuffer());
287
- return { object: await statObject(safe), bytes };
352
+ if (!(await present(safe))) throw objectNotFound(DRIVER_NAME, safe);
353
+ // The HEAD comes FIRST, and it is the whole point: a client PUT straight into the bucket is
354
+ // bounded by nothing on this disk, so `arrayBuffer()` on whatever is there was heap growth
355
+ // the uploader chose. Refused on the provider's own size before a byte is read.
356
+ const object = await statObject(safe);
357
+ if (object.size > maxGetBytes) throw getTooLarge(DRIVER_NAME, safe, object.size, maxGetBytes);
358
+ const bytes = new Uint8Array(await file.arrayBuffer().catch(refusedRead(safe)));
359
+ return { object, bytes };
288
360
  },
289
361
 
290
362
  async stream(key: string): Promise<ReadableStream<Uint8Array>> {
291
363
  const safe = assertSafeKey(key);
292
364
  const file = conn().file(safe);
293
- if (!(await file.exists())) throw objectNotFound(DRIVER_NAME, safe);
365
+ if (!(await present(safe))) throw objectNotFound(DRIVER_NAME, safe);
294
366
  return file.stream();
295
367
  },
296
368
 
@@ -305,12 +377,12 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
305
377
  const source = assertSafeKey(from);
306
378
  const destination = assertSafeKey(to);
307
379
  const file = conn().file(source);
308
- if (!(await file.exists())) throw objectNotFound(DRIVER_NAME, source);
309
- const stat = await file.stat();
380
+ if (!(await present(source))) throw objectNotFound(DRIVER_NAME, source);
381
+ const stat = await file.stat().catch(refusedRead(source));
310
382
  await conn()
311
383
  .file(destination)
312
384
  .write(file, { type: stat.type ?? DEFAULT_CONTENT_TYPE })
313
- .catch(misconfigured);
385
+ .catch(refusedWrite(destination));
314
386
  return statObject(destination);
315
387
  },
316
388
 
@@ -333,13 +405,14 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
333
405
  },
334
406
 
335
407
  async exists(key: string): Promise<boolean> {
336
- return conn().file(assertSafeKey(key)).exists();
408
+ return present(assertSafeKey(key));
337
409
  },
338
410
 
339
411
  async list(listOptions?: ListOptions): Promise<ListPage> {
340
412
  const prefix = listOptions?.prefix ?? '';
341
413
  // Refused at the seam both drivers share, before the provider is asked: `maxKeys: 0` used to
342
414
  // go straight through, while the local disk answered a complete-looking empty page.
415
+ assertListOptions(listOptions);
343
416
  const maxKeys = resolveListLimit(listOptions?.limit);
344
417
  // `conn()` OUTSIDE the try: a missing credential or an absent `Bun.S3Client` is this disk
345
418
  // misconfigured, and it already answers with its own code and its own fix.
@@ -375,7 +448,7 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
375
448
  key: entry.key,
376
449
  size: entry.size ?? 0,
377
450
  etag: entry.eTag ?? '',
378
- lastModified: toDate(entry.lastModified),
451
+ ...dated(entry.lastModified),
379
452
  });
380
453
  }
381
454
  const cursor = result.nextContinuationToken;
package/src/driver.ts CHANGED
@@ -23,7 +23,12 @@ export interface StorageListEntry {
23
23
  /** Absent means the driver's listing does not carry it. Never a fabricated default. */
24
24
  readonly contentType?: string | undefined;
25
25
  readonly etag: string;
26
- readonly lastModified: Date;
26
+ /**
27
+ * ABSENT when the provider did not say — never epoch 0, which every age test reads as "older
28
+ * than any window", and never an invalid `Date`, which is a value a reader has to remember to
29
+ * test. A reader that needs an age handles `undefined`; `sweepOrphans` spares it.
30
+ */
31
+ readonly lastModified?: Date | undefined;
27
32
  /** Present only when the driver actually stored what `put()` was handed — never invented. */
28
33
  readonly cacheControl?: string | undefined;
29
34
  readonly metadata?: Readonly<Record<string, string>> | undefined;
@@ -115,6 +120,17 @@ export interface StorageDriver {
115
120
  */
116
121
  registerAs?(diskName: string): void;
117
122
  put(key: string, body: StorageBody, options?: PutOptions): Promise<StorageObject>;
123
+ /**
124
+ * What a read would say about the object, WITHOUT its bytes — `undefined` when nothing is there.
125
+ * `promoteAttachment` decides on `stat().size`, because a presigned PUT lands on the disk
126
+ * unmeasured and `get()` is the one call that must not be how its size is learned. Required: a
127
+ * driver that cannot measure cannot be promoted from, and that is a compile error, not a refusal.
128
+ */
129
+ stat(key: string): Promise<StorageObject | undefined>;
130
+ /**
131
+ * The whole object, BUFFERED — so bounded: an object over the driver's `maxGetBytes` is
132
+ * `X_STORAGE_TOO_LARGE`, exactly as `put()` refuses a body over `maxPutBytes`. Past it, `stream()`.
133
+ */
118
134
  get(key: string): Promise<StorageRead>;
119
135
  /** Bytes without buffering — the only safe path for anything over a few MB. */
120
136
  stream(key: string): Promise<ReadableStream<Uint8Array>>;
@@ -181,6 +197,45 @@ export function resolveListLimit(limit: number | undefined): number {
181
197
  return limit;
182
198
  }
183
199
 
200
+ const isStringRecord = (value: unknown): boolean =>
201
+ typeof value === 'object' &&
202
+ value !== null &&
203
+ !Array.isArray(value) &&
204
+ Object.values(value).every((entry) => typeof entry === 'string');
205
+
206
+ /**
207
+ * What `put()` was told ABOUT the bytes, held to its type before a byte moves. An untyped caller's
208
+ * `metadata: { n: 1n }` reached `JSON.stringify` on the local disk as a bare `TypeError`, and the
209
+ * memory disk stored it — against the `Record<string, string>` every reader is typed on.
210
+ */
211
+ export function assertPutOptions(driver: string, options: PutOptions | undefined): void {
212
+ if (options === undefined) return;
213
+ for (const name of ['contentType', 'cacheControl'] as const) {
214
+ assert(
215
+ options[name] === undefined || typeof options[name] === 'string',
216
+ `put() on the ${driver} disk was given a ${name} that is not a string`,
217
+ `put(key, body, { ${name}: 'a string' }) — or omit ${name}`,
218
+ );
219
+ }
220
+ assert(
221
+ options.metadata === undefined || isStringRecord(options.metadata),
222
+ `put() on the ${driver} disk was given metadata that is not a plain object of string values`,
223
+ "put(key, body, { metadata: { name: 'value' } }) — every value a string: String(n) a number, JSON.stringify anything nested",
224
+ );
225
+ }
226
+
227
+ /** A `prefix` or a `cursor` that is not a string reached `startsWith` and `<=` as whatever it was. */
228
+ export function assertListOptions(options: ListOptions | undefined): void {
229
+ for (const name of ['prefix', 'cursor'] as const) {
230
+ const value = options?.[name];
231
+ assert(
232
+ value === undefined || typeof value === 'string',
233
+ `list() was given a ${name} that is not a string`,
234
+ `disk.list({ ${name}: 'a string' }) — or omit ${name}`,
235
+ );
236
+ }
237
+ }
238
+
184
239
  /** What `toBytes` needs to refuse a body: the ceiling, and the key to name in the refusal. */
185
240
  export interface ByteLimit {
186
241
  readonly driver: string;
@@ -212,6 +267,13 @@ export async function toBytes(body: StorageBody, limit: ByteLimit): Promise<Uint
212
267
  }
213
268
  return new Uint8Array(await body.arrayBuffer());
214
269
  }
270
+ // The type says so; an untyped caller's `put(key, 'a string')` reached `getReader` as a
271
+ // bare `TypeError`.
272
+ assert(
273
+ body instanceof ReadableStream,
274
+ `put() on the ${limit.driver} disk was given a body that is not a Uint8Array, a Blob or a ReadableStream`,
275
+ "put(key, new TextEncoder().encode(text)) — bytes, never a string: encode it, or pass the request's own body stream",
276
+ );
215
277
  return readBounded(body, limit);
216
278
  }
217
279
 
package/src/errors.ts CHANGED
@@ -2,7 +2,12 @@
2
2
  // rejected upload must tell the caller which constraint fired and where that constraint is
3
3
  // configured, or the caller retries the same bytes forever.
4
4
 
5
- import { registerErrorCodes, renderThrowable, UltimateError } from '@ultimat3/core';
5
+ import {
6
+ registerErrorCodes,
7
+ renderFixLiteral,
8
+ renderThrowable,
9
+ UltimateError,
10
+ } from '@ultimat3/core';
6
11
 
7
12
  /** Codes this package declares and owns. */
8
13
  export const STORAGE_OWNED_ERROR_CODES = [
@@ -21,11 +26,14 @@ export const STORAGE_OWNED_ERROR_CODES = [
21
26
  'X_STORAGE_LIST_FAILED',
22
27
  'X_STORAGE_QUARANTINED',
23
28
  'X_STORAGE_NOT_PENDING',
29
+ 'X_STORAGE_KEY_CONFLICT',
30
+ 'X_STORAGE_PUT_FAILED',
31
+ 'X_STORAGE_READ_FAILED',
24
32
  ] as const;
25
33
 
26
34
  /**
27
- * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s. `storageNotImplemented()` throws it and this package
28
- * keeps no title for it — one code, one owner, one title, or the two copies drift apart in silence.
35
+ * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s. The drivers throw core's own `NotImplementedError`
36
+ * and this package keeps no title for it — one code, one owner, one title, or the two copies drift apart in silence.
29
37
  * `X_IMAGE_UNSUPPORTED` / `X_IMAGE_DECODE_FAILED` are core's too and surface unwrapped (`image.ts`).
30
38
  * `X_ENV_MISSING` is core's for the same reason: an unset `STORAGE_SIGNING_SECRET` outside
31
39
  * development is a missing environment variable, not a storage concept needing its own code.
@@ -57,6 +65,9 @@ export const STORAGE_ERROR_TITLES: Readonly<Record<StorageOwnedErrorCode, string
57
65
  X_STORAGE_LIST_FAILED: 'the objects could not be listed',
58
66
  X_STORAGE_QUARANTINED: 'the object is still in quarantine',
59
67
  X_STORAGE_NOT_PENDING: 'the key is not a pending upload',
68
+ X_STORAGE_KEY_CONFLICT: 'the key collides with another key on the local disk',
69
+ X_STORAGE_PUT_FAILED: 'the object could not be written',
70
+ X_STORAGE_READ_FAILED: 'the object could not be read',
60
71
  };
61
72
 
62
73
  // One unconditional call, so a second package claiming one of storage's codes throws
@@ -161,6 +172,49 @@ export const putTooLarge = (
161
172
  meta: { disk, key, bytes, maxBytes },
162
173
  });
163
174
 
175
+ /**
176
+ * The SERVER-side `get()` ceiling — `putTooLarge`'s twin. `get()` buffers the whole object, and an
177
+ * object's size is not this process's to choose: a presigned PUT lands in the bucket unmeasured,
178
+ * so a later `get()` of it was heap growth the uploader picked.
179
+ */
180
+ export const getTooLarge = (
181
+ disk: string,
182
+ key: string,
183
+ bytes: number,
184
+ maxBytes: number,
185
+ ): StorageError =>
186
+ new StorageError({
187
+ code: 'X_STORAGE_TOO_LARGE',
188
+ cause: `"${key}" is ${bytes}B against the ${disk} disk's get ceiling of ${maxBytes}B, and get() buffers the whole object`,
189
+ fix:
190
+ `read it with disk.stream(key) instead of get(), or raise the ceiling: ` +
191
+ (disk === 's3'
192
+ ? `s3Driver({ bucket, maxGetBytes: ${bytes} })`
193
+ : disk === 'memory'
194
+ ? `memoryDriver({ maxGetBytes: ${bytes} })`
195
+ : `localDriver({ root, maxGetBytes: ${bytes} })`),
196
+ meta: { disk, key, bytes, maxBytes },
197
+ });
198
+
199
+ /**
200
+ * The local disk stores an object as a FILE at its key, so a key cannot also be the directory of
201
+ * another: `put('a')` then `put('a/b')`, or the reverse, or the same pair one tree over in the
202
+ * sidecars (`a` and `a.json/b`). It surfaced as a bare `ENOTDIR` / `EISDIR`. S3 and the memory
203
+ * disk hold both — this is the local disk's own limit, refused by name rather than hidden.
204
+ *
205
+ * The `fix` lists what is in the way: the remedy is storing one of the two under another key, and
206
+ * which one is only decidable by a reader who can see both.
207
+ */
208
+ export const keyConflict = (disk: string, key: string, blocking: string): StorageError =>
209
+ new StorageError({
210
+ code: 'X_STORAGE_KEY_CONFLICT',
211
+ cause: `the "${disk}" disk is a local one and cannot store "${key}": "${blocking}" is already on it, and a POSIX path is a file or a directory, never both — so one key cannot be a path prefix of another here. Store one of the two under a different key; an s3 disk holds both`,
212
+ // The REGISTERED name, never the driver kind: `disk('local')` on a disk registered as
213
+ // `uploads` lists another disk, or answers X_STORAGE_DISK_UNKNOWN.
214
+ fix: `disk(${renderFixLiteral(disk, "'local'")}).list({ prefix: ${renderFixLiteral(blocking, "'a/'")} })`,
215
+ meta: { disk, key, blocking },
216
+ });
217
+
164
218
  /** The declared type is not on the allowlist at all. */
165
219
  export const contentTypeNotAllowed = (
166
220
  key: string,
@@ -258,6 +312,43 @@ export const deleteFailed = (
258
312
  });
259
313
  };
260
314
 
315
+ /**
316
+ * A write the disk REFUSED — `deleteFailed`'s twin for `put()` and `copy()`. The local disk's
317
+ * `EACCES` / `ENOSPC` / `EROFS` left as a bare filesystem `Error`, and a provider's refused PUT as
318
+ * a bare `S3Error`: no code, no fix, nothing for the http error map to render but an anonymous 500.
319
+ * The `fix` is the driver's, for `deleteFailed`'s reason.
320
+ */
321
+ export const putFailed = (disk: string, key: string, error: unknown, fix: string): StorageError => {
322
+ const reason = renderThrowable(error);
323
+ return new StorageError({
324
+ code: 'X_STORAGE_PUT_FAILED',
325
+ cause: `disk "${disk}" refused to write "${key}": ${reason}`,
326
+ fix,
327
+ meta: { disk, key, reason },
328
+ });
329
+ };
330
+
331
+ /**
332
+ * A read the disk REFUSED — `putFailed`'s read-side twin, for `get`, `stat`, `exists`, `stream` and
333
+ * the read half of `copy`. A denied `s3:GetObject`, a throttle, `EACCES`, `EIO`. An object that is
334
+ * simply absent is `objectNotFound`, never this: "not there" and "could not look" need opposite
335
+ * answers from a caller. The `fix` is the driver's, for `deleteFailed`'s reason.
336
+ */
337
+ export const readFailed = (
338
+ disk: string,
339
+ key: string,
340
+ error: unknown,
341
+ fix: string,
342
+ ): StorageError => {
343
+ const reason = renderThrowable(error);
344
+ return new StorageError({
345
+ code: 'X_STORAGE_READ_FAILED',
346
+ cause: `disk "${disk}" refused to read "${key}": ${reason}`,
347
+ fix,
348
+ meta: { disk, key, reason },
349
+ });
350
+ };
351
+
261
352
  /**
262
353
  * A listing the disk REFUSED — a denied `s3:ListBucket`, a throttle, an expired credential, a root
263
354
  * this process cannot read. Exactly `deleteFailed`'s shape and for exactly its reason: an empty
@@ -363,12 +454,3 @@ export const signingSecretMissing = (environment: string, disk = 'local'): Stora
363
454
  fix: 'export STORAGE_SIGNING_SECRET="$(openssl rand -hex 32)"',
364
455
  meta: { key: 'STORAGE_SIGNING_SECRET', environment },
365
456
  });
366
-
367
- /** An interface-complete driver whose remote half is not bound yet. Always carries a fix. */
368
- export const storageNotImplemented = (feature: string, fix: string): StorageError =>
369
- new StorageError({
370
- code: 'X_NOT_IMPLEMENTED',
371
- cause: `${feature} is declared but not implemented in @ultimat3/storage`,
372
- fix,
373
- meta: { feature },
374
- });
package/src/grant.ts CHANGED
@@ -83,12 +83,12 @@ export async function grantUpload(input: GrantUploadInput): Promise<UploadGrant>
83
83
  }
84
84
 
85
85
  // Refused here as well as in the presigner one call down: `expiresAt: now + NaN` is `NaN` in the
86
- // grant this function RETURNS, and the message a caller can act on names `createUploadGrant`'s
86
+ // grant this function RETURNS, and the message a caller can act on names `grantUpload`'s
87
87
  // own option rather than `buildSignedUrl`'s.
88
88
  // `=== undefined`, never `??`, for the reason `buildSignedUrl` states: `??` coalesces on `null`
89
89
  // too, so an explicit one took the default instead of this refusal.
90
90
  const expiresInMs = finiteCount(
91
- 'createUploadGrant',
91
+ 'grantUpload',
92
92
  'expiresInMs',
93
93
  input.expiresInMs === undefined ? DEFAULT_SIGNED_URL_TTL_MS : input.expiresInMs,
94
94
  1,
package/src/image.ts CHANGED
@@ -6,12 +6,13 @@
6
6
  import {
7
7
  assertFiniteImageQuality,
8
8
  blurDataUrl,
9
+ finiteCount,
9
10
  type ImageFormat,
10
11
  imageUnsupported,
11
12
  probeImage,
12
13
  transformImageBytes,
13
14
  } from '@ultimat3/core';
14
- import { assertSafeKey, keyExtname } from './path';
15
+ import { assertSafeKey } from './path';
15
16
 
16
17
  /**
17
18
  * The formats a stored VARIANT can be minted in — a strict subset of the ones `@ultimat3/core`
@@ -67,13 +68,28 @@ const FORMAT_EXTENSIONS: Readonly<Record<VariantFormat, string>> = {
67
68
  png: 'png',
68
69
  };
69
70
 
71
+ /**
72
+ * One edge of a variant box: a whole number of pixels, at least one. Core's `finiteCount`, for the
73
+ * reason `quality` goes through core's screen below — `{ width: NaN }` minted `a@wNaN.webp`, a
74
+ * writable key for bytes no encoder can produce, and `w1.5` / `w-3` are an unbounded number of
75
+ * keys in a bucket. `!== undefined`, never a truthiness test: an explicit `null` is refused.
76
+ */
77
+ function edge(subject: string, option: 'width' | 'height', value: number | undefined): void {
78
+ if (value !== undefined) finiteCount(subject, option, value, 1);
79
+ }
80
+
70
81
  /**
71
82
  * Derived, not stored: the same source + transform always yields the same key, so a variant
72
83
  * is a cache lookup rather than a database row.
84
+ *
85
+ * The WHOLE source key is the stem, extension included. It used to be cut, so `p/hero.png` and
86
+ * `p/hero.jpg` minted one `p/hero@w100.webp` and whichever was requested first was served for
87
+ * both — two sources, one cache identity.
73
88
  */
74
89
  export function variantKey(sourceKey: string, transform: ImageTransform): string {
75
90
  const safe = assertSafeKey(sourceKey);
76
- const stem = safe.slice(0, safe.length - keyExtname(safe).length);
91
+ edge('variantKey', 'width', transform.width);
92
+ edge('variantKey', 'height', transform.height);
77
93
  // `string`, not `VariantFormat`: a format off a query string or through an `as` reaches here as
78
94
  // any string core can probe, and indexing FORMAT_EXTENSIONS with `gif` minted
79
95
  // `photos/hero@full.undefined` — a well-formed, writable key naming a file nothing can serve.
@@ -101,7 +117,7 @@ export function variantKey(sourceKey: string, transform: ImageTransform): string
101
117
  );
102
118
  if (quality !== DEFAULT_QUALITY) parts.push(`q${quality}`);
103
119
  if (parts.length === 0) parts.push('full');
104
- return assertSafeKey(`${stem}@${parts.join('-')}.${FORMAT_EXTENSIONS[format]}`);
120
+ return assertSafeKey(`${safe}@${parts.join('-')}.${FORMAT_EXTENSIONS[format]}`);
105
121
  }
106
122
 
107
123
  export interface SrcsetDescriptor {
@@ -139,23 +155,33 @@ export function srcsetDescriptors(
139
155
  }));
140
156
  }
141
157
 
142
- /** Aspect-ratio fitting. `cover` rounds up so the box is always fully covered. */
158
+ /** A derived edge is never zero: 1000x1 at width 100 rounded to a height no encoder can fill. */
159
+ const atLeastOne = (pixels: number): number => Math.max(1, Math.round(pixels));
160
+
161
+ /**
162
+ * Aspect-ratio fitting. `cover` is the exact box asked for; everything else fits INSIDE the box
163
+ * and never above the source — upscaling is never useful, whichever edges were named.
164
+ */
143
165
  export function fitDimensions(source: ImageSize, transform: ImageTransform): ImageSize {
144
166
  const ratio = source.height / source.width;
145
167
  const { width, height } = transform;
168
+ edge('fitDimensions', 'width', width);
169
+ edge('fitDimensions', 'height', height);
146
170
  if (width === undefined && height === undefined) return source;
147
171
  if (height === undefined) {
148
172
  const target = Math.min(width ?? source.width, source.width);
149
- return { width: target, height: Math.round(target * ratio) };
173
+ return { width: target, height: atLeastOne(target * ratio) };
150
174
  }
151
175
  if (width === undefined) {
152
176
  const target = Math.min(height, source.height);
153
- return { width: Math.round(target / ratio), height: target };
177
+ return { width: atLeastOne(target / ratio), height: target };
154
178
  }
155
179
  // `cover` crops to the exact box; `contain` scales down until both sides fit.
156
180
  if (transform.fit === 'cover') return { width, height };
157
- const scale = Math.min(width / source.width, height / source.height);
158
- return { width: Math.round(source.width * scale), height: Math.round(source.height * scale) };
181
+ // Clamped at 1: a single named edge already stopped at the source, and two of them did not —
182
+ // a 40x20 source asked for 400x400 came back 400x200.
183
+ const scale = Math.min(1, width / source.width, height / source.height);
184
+ return { width: atLeastOne(source.width * scale), height: atLeastOne(source.height * scale) };
159
185
  }
160
186
 
161
187
  /**
package/src/index.ts CHANGED
@@ -43,22 +43,18 @@ export type {
43
43
  StorageRead,
44
44
  } from './driver';
45
45
  export {
46
+ // Exported for the same reason `toBytes` is: a driver written outside this package has to
47
+ // refuse a `limit` the same way both shipped ones do, or it is a third answer to one question.
48
+ assertPutOptions,
46
49
  DEFAULT_CONTENT_TYPE,
47
50
  DEFAULT_LIST_LIMIT,
48
51
  etagOf,
49
- // Exported for the same reason `toBytes` is: a driver written outside this package has to
50
- // refuse a `limit` the same way both shipped ones do, or it is a third answer to one question.
51
52
  resolveListLimit,
52
53
  sha256Base64,
53
54
  toBytes,
54
55
  } from './driver';
55
56
  export type { LocalDriverOptions } from './driver-local';
56
- export {
57
- DEV_SIGNING_SECRET,
58
- localDriver,
59
- STORAGE_SIGNING_SECRET_KEY,
60
- usesDevStorageSecret,
61
- } from './driver-local';
57
+ export { localDriver } from './driver-local';
62
58
  export type { MemoryDriverOptions, MemoryStorageDriver } from './driver-memory';
63
59
  export { memoryDriver } from './driver-memory';
64
60
  export type {
@@ -78,13 +74,17 @@ export {
78
74
  contentTypeUnrecognised,
79
75
  deleteFailed,
80
76
  diskUnknown,
77
+ getTooLarge,
81
78
  isStorageError,
79
+ keyConflict,
82
80
  listFailed,
83
81
  objectNotFound,
84
82
  orgMismatch,
85
83
  pathUnsafe,
84
+ putFailed,
86
85
  putTooLarge,
87
86
  quarantined,
87
+ readFailed,
88
88
  STORAGE_ERROR_CODES,
89
89
  STORAGE_ERROR_TITLES,
90
90
  StorageError,
@@ -92,7 +92,6 @@ export {
92
92
  signedUrlRejected,
93
93
  signedUrlUnverifiable,
94
94
  signingSecretMissing,
95
- storageNotImplemented,
96
95
  tooLarge,
97
96
  uploadFailed,
98
97
  } from './errors';
@@ -152,9 +151,15 @@ export {
152
151
  SIGNED_URL_VERSION,
153
152
  signConstraints,
154
153
  signedUrlBaseFor,
154
+ signedUrlBasePath,
155
155
  timingSafeEqual,
156
156
  verifySignedUrl,
157
157
  } from './signed-url';
158
+ export {
159
+ DEV_SIGNING_SECRET,
160
+ STORAGE_SIGNING_SECRET_KEY,
161
+ usesDevStorageSecret,
162
+ } from './signing-secret';
158
163
  export type { Storage, StorageConfig } from './storage';
159
164
  export { definedStorage, defineStorage, disk, resetStorage, storage } from './storage';
160
165
  export type { UploadCandidate, UploadPolicy, UploadPolicyInit, ValidatedUpload } from './upload';