@ultimat3/storage 29.3.2 → 30.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CLAUDE.md +27 -47
  2. package/README.md +130 -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-read.ts +13 -0
  11. package/src/driver-local-remove.ts +39 -0
  12. package/src/driver-local-stream.ts +104 -0
  13. package/src/driver-local-write.ts +28 -5
  14. package/src/driver-local.ts +257 -283
  15. package/src/driver-memory-multipart.ts +41 -0
  16. package/src/driver-memory.ts +205 -135
  17. package/src/driver-s3-client.ts +9 -0
  18. package/src/driver-s3-copy.ts +35 -0
  19. package/src/driver-s3-lock.ts +9 -2
  20. package/src/driver-s3-multipart-session.ts +87 -0
  21. package/src/driver-s3-multipart.ts +177 -0
  22. package/src/driver-s3-presign.ts +52 -0
  23. package/src/driver-s3-put-stream.ts +97 -0
  24. package/src/driver-s3-range.ts +99 -0
  25. package/src/driver-s3-settle.ts +150 -0
  26. package/src/driver-s3-signed.ts +69 -4
  27. package/src/driver-s3-sse.ts +29 -0
  28. package/src/driver-s3.ts +113 -65
  29. package/src/driver.ts +98 -38
  30. package/src/errors-multipart.ts +35 -0
  31. package/src/errors-promote.ts +14 -0
  32. package/src/errors-range.ts +47 -0
  33. package/src/errors-sniff.ts +35 -0
  34. package/src/errors-stream.ts +62 -0
  35. package/src/errors.ts +16 -1
  36. package/src/expire.ts +169 -0
  37. package/src/grant-multipart.ts +196 -0
  38. package/src/grant.ts +42 -25
  39. package/src/image-warm.ts +84 -0
  40. package/src/image.ts +57 -0
  41. package/src/index.ts +81 -2
  42. package/src/multipart-staged.ts +127 -0
  43. package/src/multipart.ts +119 -0
  44. package/src/put-stream.ts +52 -0
  45. package/src/range.ts +102 -0
  46. package/src/signed-url.ts +59 -2
  47. package/src/subject-target.ts +82 -0
  48. package/src/upload-client-multipart.ts +156 -0
  49. package/src/upload-client.ts +21 -0
  50. package/src/upload.ts +107 -21
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
  }
package/src/index.ts CHANGED
@@ -3,8 +3,11 @@
3
3
 
4
4
  export type { AcceptSignedUploadInput, SignedRequestInput } from './accept';
5
5
  export { acceptSignedUpload, readSignedObject, signedUploadConstraints } from './accept';
6
+ export type { AcceptSignedPartInput } from './accept-part';
7
+ export { acceptSignedPart } from './accept-part';
6
8
  export type {
7
9
  AttachmentTarget,
10
+ DeleteVariantsInput,
8
11
  PromoteAttachmentInput,
9
12
  ReleaseQuarantineInput,
10
13
  SweepFailure,
@@ -14,6 +17,7 @@ export type {
14
17
  export {
15
18
  attachmentKey,
16
19
  attachmentPrefix,
20
+ deleteVariants,
17
21
  isPendingKey,
18
22
  isQuarantinedKey,
19
23
  PENDING_SEGMENT,
@@ -28,8 +32,11 @@ export {
28
32
  uploadExtension,
29
33
  uploadName,
30
34
  } from './attachment';
35
+ export type { PumpLimit } from './byte-pump';
36
+ export { pumpBounded } from './byte-pump';
31
37
  export type {
32
38
  ByteLimit,
39
+ CopyOptions,
33
40
  ListOptions,
34
41
  PutOptions,
35
42
  ServerSideEncryption,
@@ -50,6 +57,7 @@ export {
50
57
  etagOf,
51
58
  resolveListLimit,
52
59
  sha256Base64,
60
+ signedByteBound,
53
61
  toBytes,
54
62
  } from './driver';
55
63
  export type { LocalDriverOptions } from './driver-local';
@@ -96,9 +104,33 @@ export {
96
104
  uploadFailed,
97
105
  xmlBodyUnreadable,
98
106
  } from './errors';
107
+ export { multipartUnsupported, partsInvalid, uploadUnknown } from './errors-multipart';
99
108
  export { alreadyPromoted, quarantined } from './errors-promote';
109
+ export { rangeTooLarge, rangeUnsatisfiable } from './errors-range';
110
+ export type {
111
+ AbortResult,
112
+ AbortStaleUploadsInput,
113
+ ExpireObjectsInput,
114
+ ExpireResult,
115
+ ObjectPurgeTargetInput,
116
+ } from './expire';
117
+ export { abortStaleUploads, expireObjects, objectPurgeTarget } from './expire';
100
118
  export type { GrantUploadInput, UploadGrant, UploadRequest } from './grant';
101
119
  export { grantUpload } from './grant';
120
+ export type {
121
+ CompleteUploadInput,
122
+ GrantMultipartInput,
123
+ MultipartGrant,
124
+ SignPartInput,
125
+ UploadSessionInput,
126
+ } from './grant-multipart';
127
+ export {
128
+ abortUpload,
129
+ completeUpload,
130
+ grantMultipart,
131
+ listParts,
132
+ signPart,
133
+ } from './grant-multipart';
102
134
  export type {
103
135
  ImageFit,
104
136
  ImageSize,
@@ -119,8 +151,21 @@ export {
119
151
  srcsetDescriptors,
120
152
  transformImage,
121
153
  VARIANT_FORMATS,
154
+ variantFormatOf,
122
155
  variantKey,
156
+ variantRevision,
157
+ variantSourceOf,
123
158
  } from './image';
159
+ export type { WarmFailure, WarmVariantsInput, WarmVariantsResult } from './image-warm';
160
+ export { warmVariants } from './image-warm';
161
+ export type {
162
+ CompletedPart,
163
+ MultipartCapability,
164
+ MultipartSession,
165
+ SignPartOptions,
166
+ UploadedPart,
167
+ } from './multipart';
168
+ export { MAX_PART_BYTES, MAX_PARTS, MIN_PART_BYTES, matchParts, partBytesFor } from './multipart';
124
169
  export type {
125
170
  LockedAction,
126
171
  ObjectLock,
@@ -129,7 +174,6 @@ export type {
129
174
  RetentionMode,
130
175
  } from './object-lock';
131
176
  export { isLocked, ObjectLockedError, RETENTION_MODES } from './object-lock';
132
-
133
177
  export {
134
178
  assertSafeKey,
135
179
  isSafeKey,
@@ -144,7 +188,16 @@ export {
144
188
  orgPrefix,
145
189
  scopedKey,
146
190
  } from './path';
191
+ export type { PutStreamOptions } from './put-stream';
192
+ export { assertPutStream, streamLimit } from './put-stream';
193
+ /**
194
+ * `get(key, { range })`. A third-party driver screens and clamps through these two, or it is a
195
+ * fourth answer to what a range means.
196
+ */
197
+ export type { ByteRange, GetOptions } from './range';
198
+ export { assertGetOptions, assertStreamRange, servedRange, streamedRange } from './range';
147
199
  export type {
200
+ SignedPart,
148
201
  SignedUrlConstraints,
149
202
  SignedUrlFailure,
150
203
  SignedUrlInput,
@@ -171,15 +224,31 @@ export {
171
224
  } from './signing-secret';
172
225
  export type { Storage, StorageConfig } from './storage';
173
226
  export { definedStorage, defineStorage, disk, resetStorage, storage } from './storage';
174
- export type { UploadCandidate, UploadPolicy, UploadPolicyInit, ValidatedUpload } from './upload';
227
+ export type {
228
+ StorageSubjectRef,
229
+ StorageSubjectTarget,
230
+ StorageSubjectTargetOptions,
231
+ } from './subject-target';
232
+ export { STORAGE_SUBJECT_COLUMNS, storageSubjectTarget } from './subject-target';
233
+ export type {
234
+ StoredHeadCandidate,
235
+ UploadCandidate,
236
+ UploadPolicy,
237
+ UploadPolicyInit,
238
+ ValidatedHead,
239
+ ValidatedUpload,
240
+ } from './upload';
175
241
  export {
176
242
  contentTypeMatches,
177
243
  DEFAULT_MAX_UPLOAD_BYTES,
178
244
  DOCUMENT_CONTENT_TYPES,
179
245
  IMAGE_CONTENT_TYPES,
246
+ MAX_LEADING_WHITESPACE,
180
247
  normalizeContentType,
248
+ SNIFF_WINDOW_BYTES,
181
249
  sniffContentType,
182
250
  uploadPolicy,
251
+ validateStoredHead,
183
252
  validateUpload,
184
253
  } from './upload';
185
254
  export type {
@@ -196,3 +265,13 @@ export {
196
265
  uploadFile,
197
266
  xhrSignedPut,
198
267
  } from './upload-client';
268
+ export type {
269
+ MultipartUploadClient,
270
+ MultipartUploadInput,
271
+ UploadSessionRef,
272
+ } from './upload-client-multipart';
273
+ export {
274
+ DEFAULT_PART_THRESHOLD_BYTES,
275
+ PART_CONCURRENCY,
276
+ uploadInParts,
277
+ } from './upload-client-multipart';
@@ -0,0 +1,127 @@
1
+ // Single responsibility: an upload in parts on a disk that signs its own URLs (`local`, `memory`):
2
+ // the session rules over a `PartStore` the disk supplies. Same contract as s3's, so dev and tests
3
+ // behave as production: a part URL is signed, a part lands only in an open session, a Complete
4
+ // assembles exactly the parts named and drops the session, and an abort leaves nothing.
5
+
6
+ import type { Clock } from '@ultimat3/core';
7
+ import { etagOf, type StorageDriver, type StorageObject } from './driver';
8
+ import { uploadUnknown } from './errors-multipart';
9
+ import type { MultipartCapability, MultipartSession, UploadedPart } from './multipart';
10
+ import { assertSafeKey } from './path';
11
+
12
+ /** What a staged session records at `create`. `initiated` is ISO, so a file store can hold it. */
13
+ export interface StagedSession {
14
+ readonly key: string;
15
+ readonly contentType: string;
16
+ readonly initiated: string;
17
+ }
18
+
19
+ /** Where a disk keeps open sessions and their parts: a directory, or a `Map`. */
20
+ export interface PartStore {
21
+ open(uploadId: string, session: StagedSession): Promise<void>;
22
+ session(uploadId: string): Promise<StagedSession | undefined>;
23
+ writePart(uploadId: string, part: UploadedPart, bytes: Uint8Array): Promise<void>;
24
+ /** Ascending by part number. */
25
+ parts(uploadId: string): Promise<readonly UploadedPart[]>;
26
+ /** The named parts' bytes, end to end, read as the stream is pulled. */
27
+ read(uploadId: string, parts: readonly UploadedPart[]): ReadableStream<Uint8Array>;
28
+ drop(uploadId: string): Promise<void>;
29
+ sessions(): Promise<readonly (readonly [string, StagedSession])[]>;
30
+ }
31
+
32
+ export interface StagedMultipartOptions {
33
+ readonly store: PartStore;
34
+ readonly clock: Clock;
35
+ /** The disk the parts belong to: it signs their URLs and takes the Complete's stream. */
36
+ readonly driver: StorageDriver;
37
+ }
38
+
39
+ /**
40
+ * The disk with `multipart` attached IN PLACE, never spread: a spread reads `signedUrlBase` once,
41
+ * and `registerAs` re-points it after construction.
42
+ */
43
+ export function withStagedMultipart<D extends StorageDriver>(
44
+ driver: D,
45
+ store: PartStore,
46
+ clock: Clock,
47
+ ): D {
48
+ return Object.assign(driver, { multipart: stagedMultipart({ store, clock, driver }) });
49
+ }
50
+
51
+ export function stagedMultipart(options: StagedMultipartOptions): MultipartCapability {
52
+ const { store } = options;
53
+ /** The session, held to the key it was opened for: an id is never a capability on its own. */
54
+ const openSession = async (key: string, uploadId: string): Promise<StagedSession> => {
55
+ const session = await store.session(uploadId);
56
+ if (session === undefined || session.key !== key) {
57
+ throw uploadUnknown(options.driver.name, key, uploadId);
58
+ }
59
+ return session;
60
+ };
61
+
62
+ return {
63
+ async create(key, { contentType }): Promise<string> {
64
+ const safe = assertSafeKey(key);
65
+ const uploadId = crypto.randomUUID();
66
+ await store.open(uploadId, {
67
+ key: safe,
68
+ contentType,
69
+ initiated: options.clock.now().toISOString(),
70
+ });
71
+ return uploadId;
72
+ },
73
+
74
+ signPart: (key, uploadId, partNumber, { contentLength, expiresInMs }) =>
75
+ options.driver.signedUrl(key, {
76
+ method: 'PUT',
77
+ contentLength,
78
+ maxBytes: contentLength,
79
+ expiresInMs,
80
+ part: { uploadId, partNumber },
81
+ }),
82
+
83
+ async listParts(key, uploadId): Promise<readonly UploadedPart[]> {
84
+ await openSession(assertSafeKey(key), uploadId);
85
+ return store.parts(uploadId);
86
+ },
87
+
88
+ async receivePart(key, uploadId, partNumber, bytes): Promise<UploadedPart> {
89
+ await openSession(assertSafeKey(key), uploadId);
90
+ const part = { partNumber, etag: etagOf(bytes), size: bytes.byteLength };
91
+ await store.writePart(uploadId, part, bytes);
92
+ return part;
93
+ },
94
+
95
+ async complete(key, uploadId, parts): Promise<StorageObject> {
96
+ const safe = assertSafeKey(key);
97
+ const session = await openSession(safe, uploadId);
98
+ const total = parts.reduce((sum, part) => sum + part.size, 0);
99
+ const object = await options.driver.putStream(safe, store.read(uploadId, parts), {
100
+ maxBytes: Math.max(total, 1),
101
+ contentType: session.contentType,
102
+ });
103
+ await store.drop(uploadId);
104
+ return object;
105
+ },
106
+
107
+ async abort(key, uploadId): Promise<void> {
108
+ const safe = assertSafeKey(key);
109
+ const session = await store.session(uploadId);
110
+ if (session?.key === safe) await store.drop(uploadId);
111
+ },
112
+
113
+ async listUploads(prefix): Promise<readonly MultipartSession[]> {
114
+ const open = await store.sessions();
115
+ return open
116
+ .filter(([, session]) => prefix === undefined || session.key.startsWith(prefix))
117
+ .map(([uploadId, session]) => {
118
+ const initiated = new Date(session.initiated);
119
+ return {
120
+ key: session.key,
121
+ uploadId,
122
+ ...(Number.isNaN(initiated.getTime()) ? {} : { initiated }),
123
+ };
124
+ });
125
+ },
126
+ };
127
+ }