@ultimat3/storage 23.0.0 → 25.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.
@@ -0,0 +1,114 @@
1
+ // Single responsibility: what the local disk KNOWS about one object — its bytes file, its sidecar,
2
+ // and the pending marker that says whether the two can be trusted as a pair. A write that died
3
+ // between its renames leaves a sidecar describing bytes that never landed; this is where that is
4
+ // read as "type unknown" instead of served as the truth.
5
+
6
+ import { renderFixShellArg, stringField } from '@ultimat3/core';
7
+ import type { StorageListEntry } from './driver';
8
+ import { parseSidecar, type Sidecar } from './driver-local-sidecar';
9
+ import { pendingPathOf, sidecarPathOf } from './driver-local-write';
10
+ import { isStorageError, objectNotFound, readFailed } from './errors';
11
+
12
+ /** The sidecar as recorded — trusted or not. The lock check reads it whole: a doubt never unlocks. */
13
+ export async function readSidecar(root: string, key: string): Promise<Sidecar | undefined> {
14
+ const file = Bun.file(`${root}/${sidecarPathOf(key)}`);
15
+ if (!(await file.exists())) return undefined;
16
+ try {
17
+ const raw: unknown = await file.json();
18
+ return parseSidecar(raw);
19
+ } catch {
20
+ return undefined;
21
+ }
22
+ }
23
+
24
+ /** The etag a write in flight — or one that died — announced before it moved anything. */
25
+ async function pendingEtag(root: string, key: string): Promise<string | undefined> {
26
+ const file = Bun.file(`${root}/${pendingPathOf(key)}`);
27
+ if (!(await file.exists())) return undefined;
28
+ return (await file.text().catch(() => '')).trim();
29
+ }
30
+
31
+ /**
32
+ * A read the OS refused, coded: `EACCES`, `EIO`, a directory where the bytes should be. A file
33
+ * that is simply gone — deleted between the `exists()` and the read — is "not found", as it
34
+ * would have been one call earlier.
35
+ */
36
+ const refusedRead =
37
+ (root: string, key: string, disk: string) =>
38
+ (error: unknown): never => {
39
+ if (isStorageError(error)) throw error;
40
+ throw stringField(error, 'code') === 'ENOENT'
41
+ ? objectNotFound(disk, key)
42
+ : readFailed(
43
+ 'local',
44
+ key,
45
+ error,
46
+ `ls -l ${renderFixShellArg(`${root}/${key}`, '<the object file>')}`,
47
+ );
48
+ };
49
+
50
+ /**
51
+ * The whole object, buffered — `get()`'s one read. `disk` is the name `defineStorage` registered,
52
+ * because a not-found `fix:` is `disk('<it>').list(…)`; `local` only for a driver nobody named.
53
+ */
54
+ export async function readObjectBytes(
55
+ root: string,
56
+ key: string,
57
+ disk = 'local',
58
+ ): Promise<Uint8Array> {
59
+ return Bun.file(`${root}/${key}`)
60
+ .arrayBuffer()
61
+ .then((buffer) => new Uint8Array(buffer), refusedRead(root, key, disk));
62
+ }
63
+
64
+ /** `etagOf`, streamed: a copy or a `stat()` must settle a doubt without buffering the object. */
65
+ export async function etagOfFile(root: string, key: string, disk = 'local'): Promise<string> {
66
+ const hasher = new Bun.CryptoHasher('sha256');
67
+ try {
68
+ for await (const chunk of Bun.file(`${root}/${key}`).stream()) hasher.update(chunk);
69
+ } catch (error) {
70
+ return refusedRead(root, key, disk)(error);
71
+ }
72
+ return hasher.digest('hex').slice(0, 32);
73
+ }
74
+
75
+ /**
76
+ * One object's facts, or `undefined` when there are no bytes at the key — the bytes file is what
77
+ * makes an object exist, so a sidecar alone (a fresh write that died early) is nothing at all.
78
+ *
79
+ * The sidecar is TRUSTED unless the pending marker names its etag: that is the one state in which
80
+ * the sidecar is the new write's and the bytes may still be the old object's. Then, and for an
81
+ * object with no sidecar, the answer depends on `trueEtag`:
82
+ * - absent (a listing, which never reads bytes): no content type, `etag: ''` — "cannot know";
83
+ * - given: the bytes decide. A match keeps the sidecar; a mismatch drops every field it claimed
84
+ * and reports the bytes' own etag.
85
+ *
86
+ * No path here answers a type or an etag the bytes were not checked against while in doubt.
87
+ */
88
+ export async function headObject(
89
+ root: string,
90
+ key: string,
91
+ trueEtag?: () => string | Promise<string>,
92
+ ): Promise<StorageListEntry | undefined> {
93
+ const file = Bun.file(`${root}/${key}`);
94
+ if (!(await file.exists())) return undefined;
95
+ const recorded = await readSidecar(root, key);
96
+ const inDoubt = recorded !== undefined && (await pendingEtag(root, key)) === recorded.etag;
97
+ let sidecar = recorded;
98
+ let etag = recorded?.etag ?? '';
99
+ if (recorded === undefined || inDoubt) {
100
+ etag = trueEtag === undefined ? '' : await trueEtag();
101
+ if (recorded?.etag !== etag) sidecar = undefined;
102
+ }
103
+ return {
104
+ key,
105
+ size: file.size,
106
+ etag,
107
+ // The recorded instant when the sidecar is trusted, the file's mtime otherwise: one source per
108
+ // object, and the one `put()`/`copy()` returned — on the disk's injected clock.
109
+ lastModified: new Date(sidecar?.lastModified ?? file.lastModified),
110
+ ...(sidecar?.contentType === undefined ? {} : { contentType: sidecar.contentType }),
111
+ ...(sidecar?.cacheControl === undefined ? {} : { cacheControl: sidecar.cacheControl }),
112
+ ...(sidecar?.metadata === undefined ? {} : { metadata: sidecar.metadata }),
113
+ };
114
+ }
@@ -0,0 +1,96 @@
1
+ // Single responsibility: the local disk's sidecar — the JSON file under `.meta/` holding what a
2
+ // POSIX file has nowhere to keep (content type, etag, cache-control, user metadata, the object
3
+ // lock), and the one parser that reads it back as untrusted bytes off a disk.
4
+
5
+ import { isRetentionMode, type ObjectLock, type RetentionMode } from './object-lock';
6
+
7
+ /** The lock as JSON holds it: the instant as an ISO string, the hold only when it is on. */
8
+ export interface SidecarLock {
9
+ readonly retention?: { readonly mode: RetentionMode; readonly retainUntil: string } | undefined;
10
+ readonly legalHold?: true | undefined;
11
+ }
12
+
13
+ export interface Sidecar extends SidecarLock {
14
+ readonly contentType: string;
15
+ readonly etag: string;
16
+ readonly cacheControl?: string | undefined;
17
+ readonly metadata?: Readonly<Record<string, string>> | undefined;
18
+ /**
19
+ * The write's instant as ISO, read off the disk's injected `Clock` — what `put()`/`copy()`
20
+ * returned, so a later `stat`/`get`/`list` reports the same instant (the memory disk's rule).
21
+ * Absent on a sidecar written before it was recorded: the file's mtime answers instead.
22
+ */
23
+ readonly lastModified?: string | undefined;
24
+ }
25
+
26
+ /** `ObjectLock` → the fields a sidecar records. Nothing when nothing locks the object. */
27
+ export function sidecarLock(lock: ObjectLock | undefined): SidecarLock {
28
+ if (lock === undefined) return {};
29
+ return {
30
+ ...(lock.retention === undefined
31
+ ? {}
32
+ : {
33
+ retention: {
34
+ mode: lock.retention.mode,
35
+ retainUntil: lock.retention.retainUntil.toISOString(),
36
+ },
37
+ }),
38
+ ...(lock.legalHold ? { legalHold: true } : {}),
39
+ };
40
+ }
41
+
42
+ /** The recorded lock as `ObjectLock`. A retention that does not parse is no retention. */
43
+ export function lockOfSidecar(sidecar: SidecarLock | undefined): ObjectLock {
44
+ const recorded = sidecar?.retention;
45
+ return {
46
+ ...(recorded === undefined
47
+ ? {}
48
+ : { retention: { mode: recorded.mode, retainUntil: new Date(recorded.retainUntil) } }),
49
+ legalHold: sidecar?.legalHold === true,
50
+ };
51
+ }
52
+
53
+ function parseRetention(raw: unknown): SidecarLock['retention'] {
54
+ if (typeof raw !== 'object' || raw === null) return undefined;
55
+ const mode = (raw as Record<string, unknown>)['mode'];
56
+ const until = (raw as Record<string, unknown>)['retainUntil'];
57
+ if (!isRetentionMode(mode) || typeof until !== 'string') return undefined;
58
+ if (!Number.isFinite(Date.parse(until))) return undefined;
59
+ return { mode, retainUntil: until };
60
+ }
61
+
62
+ // `!Array.isArray` is the load-bearing clause, matching `isPlainObject` in
63
+ // `@ultimat3/schema`'s `builder.ts`: `typeof [] === 'object'` and every value of `['a','b']` is a
64
+ // string, so an array in the `metadata` slot was handed back through `head()`/`get()` as object
65
+ // metadata — against a `Record<string, string>` every reader downstream is typed on.
66
+ const isStringRecord = (value: unknown): value is Readonly<Record<string, string>> =>
67
+ typeof value === 'object' &&
68
+ value !== null &&
69
+ !Array.isArray(value) &&
70
+ Object.values(value as Record<string, unknown>).every((entry) => typeof entry === 'string');
71
+
72
+ export function parseSidecar(raw: unknown): Sidecar | undefined {
73
+ if (typeof raw !== 'object' || raw === null) return undefined;
74
+ const record = raw as Record<string, unknown>;
75
+ const contentType = record['contentType'];
76
+ const etag = record['etag'];
77
+ if (typeof contentType !== 'string' || typeof etag !== 'string') return undefined;
78
+ // `put()` writes cacheControl/metadata into the same sidecar — dropping them here silently
79
+ // truncated what was just written, even though `Sidecar` itself declares both.
80
+ const cacheControl = record['cacheControl'];
81
+ const metadata = record['metadata'];
82
+ const retention = parseRetention(record['retention']);
83
+ const lastModified = record['lastModified'];
84
+ return {
85
+ contentType,
86
+ etag,
87
+ ...(typeof cacheControl === 'string' ? { cacheControl } : {}),
88
+ ...(isStringRecord(metadata) ? { metadata } : {}),
89
+ // An instant that does not parse is no instant — never an invalid `Date` handed to a reader.
90
+ ...(typeof lastModified === 'string' && Number.isFinite(Date.parse(lastModified))
91
+ ? { lastModified }
92
+ : {}),
93
+ ...(retention === undefined ? {} : { retention }),
94
+ ...(record['legalHold'] === true ? { legalHold: true } : {}),
95
+ };
96
+ }
@@ -0,0 +1,185 @@
1
+ // Single responsibility: how the local disk WRITES one object — the bytes, the sidecar and a
2
+ // pending marker, staged under `.meta/.tmp/` and renamed into place in the one order whose every
3
+ // crash point reads as absent, whole or untyped; refused by code when the disk says no.
4
+
5
+ // why: Bun has no `rename` and no `mkdir` — `Bun.write` creates parents and replaces in place,
6
+ // which is exactly the non-atomic write this file exists to stop. Delete both the day Bun ships them.
7
+ import { mkdir, rename } from 'node:fs/promises';
8
+ import { renderFixShellArg, stringField } from '@ultimat3/core';
9
+ import type { Sidecar } from './driver-local-sidecar';
10
+ import { isStorageError, keyConflict, putFailed } from './errors';
11
+ import { META_DIR } from './path';
12
+
13
+ /** Inside `.meta/`, so the listing's one filter already keeps a staged file out of the keys. */
14
+ const STAGING_DIR = `${META_DIR}/.tmp`;
15
+
16
+ export const sidecarPathOf = (key: string): string => `${META_DIR}/${key}.json`;
17
+ /** Beside the sidecar, and never one itself: a sidecar's name ends in `.json`. */
18
+ export const pendingPathOf = (key: string): string => `${META_DIR}/${key}.json.pending`;
19
+
20
+ const codeOf = (error: unknown): string | undefined => stringField(error, 'code');
21
+ /** `mkdir -p` through a file, a rename onto a directory, a write under a file. */
22
+ const isPathConflict = (error: unknown): boolean =>
23
+ ['ENOTDIR', 'EEXIST', 'EISDIR', 'ENOTEMPTY'].includes(codeOf(error) ?? '');
24
+ const isAbsent = (error: unknown): boolean => ['ENOENT', 'ENOTDIR'].includes(codeOf(error) ?? '');
25
+
26
+ /** The object KEY a path inside the root stands for — a sidecar's path names its object. */
27
+ const keyAt = (relative: string): string =>
28
+ relative.startsWith(`${META_DIR}/`)
29
+ ? relative.slice(META_DIR.length + 1).replace(/\.json(\.pending)?$/, '')
30
+ : relative;
31
+
32
+ /** The first ancestor of `relative` that is a FILE: the stored object standing where a directory must be. */
33
+ async function fileInTheWay(root: string, relative: string): Promise<string | undefined> {
34
+ const segments = relative.split('/').slice(0, -1);
35
+ for (let depth = 1; depth <= segments.length; depth += 1) {
36
+ const ancestor = segments.slice(0, depth).join('/');
37
+ if (await Bun.file(`${root}/${ancestor}`).exists()) return keyAt(ancestor);
38
+ }
39
+ // Nothing INSIDE the root is in the way: the root itself is the problem, and that is the
40
+ // disk refusing the write, not two keys colliding.
41
+ return undefined;
42
+ }
43
+
44
+ /**
45
+ * Make `relative` writable as a FILE, or refuse. Runs for both of an object's paths BEFORE either
46
+ * is touched: the sidecar tree collides where the object tree does not (`a` and `a.json/b`), and a
47
+ * put() that wrote its bytes and then failed on its sidecar left an object nobody had recorded.
48
+ */
49
+ async function claim(write: ObjectWrite, relative: string): Promise<void> {
50
+ const { root, key } = write;
51
+ const target = `${root}/${relative}`;
52
+ try {
53
+ await mkdir(target.slice(0, target.lastIndexOf('/')), { recursive: true });
54
+ } catch (error) {
55
+ const blocking = isPathConflict(error) ? await fileInTheWay(root, relative) : undefined;
56
+ throw blocking === undefined ? error : keyConflict(write.disk, key, blocking);
57
+ }
58
+ let directory = false;
59
+ try {
60
+ directory = (await Bun.file(target).stat()).isDirectory();
61
+ } catch (error) {
62
+ if (!isAbsent(error)) throw error;
63
+ }
64
+ // The keys in the way live UNDER this path: `<key>/…`, or `<key>.json/…` in the sidecar tree.
65
+ const beneath = relative.startsWith(`${META_DIR}/`)
66
+ ? relative.slice(META_DIR.length + 1)
67
+ : relative;
68
+ if (directory) throw keyConflict(write.disk, key, `${beneath}/`);
69
+ }
70
+
71
+ const discard = async (path: string): Promise<void> => {
72
+ await Bun.file(path)
73
+ .delete()
74
+ .catch(() => undefined);
75
+ };
76
+
77
+ export interface ObjectWrite {
78
+ readonly root: string;
79
+ readonly key: string;
80
+ /** The name the disk was REGISTERED under — what a refusal's `disk('…')` call must name. */
81
+ 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;
84
+ /** `etag` MUST be the body's own: it is what the pending marker announces. */
85
+ readonly sidecar: Sidecar;
86
+ }
87
+
88
+ /** The four mutations a commit makes, in order — the seam a crash test dies at. */
89
+ export interface WriteSteps {
90
+ rename(from: string, to: string): Promise<void>;
91
+ unlink(path: string): Promise<void>;
92
+ }
93
+
94
+ const REAL_STEPS: WriteSteps = {
95
+ rename: (from, to) => rename(from, to),
96
+ unlink: async (path) => {
97
+ await Bun.file(path).delete();
98
+ },
99
+ };
100
+
101
+ /**
102
+ * Stage three files, then four steps: (1) rename the pending MARKER, which holds the new etag;
103
+ * (2) rename the sidecar; (3) rename the bytes; (4) remove the marker. Each rename replaces a
104
+ * directory entry whole, so no reader ever opens half a file.
105
+ *
106
+ * The order is chosen by what a crash leaves (`driver-local-crash.test.ts` dies at every step):
107
+ * - before (3), a FRESH key has no bytes file and so no object — absent, never half-present;
108
+ * - before (2), an overwrite still has the previous sidecar over the previous bytes — whole;
109
+ * - between (2) and (3), the new sidecar sits over the OLD bytes, and the marker naming that
110
+ * sidecar's etag is what tells `headObject` not to believe it;
111
+ * - between (3) and (4) the pair is whole and still marked, so a read re-checks it and a listing
112
+ * reports it untyped until the next put.
113
+ * Bytes-first has no such table: its torn overwrite is new bytes under the old type, undetectable.
114
+ */
115
+ async function commit(write: ObjectWrite, steps: WriteSteps): Promise<void> {
116
+ const sidecarPath = sidecarPathOf(write.key);
117
+ const pendingPath = pendingPathOf(write.key);
118
+ await claim(write, write.key);
119
+ await claim(write, sidecarPath);
120
+ await claim(write, pendingPath);
121
+ const staged = `${write.root}/${STAGING_DIR}/${crypto.randomUUID()}`;
122
+ // A second writer can make the colliding key between `claim` and the rename; only a RENAME's
123
+ // refusal is that, never a failed staging write.
124
+ const settle = async (suffix: string, relative: string): Promise<void> => {
125
+ await steps.rename(`${staged}.${suffix}`, `${write.root}/${relative}`).catch((error) => {
126
+ throw isPathConflict(error) ? keyConflict(write.disk, write.key, write.key) : error;
127
+ });
128
+ };
129
+ try {
130
+ await Bun.write(`${staged}.object`, write.body);
131
+ await Bun.write(`${staged}.sidecar`, JSON.stringify(write.sidecar));
132
+ await Bun.write(`${staged}.pending`, write.sidecar.etag);
133
+ await settle('pending', pendingPath);
134
+ await settle('sidecar', sidecarPath);
135
+ await settle('object', write.key);
136
+ } catch (error) {
137
+ for (const suffix of ['object', 'sidecar', 'pending']) await discard(`${staged}.${suffix}`);
138
+ throw error;
139
+ }
140
+ // The object is COMMITTED: all three renames landed. A marker that will not clear is not a
141
+ // failed put — reporting one told the caller to retry a write every reader can already see.
142
+ // What it leaves is exactly the crash-at-step-4 state, which `headObject` re-checks.
143
+ await steps.unlink(`${write.root}/${pendingPath}`).catch(() => undefined);
144
+ }
145
+
146
+ /**
147
+ * The local write, coded. Whatever the filesystem refuses with — `EACCES`, `ENOSPC`, `EROFS`, a
148
+ * root that is a file — leaves here as `X_STORAGE_PUT_FAILED` carrying the errno, where it used to
149
+ * be the bare `Error` out of `Bun.write`. A refusal this package already named passes through.
150
+ */
151
+ export async function commitObject(
152
+ write: ObjectWrite,
153
+ steps: WriteSteps = REAL_STEPS,
154
+ ): Promise<void> {
155
+ try {
156
+ await commit(write, steps);
157
+ } catch (error) {
158
+ if (isStorageError(error)) throw error;
159
+ const at = renderFixShellArg(write.root, '<the disk root>');
160
+ throw putFailed(write.disk, write.key, error, `ls -ld ${at} && df -h ${at}`);
161
+ }
162
+ }
163
+
164
+ /** Runs `task` after every earlier task queued under the same key has settled. */
165
+ export type KeyedQueue = <T>(key: string, task: () => Promise<T>) => Promise<T>;
166
+
167
+ /**
168
+ * One writer — or one reader — per key at a time, inside this process. An object is two files, so
169
+ * two unserialised `put()`s of one key interleaved their four writes and left one's bytes under the
170
+ * other's etag; a `get()` between a writer's two renames read the same mismatch.
171
+ */
172
+ export function keyedQueue(): KeyedQueue {
173
+ const tails = new Map<string, Promise<unknown>>();
174
+ return async <T>(key: string, task: () => Promise<T>): Promise<T> => {
175
+ const result = (tails.get(key) ?? Promise.resolve()).then(task, task);
176
+ const tail = result.catch(() => undefined);
177
+ tails.set(key, tail);
178
+ try {
179
+ return await result;
180
+ } finally {
181
+ // Only the LAST task clears the entry, or a Map of every key ever written is a leak.
182
+ if (tails.get(key) === tail) tails.delete(key);
183
+ }
184
+ };
185
+ }