@ultimat3/storage 24.0.0 → 25.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.
- package/CLAUDE.md +36 -40
- package/README.md +86 -17
- package/package.json +3 -3
- package/src/attachment.ts +6 -9
- package/src/driver-local-read.ts +19 -9
- package/src/driver-local-sidecar.ts +61 -3
- package/src/driver-local.ts +98 -33
- package/src/driver-memory-conflict.ts +45 -0
- package/src/driver-memory.ts +75 -16
- package/src/driver-s3-absent.ts +32 -0
- package/src/driver-s3-client.ts +94 -0
- package/src/driver-s3-lock.ts +131 -0
- package/src/driver-s3-signed.ts +220 -0
- package/src/driver-s3.ts +95 -155
- package/src/driver.ts +36 -19
- package/src/errors.ts +23 -6
- package/src/index.ts +15 -7
- package/src/object-lock.ts +173 -0
- package/src/signed-url.ts +0 -4
- package/src/signing-secret.ts +12 -7
- package/src/upload.ts +51 -0
package/CLAUDE.md
CHANGED
|
@@ -38,14 +38,19 @@ Tier 1. Object storage: named disks, safe keys, signed URLs, sniffed uploads.
|
|
|
38
38
|
|
|
39
39
|
| File | Owns |
|
|
40
40
|
|---|---|
|
|
41
|
-
| `driver.ts` | `StorageDriver` contract (9 methods; `registerAs`/`verifySigned` optional), `assertPutOptions`, + bounded `toBytes`/`sha256Base64`/`etagOf` |
|
|
41
|
+
| `driver.ts` | `StorageDriver` contract (9 methods; `registerAs`/`verifySigned`/`retentionOf` optional), `assertPutOptions`, + bounded `toBytes`/`sha256Base64`/`etagOf` |
|
|
42
42
|
| `driver-local.ts` | dev default over `Bun.file`, `.meta/` sidecars, `Bun.Glob` listing |
|
|
43
43
|
| `driver-local-write.ts` | the local WRITE: staged under `.meta/.tmp/`, renamed marker → sidecar → bytes, `X_STORAGE_KEY_CONFLICT` / `X_STORAGE_PUT_FAILED`, the per-key queue |
|
|
44
44
|
| `driver-local-read.ts` | `headObject`: sidecar + pending marker → trusted, re-checked, or untyped |
|
|
45
45
|
| `driver-local-sidecar.ts` | the sidecar shape and its one parser |
|
|
46
46
|
| `signing-secret.ts` | `DEV_SIGNING_SECRET`, `usesDevStorageSecret`, `resolveSigningSecret` |
|
|
47
|
-
| `driver-memory.ts` | `
|
|
48
|
-
| `driver-
|
|
47
|
+
| `driver-memory-conflict.ts` | `claim()`'s conflict rule over keys |
|
|
48
|
+
| `driver-memory.ts` | `memoryStorageDriver()` — a test's disk over a `Map`: `localDriver`'s contract and refusals, `objects()` for an assertion about the bucket. `driver-memory.test.ts` runs each claim on BOTH disks. Signs through `resolveSigningSecret` (`signing-secret.ts`), the one rule both share |
|
|
49
|
+
| `driver-s3.ts` | the s3 disk: Bun where it can, the signed wire where it cannot |
|
|
50
|
+
| `driver-s3-client.ts` | `S3*Like` + `buildClient`, lazy (import never opens a socket) |
|
|
51
|
+
| `driver-s3-signed.ts` | signed requests: Bun's addressing restated, `requireEnv`, `fetch`, refusal as `S3Error` fields |
|
|
52
|
+
| `driver-s3-lock.ts` | the signed PUT's headers; `?retention` / `?legal-hold` |
|
|
53
|
+
| `object-lock.ts` | lock types, the `put()` screen, `isLocked`, the local/memory refusals |
|
|
49
54
|
| `driver-s3-region.ts` | a provider's wrong-region refusal → `X_CONFIG_INVALID` whose fix names `S3_REGION` |
|
|
50
55
|
| `path.ts` | key validation + `META_DIR` + `scopedKey`/`isWithinOrg`/`isTenantScoped` tenant boundary |
|
|
51
56
|
| `signed-url.ts` | HMAC over the constraint tuple, constant-time verify |
|
|
@@ -70,8 +75,8 @@ Gotchas:
|
|
|
70
75
|
(`As of 2026-10`). `put('a')` then `put('a/b')` — or the reverse, or `a` beside `a.json/b` in the
|
|
71
76
|
sidecar tree — is `X_STORAGE_KEY_CONFLICT`, decided by `claim()` for BOTH paths before either is
|
|
72
77
|
touched. Suffixed on-disk names would lift it and orphan every existing root's objects without a
|
|
73
|
-
migration; do not change the layout without one.
|
|
74
|
-
|
|
78
|
+
migration; do not change the layout without one. Memory emulates it; s3 holds both. A key that
|
|
79
|
+
is only a directory is ABSENT to `delete()` (test a refusal with `driver-local-fixture.ts`).
|
|
75
80
|
- **The local commit order is marker → sidecar → bytes → clear marker. Do not reorder**
|
|
76
81
|
(`As of 2026-10`). Bytes-first serves new bytes under the old type; sidecar-first alone serves
|
|
77
82
|
old bytes under the new one. The marker holds the new etag, so `headObject` knows the one state
|
|
@@ -91,8 +96,7 @@ Gotchas:
|
|
|
91
96
|
one secret, so the canonical string must name the disk. Path, not origin — the verifying route
|
|
92
97
|
sees a path — and `signedUrlBasePath` is how an absolute `baseUrl` is compared.
|
|
93
98
|
- **`isWithinOrg` never throws**: an org id that cannot be one contains nothing.
|
|
94
|
-
- **A variant key keeps the whole source key** (`hero.png@w640.webp`)
|
|
95
|
-
only by extension are two cache identities.
|
|
99
|
+
- **A variant key keeps the whole source key** (`hero.png@w640.webp`): two cache identities.
|
|
96
100
|
- **A driver's semantics are pinned in ONE test with the other driver beside them.**
|
|
97
101
|
`driver-parity.test.ts` drives `localDriver` over a temp dir and `s3Driver` over `FakeS3Client`
|
|
98
102
|
in a single `test()` per claim, so neither disk can move alone. Where the two genuinely cannot
|
|
@@ -107,35 +111,28 @@ Gotchas:
|
|
|
107
111
|
page), so a paging caller read "complete, and there is nothing here" over a full disk, while the
|
|
108
112
|
s3 disk handed `maxKeys: 0` to the provider. Core's `assert` (`X_INVARIANT`), for the reason
|
|
109
113
|
`@ultimat3/seo`'s `chunk()` uses it: a bound with no code of its own is still a coded refusal.
|
|
114
|
+
- **`list()` answers core's `Page<StorageListEntry>`, and no storage-local page type exists**
|
|
115
|
+
(`As of 25.0.0`; `ListPage` is deleted). Every driver builds it with core's `pageOf`, so
|
|
116
|
+
`hasMore` is derived from the cursor and cannot disagree with it. The cursor is minted only when
|
|
117
|
+
a row past the page exists — never from `rows.length === limit`, the full-last-page defect a
|
|
118
|
+
query's cursor had — and the full-last-page case is pinned on memory, local and live s3.
|
|
110
119
|
- **`META_DIR` is reserved case-INSENSITIVELY** (`As of 2026-08`), exactly as `isTenantScoped`
|
|
111
|
-
folds
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
one, and `path.test.ts` pins both halves.
|
|
116
|
-
- **`disk(name)` resolves through a `Map`, never `config.disks[name]`.** The bracket read walked
|
|
117
|
-
the prototype chain, so `disk('constructor')` handed back the `Object` function and the next
|
|
118
|
-
`.put()` was a bare `TypeError` — `X_STORAGE_DISK_UNKNOWN` unreachable for `constructor`,
|
|
119
|
-
`toString`, `valueOf`, `hasOwnProperty` and `__proto__`, in a function whose own
|
|
120
|
-
`default:` check already read `Object.keys`.
|
|
120
|
+
folds: on APFS/NTFS `.META/` IS `.meta/`. The whole SEGMENT is compared, never a prefix
|
|
121
|
+
(`.metadata/a.json` is an ordinary key); `path.test.ts` pins both halves.
|
|
122
|
+
- **`disk(name)` resolves through a `Map`, never `config.disks[name]`**: the bracket read walked
|
|
123
|
+
the prototype chain (`disk('constructor')` was the `Object` function, not `X_STORAGE_DISK_UNKNOWN`).
|
|
121
124
|
- **`list()` is idempotent for an EMPTY disk and for nothing else** (`As of 2026-08`) — exactly
|
|
122
|
-
`delete()`'s rule
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
`storageClass` and no header hook for `x-amz-meta-*` or `Cache-Control`, so `s3Driver` refuses
|
|
134
|
-
(`X_NOT_IMPLEMENTED`, with the out-of-band `aws s3 cp` in the fix) while `localDriver` stores both
|
|
135
|
-
in its sidecar and reads them back. **Do not "fix" this by making the local disk refuse too** —
|
|
136
|
-
that deletes a working capability and the two `StorageListEntry` fields that carry it, to buy
|
|
137
|
-
symmetry with a limitation that is Bun's and temporary. The day the hook lands, the s3 half of
|
|
138
|
-
`driver-parity.test.ts` fails and the resolution is to make s3 store them.
|
|
125
|
+
`delete()`'s rule. `ENOENT` (a root nobody has written to) is an empty page; everything else is
|
|
126
|
+
`X_STORAGE_LIST_FAILED`, whose `fix` the DRIVER supplies.
|
|
127
|
+
- **`head()` and `list()` NEVER read an object's bytes** — an unknowable etag is `''`, as on s3.
|
|
128
|
+
`get()` hashes bytes it holds; `copy()` passes `hash: true`, so no sidecar records `etag: ''`.
|
|
129
|
+
- **s3 `put()` with `metadata`/`cacheControl`/`retention`/`legalHold` is ONE signed PUT** (core's
|
|
130
|
+
`signAwsRequest`; Bun's `write` takes no header); a plain `put()` stays on Bun. Reads stay Bun's
|
|
131
|
+
HEAD, so s3 `get()` reports neither field — pinned in `driver-parity.test.ts`.
|
|
132
|
+
- **Object Lock: local/memory REFUSE delete/overwrite/copy-onto** (`X_STORAGE_OBJECT_LOCKED`);
|
|
133
|
+
S3 accepts both (delete marker, new version; the locked version stays). Pinned divergence: one
|
|
134
|
+
version per key. A pair in doubt never unlocks. `NoSuchObjectLockConfiguration` is a 404.
|
|
135
|
+
- **A live suite signs on a wall clock** (`performance`): `Date` is frozen → `RequestTimeTooSkewed`.
|
|
139
136
|
- **`signedUrl({ maxBytes })` is signed on `local` and unenforceable on `s3`, and the s3 driver may
|
|
140
137
|
NOT refuse it.** `grantUpload` passes `maxBytes: policy.maxBytes` on every grant, so a refusal
|
|
141
138
|
would break every s3 upload an app mints. S3 has no request header for a size and Bun's `presign`
|
|
@@ -193,7 +190,7 @@ Gotchas:
|
|
|
193
190
|
the secret, `isLocal()` and the refusal's environment all read `options.env ?? process.env`, so
|
|
194
191
|
the boot's guard and the constructor it guards cannot answer from two tables.
|
|
195
192
|
`driver-local-boot.test.ts` pins it by mutation.
|
|
196
|
-
- **The mounted read half is `@ultimat3/cli`'s `
|
|
193
|
+
- **The mounted read half is `@ultimat3/cli`'s `runtime-storage.ts`, not this package.** `GET
|
|
197
194
|
/_storage/:disk/*key` gates on `@ultimat3/policy`'s `evaluate()` (`storage:read`), which is tier
|
|
198
195
|
2 and unreachable from here — so a "serve this object" helper in this package could only ever be
|
|
199
196
|
a second authz path. This package's contribution to that route is `assertSafeKey`,
|
|
@@ -229,7 +226,7 @@ Gotchas:
|
|
|
229
226
|
held to `[A-Za-z0-9-]`: a provider's sentence may put nothing else into a `fix:`. A HEAD has no
|
|
230
227
|
body, so `exists()`/`stat()` never see it. `driver-s3.live.test.ts` runs with no region on purpose.
|
|
231
228
|
- The signature check runs BEFORE the expiry check. Do not reorder.
|
|
232
|
-
- `timingSafeEqual` is `@ultimat3/core`'s (`signed-url.ts` imports
|
|
229
|
+
- `timingSafeEqual` is `@ultimat3/core`'s (`signed-url.ts` imports it) — the same
|
|
233
230
|
implementation `@ultimat3/auth` uses, not a second copy. Add new secret comparisons through it.
|
|
234
231
|
- `verifySignedUrl` never throws, and `parseConstraints` is where that is kept: the key is decoded
|
|
235
232
|
through the guarded `decodeSegment`, so a `%ZZ` in the path is `'malformed'` rather than the bare
|
|
@@ -250,7 +247,7 @@ Gotchas:
|
|
|
250
247
|
(`X_CONFIG_INVALID`) — it could only mint under one of them. `@ultimat3/cli`'s
|
|
251
248
|
`STORAGE_BASE_PATH` is a third statement of the mount prefix and should import
|
|
252
249
|
`DEFAULT_SIGNED_URL_BASE` instead.
|
|
253
|
-
- **`accept.ts` asks the `isTenantScoped`/`isWithinOrg` PAIR, exactly as `
|
|
250
|
+
- **`accept.ts` asks the `isTenantScoped`/`isWithinOrg` PAIR, exactly as `runtime-storage.ts` does.**
|
|
254
251
|
`isWithinOrg` alone refused every un-scoped key, so an app's own `brand/logo.png` was unreachable
|
|
255
252
|
through a URL it had just signed. `isTenantScoped` is case-INSENSITIVE and `isWithinOrg` is not:
|
|
256
253
|
`Org/o2/x` and `org/o2/x` are one file on APFS/NTFS, so the fold has to count as tenant-scoped
|
|
@@ -264,9 +261,8 @@ Gotchas:
|
|
|
264
261
|
signed URL is a capability; a leaked capability must still not cross a tenant.
|
|
265
262
|
- `X_STORAGE_ORG_MISMATCH` maps to **404**, not 403 (`@ultimat3/http`'s `error-map.ts`). 403 would
|
|
266
263
|
confirm a key exists to the one caller who must not learn it.
|
|
267
|
-
-
|
|
268
|
-
|
|
269
|
-
stub driver with authored timestamps, and `driver-local.test.ts` proves the disk half.
|
|
264
|
+
- **Local `lastModified` is the `Clock`'s, kept in the sidecar** — one instant from `put`/`copy`
|
|
265
|
+
to every read; mtime only with no trusted sidecar.
|
|
270
266
|
- `upload-client.ts` defaults to `XMLHttpRequest`, not `fetch`: `fetch` reports no upload
|
|
271
267
|
progress in any shipping browser, and a bar that jumps 0 → 100 is a bar that is lying. It is
|
|
272
268
|
**the declared XHR seam** of plan 101 (decision 13): the one `XMLHttpRequest` the framework
|
package/README.md
CHANGED
|
@@ -23,10 +23,10 @@ Swapping `local` for `s3` in `app.config.ts` changes no call site. `x dev` needs
|
|
|
23
23
|
| Driver | Backing | For | Signed URLs |
|
|
24
24
|
|---|---|---|---|
|
|
25
25
|
| `localDriver` | `Bun.file`/`Bun.write`, one root dir | dev, tests, single-node | HMAC + dev route |
|
|
26
|
-
| `s3Driver` | `Bun.s3` | prod: any S3-compatible endpoint — AWS, R2, a self-hosted gateway | provider presign |
|
|
27
|
-
| `
|
|
26
|
+
| `s3Driver` | `Bun.s3`, plus core's `signAwsRequest` over `fetch` for the requests Bun has no option for | prod: any S3-compatible endpoint — AWS, R2, a self-hosted gateway | provider presign |
|
|
27
|
+
| `memoryStorageDriver` | a `Map` in this process | a TEST's disk — never a deployment's: a restart is every object gone | HMAC, the local disk's own |
|
|
28
28
|
|
|
29
|
-
`
|
|
29
|
+
`memoryStorageDriver()` is what a suite holds instead of a temp directory or a hand-written fake: it
|
|
30
30
|
answers every method `localDriver` does, refuses what it refuses (an unsafe key, a wrong checksum,
|
|
31
31
|
a body past `maxPutBytes`, `serverSideEncryption`), and signs under the same rule — the published
|
|
32
32
|
development key in `development` and `test` only. `objects()` hands back a COPY of every stored
|
|
@@ -34,13 +34,13 @@ object's bytes by key, for the assertion a test makes about the bucket itself.
|
|
|
34
34
|
|
|
35
35
|
```ts
|
|
36
36
|
import { afterEach, beforeEach } from 'bun:test';
|
|
37
|
-
import { defineStorage, disk,
|
|
37
|
+
import { defineStorage, disk, memoryStorageDriver, resetStorage } from '@ultimat3/storage';
|
|
38
38
|
|
|
39
39
|
// A fresh driver per test: `resetStorage()` forgets the REGISTRY, never a driver's objects, so a
|
|
40
40
|
// driver held across tests carries every earlier test's writes.
|
|
41
|
-
let sessions =
|
|
41
|
+
let sessions = memoryStorageDriver();
|
|
42
42
|
beforeEach(() => {
|
|
43
|
-
sessions =
|
|
43
|
+
sessions = memoryStorageDriver();
|
|
44
44
|
defineStorage({ disks: { sessions } });
|
|
45
45
|
});
|
|
46
46
|
afterEach(() => resetStorage());
|
|
@@ -64,10 +64,19 @@ network, but never this process's heap. Both arguments go through `assertSafeKey
|
|
|
64
64
|
One S3 driver covers all three backends — the difference is `endpoint` + `forcePathStyle`.
|
|
65
65
|
Credentials are **env var NAMES** (`accessKeyIdEnv`, default `S3_ACCESS_KEY_ID`), never
|
|
66
66
|
literals: a key in `app.config.ts` is a key in git. Missing ones throw `X_ENV_MISSING`.
|
|
67
|
+
Endpoint, region and session token resolve ONCE for both transports, as Bun reads them: the
|
|
68
|
+
option, then `S3_ENDPOINT`/`AWS_ENDPOINT`, `S3_REGION`/`AWS_REGION`,
|
|
69
|
+
`S3_SESSION_TOKEN`/`AWS_SESSION_TOKEN` (or the one `sessionTokenEnv` names), from
|
|
70
|
+
`s3Driver({ env })` or `process.env`. `s3Driver({ fetch, clock })` inject the signed path's transport and its clock (tests; default the
|
|
71
|
+
global `fetch` and `systemClock`); `S3FetchLike` is the shape.
|
|
67
72
|
`localDriver` keeps content type, etag, `cacheControl` and `metadata` in a `<root>/.meta/`
|
|
68
73
|
sidecar so `get()`/`list()` round-trip everything `put()` was handed; sidecars never appear in
|
|
69
|
-
`list()`. `s3Driver`
|
|
70
|
-
Bun
|
|
74
|
+
`list()`. `s3Driver` STORES both — a `put()` carrying `cacheControl`, `metadata`, `retention` or
|
|
75
|
+
`legalHold` goes past the Bun client as one SigV4-signed PUT (`x-amz-meta-*`, `Cache-Control`,
|
|
76
|
+
`Content-MD5`), against the same endpoint, region and credentials — but its `get()`/`stat()` read
|
|
77
|
+
through Bun's HEAD, which exposes neither, so there they are absent rather than invented. A plain
|
|
78
|
+
`put()` stays on the Bun client. A metadata name must be a header token and every value printable
|
|
79
|
+
ASCII (`X_INVARIANT` otherwise): S3 carries them as headers.
|
|
71
80
|
|
|
72
81
|
**The local disk writes an object by rename, in an order no crash can make lie.** An object there
|
|
73
82
|
is two files, so a `put()` or `copy()` stages bytes, sidecar and a *pending marker* (the new etag)
|
|
@@ -99,12 +108,16 @@ non-string `list()` prefix or cursor are `X_INVARIANT` (`driver-read-failed.test
|
|
|
99
108
|
method on every disk). The one thing no code can cover is a `stream()` that fails after it was
|
|
100
109
|
handed back: that is the stream's own error.
|
|
101
110
|
|
|
102
|
-
**A key cannot be another key's path on the local disk
|
|
103
|
-
`put('a')` then `put('a/b')` — or the reverse,
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
111
|
+
**A key cannot be another key's path on the local disk — or on the memory disk standing in for
|
|
112
|
+
it.** A POSIX path is a file or a directory: `put('a')` then `put('a/b')` — or the reverse, a
|
|
113
|
+
`copy()` onto either, or `a` beside `a.json/b`, which collide in the sidecar tree — is
|
|
114
|
+
`X_STORAGE_KEY_CONFLICT`, refused before a byte moves, where it used to be a bare `ENOTDIR` /
|
|
115
|
+
`EISDIR`. Its `fix` names the disk as `defineStorage` registered it, not the driver kind.
|
|
116
|
+
`memoryStorageDriver` refuses the same keys with the same `blocking` (a suite on it stands in for
|
|
117
|
+
`x dev`'s disk); `s3Driver` holds both. `driver-local-memory-parity.test.ts` holds local and memory
|
|
118
|
+
to one answer, `driver-contract.test.ts` pins the s3 divergence. `delete()` of a key that is only
|
|
119
|
+
another key's directory (`delete('c')` beside `c/d`) is deleting an absent key: not an error, and
|
|
120
|
+
nothing beneath it is touched.
|
|
108
121
|
|
|
109
122
|
`stat(key)` answers what a read would — size, type, etag, age — **without the bytes**, and
|
|
110
123
|
`undefined` for nothing there. It is a required `StorageDriver` method: `promoteAttachment` measures
|
|
@@ -112,7 +125,10 @@ with it, and a driver that cannot measure does not compile.
|
|
|
112
125
|
|
|
113
126
|
`lastModified` is **optional** on a listing entry and on a `StorageObject`: absent when the
|
|
114
127
|
provider reported none, never epoch 0 — which `sweepOrphans` read as "older than any window" and
|
|
115
|
-
deleted. A reader that needs an age handles `undefined`; the sweep spares it.
|
|
128
|
+
deleted. A reader that needs an age handles `undefined`; the sweep spares it. On the local and
|
|
129
|
+
memory disks it is the write's instant on the disk's injected `clock`: what `put()`/`copy()`
|
|
130
|
+
returned is what every later `stat`/`get`/`list` reports (the local disk records it in the
|
|
131
|
+
sidecar; an object written before that, or with no trusted sidecar, reports the file's mtime).
|
|
116
132
|
|
|
117
133
|
**`StorageListEntry.contentType` is optional; `StorageObject.contentType` is not.** S3's
|
|
118
134
|
`ListObjectsV2` returns no Content-Type, so a listed s3 object simply has none — reading the real
|
|
@@ -131,6 +147,28 @@ disk answered `exists()` true and `list()` nothing for the same object. **`limit
|
|
|
131
147
|
integer or a refusal** (`X_INVARIANT`, at the `ListOptions` seam): `limit: 0` used to read back as
|
|
132
148
|
a complete, empty page on the local disk and as `maxKeys: 0` on s3.
|
|
133
149
|
|
|
150
|
+
**A listing is core's ONE `Page`** — `Page<StorageListEntry>`, the shape an entity `findMany` and
|
|
151
|
+
a query `.page()` answer: `{ rows, nextCursor, hasMore }`, `nextCursor` a string exactly when
|
|
152
|
+
`hasMore` is true. The cursor exists only when another object does — the local and memory disks
|
|
153
|
+
look one key past the page, s3 reads `IsTruncated` and takes `NextContinuationToken` — so a full
|
|
154
|
+
last page answers `hasMore: false`, never a cursor to an empty page. `cursor: null` is the first
|
|
155
|
+
page, so a walk threads `nextCursor` straight back:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
import type { Page } from '@ultimat3/core';
|
|
159
|
+
import { disk, type StorageListEntry } from '@ultimat3/storage';
|
|
160
|
+
|
|
161
|
+
const keys: string[] = [];
|
|
162
|
+
let cursor: string | null = null;
|
|
163
|
+
do {
|
|
164
|
+
const page: Page<StorageListEntry> = await disk('uploads').list({ prefix: 'org/o1/', cursor });
|
|
165
|
+
keys.push(...page.rows.map((object) => object.key));
|
|
166
|
+
cursor = page.nextCursor;
|
|
167
|
+
} while (cursor !== null);
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Before 25.0.0 it was `ListPage { objects, truncated, cursor? }`, its own shape beside core's.
|
|
171
|
+
|
|
134
172
|
## `put()` is for objects that fit in memory
|
|
135
173
|
|
|
136
174
|
`put()` buffers the whole body — size and checksum have to be known before the object exists —
|
|
@@ -151,6 +189,36 @@ the disk's own size (a HEAD on s3) before a byte is read — and is read with `s
|
|
|
151
189
|
because an object's size is not the server's to choose: a presigned PUT lands in a bucket
|
|
152
190
|
unmeasured, so a later `get()` of it was heap growth the uploader picked.
|
|
153
191
|
|
|
192
|
+
## Write-once objects (S3 Object Lock)
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
import { systemClock } from '@ultimat3/core';
|
|
196
|
+
import { disk, isLocked } from '@ultimat3/storage';
|
|
197
|
+
|
|
198
|
+
const key = 'org/o1/ledger.csv';
|
|
199
|
+
const bytes = new TextEncoder().encode('a,b\n');
|
|
200
|
+
const now = systemClock.now();
|
|
201
|
+
await disk('ledger').put(key, bytes, {
|
|
202
|
+
retention: { mode: 'COMPLIANCE', retainUntil: new Date(now.getTime() + 7 * 86_400_000) },
|
|
203
|
+
legalHold: true,
|
|
204
|
+
});
|
|
205
|
+
const lock = await disk('ledger').retentionOf?.(key); // { retention: { mode, retainUntil }, legalHold: true }
|
|
206
|
+
isLocked(lock, now); // a hold, or a retention still ahead
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
| | s3 | local, memory |
|
|
210
|
+
|---|---|---|
|
|
211
|
+
| `put({ retention, legalHold })` | `x-amz-object-lock-mode` / `-retain-until-date` (ISO) / `-legal-hold` (`ON`/`OFF`) on the signed PUT — the bucket must be CREATED with Object Lock | recorded in the sidecar (local) or beside the bytes (memory) |
|
|
212
|
+
| `retentionOf(key)` | signed `GET ?retention` + `?legal-hold`; a bucket without Object Lock, or an object with none, is `{ legalHold: false }` | from what `put()` recorded |
|
|
213
|
+
| `delete` / overwrite of a locked object | sent: S3 locks a VERSION, so a DELETE writes a delete marker and a PUT a newer version — the locked version stays | **refused** — `X_STORAGE_OBJECT_LOCKED` (`ObjectLockedError`, HTTP 409; `.lock` carries the lock) — until it lapses: one version per key, so refusing is the only way to keep the bytes |
|
|
214
|
+
|
|
215
|
+
`retainUntil` must be a valid instant in the future and `mode` `GOVERNANCE` or `COMPLIANCE`
|
|
216
|
+
(`X_INVARIANT` before a byte moves). `COMPLIANCE` cannot be shortened by anyone; extending a
|
|
217
|
+
retention, releasing a hold or bypassing `GOVERNANCE` is out of band:
|
|
218
|
+
`aws s3api put-object-retention` / `put-object-legal-hold`. A `copy()` does not carry the source's
|
|
219
|
+
lock — on S3 it is per version and set by the request. The local refusal is in-process
|
|
220
|
+
(`keyedQueue`), like every other local write rule.
|
|
221
|
+
|
|
154
222
|
## Server-side encryption, storage classes, lifecycle
|
|
155
223
|
|
|
156
224
|
`PutOptions.serverSideEncryption` exists so the gap is visible **at the type level**, and every
|
|
@@ -349,9 +417,10 @@ Inside `pending/` deliberately: an upload nobody ever scanned is still an orphan
|
|
|
349
417
|
| `X_STORAGE_QUARANTINED` | `promoteAttachment` on a key nothing has released from `pending/quarantine/` |
|
|
350
418
|
| `X_STORAGE_NOT_PENDING` | `promoteAttachment` on a key outside the org's `pending/` prefix — most often another row's attached key |
|
|
351
419
|
| `X_STORAGE_PUT_FAILED` | the disk REFUSED a `put()`/`copy()` — `EACCES`, `ENOSPC`, `EROFS`, a provider's refused PUT |
|
|
420
|
+
| `X_STORAGE_OBJECT_LOCKED` | local, memory: a delete, overwrite or copy onto a key under retention or a legal hold; the cause names the key, the mode and `retainUntil` or the hold, and the bytes are unchanged. Never raised by s3, where the provider keeps the locked version |
|
|
352
421
|
| `X_STORAGE_READ_FAILED` | the disk REFUSED a `get`/`stat`/`exists`/`stream` or the read half of a `copy` — a denied `s3:GetObject`, a throttle, an unreadable file. An **absent** object is still `X_STORAGE_NOT_FOUND`, including one deleted between the existence check and the read |
|
|
353
|
-
| `X_STORAGE_KEY_CONFLICT` | local
|
|
354
|
-
| `X_NOT_IMPLEMENTED` |
|
|
422
|
+
| `X_STORAGE_KEY_CONFLICT` | local and memory disks (never s3): the key's path is another key's directory, or the reverse (`a` and `a/b`) |
|
|
423
|
+
| `X_NOT_IMPLEMENTED` | `serverSideEncryption` on any driver |
|
|
355
424
|
| `X_ENV_MISSING` | core's: S3 credential env vars, or a `localDriver` built outside development where neither `signingSecret` nor `STORAGE_SIGNING_SECRET` holds a secret other than the published `DEV_SIGNING_SECRET` |
|
|
356
425
|
| `X_IMAGE_UNSUPPORTED` | core's: an `avif` encode, a source no built-in decoder reads, or a `variantKey` format no variant can carry |
|
|
357
426
|
| `X_IMAGE_DECODE_FAILED` | core's: truncated or corrupt image bytes |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/storage",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "25.1.0",
|
|
4
4
|
"description": "Named disks over Bun.file and Bun.s3: safe keys, signed URLs, sniffed uploads",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -25,13 +25,13 @@
|
|
|
25
25
|
"LICENSE"
|
|
26
26
|
],
|
|
27
27
|
"engines": {
|
|
28
|
-
"bun": ">=1.4.
|
|
28
|
+
"bun": ">=1.4.2"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "
|
|
35
|
+
"@ultimat3/core": "25.1.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
package/src/attachment.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import type { Clock } from '@ultimat3/core';
|
|
8
8
|
import { assert, finiteCount, renderThrowable, systemClock } from '@ultimat3/core';
|
|
9
|
-
import type {
|
|
9
|
+
import type { StorageDriver, StorageListEntry, StorageObject } from './driver';
|
|
10
10
|
import { notPending, objectNotFound, orgMismatch, quarantined, tooLarge } from './errors';
|
|
11
11
|
import { isWithinOrg, orgPrefix, scopedKey } from './path';
|
|
12
12
|
import type { UploadPolicy } from './upload';
|
|
@@ -229,13 +229,10 @@ export async function sweepOrphans(input: SweepOrphansInput): Promise<SweepResul
|
|
|
229
229
|
const prefix = pendingPrefix(input.orgId);
|
|
230
230
|
const deleted: string[] = [];
|
|
231
231
|
const failed: SweepFailure[] = [];
|
|
232
|
-
let cursor: string |
|
|
232
|
+
let cursor: string | null = null;
|
|
233
233
|
do {
|
|
234
|
-
const page
|
|
235
|
-
|
|
236
|
-
...(cursor === undefined ? {} : { cursor }),
|
|
237
|
-
});
|
|
238
|
-
for (const object of page.objects) {
|
|
234
|
+
const page = await input.disk.list({ prefix, cursor });
|
|
235
|
+
for (const object of page.rows) {
|
|
239
236
|
// An object whose age the provider did not report is SPARED: only a proven age is swept.
|
|
240
237
|
if (object.lastModified === undefined || object.lastModified.getTime() > cutoff) continue;
|
|
241
238
|
if ((await input.keep?.(object)) === true) continue;
|
|
@@ -246,7 +243,7 @@ export async function sweepOrphans(input: SweepOrphansInput): Promise<SweepResul
|
|
|
246
243
|
failed.push({ key: object.key, reason: renderThrowable(error) });
|
|
247
244
|
}
|
|
248
245
|
}
|
|
249
|
-
cursor = page.
|
|
250
|
-
} while (cursor !==
|
|
246
|
+
cursor = page.nextCursor;
|
|
247
|
+
} while (cursor !== null);
|
|
251
248
|
return { deleted, failed };
|
|
252
249
|
}
|
package/src/driver-local-read.ts
CHANGED
|
@@ -9,7 +9,8 @@ import { parseSidecar, type Sidecar } from './driver-local-sidecar';
|
|
|
9
9
|
import { pendingPathOf, sidecarPathOf } from './driver-local-write';
|
|
10
10
|
import { isStorageError, objectNotFound, readFailed } from './errors';
|
|
11
11
|
|
|
12
|
-
|
|
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> {
|
|
13
14
|
const file = Bun.file(`${root}/${sidecarPathOf(key)}`);
|
|
14
15
|
if (!(await file.exists())) return undefined;
|
|
15
16
|
try {
|
|
@@ -33,11 +34,11 @@ async function pendingEtag(root: string, key: string): Promise<string | undefine
|
|
|
33
34
|
* would have been one call earlier.
|
|
34
35
|
*/
|
|
35
36
|
const refusedRead =
|
|
36
|
-
(root: string, key: string) =>
|
|
37
|
+
(root: string, key: string, disk: string) =>
|
|
37
38
|
(error: unknown): never => {
|
|
38
39
|
if (isStorageError(error)) throw error;
|
|
39
40
|
throw stringField(error, 'code') === 'ENOENT'
|
|
40
|
-
? objectNotFound(
|
|
41
|
+
? objectNotFound(disk, key)
|
|
41
42
|
: readFailed(
|
|
42
43
|
'local',
|
|
43
44
|
key,
|
|
@@ -46,20 +47,27 @@ const refusedRead =
|
|
|
46
47
|
);
|
|
47
48
|
};
|
|
48
49
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
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> {
|
|
51
59
|
return Bun.file(`${root}/${key}`)
|
|
52
60
|
.arrayBuffer()
|
|
53
|
-
.then((buffer) => new Uint8Array(buffer), refusedRead(root, key));
|
|
61
|
+
.then((buffer) => new Uint8Array(buffer), refusedRead(root, key, disk));
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
/** `etagOf`, streamed: a copy or a `stat()` must settle a doubt without buffering the object. */
|
|
57
|
-
export async function etagOfFile(root: string, key: string): Promise<string> {
|
|
65
|
+
export async function etagOfFile(root: string, key: string, disk = 'local'): Promise<string> {
|
|
58
66
|
const hasher = new Bun.CryptoHasher('sha256');
|
|
59
67
|
try {
|
|
60
68
|
for await (const chunk of Bun.file(`${root}/${key}`).stream()) hasher.update(chunk);
|
|
61
69
|
} catch (error) {
|
|
62
|
-
return refusedRead(root, key)(error);
|
|
70
|
+
return refusedRead(root, key, disk)(error);
|
|
63
71
|
}
|
|
64
72
|
return hasher.digest('hex').slice(0, 32);
|
|
65
73
|
}
|
|
@@ -96,7 +104,9 @@ export async function headObject(
|
|
|
96
104
|
key,
|
|
97
105
|
size: file.size,
|
|
98
106
|
etag,
|
|
99
|
-
|
|
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),
|
|
100
110
|
...(sidecar?.contentType === undefined ? {} : { contentType: sidecar.contentType }),
|
|
101
111
|
...(sidecar?.cacheControl === undefined ? {} : { cacheControl: sidecar.cacheControl }),
|
|
102
112
|
...(sidecar?.metadata === undefined ? {} : { metadata: sidecar.metadata }),
|
|
@@ -1,12 +1,62 @@
|
|
|
1
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
|
|
3
|
-
// parser that reads it back as untrusted bytes off a disk.
|
|
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
4
|
|
|
5
|
-
|
|
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 {
|
|
6
14
|
readonly contentType: string;
|
|
7
15
|
readonly etag: string;
|
|
8
16
|
readonly cacheControl?: string | undefined;
|
|
9
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 };
|
|
10
60
|
}
|
|
11
61
|
|
|
12
62
|
// `!Array.isArray` is the load-bearing clause, matching `isPlainObject` in
|
|
@@ -29,10 +79,18 @@ export function parseSidecar(raw: unknown): Sidecar | undefined {
|
|
|
29
79
|
// truncated what was just written, even though `Sidecar` itself declares both.
|
|
30
80
|
const cacheControl = record['cacheControl'];
|
|
31
81
|
const metadata = record['metadata'];
|
|
82
|
+
const retention = parseRetention(record['retention']);
|
|
83
|
+
const lastModified = record['lastModified'];
|
|
32
84
|
return {
|
|
33
85
|
contentType,
|
|
34
86
|
etag,
|
|
35
87
|
...(typeof cacheControl === 'string' ? { cacheControl } : {}),
|
|
36
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 } : {}),
|
|
37
95
|
};
|
|
38
96
|
}
|