@ultimat3/storage 29.4.0 → 30.1.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
@@ -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
+ }
@@ -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
- /** A `BunFile` is a `Blob`: a copy hands the source file over and no byte crosses the heap. */
83
- readonly body: Uint8Array | Blob;
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
- await Bun.write(`${staged}.object`, write.body);
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);