@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.
- package/CLAUDE.md +27 -47
- package/README.md +130 -12
- package/package.json +12 -2
- package/src/accept-part.ts +30 -0
- package/src/accept.ts +7 -0
- package/src/attachment.ts +99 -6
- package/src/byte-pump.ts +46 -0
- package/src/driver-local-list.ts +75 -0
- package/src/driver-local-multipart.ts +133 -0
- package/src/driver-local-read.ts +13 -0
- package/src/driver-local-remove.ts +39 -0
- package/src/driver-local-stream.ts +104 -0
- package/src/driver-local-write.ts +28 -5
- package/src/driver-local.ts +257 -283
- package/src/driver-memory-multipart.ts +41 -0
- package/src/driver-memory.ts +205 -135
- package/src/driver-s3-client.ts +9 -0
- package/src/driver-s3-copy.ts +35 -0
- package/src/driver-s3-lock.ts +9 -2
- package/src/driver-s3-multipart-session.ts +87 -0
- package/src/driver-s3-multipart.ts +177 -0
- package/src/driver-s3-presign.ts +52 -0
- package/src/driver-s3-put-stream.ts +97 -0
- package/src/driver-s3-range.ts +99 -0
- package/src/driver-s3-settle.ts +150 -0
- package/src/driver-s3-signed.ts +69 -4
- package/src/driver-s3-sse.ts +29 -0
- package/src/driver-s3.ts +113 -65
- package/src/driver.ts +98 -38
- package/src/errors-multipart.ts +35 -0
- package/src/errors-promote.ts +14 -0
- package/src/errors-range.ts +47 -0
- package/src/errors-sniff.ts +35 -0
- package/src/errors-stream.ts +62 -0
- package/src/errors.ts +16 -1
- package/src/expire.ts +169 -0
- package/src/grant-multipart.ts +196 -0
- package/src/grant.ts +42 -25
- package/src/image-warm.ts +84 -0
- package/src/image.ts +57 -0
- package/src/index.ts +81 -2
- package/src/multipart-staged.ts +127 -0
- package/src/multipart.ts +119 -0
- package/src/put-stream.ts +52 -0
- package/src/range.ts +102 -0
- package/src/signed-url.ts +59 -2
- package/src/subject-target.ts +82 -0
- package/src/upload-client-multipart.ts +156 -0
- package/src/upload-client.ts +21 -0
- package/src/upload.ts +107 -21
package/src/byte-pump.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Single responsibility: the one counting read of a body whose length is the sender's: `put()`
|
|
2
|
+
// buffering a stream and `putStream` writing one both come through here. The caller says where the
|
|
3
|
+
// chunks go and which refusal the limit raises; none of them decides when the body is too long.
|
|
4
|
+
|
|
5
|
+
import { assert, describeValue } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
export interface PumpLimit {
|
|
8
|
+
readonly driver: string;
|
|
9
|
+
readonly maxBytes: number;
|
|
10
|
+
/** The error to throw past `maxBytes`, given how far the body had got. */
|
|
11
|
+
readonly refuse: (total: number) => Error;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Every chunk handed to `sink` in order, and how many bytes they came to. Past the limit the sink
|
|
16
|
+
* sees nothing more. The reader is cancelled on every way out, so a refused body stops arriving
|
|
17
|
+
* rather than filling a socket buffer nobody will read. An already-closed stream answers that with
|
|
18
|
+
* a no-op.
|
|
19
|
+
*/
|
|
20
|
+
export async function pumpBounded(
|
|
21
|
+
body: ReadableStream<Uint8Array>,
|
|
22
|
+
limit: PumpLimit,
|
|
23
|
+
sink: (chunk: Uint8Array) => void | Promise<void>,
|
|
24
|
+
): Promise<number> {
|
|
25
|
+
const reader = body.getReader();
|
|
26
|
+
let total = 0;
|
|
27
|
+
try {
|
|
28
|
+
for (;;) {
|
|
29
|
+
const next = await reader.read();
|
|
30
|
+
if (next.done) return total;
|
|
31
|
+
assert(
|
|
32
|
+
next.value instanceof Uint8Array,
|
|
33
|
+
`the stream handed to the ${limit.driver} disk yielded a chunk that is not a Uint8Array (${describeValue(next.value)})`,
|
|
34
|
+
'stream.pipeThrough(new TextEncoderStream()) # bytes, never strings',
|
|
35
|
+
);
|
|
36
|
+
total += next.value.byteLength;
|
|
37
|
+
if (total > limit.maxBytes) throw limit.refuse(total);
|
|
38
|
+
// Awaited only when the sink is async: a needless await is one more turn in which the stream
|
|
39
|
+
// pulls ahead, and a refused body must have pulled as little as possible.
|
|
40
|
+
const pending = sink(next.value);
|
|
41
|
+
if (pending !== undefined) await pending;
|
|
42
|
+
}
|
|
43
|
+
} finally {
|
|
44
|
+
await reader.cancel().catch(() => undefined);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Single responsibility: one page of the local disk's listing — every object key under a prefix,
|
|
2
|
+
// the `.meta/` sidecar tree kept out of the namespace, ordered, cut at the cursor, and a refused
|
|
3
|
+
// scan reported rather than read as an empty disk (`sweepOrphans` trusts this listing).
|
|
4
|
+
|
|
5
|
+
import { type Page, pageOf, renderFixShellArg } from '@ultimat3/core';
|
|
6
|
+
import type { StorageListEntry } from './driver';
|
|
7
|
+
import { isMissingFile } from './driver-local-remove';
|
|
8
|
+
import { listFailed } from './errors';
|
|
9
|
+
import { META_DIR } from './path';
|
|
10
|
+
|
|
11
|
+
export interface LocalListInput {
|
|
12
|
+
readonly root: string;
|
|
13
|
+
readonly prefix: string;
|
|
14
|
+
/** The last key of the previous page; keys at or before it are skipped. */
|
|
15
|
+
readonly cursor: string | undefined;
|
|
16
|
+
/** A positive integer, already resolved by `resolveListLimit`. */
|
|
17
|
+
readonly limit: number;
|
|
18
|
+
/** Each key's listing view; `undefined` drops a key that went away mid-page. */
|
|
19
|
+
readonly head: (key: string) => Promise<StorageListEntry | undefined>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function listLocalPage(input: LocalListInput): Promise<Page<StorageListEntry>> {
|
|
23
|
+
const { root, prefix, cursor, limit } = input;
|
|
24
|
+
const keys: string[] = [];
|
|
25
|
+
try {
|
|
26
|
+
// `dot: true`, and it is load-bearing: without it a glob matches no dot-prefixed entry, so
|
|
27
|
+
// every object whose key has one — `.hidden.txt`, `org/o1/pending/.x.png`, the
|
|
28
|
+
// `.metadata/a.json` `path.test.ts` pins as legal — was absent from the listing while
|
|
29
|
+
// `put`/`get`/`exists` handled it normally and the s3 listing returned it. `sweepOrphans`
|
|
30
|
+
// pages through `list()`, so those objects were swept as if they did not exist: a false
|
|
31
|
+
// erasure report by omission, which is what the classification below exists to prevent.
|
|
32
|
+
for await (const entry of new Bun.Glob('**/*').scan({
|
|
33
|
+
cwd: root,
|
|
34
|
+
onlyFiles: true,
|
|
35
|
+
dot: true,
|
|
36
|
+
})) {
|
|
37
|
+
const key = entry.replaceAll('\\', '/');
|
|
38
|
+
// The real filter now, not a second line of defence: the glob above yields the sidecar
|
|
39
|
+
// tree, and this is the one thing keeping `.meta/<key>.json` out of the object
|
|
40
|
+
// namespace. Folded like `assertSafeKey`'s reservation — `.META/` and `.meta/` are one
|
|
41
|
+
// directory on APFS and NTFS, and listing a key `get()` would refuse is the worse half.
|
|
42
|
+
if (key.toLowerCase().startsWith(`${META_DIR}/`)) continue;
|
|
43
|
+
if (!key.startsWith(prefix)) continue;
|
|
44
|
+
// The cursor IS the last key of the previous page — lexicographic order keeps it stable.
|
|
45
|
+
if (cursor !== undefined && key <= cursor) continue;
|
|
46
|
+
keys.push(key);
|
|
47
|
+
}
|
|
48
|
+
} catch (error) {
|
|
49
|
+
// A disk nobody has written to yet has no directory: an empty listing, not an error.
|
|
50
|
+
if (isMissingFile(error)) return pageOf([], null);
|
|
51
|
+
// Everything else is a refusal, and a bare `catch` reported all of them as "this disk is
|
|
52
|
+
// empty" — `EACCES` on the root, `ENOTDIR` on a root that is a file, an I/O error on the
|
|
53
|
+
// mount. `sweepOrphans` walks `list()`, so that swallow certified an unreadable prefix as
|
|
54
|
+
// having no orphans: the same false report `delete()`'s `.catch(() => undefined)` used to
|
|
55
|
+
// make, one call to the left.
|
|
56
|
+
throw listFailed(
|
|
57
|
+
'local',
|
|
58
|
+
prefix,
|
|
59
|
+
error,
|
|
60
|
+
`make the disk root readable by this process, then retry: ls -ld ${renderFixShellArg(root, "'<the disk root>'")}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
keys.sort();
|
|
64
|
+
const page = keys.slice(0, limit);
|
|
65
|
+
const objects: StorageListEntry[] = [];
|
|
66
|
+
for (const key of page) {
|
|
67
|
+
const object = await input.head(key);
|
|
68
|
+
if (object !== undefined) objects.push(object);
|
|
69
|
+
}
|
|
70
|
+
// The cursor is minted only when a key PAST the page exists — `keys` is every match, so a
|
|
71
|
+
// full last page answers no cursor. `limit` is a positive integer, so a page with more after
|
|
72
|
+
// it always HAS a last key: the `?? null` is the type's, not a second condition. It used to
|
|
73
|
+
// be one, and `limit: 0` fell through it — an empty page reported as complete.
|
|
74
|
+
return pageOf(objects, keys.length > page.length ? (page.at(-1) ?? null) : null);
|
|
75
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Single responsibility: where the local disk keeps an open upload in parts — one directory per
|
|
2
|
+
// session under `.meta/.multipart/`, which no key can name (`META_DIR` is reserved) and `list()`
|
|
3
|
+
// never yields. A part's bytes land in a staged file renamed into place, so a dropped part is
|
|
4
|
+
// absent rather than torn; its etag and size sit beside it.
|
|
5
|
+
|
|
6
|
+
// why: Bun has no `mkdir`, no `readdir` and no recursive remove; a session is a directory.
|
|
7
|
+
import { mkdir, readdir, rename, rm } from 'node:fs/promises';
|
|
8
|
+
import type { UploadedPart } from './multipart';
|
|
9
|
+
import type { PartStore, StagedSession } from './multipart-staged';
|
|
10
|
+
import { META_DIR } from './path';
|
|
11
|
+
|
|
12
|
+
/** Beside the write staging under `.meta/`, so one reserved segment covers both. */
|
|
13
|
+
export const MULTIPART_DIR = `${META_DIR}/.multipart`;
|
|
14
|
+
|
|
15
|
+
const PART_FILE = /^(\d{1,5})\.part$/;
|
|
16
|
+
|
|
17
|
+
const isPart = (value: unknown): value is UploadedPart =>
|
|
18
|
+
typeof value === 'object' &&
|
|
19
|
+
value !== null &&
|
|
20
|
+
'partNumber' in value &&
|
|
21
|
+
Number.isSafeInteger(value.partNumber) &&
|
|
22
|
+
'etag' in value &&
|
|
23
|
+
typeof value.etag === 'string' &&
|
|
24
|
+
'size' in value &&
|
|
25
|
+
Number.isSafeInteger(value.size);
|
|
26
|
+
|
|
27
|
+
const isSession = (value: unknown): value is StagedSession =>
|
|
28
|
+
typeof value === 'object' &&
|
|
29
|
+
value !== null &&
|
|
30
|
+
'key' in value &&
|
|
31
|
+
typeof value.key === 'string' &&
|
|
32
|
+
'contentType' in value &&
|
|
33
|
+
typeof value.contentType === 'string' &&
|
|
34
|
+
'initiated' in value &&
|
|
35
|
+
typeof value.initiated === 'string';
|
|
36
|
+
|
|
37
|
+
/** A file that is absent or unreadable as JSON is no session: the directory is the disk's own. */
|
|
38
|
+
const readJson = async (path: string): Promise<unknown> => {
|
|
39
|
+
const file = Bun.file(path);
|
|
40
|
+
if (!(await file.exists())) return undefined;
|
|
41
|
+
return file.json().catch(() => undefined);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Upload ids are the disk's own UUIDs; anything else never names a directory here. */
|
|
45
|
+
const SESSION_ID = /^[A-Za-z0-9-]{1,64}$/;
|
|
46
|
+
|
|
47
|
+
export function localPartStore(root: string): PartStore {
|
|
48
|
+
const dirOf = (uploadId: string): string | undefined =>
|
|
49
|
+
SESSION_ID.test(uploadId) ? `${root}/${MULTIPART_DIR}/${uploadId}` : undefined;
|
|
50
|
+
const sessionAt = async (uploadId: string): Promise<StagedSession | undefined> => {
|
|
51
|
+
const dir = dirOf(uploadId);
|
|
52
|
+
if (dir === undefined) return undefined;
|
|
53
|
+
const session = await readJson(`${dir}/session.json`);
|
|
54
|
+
return isSession(session) ? session : undefined;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
async open(uploadId, session): Promise<void> {
|
|
59
|
+
const dir = dirOf(uploadId) ?? '';
|
|
60
|
+
await mkdir(dir, { recursive: true });
|
|
61
|
+
await Bun.write(`${dir}/session.json`, JSON.stringify(session));
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
session: sessionAt,
|
|
65
|
+
|
|
66
|
+
async writePart(uploadId, part, bytes): Promise<void> {
|
|
67
|
+
const dir = dirOf(uploadId) ?? '';
|
|
68
|
+
const staged = `${dir}/${part.partNumber}.part.tmp`;
|
|
69
|
+
await Bun.write(staged, bytes);
|
|
70
|
+
await Bun.write(`${dir}/${part.partNumber}.json`, JSON.stringify(part));
|
|
71
|
+
await rename(staged, `${dir}/${part.partNumber}.part`);
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async parts(uploadId): Promise<readonly UploadedPart[]> {
|
|
75
|
+
const dir = dirOf(uploadId);
|
|
76
|
+
if (dir === undefined) return [];
|
|
77
|
+
const names = await readdir(dir).catch(() => []);
|
|
78
|
+
const parts: UploadedPart[] = [];
|
|
79
|
+
for (const name of names) {
|
|
80
|
+
const number = PART_FILE.exec(name)?.[1];
|
|
81
|
+
if (number === undefined) continue;
|
|
82
|
+
const part = await readJson(`${dir}/${number}.json`);
|
|
83
|
+
if (isPart(part)) parts.push(part);
|
|
84
|
+
}
|
|
85
|
+
return parts.sort((a, b) => a.partNumber - b.partNumber);
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
read(uploadId, parts): ReadableStream<Uint8Array> {
|
|
89
|
+
const dir = dirOf(uploadId) ?? '';
|
|
90
|
+
let at = 0;
|
|
91
|
+
let current: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
92
|
+
return new ReadableStream<Uint8Array>({
|
|
93
|
+
async pull(controller): Promise<void> {
|
|
94
|
+
for (;;) {
|
|
95
|
+
if (current === undefined) {
|
|
96
|
+
const part = parts[at];
|
|
97
|
+
if (part === undefined) {
|
|
98
|
+
controller.close();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
at += 1;
|
|
102
|
+
current = Bun.file(`${dir}/${part.partNumber}.part`).stream().getReader();
|
|
103
|
+
}
|
|
104
|
+
const next = await current.read();
|
|
105
|
+
if (!next.done) {
|
|
106
|
+
controller.enqueue(next.value);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
current = undefined;
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
async cancel(reason): Promise<void> {
|
|
113
|
+
await current?.cancel(reason);
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
async drop(uploadId): Promise<void> {
|
|
119
|
+
const dir = dirOf(uploadId);
|
|
120
|
+
if (dir !== undefined) await rm(dir, { recursive: true, force: true });
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
async sessions(): Promise<readonly (readonly [string, StagedSession])[]> {
|
|
124
|
+
const ids = await readdir(`${root}/${MULTIPART_DIR}`).catch(() => []);
|
|
125
|
+
const open: (readonly [string, StagedSession])[] = [];
|
|
126
|
+
for (const uploadId of ids) {
|
|
127
|
+
const session = await sessionAt(uploadId);
|
|
128
|
+
if (session !== undefined) open.push([uploadId, session]);
|
|
129
|
+
}
|
|
130
|
+
return open;
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
package/src/driver-local-read.ts
CHANGED
|
@@ -61,6 +61,19 @@ export async function readObjectBytes(
|
|
|
61
61
|
.then((buffer) => new Uint8Array(buffer), refusedRead(root, key, disk));
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/** `readObjectBytes` for the inclusive window `[first, last]` — `Bun.file().slice()`, one read. */
|
|
65
|
+
export async function readObjectRange(
|
|
66
|
+
root: string,
|
|
67
|
+
key: string,
|
|
68
|
+
disk: string,
|
|
69
|
+
[first, last]: readonly [number, number],
|
|
70
|
+
): Promise<Uint8Array> {
|
|
71
|
+
return Bun.file(`${root}/${key}`)
|
|
72
|
+
.slice(first, last + 1)
|
|
73
|
+
.arrayBuffer()
|
|
74
|
+
.then((buffer) => new Uint8Array(buffer), refusedRead(root, key, disk));
|
|
75
|
+
}
|
|
76
|
+
|
|
64
77
|
/** `etagOf`, streamed: a copy or a `stat()` must settle a doubt without buffering the object. */
|
|
65
78
|
export async function etagOfFile(root: string, key: string, disk = 'local'): Promise<string> {
|
|
66
79
|
const hasher = new Bun.CryptoHasher('sha256');
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Single responsibility: how the local disk unlinks one file of an object, and which failures mean
|
|
2
|
+
// "already gone". A refused unlink is reported, never swallowed: an erasure sweep that hears
|
|
3
|
+
// "deleted" for bytes still on disk repeats the one lie it exists to prevent.
|
|
4
|
+
|
|
5
|
+
import { renderFixShellArg, stringField } from '@ultimat3/core';
|
|
6
|
+
import { deleteFailed } from './errors';
|
|
7
|
+
|
|
8
|
+
/** `ENOENT` is the one delete failure that means "already in the desired state". */
|
|
9
|
+
export const isMissingFile = (error: unknown): boolean => stringField(error, 'code') === 'ENOENT';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A directory at an object's path is OTHER keys' parent (`c` beside `c/d`), never an object: no
|
|
13
|
+
* bytes are stored at the key, so deleting it is deleting an absent key. Asked only after an
|
|
14
|
+
* unlink failed, because the errno for "that is a directory" is `EISDIR` on Linux and `EPERM` on
|
|
15
|
+
* macOS — the stat is the portable answer.
|
|
16
|
+
*/
|
|
17
|
+
const isDirectory = async (path: string): Promise<boolean> =>
|
|
18
|
+
Bun.file(path)
|
|
19
|
+
.stat()
|
|
20
|
+
.then(
|
|
21
|
+
(stats) => stats.isDirectory(),
|
|
22
|
+
() => false,
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
/** Removes one path under `root`, or reports WHY it could not — a swallowed refusal is a false erasure. */
|
|
26
|
+
export async function removeIfPresent(root: string, path: string, key: string): Promise<void> {
|
|
27
|
+
try {
|
|
28
|
+
await Bun.file(path).delete();
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (isMissingFile(error) || (await isDirectory(path))) return;
|
|
31
|
+
throw deleteFailed(
|
|
32
|
+
'local',
|
|
33
|
+
key,
|
|
34
|
+
error,
|
|
35
|
+
// Screened: a key may carry `$(…)` and a root is configuration, and a fix: is pasted.
|
|
36
|
+
`make the disk root writable by this process, then retry: ls -ld ${renderFixShellArg(root, "'<the disk root>'")} && rm -f ${renderFixShellArg(path, "'<the object file>'")}`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Single responsibility: the local disk's `putStream`: the body counted and hashed into one staged
|
|
2
|
+
// file the commit renames into place, so any length costs one chunk of heap. Staged outside the key's
|
|
3
|
+
// queue, so a slow sender does not hold the key's readers.
|
|
4
|
+
|
|
5
|
+
// why: Bun has no `mkdir`; `Bun.write` makes parents but cannot open the incremental writer here.
|
|
6
|
+
import { mkdir } from 'node:fs/promises';
|
|
7
|
+
import { type Clock, isUltimateError, renderFixShellArg } from '@ultimat3/core';
|
|
8
|
+
import { pumpBounded } from './byte-pump';
|
|
9
|
+
import { DEFAULT_CONTENT_TYPE, type StorageObject } from './driver';
|
|
10
|
+
import type { Sidecar } from './driver-local-sidecar';
|
|
11
|
+
import { commitObject, STAGING_DIR } from './driver-local-write';
|
|
12
|
+
import { putFailed } from './errors';
|
|
13
|
+
import type { PutStreamOptions } from './put-stream';
|
|
14
|
+
import { streamLimit } from './put-stream';
|
|
15
|
+
|
|
16
|
+
export interface StagedStream {
|
|
17
|
+
readonly path: string;
|
|
18
|
+
readonly size: number;
|
|
19
|
+
/** `etagOf` over the same bytes, so a streamed object and a `put()` of it report one etag. */
|
|
20
|
+
readonly etag: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface LocalStreamInput extends StageStreamInput {
|
|
24
|
+
readonly clock: Clock;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Stage, then commit. `inTurn` is the disk's lock check in the key's turn, then `then`: asked before
|
|
29
|
+
* the transfer too, because a locked key should not cost the whole body to refuse.
|
|
30
|
+
*/
|
|
31
|
+
export async function putLocalStream(
|
|
32
|
+
input: LocalStreamInput,
|
|
33
|
+
options: PutStreamOptions,
|
|
34
|
+
inTurn: (then: () => Promise<void>) => Promise<void>,
|
|
35
|
+
): Promise<StorageObject> {
|
|
36
|
+
const { root, key, disk, clock } = input;
|
|
37
|
+
await inTurn(async () => undefined);
|
|
38
|
+
const staged = await stageStream(input);
|
|
39
|
+
try {
|
|
40
|
+
const lastModified = clock.now();
|
|
41
|
+
const sidecar: Sidecar = {
|
|
42
|
+
contentType: options.contentType ?? DEFAULT_CONTENT_TYPE,
|
|
43
|
+
etag: staged.etag,
|
|
44
|
+
cacheControl: options.cacheControl,
|
|
45
|
+
metadata: options.metadata,
|
|
46
|
+
lastModified: lastModified.toISOString(),
|
|
47
|
+
};
|
|
48
|
+
const body = { stagedAt: staged.path };
|
|
49
|
+
await inTurn(() => commitObject({ root, key, disk, body, sidecar }));
|
|
50
|
+
return {
|
|
51
|
+
key,
|
|
52
|
+
size: staged.size,
|
|
53
|
+
contentType: sidecar.contentType,
|
|
54
|
+
etag: sidecar.etag,
|
|
55
|
+
lastModified,
|
|
56
|
+
...(sidecar.cacheControl === undefined ? {} : { cacheControl: sidecar.cacheControl }),
|
|
57
|
+
...(sidecar.metadata === undefined ? {} : { metadata: sidecar.metadata }),
|
|
58
|
+
};
|
|
59
|
+
} finally {
|
|
60
|
+
await discardStaged(staged.path);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface StageStreamInput {
|
|
65
|
+
readonly root: string;
|
|
66
|
+
readonly key: string;
|
|
67
|
+
readonly disk: string;
|
|
68
|
+
readonly body: ReadableStream<Uint8Array>;
|
|
69
|
+
readonly maxBytes: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A staged file gone, or never there. Safe after the commit renamed it away. */
|
|
73
|
+
export const discardStaged = async (path: string): Promise<void> => {
|
|
74
|
+
await Bun.file(path)
|
|
75
|
+
.delete()
|
|
76
|
+
.catch(() => undefined);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Backpressure is the `flush`: awaiting it is what keeps a fast sender from filling the heap. */
|
|
80
|
+
export async function stageStream(input: StageStreamInput): Promise<StagedStream> {
|
|
81
|
+
const { root, key, disk, body, maxBytes } = input;
|
|
82
|
+
const path = `${root}/${STAGING_DIR}/${crypto.randomUUID()}.incoming`;
|
|
83
|
+
const hasher = new Bun.CryptoHasher('sha256');
|
|
84
|
+
let sink: ReturnType<ReturnType<typeof Bun.file>['writer']> | undefined;
|
|
85
|
+
try {
|
|
86
|
+
await mkdir(`${root}/${STAGING_DIR}`, { recursive: true });
|
|
87
|
+
const open = Bun.file(path).writer();
|
|
88
|
+
sink = open;
|
|
89
|
+
const size = await pumpBounded(body, streamLimit('local', key, maxBytes), async (chunk) => {
|
|
90
|
+
hasher.update(chunk);
|
|
91
|
+
open.write(chunk);
|
|
92
|
+
await open.flush();
|
|
93
|
+
});
|
|
94
|
+
await open.end();
|
|
95
|
+
sink = undefined;
|
|
96
|
+
return { path, size, etag: hasher.digest('hex').slice(0, 32) };
|
|
97
|
+
} catch (error) {
|
|
98
|
+
await Promise.resolve(sink?.end()).catch(() => undefined);
|
|
99
|
+
await discardStaged(path);
|
|
100
|
+
if (isUltimateError(error)) throw error;
|
|
101
|
+
const at = renderFixShellArg(root, '<the disk root>');
|
|
102
|
+
throw putFailed(disk, key, error, `ls -ld ${at} && df -h ${at}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -5,13 +5,29 @@
|
|
|
5
5
|
// why: Bun has no `rename` and no `mkdir` — `Bun.write` creates parents and replaces in place,
|
|
6
6
|
// which is exactly the non-atomic write this file exists to stop. Delete both the day Bun ships them.
|
|
7
7
|
import { mkdir, rename } from 'node:fs/promises';
|
|
8
|
-
import { renderFixShellArg, stringField } from '@ultimat3/core';
|
|
8
|
+
import { NotImplementedError, renderFixShellArg, stringField } from '@ultimat3/core';
|
|
9
|
+
import type { PutOptions } from './driver';
|
|
9
10
|
import type { Sidecar } from './driver-local-sidecar';
|
|
10
11
|
import { isStorageError, keyConflict, putFailed } from './errors';
|
|
11
12
|
import { META_DIR } from './path';
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* A POSIX file is not encrypted at rest by this driver, and recording the request in the sidecar
|
|
16
|
+
* would answer a security review with a field the disk never honoured. Refused on the DEV disk
|
|
17
|
+
* too, and deliberately: a `put()` that succeeds locally and throws in production is a gap an app
|
|
18
|
+
* meets on the worst day, and both drivers refusing is one rule instead of two.
|
|
19
|
+
*/
|
|
20
|
+
export function refuseUnsupportedPut(putOptions?: PutOptions): void {
|
|
21
|
+
if (putOptions?.serverSideEncryption === undefined) return;
|
|
22
|
+
throw new NotImplementedError({
|
|
23
|
+
cause:
|
|
24
|
+
'server-side encryption on the local driver (it writes plain files under one root) is not implemented by this driver — drop serverSideEncryption from put() and encrypt the disk itself: an s3Driver over a bucket with a default KMS rule, or a LUKS/FileVault volume under root',
|
|
25
|
+
fix: 'put(key, body)',
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
13
29
|
/** Inside `.meta/`, so the listing's one filter already keeps a staged file out of the keys. */
|
|
14
|
-
const STAGING_DIR = `${META_DIR}/.tmp`;
|
|
30
|
+
export const STAGING_DIR = `${META_DIR}/.tmp`;
|
|
15
31
|
|
|
16
32
|
export const sidecarPathOf = (key: string): string => `${META_DIR}/${key}.json`;
|
|
17
33
|
/** Beside the sidecar, and never one itself: a sidecar's name ends in `.json`. */
|
|
@@ -79,8 +95,11 @@ export interface ObjectWrite {
|
|
|
79
95
|
readonly key: string;
|
|
80
96
|
/** The name the disk was REGISTERED under — what a refusal's `disk('…')` call must name. */
|
|
81
97
|
readonly disk: string;
|
|
82
|
-
/**
|
|
83
|
-
|
|
98
|
+
/**
|
|
99
|
+
* A `BunFile` is a `Blob`: a copy hands the source file over and no byte crosses the heap. A
|
|
100
|
+
* `stagedAt` is a file `putStream` already wrote under `STAGING_DIR`, renamed rather than copied.
|
|
101
|
+
*/
|
|
102
|
+
readonly body: Uint8Array | Blob | { readonly stagedAt: string };
|
|
84
103
|
/** `etag` MUST be the body's own: it is what the pending marker announces. */
|
|
85
104
|
readonly sidecar: Sidecar;
|
|
86
105
|
}
|
|
@@ -127,7 +146,11 @@ async function commit(write: ObjectWrite, steps: WriteSteps): Promise<void> {
|
|
|
127
146
|
});
|
|
128
147
|
};
|
|
129
148
|
try {
|
|
130
|
-
|
|
149
|
+
if (write.body instanceof Uint8Array || write.body instanceof Blob) {
|
|
150
|
+
await Bun.write(`${staged}.object`, write.body);
|
|
151
|
+
} else {
|
|
152
|
+
await rename(write.body.stagedAt, `${staged}.object`);
|
|
153
|
+
}
|
|
131
154
|
await Bun.write(`${staged}.sidecar`, JSON.stringify(write.sidecar));
|
|
132
155
|
await Bun.write(`${staged}.pending`, write.sidecar.etag);
|
|
133
156
|
await settle('pending', pendingPath);
|