@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
@@ -0,0 +1,177 @@
1
+ // Single responsibility: the S3 multipart calls (create, part, complete, abort, list parts, list uploads) over the disk's signed requests. Each answers
2
+ // what its caller goes on with, or hands the provider's refusal to the disk's coded `refused`. The
3
+ // order they run in, and what a failure aborts, is the caller's.
4
+
5
+ import { escapeHtml } from '@ultimat3/core';
6
+ import { type S3Refusal, type S3WireOptions, sendSigned, xmlField } from './driver-s3-signed';
7
+ import type { MultipartSession, UploadedPart } from './multipart';
8
+
9
+ export interface S3UploadTarget {
10
+ readonly wire: S3WireOptions;
11
+ readonly key: string;
12
+ /** The disk's coded write refusal. A refusal is never thrown bare. */
13
+ readonly refused: (refusal: S3Refusal) => never;
14
+ }
15
+
16
+ const unreadable = (what: string): S3Refusal => ({
17
+ name: 'S3Error',
18
+ code: 'UnreadableAnswer',
19
+ message: `the provider's answer to ${what} carried nothing to go on`,
20
+ statusCode: 200,
21
+ });
22
+
23
+ /** Every `<name>…</name>` block of an S3 listing, in order; the shapes read here are flat. */
24
+ export const xmlBlocks = (xml: string, name: string): readonly string[] =>
25
+ [...xml.matchAll(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`, 'g'))].map(
26
+ (match) => match[1] ?? '',
27
+ );
28
+
29
+ /** XML's entities: S3 writes an etag as `&quot;…&quot;` and a key's `&` as `&amp;`. */
30
+ export const xmlText = (text: string): string =>
31
+ text
32
+ // A numeric reference too: a gateway writes the etag's quotes as `&#34;`, S3 as `&quot;`.
33
+ .replace(/&#(x[0-9a-f]{1,6}|[0-9]{1,7});/gi, (_, code: string) =>
34
+ String.fromCodePoint(
35
+ Math.min(Number(code[0] === 'x' || code[0] === 'X' ? `0${code}` : code), 0x10ffff),
36
+ ),
37
+ )
38
+ .replaceAll('&quot;', '"')
39
+ .replaceAll('&apos;', "'")
40
+ .replaceAll('&lt;', '<')
41
+ .replaceAll('&gt;', '>')
42
+ .replaceAll('&amp;', '&');
43
+
44
+ // Core's table: `&#39;`/`&quot;` are numeric/XML references too, so its output is valid XML text.
45
+ const completeXml = (parts: readonly { partNumber: number; etag: string }[]): Uint8Array =>
46
+ new TextEncoder().encode(
47
+ `<CompleteMultipartUpload>${parts
48
+ .map(
49
+ ({ partNumber, etag }) =>
50
+ `<Part><PartNumber>${partNumber}</PartNumber><ETag>${escapeHtml(etag)}</ETag></Part>`,
51
+ )
52
+ .join('')}</CompleteMultipartUpload>`,
53
+ );
54
+
55
+ /** CreateMultipartUpload. The object's own headers travel here: a part has none. */
56
+ export async function createS3Upload(
57
+ { wire, key, refused }: S3UploadTarget,
58
+ headers: Readonly<Record<string, string>>,
59
+ ): Promise<string> {
60
+ const result = await sendSigned(wire, { method: 'POST', key, headers, query: { uploads: '' } });
61
+ if (!result.ok) return refused(result.refusal);
62
+ const id = xmlField(await result.response.text(), 'UploadId');
63
+ return id === undefined ? refused(unreadable('CreateMultipartUpload')) : id;
64
+ }
65
+
66
+ /** UploadPart, answering the etag the Complete must name. */
67
+ export async function uploadS3Part(
68
+ { wire, key, refused }: S3UploadTarget,
69
+ uploadId: string,
70
+ partNumber: number,
71
+ body: Uint8Array,
72
+ ): Promise<string> {
73
+ const result = await sendSigned(wire, {
74
+ method: 'PUT',
75
+ key,
76
+ query: { partNumber: String(partNumber), uploadId },
77
+ body,
78
+ });
79
+ if (!result.ok) return refused(result.refusal);
80
+ await result.response.body?.cancel().catch(() => undefined);
81
+ const etag = result.response.headers.get('etag');
82
+ return etag === null ? refused(unreadable('UploadPart')) : etag;
83
+ }
84
+
85
+ /** CompleteMultipartUpload, `parts` ascending. The object exists only once this returns. */
86
+ export async function completeS3Upload(
87
+ { wire, key, refused }: S3UploadTarget,
88
+ uploadId: string,
89
+ parts: readonly { readonly partNumber: number; readonly etag: string }[],
90
+ ): Promise<void> {
91
+ const result = await sendSigned(wire, {
92
+ method: 'POST',
93
+ key,
94
+ query: { uploadId },
95
+ body: completeXml(parts),
96
+ });
97
+ if (!result.ok) return refused(result.refusal);
98
+ // S3 answers a failed Complete with 200 and an `<Error>` body, once it has started working.
99
+ const answer = await result.response.text();
100
+ if (answer.includes('<Error>')) {
101
+ refused({
102
+ name: 'S3Error',
103
+ code: xmlField(answer, 'Code') ?? 'CompleteFailed',
104
+ message: xmlField(answer, 'Message') ?? 'the provider refused the Complete',
105
+ statusCode: 200,
106
+ });
107
+ }
108
+ }
109
+
110
+ /** AbortMultipartUpload. `undefined` on success; the refusal otherwise, for the caller to weigh. */
111
+ export async function abortS3Upload(
112
+ { wire, key }: S3UploadTarget,
113
+ uploadId: string,
114
+ ): Promise<S3Refusal | undefined> {
115
+ const result = await sendSigned(wire, { method: 'DELETE', key, query: { uploadId } });
116
+ return result.ok ? undefined : result.refusal;
117
+ }
118
+
119
+ /** ListParts, every page: what landed, which is the resume and the Complete's etags. */
120
+ export async function listS3Parts(
121
+ { wire, key, refused }: S3UploadTarget,
122
+ uploadId: string,
123
+ ): Promise<readonly UploadedPart[]> {
124
+ const parts: UploadedPart[] = [];
125
+ let marker: string | undefined;
126
+ for (;;) {
127
+ const query = { uploadId, ...(marker === undefined ? {} : { 'part-number-marker': marker }) };
128
+ const result = await sendSigned(wire, { method: 'GET', key, query });
129
+ if (!result.ok) return refused(result.refusal);
130
+ const answer = await result.response.text();
131
+ for (const block of xmlBlocks(answer, 'Part')) {
132
+ const partNumber = Number(xmlField(block, 'PartNumber'));
133
+ const size = Number(xmlField(block, 'Size'));
134
+ const etag = xmlText(xmlField(block, 'ETag') ?? '');
135
+ if (Number.isSafeInteger(partNumber) && Number.isSafeInteger(size) && etag !== '') {
136
+ parts.push({ partNumber, etag, size });
137
+ }
138
+ }
139
+ marker = xmlField(answer, 'NextPartNumberMarker');
140
+ if (xmlField(answer, 'IsTruncated') !== 'true' || marker === undefined) break;
141
+ }
142
+ return parts.sort((a, b) => a.partNumber - b.partNumber);
143
+ }
144
+
145
+ /** ListMultipartUploads under a prefix, every page: the sessions nobody completed or aborted. */
146
+ export async function listS3Uploads(
147
+ { wire, refused }: Omit<S3UploadTarget, 'key'>,
148
+ prefix: string | undefined,
149
+ ): Promise<readonly MultipartSession[]> {
150
+ const sessions: MultipartSession[] = [];
151
+ let markers: Readonly<Record<string, string>> = {};
152
+ for (;;) {
153
+ const query = { uploads: '', ...(prefix === undefined ? {} : { prefix }), ...markers };
154
+ const result = await sendSigned(wire, { method: 'GET', key: '', query });
155
+ if (!result.ok) return refused(result.refusal);
156
+ const answer = await result.response.text();
157
+ for (const block of xmlBlocks(answer, 'Upload')) {
158
+ const key = xmlText(xmlField(block, 'Key') ?? '');
159
+ const uploadId = xmlText(xmlField(block, 'UploadId') ?? '');
160
+ const initiated = new Date(xmlField(block, 'Initiated') ?? '');
161
+ if (key === '' || uploadId === '') continue;
162
+ sessions.push({
163
+ key,
164
+ uploadId,
165
+ ...(Number.isNaN(initiated.getTime()) ? {} : { initiated }),
166
+ });
167
+ }
168
+ const nextKey = xmlField(answer, 'NextKeyMarker');
169
+ const nextId = xmlField(answer, 'NextUploadIdMarker');
170
+ if (xmlField(answer, 'IsTruncated') !== 'true' || nextKey === undefined) break;
171
+ markers = {
172
+ 'key-marker': xmlText(nextKey),
173
+ ...(nextId === undefined ? {} : { 'upload-id-marker': xmlText(nextId) }),
174
+ };
175
+ }
176
+ return sessions;
177
+ }
@@ -0,0 +1,52 @@
1
+ // Single responsibility: the s3 disk's `signedUrl`, both presigners under one rule. Bun's presign
2
+ // binds method and expiry and signs `host` alone: `type` becomes `response-content-type`, which
3
+ // governs a GET, so a PUT's `Content-Type` is unsigned and `promoteAttachment` sniffs the object. A
4
+ // `contentLength` leaves Bun for core's signer, the one way a presign can hold a size.
5
+
6
+ import { assert, finiteCount, MAX_AWS_PRESIGN_SECONDS } from '@ultimat3/core';
7
+ import { type SignedUrlOptions, signedByteBound } from './driver';
8
+ import type { S3FileLike } from './driver-s3-client';
9
+ import { presignBoundPut, type S3WireOptions } from './driver-s3-signed';
10
+ import { uploadSizeUndeclared } from './errors-stream';
11
+ import { assertSafeKey } from './path';
12
+ import { DEFAULT_SIGNED_URL_TTL_MS } from './signed-url';
13
+
14
+ export async function s3SignedUrl(
15
+ wire: S3WireOptions,
16
+ fileOf: (key: string) => S3FileLike,
17
+ key: string,
18
+ urlOptions: SignedUrlOptions | undefined,
19
+ ): Promise<string> {
20
+ // Screened with the SAME function `buildSignedUrl` applies on the local disk: `Math.ceil(NaN /
21
+ // 1000)` is `NaN`, so `X-Amz-Expires=NaN` went to AWS and the app got a 403 on a link it believed
22
+ // it had just minted. `=== undefined` rather than `??`, so an explicit `null` is refused.
23
+ const expiresInMs = finiteCount(
24
+ 'the s3 driver',
25
+ 'expiresInMs',
26
+ urlOptions?.expiresInMs === undefined ? DEFAULT_SIGNED_URL_TTL_MS : urlOptions.expiresInMs,
27
+ 1,
28
+ );
29
+ const seconds = Math.ceil(expiresInMs / 1000);
30
+ // Before the branch, so Bun's presign cannot mint a URL past SigV4's limit the other refuses.
31
+ assert(
32
+ seconds <= MAX_AWS_PRESIGN_SECONDS,
33
+ `signedUrl() on the s3 disk was given expiresInMs ${expiresInMs}, past SigV4's seven days`,
34
+ `signedUrl(key, { expiresInMs: ${MAX_AWS_PRESIGN_SECONDS * 1000} }) # the longest a presign lives`,
35
+ );
36
+ const safe = assertSafeKey(key);
37
+ const contentLength = signedByteBound('s3', urlOptions);
38
+ if (urlOptions?.contentLength !== undefined && contentLength !== undefined) {
39
+ return presignBoundPut(wire, safe, contentLength, seconds, urlOptions?.part);
40
+ }
41
+ // A part names its exact size or nothing: Bun's presign below would drop it and sign a whole PUT.
42
+ if (urlOptions?.part !== undefined) throw uploadSizeUndeclared(safe, undefined);
43
+ // A ceiling bounds a PUT; a read has no body, and the local disks ignore it there too.
44
+ if (urlOptions?.method === 'PUT' && urlOptions.maxBytes !== undefined) {
45
+ throw uploadSizeUndeclared(safe, undefined);
46
+ }
47
+ return fileOf(safe).presign({
48
+ method: urlOptions?.method ?? 'GET',
49
+ expiresIn: seconds,
50
+ ...(urlOptions?.contentType === undefined ? {} : { type: urlOptions.contentType }),
51
+ });
52
+ }
@@ -0,0 +1,97 @@
1
+ // Single responsibility: `putStream` on the s3 wire — the pump that cuts a body into parts. Not
2
+ // `S3File.writer()`, measured on Bun 1.4.4: `end(error)` COMPLETES the upload, so an overrun would
3
+ // replace the object at the key, and the writer cannot send `x-amz-meta-*`. Here the object does not
4
+ // exist until the Complete, and every other exit aborts.
5
+
6
+ import { pumpBounded } from './byte-pump';
7
+ import type { PutOptions } from './driver';
8
+ import { objectHeaders, signedPut } from './driver-s3-lock';
9
+ import {
10
+ abortS3Upload,
11
+ completeS3Upload,
12
+ createS3Upload,
13
+ type S3UploadTarget,
14
+ uploadS3Part,
15
+ } from './driver-s3-multipart';
16
+ import {
17
+ isAnswered,
18
+ multipartEtagOf,
19
+ type S3Stat,
20
+ sentObject,
21
+ settleComplete,
22
+ settleLostPut,
23
+ } from './driver-s3-settle';
24
+ import type { S3WireOptions } from './driver-s3-signed';
25
+ import { partBytesFor } from './multipart';
26
+ import type { PutStreamOptions } from './put-stream';
27
+ import { streamLimit } from './put-stream';
28
+
29
+ export interface S3PutStreamInput extends Pick<S3UploadTarget, 'key' | 'refused'> {
30
+ readonly body: ReadableStream<Uint8Array>;
31
+ readonly options: PutStreamOptions;
32
+ readonly maxBytes: number;
33
+ readonly driver: string;
34
+ /** The disk's HEAD, asked only when a last request's answer was lost. */
35
+ readonly stat: S3Stat;
36
+ }
37
+
38
+ /** The whole body, stored — or nothing stored and the key as it was. */
39
+ export async function putS3Stream(wire: S3WireOptions, input: S3PutStreamInput): Promise<void> {
40
+ const { key, body, options, maxBytes, driver, refused, stat } = input;
41
+ const target: S3UploadTarget = { wire, key, refused };
42
+ const putOptions: PutOptions = options;
43
+ // Screened before a byte is read: a header that cannot travel fails here, not on the Create.
44
+ const headers = objectHeaders(putOptions);
45
+ const partBytes = partBytesFor(maxBytes);
46
+ let chunks: Uint8Array[] = [];
47
+ let held = 0;
48
+ let total = 0;
49
+ let uploadId: string | undefined;
50
+ const etags: string[] = [];
51
+
52
+ const sendPart = async (): Promise<void> => {
53
+ uploadId ??= await createS3Upload(target, headers);
54
+ const part = new Uint8Array(Bun.concatArrayBuffers(chunks));
55
+ chunks = [];
56
+ held = 0;
57
+ etags.push(await uploadS3Part(target, uploadId, etags.length + 1, part));
58
+ };
59
+
60
+ try {
61
+ await pumpBounded(body, streamLimit(driver, key, maxBytes), async (chunk) => {
62
+ chunks.push(chunk);
63
+ held += chunk.byteLength;
64
+ total += chunk.byteLength;
65
+ if (held >= partBytes) await sendPart();
66
+ });
67
+ if (uploadId === undefined) {
68
+ const whole = new Uint8Array(Bun.concatArrayBuffers(chunks));
69
+ const rethrow = (error: unknown): never => {
70
+ throw error;
71
+ };
72
+ const refusal = await signedPut(wire, key, whole, putOptions).catch(
73
+ settleLostPut(stat, { driver, key, bytes: whole, attributes: options }, rethrow),
74
+ );
75
+ if (refusal !== undefined) refused(refusal);
76
+ return;
77
+ }
78
+ if (held > 0) await sendPart();
79
+ const open = uploadId;
80
+ await completeS3Upload(
81
+ target,
82
+ open,
83
+ etags.map((etag, at) => ({ partNumber: at + 1, etag })),
84
+ ).catch(async (lost: unknown) => {
85
+ if (isAnswered(lost)) throw lost;
86
+ // Settled here, abort included, so the catch below must not abort a second time.
87
+ uploadId = undefined;
88
+ const sent = sentObject(total, multipartEtagOf(etags), options);
89
+ await settleComplete(stat, { ...target, driver }, open, sent, lost);
90
+ });
91
+ } catch (error) {
92
+ // Parts bill until they are aborted. The refusal the caller reads is the first one, so an abort
93
+ // that fails too is left to the bucket's lifecycle rule rather than replacing it.
94
+ if (uploadId !== undefined) await abortS3Upload(target, uploadId).catch(() => undefined);
95
+ throw error;
96
+ }
97
+ }
@@ -0,0 +1,99 @@
1
+ // Single responsibility: the s3 disk's ranged read — ONE signed GET whose `Range:` answer carries
2
+ // the bytes AND what they are (`Content-Range`'s window and total size, `ETag`, type, date), so the
3
+ // slice and its description come from one response about one version. A HEAD beside it could not
4
+ // promise that: a write between the two, or an A → B → A overwrite of one length, slips past both.
5
+
6
+ import type { StorageObject, StorageRead } from './driver';
7
+ import { DEFAULT_CONTENT_TYPE } from './driver';
8
+ import { type S3Refusal, type S3WireOptions, sendSigned } from './driver-s3-signed';
9
+ import { rangeTooLarge, rangeUnsatisfiable } from './errors-range';
10
+ import type { ByteRange } from './range';
11
+
12
+ /** What the driver lends this read: its coded refusals and its own HEAD for a 416's size. */
13
+ export interface S3RangeDeps {
14
+ readonly driver: string;
15
+ readonly maxGetBytes: number;
16
+ /** A refusal the provider gave (403, 503, a wrong region, a 404) as the disk's coded error. */
17
+ readonly refused: (key: string, refusal: S3Refusal) => never;
18
+ /** The disk's HEAD — asked only after a 416, for the size the refusal names. */
19
+ readonly sizeOf: (key: string) => Promise<number>;
20
+ /** A response the read cannot use: no `Content-Range`, or one that is not the window asked. */
21
+ readonly unreadable: (key: string, why: string) => never;
22
+ }
23
+
24
+ const CONTENT_RANGE = /^bytes (\d+)-(\d+)\/(\d+)$/;
25
+
26
+ /**
27
+ * The window asked, CAPPED at `maxGetBytes` before it is sent: the ceiling bounds the range, and the
28
+ * object's size is not known until the answer — so at most the ceiling crosses the wire, and an
29
+ * answer that shows the object went on past the cap is refused before its body is read.
30
+ */
31
+ const errorMessage = (thrown: unknown): string =>
32
+ thrown instanceof Error ? thrown.message : String(thrown);
33
+
34
+ export async function readS3Range(
35
+ wire: S3WireOptions,
36
+ key: string,
37
+ range: ByteRange,
38
+ deps: S3RangeDeps,
39
+ ): Promise<StorageRead> {
40
+ const [first, last] = range;
41
+ const capped = Math.min(last, first + deps.maxGetBytes - 1);
42
+ const result = await sendSigned(wire, {
43
+ method: 'GET',
44
+ key,
45
+ headers: { range: `bytes=${first}-${capped}` },
46
+ });
47
+ if (!result.ok) {
48
+ if (result.refusal.statusCode !== 416) return deps.refused(key, result.refusal);
49
+ // A first byte at or past the end — of the object as it is NOW, which the HEAD names.
50
+ throw rangeUnsatisfiable(deps.driver, key, range, await deps.sizeOf(key));
51
+ }
52
+ const { response } = result;
53
+ const window = CONTENT_RANGE.exec(response.headers.get('content-range') ?? '');
54
+ if (response.status !== 206 || window === null) {
55
+ await response.body?.cancel().catch(() => undefined);
56
+ return deps.unreadable(key, `the ranged GET answered ${response.status} with no byte range`);
57
+ }
58
+ const served: ByteRange = [Number(window[1]), Number(window[2])];
59
+ const size = Number(window[3]);
60
+ if (served[0] !== first || served[1] > capped) {
61
+ await response.body?.cancel().catch(() => undefined);
62
+ return deps.unreadable(
63
+ key,
64
+ `asked bytes ${first}-${capped}, answered ${served[0]}-${served[1]}`,
65
+ );
66
+ }
67
+ // Cut by the CAP, not by the end of the object: the range asked is over the ceiling.
68
+ if (last > capped && size - 1 > capped) {
69
+ await response.body?.cancel().catch(() => undefined);
70
+ throw rangeTooLarge(deps.driver, key, [first, Math.min(last, size - 1)], deps.maxGetBytes);
71
+ }
72
+ let bytes: Uint8Array;
73
+ try {
74
+ bytes = new Uint8Array(await response.arrayBuffer());
75
+ } catch (thrown) {
76
+ // The headers came, the body broke (a reset connection): the disk's coded read failure.
77
+ return deps.unreadable(key, `the ranged body broke mid-read: ${errorMessage(thrown)}`);
78
+ }
79
+ if (bytes.byteLength !== served[1] - served[0] + 1) {
80
+ return deps.unreadable(
81
+ key,
82
+ `Content-Range promised ${served[1] - served[0] + 1}B, the body held ${bytes.byteLength}B`,
83
+ );
84
+ }
85
+ return { object: objectOf(key, size, response.headers), bytes, range: served };
86
+ }
87
+
88
+ /** The WHOLE object, as the same response describes it — never a second request's view. */
89
+ function objectOf(key: string, size: number, headers: Headers): StorageObject {
90
+ const modified = new Date(headers.get('last-modified') ?? '');
91
+ return {
92
+ key,
93
+ size,
94
+ contentType: headers.get('content-type') ?? DEFAULT_CONTENT_TYPE,
95
+ etag: headers.get('etag') ?? '',
96
+ // Omitted when the provider sent none, never epoch 0 — `StorageListEntry.lastModified`.
97
+ ...(Number.isFinite(modified.getTime()) ? { lastModified: modified } : {}),
98
+ };
99
+ }
@@ -0,0 +1,150 @@
1
+ // Single responsibility: what a write whose answer was lost actually did. The last request went out
2
+ // and the socket died, so the provider may have stored the object. Settled by a stat against what
3
+ // was sent, and for a multipart upload by an abort, whose answer says whether it was still open.
4
+ // What neither can settle is `X_STORAGE_WRITE_OUTCOME_UNKNOWN`, never a refusal the disk did not make.
5
+
6
+ import { isUltimateError, stringField } from '@ultimat3/core';
7
+ import { DEFAULT_CONTENT_TYPE, type PutOptions } from './driver';
8
+ import { abortS3Upload, type S3UploadTarget } from './driver-s3-multipart';
9
+ import { writeOutcomeUnknown } from './errors-stream';
10
+
11
+ /** The disk's own HEAD: `undefined` for nothing there. A throw is a stat that could not answer. */
12
+ export type S3Stat = (
13
+ key: string,
14
+ ) => Promise<
15
+ { readonly size: number; readonly etag: string; readonly contentType: string } | undefined
16
+ >;
17
+
18
+ export interface SentObject {
19
+ readonly size: number;
20
+ /** The etag the provider gives exactly these bytes, unquoted; `undefined` when it cannot be known. */
21
+ readonly etag: string | undefined;
22
+ readonly contentType: string;
23
+ /**
24
+ * The write set something a HEAD does not read back (metadata, cache-control, a lock). Then a
25
+ * match proves only the bytes, which an overwrite of the same bytes matches already: unknown.
26
+ */
27
+ readonly unverifiable: boolean;
28
+ }
29
+
30
+ /** What a write said about the object, beyond its bytes. */
31
+ export interface SentAttributes
32
+ extends Pick<PutOptions, 'contentType' | 'cacheControl' | 'metadata'> {
33
+ readonly retention?: unknown;
34
+ readonly legalHold?: unknown;
35
+ }
36
+
37
+ /** `SentObject` for `size` bytes of `etag` under `attributes`. */
38
+ export const sentObject = (
39
+ size: number,
40
+ etag: string | undefined,
41
+ attributes: SentAttributes | undefined,
42
+ ): SentObject => ({
43
+ size,
44
+ etag,
45
+ contentType: attributes?.contentType ?? DEFAULT_CONTENT_TYPE,
46
+ unverifiable:
47
+ attributes?.metadata !== undefined ||
48
+ attributes?.cacheControl !== undefined ||
49
+ attributes?.retention !== undefined ||
50
+ attributes?.legalHold !== undefined,
51
+ });
52
+
53
+ const unquoted = (etag: string): string => etag.replace(/^W\//, '').replaceAll('"', '');
54
+
55
+ const md5Hex = (bytes: Uint8Array): string =>
56
+ new Bun.CryptoHasher('md5').update(bytes).digest('hex');
57
+
58
+ /** A single PUT's etag: the body's MD5. A provider encrypting with KMS mints another, so no match. */
59
+ export const putEtagOf = (bytes: Uint8Array): string => md5Hex(bytes);
60
+
61
+ /**
62
+ * S3's multipart etag: the MD5 of the parts' binary MD5s, then `-<count>`. Measured equal on the test
63
+ * stack's gateway. A part etag that is not an MD5 (KMS) makes it unknowable, never a guess.
64
+ */
65
+ export function multipartEtagOf(partEtags: readonly string[]): string | undefined {
66
+ const digests: Uint8Array[] = [];
67
+ for (const etag of partEtags) {
68
+ const hex = unquoted(etag);
69
+ if (!/^[0-9a-f]{32}$/i.test(hex)) return undefined;
70
+ digests.push(Uint8Array.fromHex(hex));
71
+ }
72
+ return `${md5Hex(new Uint8Array(Bun.concatArrayBuffers(digests)))}-${partEtags.length}`;
73
+ }
74
+
75
+ /** `true` when the key holds what was sent, `false` when it holds something else, `undefined` unknown. */
76
+ async function holds(stat: S3Stat, key: string, sent: SentObject): Promise<boolean | undefined> {
77
+ let found: Awaited<ReturnType<S3Stat>>;
78
+ try {
79
+ found = await stat(key);
80
+ } catch {
81
+ return undefined;
82
+ }
83
+ if (found === undefined) return false;
84
+ if (sent.etag === undefined || sent.unverifiable) return undefined;
85
+ return (
86
+ found.size === sent.size &&
87
+ unquoted(found.etag) === sent.etag &&
88
+ found.contentType === sent.contentType
89
+ );
90
+ }
91
+
92
+ /**
93
+ * Whether a failed write is the provider ANSWERING (a refusal, already coded or Bun's `S3Error`) or
94
+ * the answer lost on the wire, after which the object may be stored.
95
+ */
96
+ export const isAnswered = (error: unknown): boolean =>
97
+ isUltimateError(error) || stringField(error, 'name') === 'S3Error';
98
+
99
+ /**
100
+ * The `.catch` for a single PUT: a provider answer goes to `refused`, a lost one is settled, so
101
+ * `put()` resolves when the object landed and is `X_STORAGE_WRITE_OUTCOME_UNKNOWN` when unproven.
102
+ */
103
+ export const settleLostPut =
104
+ (
105
+ stat: S3Stat,
106
+ input: {
107
+ readonly driver: string;
108
+ readonly key: string;
109
+ readonly bytes: Uint8Array;
110
+ readonly attributes: SentAttributes | undefined;
111
+ },
112
+ refused: (error: unknown) => never,
113
+ ) =>
114
+ async (error: unknown): Promise<undefined> => {
115
+ if (isAnswered(error)) return refused(error);
116
+ const sent = sentObject(input.bytes.byteLength, putEtagOf(input.bytes), input.attributes);
117
+ await settlePut(stat, { driver: input.driver, key: input.key, lost: error }, sent);
118
+ return undefined;
119
+ };
120
+
121
+ /** A lost single PUT. It cannot be aborted, so only a match settles it. */
122
+ export async function settlePut(
123
+ stat: S3Stat,
124
+ input: { readonly driver: string; readonly key: string; readonly lost: unknown },
125
+ sent: SentObject,
126
+ ): Promise<void> {
127
+ if ((await holds(stat, input.key, sent)) === true) return;
128
+ throw writeOutcomeUnknown(input.driver, input.key, input.lost);
129
+ }
130
+
131
+ /**
132
+ * A lost Complete. Resolves when the object landed; rethrows `lost` when the abort proves the upload
133
+ * was still open, so nothing was stored and the caller's refusal is the truth.
134
+ */
135
+ export async function settleComplete(
136
+ stat: S3Stat,
137
+ target: S3UploadTarget & { readonly driver: string },
138
+ uploadId: string,
139
+ sent: SentObject,
140
+ lost: unknown,
141
+ ): Promise<void> {
142
+ if ((await holds(stat, target.key, sent)) === true) return;
143
+ const refusal = await abortS3Upload(target, uploadId).catch(() => 'unanswered' as const);
144
+ if (refusal === undefined) throw lost;
145
+ // NoSuchUpload: nothing is open, so the Complete finished. Whether with these bytes, the key says.
146
+ if (refusal !== 'unanswered' && refusal.statusCode === 404) {
147
+ if ((await holds(stat, target.key, sent)) === true) return;
148
+ }
149
+ throw writeOutcomeUnknown(target.driver, target.key, lost);
150
+ }