@ultimat3/storage 24.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.
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` | `memoryDriver()` — 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 |
48
- | `driver-s3.ts` | `Bun.S3Client`, built lazily (import must never open a socket) |
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. `driver-contract.test.ts` runs the claim on all
74
- three disks.
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`), so two sources differing
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 and for the same filesystem: `.META/a.txt.json` was a legal key that writes
112
- `<root>/.META/a.txt.json`, which on APFS and NTFS IS `<root>/.meta/a.txt.json` — the sidecar for
113
- object `a.txt` — so a caller able to name a key rewrote another object's recorded `contentType`.
114
- The whole SEGMENT is compared, never a prefix: `.metadata/a.json` is an ordinary key and stays
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, one call to the left, and both drivers broke it in opposite directions. The
123
- local one caught EVERYTHING and answered `{ objects: [], truncated: false }`, so `EACCES` on the
124
- root read as "this disk is empty"; the s3 one let a bare `S3Error` escape uncoded, with nothing
125
- for the http error map to render but a 500. `sweepOrphans` walks `list()`, so the local swallow
126
- was a false-erasure report a layer up. `ENOENT` (a root nobody has written to) is still an empty
127
- page; everything else is `X_STORAGE_LIST_FAILED`, whose `fix` the DRIVER supplies.
128
- - **`head()` and `list()` NEVER read an object's bytes.** A listing that cannot know an etag
129
- reports `''`, as the s3 listing does. `get()` hashes bytes it already holds; `copy()` passes
130
- `hash: true` so the destination sidecar never records `etag: ''`.
131
- - **`put({ metadata })` / `put({ cacheControl })` is the one `PutOptions` pair the disks disagree
132
- about, and it is pinned rather than resolved.** Bun's `S3File.write` exposes `type`, `acl` and
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 `dev-storage.ts`, not this package.** `GET
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 and re-exports it) — the same
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 `dev-storage.ts` does.**
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
- - The local driver reports the **filesystem's** `lastModified`, not the injected `Clock`, so
268
- `sweepOrphans` cannot be tested against it with a frozen clock — `attachment.test.ts` uses a
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
- | `memoryDriver` | 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 |
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
- `memoryDriver()` is what a suite holds instead of a temp directory or a hand-written fake: it
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, memoryDriver, resetStorage } from '@ultimat3/storage';
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 = memoryDriver();
41
+ let sessions = memoryStorageDriver();
42
42
  beforeEach(() => {
43
- sessions = memoryDriver();
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` cannot: it refuses `cacheControl`/`metadata` on `put()` (`X_NOT_IMPLEMENTED`,
70
- Bun exposes no header hook yet).
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.** A POSIX path is a file or a directory:
103
- `put('a')` then `put('a/b')` — or the reverse, or `a` beside `a.json/b`, which collide in the
104
- sidecar tree — is `X_STORAGE_KEY_CONFLICT`, refused before a byte moves, where it used to be a
105
- bare `ENOTDIR` / `EISDIR`. Its `fix` names the disk as `defineStorage` registered it, not the
106
- driver kind. `s3Driver` and `memoryDriver` hold both keys; `driver-contract.test.ts`
107
- pins the divergence.
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 disk only: the key's path is another key's directory, or the reverse (`a` and `a/b`) |
354
- | `X_NOT_IMPLEMENTED` | S3 user metadata / cache-control; `serverSideEncryption` on either driver |
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": "24.0.0",
3
+ "version": "25.0.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.0"
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": "24.0.0"
35
+ "@ultimat3/core": "25.0.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 { ListPage, StorageDriver, StorageListEntry, StorageObject } from './driver';
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 | undefined;
232
+ let cursor: string | null = null;
233
233
  do {
234
- const page: ListPage = await input.disk.list({
235
- prefix,
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.truncated ? page.cursor : undefined;
250
- } while (cursor !== undefined);
246
+ cursor = page.nextCursor;
247
+ } while (cursor !== null);
251
248
  return { deleted, failed };
252
249
  }
@@ -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
- async function readSidecar(root: string, key: string): Promise<Sidecar | undefined> {
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('local', key)
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
- /** The whole object, buffered — `get()`'s one read. */
50
- export async function readObjectBytes(root: string, key: string): Promise<Uint8Array> {
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
- lastModified: new Date(file.lastModified),
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), and the one
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
- export interface Sidecar {
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
  }