@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
package/src/expire.ts ADDED
@@ -0,0 +1,169 @@
1
+ // Single responsibility: objects and upload sessions past a window the APP names, swept on any
2
+ // disk. Bucket lifecycle rules stay the operator's (axiom 7); a local disk has none, and abandoned
3
+ // parts bill silently forever without one, so a sweep that works the same on every disk is
4
+ // mechanism. No timer here: `objectPurgeTarget` hands the sweep to the app's one `purge()` job.
5
+
6
+ import {
7
+ assert,
8
+ type Clock,
9
+ finiteCount,
10
+ logger,
11
+ renderThrowable,
12
+ systemClock,
13
+ } from '@ultimat3/core';
14
+ import type { SweepFailure, SweepResult } from './attachment';
15
+ import type { StorageDriver, StorageListEntry } from './driver';
16
+ import type { MultipartSession } from './multipart';
17
+
18
+ export interface ExpireObjectsInput {
19
+ readonly disk: StorageDriver;
20
+ /** Required and non-empty: a sweep of the whole disk is never a default. */
21
+ readonly prefix: string;
22
+ /** Age past which an object goes. Required: the window is the app's. */
23
+ readonly olderThanMs: number;
24
+ /** `true` spares an object the app still accounts for. */
25
+ readonly keep?: ((object: StorageListEntry) => boolean | Promise<boolean>) | undefined;
26
+ readonly clock?: Clock | undefined;
27
+ }
28
+
29
+ /** `SweepResult`'s two lists, plus what was spared: undated, inside the window, or kept. */
30
+ export interface ExpireResult extends SweepResult {
31
+ readonly spared: readonly string[];
32
+ }
33
+
34
+ const screenPrefix = (caller: string, prefix: string): string => {
35
+ assert(
36
+ typeof prefix === 'string' && prefix !== '',
37
+ `${caller} was handed an empty prefix, which is the whole disk`,
38
+ `${caller}({ disk, prefix: 'exports/', olderThanMs }) # name the prefix the window applies to`,
39
+ );
40
+ return prefix;
41
+ };
42
+
43
+ const cutoffOf = (caller: string, olderThanMs: number, clock: Clock | undefined): number =>
44
+ // Screened first: `now - NaN` is NaN, and every age comparison against it reads "old enough".
45
+ (clock ?? systemClock).now().getTime() - finiteCount(caller, 'olderThanMs', olderThanMs, 0);
46
+
47
+ /**
48
+ * Delete every object under `prefix` older than the window. An object with no reported age is
49
+ * spared, as `sweepOrphans` spares it; a refusal — a locked object included — is recorded in
50
+ * `failed` and the sweep goes on, never forced and never reported as deleted.
51
+ */
52
+ export async function expireObjects(input: ExpireObjectsInput): Promise<ExpireResult> {
53
+ const prefix = screenPrefix('expireObjects', input.prefix);
54
+ const cutoff = cutoffOf('expireObjects', input.olderThanMs, input.clock);
55
+ const deleted: string[] = [];
56
+ const failed: SweepFailure[] = [];
57
+ const spared: string[] = [];
58
+ let cursor: string | null = null;
59
+ do {
60
+ const page = await input.disk.list({ prefix, cursor });
61
+ for (const object of page.rows) {
62
+ const modified = object.lastModified?.getTime();
63
+ if (modified === undefined || modified > cutoff || (await input.keep?.(object)) === true) {
64
+ spared.push(object.key);
65
+ continue;
66
+ }
67
+ try {
68
+ await input.disk.delete(object.key);
69
+ deleted.push(object.key);
70
+ } catch (error) {
71
+ failed.push({ key: object.key, reason: renderThrowable(error) });
72
+ }
73
+ }
74
+ cursor = page.nextCursor;
75
+ } while (cursor !== null);
76
+ return { deleted, failed, spared };
77
+ }
78
+
79
+ export interface AbortStaleUploadsInput {
80
+ readonly disk: StorageDriver;
81
+ /** Age past which an open session is abandoned. */
82
+ readonly olderThanMs: number;
83
+ /** Absent is every session on the disk: an open session under no prefix bills all the same. */
84
+ readonly prefix?: string | undefined;
85
+ /** Only sessions whose key this answers `true` for: a per-tenant kind no prefix spells. */
86
+ readonly match?: ((key: string) => boolean) | undefined;
87
+ readonly clock?: Clock | undefined;
88
+ }
89
+
90
+ export interface AbortResult {
91
+ readonly aborted: readonly MultipartSession[];
92
+ readonly failed: readonly SweepFailure[];
93
+ /** Undated or inside the window: possibly a phone still on the train. */
94
+ readonly spared: readonly MultipartSession[];
95
+ }
96
+
97
+ /** Abort every upload session opened before the window and never completed. */
98
+ export async function abortStaleUploads(input: AbortStaleUploadsInput): Promise<AbortResult> {
99
+ const cutoff = cutoffOf('abortStaleUploads', input.olderThanMs, input.clock);
100
+ const multipart = input.disk.multipart;
101
+ if (multipart === undefined) return { aborted: [], failed: [], spared: [] };
102
+ const aborted: MultipartSession[] = [];
103
+ const failed: SweepFailure[] = [];
104
+ const spared: MultipartSession[] = [];
105
+ for (const session of await multipart.listUploads(input.prefix)) {
106
+ if (input.match !== undefined && !input.match(session.key)) continue;
107
+ const initiated = session.initiated?.getTime();
108
+ if (initiated === undefined || initiated > cutoff) {
109
+ spared.push(session);
110
+ continue;
111
+ }
112
+ try {
113
+ await multipart.abort(session.key, session.uploadId);
114
+ aborted.push(session);
115
+ } catch (error) {
116
+ failed.push({ key: session.key, reason: renderThrowable(error) });
117
+ }
118
+ }
119
+ return { aborted, failed, spared };
120
+ }
121
+
122
+ export interface ObjectPurgeTargetInput {
123
+ /** The step's name in the purge job, unique within it. */
124
+ readonly name: string;
125
+ readonly disk: StorageDriver;
126
+ readonly prefix: string;
127
+ /**
128
+ * Only keys this answers `true` for, under `prefix`: `org/<id>/imports/` is one kind of file
129
+ * under every tenant, and no prefix names it without also naming every avatar beside it.
130
+ */
131
+ readonly match?: ((key: string) => boolean) | undefined;
132
+ readonly olderThanMs: number;
133
+ }
134
+
135
+ /**
136
+ * Structurally `@ultimat3/jobs`' `PurgeTarget`, so this package imports nothing upward and the
137
+ * app's one `purge({ targets })` carries it. One pass expires the objects and aborts the sessions
138
+ * under the prefix, on the JOB's clock. A refusal is logged and counted out, never retried into a
139
+ * dead letter: a locked object stays locked on every attempt.
140
+ */
141
+ export function objectPurgeTarget(input: ObjectPurgeTargetInput): {
142
+ readonly name: string;
143
+ purgeExpired(nowMs: number): Promise<number>;
144
+ } {
145
+ const prefix = screenPrefix('objectPurgeTarget', input.prefix);
146
+ finiteCount('objectPurgeTarget', 'olderThanMs', input.olderThanMs, 0);
147
+ return {
148
+ name: input.name,
149
+ async purgeExpired(nowMs: number): Promise<number> {
150
+ const clock: Clock = { now: () => new Date(nowMs), monotonic: () => nowMs };
151
+ const window = { disk: input.disk, prefix, olderThanMs: input.olderThanMs, clock };
152
+ const match = input.match;
153
+ // Outside `match` is spared, never deleted: `keep` is `expireObjects`' own way of saying so.
154
+ const objects = await expireObjects(
155
+ match === undefined ? window : { ...window, keep: (object) => !match(object.key) },
156
+ );
157
+ const uploads = await abortStaleUploads({ ...window, match });
158
+ const failed = [...objects.failed, ...uploads.failed];
159
+ if (failed.length > 0) {
160
+ logger.warn('storage.expire.refused', {
161
+ target: input.name,
162
+ refused: failed.length,
163
+ first: failed[0]?.reason,
164
+ });
165
+ }
166
+ return objects.deleted.length + uploads.aborted.length;
167
+ },
168
+ };
169
+ }
@@ -0,0 +1,196 @@
1
+ // Single responsibility: the server half of an upload in parts — open a session under
2
+ // `grantUpload`'s policy gate, sign a part, list what landed, complete, abort. Each is a function the
3
+ // app wraps in its own `action()`, and each is held to the ACTOR's tenant, so an upload id read off a
4
+ // request reaches nothing outside the caller's org.
5
+
6
+ import { assert } from '@ultimat3/core';
7
+ import type { StorageDriver, StorageObject } from './driver';
8
+ import { orgMismatch, tooLarge } from './errors';
9
+ import { multipartUnsupported, partsInvalid } from './errors-multipart';
10
+ import { decideGrant, type GrantUploadInput } from './grant';
11
+ import {
12
+ type CompletedPart,
13
+ MAX_PART_BYTES,
14
+ MAX_PARTS,
15
+ MIN_PART_BYTES,
16
+ type MultipartCapability,
17
+ matchParts,
18
+ partBytesFor,
19
+ type UploadedPart,
20
+ } from './multipart';
21
+ import { isWithinOrg } from './path';
22
+ import { DEFAULT_SIGNED_URL_TTL_MS } from './signed-url';
23
+ import type { UploadPolicy } from './upload';
24
+
25
+ export interface GrantMultipartInput extends GrantUploadInput {
26
+ /** A part size the app prefers; raised to what 10,000 parts need, never below S3's 5 MiB. */
27
+ readonly partBytes?: number | undefined;
28
+ }
29
+
30
+ export interface MultipartGrant {
31
+ readonly key: string;
32
+ readonly uploadId: string;
33
+ /** Every part but the last is exactly this long. */
34
+ readonly partBytes: number;
35
+ /** How many parts the declared size takes at `partBytes`. */
36
+ readonly maxParts: number;
37
+ /** Normalised: the type the object is created under. */
38
+ readonly contentType: string;
39
+ /** The declared size, already held to the policy. The client's claim: `policy` is the bound. */
40
+ readonly size: number;
41
+ /** Epoch ms: the part URLs' window from now. Each `signPart` opens a fresh one. */
42
+ readonly expiresAt: number;
43
+ }
44
+
45
+ /** What names one open session. `orgId` is the actor's, resolved server-side. */
46
+ export interface UploadSessionInput {
47
+ readonly disk: StorageDriver;
48
+ readonly orgId: string;
49
+ readonly key: string;
50
+ readonly uploadId: string;
51
+ }
52
+
53
+ export interface SignPartInput extends UploadSessionInput {
54
+ readonly partNumber: number;
55
+ /** This part's exact size: `partBytes`, or less for the last part. Signed. */
56
+ readonly size: number;
57
+ /** The policy the grant was made under: a part no object under its `maxBytes` holds is refused. */
58
+ readonly policy: UploadPolicy;
59
+ readonly expiresInMs?: number | undefined;
60
+ }
61
+
62
+ export interface CompleteUploadInput extends UploadSessionInput {
63
+ /** Ascending. Etags are optional: the disk's own, read by `listParts` here, are what is sent. */
64
+ readonly parts: readonly CompletedPart[];
65
+ /** The policy the grant was made under: a completed object past its `maxBytes` is refused. */
66
+ readonly policy: UploadPolicy;
67
+ }
68
+
69
+ const capabilityOf = (disk: StorageDriver, key: string): MultipartCapability => {
70
+ if (disk.multipart === undefined) throw multipartUnsupported(disk.name, key);
71
+ return disk.multipart;
72
+ };
73
+
74
+ /** The key a session was opened for, held to the actor's org exactly as `accept.ts` holds a URL. */
75
+ const sessionOf = (input: UploadSessionInput): MultipartCapability => {
76
+ // Every session a grant opens is under `org/<id>/`, so a key outside the actor's org is refused
77
+ // whether or not another tenant owns it: no shared prefix takes parts.
78
+ if (!isWithinOrg(input.key, input.orgId)) {
79
+ throw orgMismatch(input.key, input.orgId);
80
+ }
81
+ return capabilityOf(input.disk, input.key);
82
+ };
83
+
84
+ /**
85
+ * The type says required; an untyped caller upgrading from the call without one does not read
86
+ * types, and with no policy nothing bounds what the session stores — `promoteAttachment`'s guard.
87
+ */
88
+ const assertPolicy = (caller: string, policy: UploadPolicy | undefined, fix: string): number => {
89
+ assert(
90
+ typeof policy?.maxBytes === 'number',
91
+ `${caller} was called with no policy, so nothing bounds the size of the upload in parts`,
92
+ fix,
93
+ );
94
+ return policy.maxBytes;
95
+ };
96
+
97
+ /**
98
+ * The smallest object part `partNumber` at `size` bytes can belong to. Every part before it is at
99
+ * least S3's floor and at least as long as it (each but the last is exactly `partBytes`), so an
100
+ * honest grant's part never implies more than its declared size, and so never more than the policy.
101
+ */
102
+ const impliedBytes = (partNumber: number, size: number): number =>
103
+ Math.max((partNumber - 1) * MIN_PART_BYTES + size, partNumber * size);
104
+
105
+ /** The part size: the app's preference, raised to what the size and the protocol require. */
106
+ const partSizeFor = (size: number, preferred: number | undefined): number => {
107
+ const floor = partBytesFor(size);
108
+ if (preferred === undefined) return floor;
109
+ assert(
110
+ Number.isSafeInteger(preferred) && preferred >= MIN_PART_BYTES && preferred <= MAX_PART_BYTES,
111
+ `grantMultipart was handed partBytes ${String(preferred)}, outside S3's ${MIN_PART_BYTES}..${MAX_PART_BYTES} bytes`,
112
+ `grantMultipart({ disk, orgId, request, partBytes: ${MIN_PART_BYTES} }) # or omit it`,
113
+ );
114
+ return Math.max(floor, preferred);
115
+ };
116
+
117
+ /** Refuses as `grantUpload` refuses, then opens the session the parts go into. */
118
+ export async function grantMultipart(input: GrantMultipartInput): Promise<MultipartGrant> {
119
+ const granted = decideGrant('grantMultipart', input);
120
+ const multipart = capabilityOf(input.disk, granted.key);
121
+ const partBytes = partSizeFor(granted.size, input.partBytes);
122
+ const uploadId = await multipart.create(granted.key, { contentType: granted.contentType });
123
+ return {
124
+ key: granted.key,
125
+ uploadId,
126
+ partBytes,
127
+ maxParts: Math.ceil(granted.size / partBytes),
128
+ contentType: granted.contentType,
129
+ size: granted.size,
130
+ expiresAt: granted.now + granted.expiresInMs,
131
+ };
132
+ }
133
+
134
+ /**
135
+ * One part's URL, its length inside the signature. A part past what an object under the policy
136
+ * holds is `X_STORAGE_TOO_LARGE`, so a session's parts cannot outgrow the policy before the
137
+ * `completeUpload` that would refuse them: unassembled parts bill too.
138
+ */
139
+ export async function signPart(input: SignPartInput): Promise<string> {
140
+ const maxBytes = assertPolicy(
141
+ 'signPart',
142
+ input.policy,
143
+ 'signPart({ disk, orgId, key, uploadId, partNumber, size, policy: uploadPolicy({ maxBytes }) }) — the policy the grant was made under',
144
+ );
145
+ const multipart = sessionOf(input);
146
+ const { partNumber, size } = input;
147
+ assert(
148
+ Number.isSafeInteger(partNumber) && partNumber >= 1 && partNumber <= MAX_PARTS,
149
+ `signPart was handed partNumber ${String(partNumber)}, outside 1..${MAX_PARTS}`,
150
+ 'signPart({ disk, orgId, key, uploadId, partNumber: 1, size })',
151
+ );
152
+ assert(
153
+ Number.isSafeInteger(size) && size >= 1 && size <= MAX_PART_BYTES,
154
+ `signPart was handed size ${String(size)}, not a part of 1..${MAX_PART_BYTES} bytes`,
155
+ 'signPart({ disk, orgId, key, uploadId, partNumber, size: grant.partBytes })',
156
+ );
157
+ const implied = impliedBytes(partNumber, size);
158
+ if (implied > maxBytes) throw tooLarge(input.key, implied, maxBytes);
159
+ return multipart.signPart(input.key, input.uploadId, partNumber, {
160
+ contentLength: size,
161
+ expiresInMs: input.expiresInMs === undefined ? DEFAULT_SIGNED_URL_TTL_MS : input.expiresInMs,
162
+ });
163
+ }
164
+
165
+ /** What landed, ascending — the resume: a client sends only the parts not here. */
166
+ export function listParts(input: UploadSessionInput): Promise<readonly UploadedPart[]> {
167
+ return sessionOf(input).listParts(input.key, input.uploadId);
168
+ }
169
+
170
+ /**
171
+ * The parts named, checked against what the disk holds, then assembled. The etags sent are the
172
+ * disk's own, so the browser never needs a bucket's CORS to expose `ETag`. A total past the policy
173
+ * aborts the session: those bytes were never going to be promoted.
174
+ */
175
+ export async function completeUpload(input: CompleteUploadInput): Promise<StorageObject> {
176
+ const maxBytes = assertPolicy(
177
+ 'completeUpload',
178
+ input.policy,
179
+ 'completeUpload({ disk, orgId, key, uploadId, parts, policy: uploadPolicy({ maxBytes }) }) — the policy the grant was made under',
180
+ );
181
+ const multipart = sessionOf(input);
182
+ const stored = await multipart.listParts(input.key, input.uploadId);
183
+ const matched = matchParts(input.parts, stored);
184
+ if ('problem' in matched) throw partsInvalid(input.key, input.uploadId, matched.problem);
185
+ const total = matched.parts.reduce((sum, part) => sum + part.size, 0);
186
+ if (total > maxBytes) {
187
+ await multipart.abort(input.key, input.uploadId);
188
+ throw tooLarge(input.key, total, maxBytes);
189
+ }
190
+ return multipart.complete(input.key, input.uploadId, matched.parts);
191
+ }
192
+
193
+ /** Drops the session and every part. An id already gone is the state asked for. */
194
+ export function abortUpload(input: UploadSessionInput): Promise<void> {
195
+ return sessionOf(input).abort(input.key, input.uploadId);
196
+ }
package/src/grant.ts CHANGED
@@ -10,6 +10,7 @@ import type { AttachmentTarget } from './attachment';
10
10
  import { attachmentKey, pendingKey, quarantineKey, uploadName } from './attachment';
11
11
  import type { StorageDriver } from './driver';
12
12
  import { contentTypeNotAllowed, tooLarge } from './errors';
13
+ import { uploadSizeUndeclared } from './errors-stream';
13
14
  import { DEFAULT_SIGNED_URL_TTL_MS } from './signed-url';
14
15
  import type { UploadPolicy } from './upload';
15
16
  import { normalizeContentType, uploadPolicy } from './upload';
@@ -19,10 +20,11 @@ export interface UploadRequest {
19
20
  readonly filename: string;
20
21
  readonly contentType: string;
21
22
  /**
22
- * Optional, and never trusted: it only buys the client an early refusal. The bytes are counted
23
- * again by `acceptSignedUpload`, which is the authority, so a lie here changes nothing.
23
+ * The file's size in bytes. Required, because it is what the URL binds: an s3 disk signs it as the
24
+ * exact `Content-Length`, and the local disks sign it as the ceiling `acceptSignedUpload` counts
25
+ * against. A lie here shrinks the client's own upload; it cannot pass the policy's `maxBytes`.
24
26
  */
25
- readonly size?: number | undefined;
27
+ readonly size: number;
26
28
  }
27
29
 
28
30
  export interface UploadGrant {
@@ -32,6 +34,7 @@ export interface UploadGrant {
32
34
  readonly method: 'PUT';
33
35
  /** Normalised. The client MUST send exactly this as `Content-Type` or the accept refuses. */
34
36
  readonly contentType: string;
37
+ /** What the URL accepts: the declared `size`, already held to the policy. */
35
38
  readonly maxBytes: number;
36
39
  /** Epoch ms. The grant's own view of the window; the signature is what actually enforces it. */
37
40
  readonly expiresAt: number;
@@ -57,12 +60,21 @@ export interface GrantUploadInput {
57
60
  readonly uploadId?: (() => string) | undefined;
58
61
  }
59
62
 
63
+ /** What every grant decides before it signs: the key, the type and size the policy accepted. */
64
+ export interface GrantedUpload {
65
+ readonly key: string;
66
+ readonly contentType: string;
67
+ readonly size: number;
68
+ readonly expiresInMs: number;
69
+ readonly now: number;
70
+ }
71
+
60
72
  /**
61
- * Refuses before it signs. A URL only exists for a request the policy already accepted, so an
62
- * over-limit or disallowed upload costs one round trip instead of a full transfer — and the
63
- * signature then carries the same two constraints, so the client cannot widen either of them.
73
+ * The refusals both grants share — `grantUpload`'s one PUT and `grantMultipart`'s parts — so a
74
+ * request one refuses the other refuses too: the key derived, never the client's; the type held to
75
+ * the allowlist; the size to the policy; the window screened.
64
76
  */
65
- export async function grantUpload(input: GrantUploadInput): Promise<UploadGrant> {
77
+ export function decideGrant(caller: string, input: GrantUploadInput): GrantedUpload {
66
78
  const policy = input.policy ?? uploadPolicy();
67
79
  const clock = input.clock ?? systemClock;
68
80
  const declared = normalizeContentType(input.request.contentType);
@@ -78,33 +90,38 @@ export async function grantUpload(input: GrantUploadInput): Promise<UploadGrant>
78
90
  throw contentTypeNotAllowed(key, declared, policy.allowedContentTypes);
79
91
  }
80
92
  const size = input.request.size;
81
- if (size !== undefined && Number.isSafeInteger(size) && size > policy.maxBytes) {
82
- throw tooLarge(key, size, policy.maxBytes);
83
- }
93
+ // Off the wire, so the type promises nothing here. An empty file has nothing to upload.
94
+ if (!Number.isSafeInteger(size) || size < 1) throw uploadSizeUndeclared(key, size);
95
+ if (size > policy.maxBytes) throw tooLarge(key, size, policy.maxBytes);
84
96
 
85
97
  // 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 `grantUpload`'s
87
- // own option rather than `buildSignedUrl`'s.
88
- // `=== undefined`, never `??`, for the reason `buildSignedUrl` states: `??` coalesces on `null`
89
- // too, so an explicit one took the default instead of this refusal.
98
+ // grant this function RETURNS, and the message a caller can act on names the grant's own option
99
+ // rather than `buildSignedUrl`'s. `=== undefined`, never `??`, for the reason `buildSignedUrl`
100
+ // states: `??` coalesces on `null` too, so an explicit one took the default instead.
90
101
  const expiresInMs = finiteCount(
91
- 'grantUpload',
102
+ caller,
92
103
  'expiresInMs',
93
104
  input.expiresInMs === undefined ? DEFAULT_SIGNED_URL_TTL_MS : input.expiresInMs,
94
105
  1,
95
106
  );
107
+ return { key, contentType: declared, size, expiresInMs, now: clock.now().getTime() };
108
+ }
109
+
110
+ /**
111
+ * Refuses before it signs. A URL only exists for a request the policy already accepted, so an
112
+ * over-limit or disallowed upload costs one round trip instead of a full transfer. The signature
113
+ * then carries the size on every disk, so the client cannot widen it: at the provider's PUT on s3
114
+ * (403 on any other length), and at `acceptSignedUpload` on the local disks. The content type is
115
+ * signed only on a disk that signs its own URLs; on s3, `promoteAttachment` sniffs it.
116
+ */
117
+ export async function grantUpload(input: GrantUploadInput): Promise<UploadGrant> {
118
+ const { key, contentType, size, expiresInMs, now } = decideGrant('grantUpload', input);
96
119
  const url = await input.disk.signedUrl(key, {
97
120
  method: 'PUT',
98
- maxBytes: policy.maxBytes,
99
- contentType: declared,
121
+ maxBytes: size,
122
+ contentLength: size,
123
+ contentType,
100
124
  expiresInMs,
101
125
  });
102
- return {
103
- key,
104
- url,
105
- method: 'PUT',
106
- contentType: declared,
107
- maxBytes: policy.maxBytes,
108
- expiresAt: clock.now().getTime() + expiresInMs,
109
- };
126
+ return { key, url, method: 'PUT', contentType, maxBytes: size, expiresAt: now + expiresInMs };
110
127
  }
@@ -0,0 +1,84 @@
1
+ // Single responsibility: write a stored image's variants AHEAD of the first request, under the exact
2
+ // keys the media route looks up, so no visitor pays the decode. The job an upload enqueues calls
3
+ // this; the request path then answers `exists` + `get` and never reaches the pipeline.
4
+
5
+ import {
6
+ assert,
7
+ finiteCount,
8
+ IMAGE_MIME_TYPES,
9
+ isUltimateError,
10
+ probeImage,
11
+ transformImageBytes,
12
+ } from '@ultimat3/core';
13
+ import type { StorageDriver } from './driver';
14
+ import { type VariantFormat, variantKey, variantRevision } from './image';
15
+
16
+ export interface WarmVariantsInput {
17
+ readonly disk: StorageDriver;
18
+ /** The SOURCE object's key, as the media route is asked for it. */
19
+ readonly key: string;
20
+ /** Whole pixels. Widths above the source's are skipped: the route never stores an upscale. */
21
+ readonly widths: readonly number[];
22
+ /** `avif` is accepted by the type and fails per variant with core's `X_IMAGE_UNSUPPORTED`. */
23
+ readonly formats: readonly VariantFormat[];
24
+ }
25
+
26
+ /** A variant that was not written: its key, and the failure's code and cause. */
27
+ export interface WarmFailure {
28
+ readonly key: string;
29
+ /** `X_IMAGE_UNSUPPORTED` for an `avif`, a storage code for a refused `put`. */
30
+ readonly code: string;
31
+ readonly cause: string;
32
+ }
33
+
34
+ export interface WarmVariantsResult {
35
+ /** The variant keys written, in width-then-format order. */
36
+ readonly written: readonly string[];
37
+ /** One entry per variant that could not be made or stored; the others were still written. */
38
+ readonly failed: readonly WarmFailure[];
39
+ /** Widths above the source's intrinsic width: never minted, never stored. */
40
+ readonly skipped: readonly number[];
41
+ }
42
+
43
+ /**
44
+ * Every `widths` x `formats` variant of `key`, keyed as `variantKey(key, { width, format,
45
+ * revision })` with the revision read off THIS read's etag — the one the bytes were made from, so
46
+ * an overwrite racing the warm-up can only leave keys its new etag never names. The transform spec
47
+ * is the builtin image driver's (`width` + `format`, default quality), so a warmed variant is the
48
+ * object the route would have stored itself.
49
+ *
50
+ * The source is READ and PROBED once, and the probe holds it to core's `MAX_IMAGE_PIXELS` before
51
+ * any pixel is decoded; each variant is then one pipeline pass over the bytes already in hand.
52
+ */
53
+ export async function warmVariants(input: WarmVariantsInput): Promise<WarmVariantsResult> {
54
+ for (const width of input.widths) finiteCount('warmVariants', 'widths', width, 1);
55
+ const source = await input.disk.get(input.key);
56
+ const revision = variantRevision(source.object.etag);
57
+ assert(
58
+ revision !== undefined,
59
+ `warmVariants('${input.key}'): the source etag "${source.object.etag}" carries no 8-hex revision, so no variant of it has a key the media route would read`,
60
+ `x doctor --json # the disk driver answers no content etag; variants of ${input.key} are served, never stored`,
61
+ );
62
+ // A header read: past `MAX_IMAGE_PIXELS` it is core's `X_IMAGE_TOO_LARGE` before any decode.
63
+ const info = probeImage(source.bytes);
64
+ const written: string[] = [];
65
+ const failed: WarmFailure[] = [];
66
+ const skipped = input.widths.filter((width) => width > info.width);
67
+ for (const width of input.widths.filter((one) => one <= info.width)) {
68
+ for (const format of input.formats) {
69
+ const key = variantKey(input.key, { width, format, revision });
70
+ try {
71
+ const bytes = await transformImageBytes(source.bytes, { width, format });
72
+ await input.disk.put(key, bytes, {
73
+ contentType: IMAGE_MIME_TYPES[probeImage(bytes).format],
74
+ });
75
+ written.push(key);
76
+ } catch (error) {
77
+ // Coded failures are per-variant answers; anything uncoded is a defect and is not swallowed.
78
+ if (!isUltimateError(error)) throw error;
79
+ failed.push({ key, code: error.code, cause: error.cause });
80
+ }
81
+ }
82
+ }
83
+ return { written, failed, skipped };
84
+ }
package/src/image.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // bytes, `transformImage()` returns exactly the size `fitDimensions()` already promised.
5
5
 
6
6
  import {
7
+ assert,
7
8
  assertFiniteImageQuality,
8
9
  blurDataUrl,
9
10
  finiteCount,
@@ -13,6 +14,7 @@ import {
13
14
  transformImageBytes,
14
15
  } from '@ultimat3/core';
15
16
  import { assertSafeKey } from './path';
17
+ import { normalizeContentType } from './upload';
16
18
 
17
19
  /**
18
20
  * The formats a stored VARIANT can be minted in — a strict subset of the ones `@ultimat3/core`
@@ -41,6 +43,50 @@ export type VariantFormat = (typeof VARIANT_FORMATS)[number];
41
43
  export const isVariantFormat = (format: string): format is VariantFormat =>
42
44
  (VARIANT_FORMATS as readonly string[]).includes(format);
43
45
 
46
+ /** The source encodings core can write back, so a variant with no `?f=` keeps its source's. */
47
+ const SOURCE_VARIANT_FORMATS: ReadonlyMap<string, VariantFormat> = new Map([
48
+ ['image/png', 'png'],
49
+ ['image/jpeg', 'jpeg'],
50
+ ['image/webp', 'webp'],
51
+ ]);
52
+
53
+ /**
54
+ * The format a variant of this stored type is written in when nobody named one. `undefined` (a
55
+ * `gif`) means no key can state what the transform will answer: such a variant is served, never
56
+ * stored. Without this `variantKey` read the absent format as `webp` while the driver kept the
57
+ * source's, so `?w=320` and `?w=320&f=webp` shared one key and the first request chose the bytes.
58
+ */
59
+ export const variantFormatOf = (contentType: string): VariantFormat | undefined =>
60
+ SOURCE_VARIANT_FORMATS.get(normalizeContentType(contentType));
61
+
62
+ const REVISION = /^[0-9a-f]{8}$/;
63
+ /** The `@…r<8 hex>.<ext>` only `variantKey({ revision })` writes. The last `@` is the variant's. */
64
+ const REVISIONED_VARIANT = /^(.+)@(?:[^@/]*-)?r([0-9a-f]{8})\.(?:avif|webp|jpg|png)$/;
65
+
66
+ /** The source and revision a stored variant's key names, or `undefined` for any other key. */
67
+ export function variantSourceOf(
68
+ key: string,
69
+ ): { readonly source: string; readonly revision: string } | undefined {
70
+ const match = REVISIONED_VARIANT.exec(key);
71
+ return match?.[1] === undefined || match[2] === undefined
72
+ ? undefined
73
+ : { source: match[1], revision: match[2] };
74
+ }
75
+
76
+ /**
77
+ * The part of a source's etag a variant key carries, so an overwritten source misses every variant
78
+ * of its old bytes. Hex only, because a provider's etag arrives quoted and a multipart one carries
79
+ * `-<parts>`. `undefined` when the etag is unknowable (`''`) or too short to tell two revisions
80
+ * apart: that variant has no honest key, so it is served, never stored.
81
+ */
82
+ export function variantRevision(etag: string): string | undefined {
83
+ const revision = etag
84
+ .toLowerCase()
85
+ .replace(/[^0-9a-f]/g, '')
86
+ .slice(0, 8);
87
+ return REVISION.test(revision) ? revision : undefined;
88
+ }
89
+
44
90
  /** `cover` fills the box and crops the overflow; `contain` fits inside it, no crop. */
45
91
  export type ImageFit = 'cover' | 'contain';
46
92
 
@@ -51,6 +97,8 @@ export interface ImageTransform {
51
97
  /** 1-100. Omitted means the format default (`DEFAULT_QUALITY`). */
52
98
  readonly quality?: number | undefined;
53
99
  readonly fit?: ImageFit | undefined;
100
+ /** `variantRevision(source.etag)`: which bytes this variant was made from. Absent, unkeyed. */
101
+ readonly revision?: string | undefined;
54
102
  }
55
103
 
56
104
  export interface ImageSize {
@@ -116,6 +164,15 @@ export function variantKey(sourceKey: string, transform: ImageTransform): string
116
164
  transform.quality === undefined ? DEFAULT_QUALITY : transform.quality,
117
165
  );
118
166
  if (quality !== DEFAULT_QUALITY) parts.push(`q${quality}`);
167
+ const { revision } = transform;
168
+ if (revision !== undefined) {
169
+ assert(
170
+ REVISION.test(revision),
171
+ 'variantKey was given a revision that is not 8 hex digits, so it would mint a key nothing can derive again',
172
+ 'variantKey(key, { width, revision: variantRevision(object.etag) })',
173
+ );
174
+ parts.push(`r${revision}`);
175
+ }
119
176
  if (parts.length === 0) parts.push('full');
120
177
  return assertSafeKey(`${safe}@${parts.join('-')}.${FORMAT_EXTENSIONS[format]}`);
121
178
  }