@12-apps/storage 1.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,238 @@
1
+ import { EXTENSION_BY_CONTENT_TYPE } from '../../content-types';
2
+ import type { RenditionOutput, RenditionSpec } from '../../renditions';
3
+ import type { Rgba, SharpImage, SharpMetadata, SharpModule } from './sharp-module';
4
+
5
+ /**
6
+ * Cutting the derived crops — the half of the sharp pipeline that decides what a
7
+ * card actually looks like.
8
+ *
9
+ * ## Why the crop PADS instead of cutting
10
+ *
11
+ * `fit: 'cover'` is the obvious way to fill a 4:3 box and it is the wrong one. A
12
+ * catalog shot is usually TALLER than 4:3 (bottles, cans, cups), so cover throws
13
+ * away the top and bottom of the product — the exact pixels the photo is of.
14
+ *
15
+ * What was wasting the box was not the aspect ratio but the MARGIN: these are
16
+ * product shots on a flat backdrop, and most of the frame is backdrop. So each
17
+ * rendition is `trim`med to the product's own bounding box first, then
18
+ * `contain`-fitted onto the exact canvas with that same backdrop colour behind
19
+ * it. The product grows until it touches the edges of the box, nothing is cut
20
+ * off, and the padding that remains is the colour the photo already had — so it
21
+ * reads as the photo rather than as a letterbox. A cut-out PNG pads with
22
+ * transparency for the same reason.
23
+ *
24
+ * Content is UPSCALED when the trimmed product is smaller than the canvas
25
+ * (deliberately no `withoutEnlargement`). The alternative pads a 200px product
26
+ * into a 1280px canvas, which the browser then scales DOWN to the card — drawing
27
+ * the product at a quarter of the size it should be.
28
+ *
29
+ * ## Animated sources are not part of this
30
+ *
31
+ * sharp models an animation as a vertical filmstrip, so trimming and padding
32
+ * would operate on the strip rather than on each frame. An animated source
33
+ * therefore yields NO renditions, which is what routes it to the flat key shape
34
+ * and keeps its frames.
35
+ */
36
+
37
+ /** WebP quality for a rendition. */
38
+ const WEBP_QUALITY = 82;
39
+
40
+ /**
41
+ * How far a pixel may differ from the corner colour and still count as backdrop.
42
+ * sharp's default (10) is tuned for exactly this: it absorbs JPEG ringing and a
43
+ * studio backdrop's gradient without eating a pale product.
44
+ */
45
+ const TRIM_THRESHOLD = 10;
46
+
47
+ /**
48
+ * Below this, a trim has eaten the picture rather than its margin — a photo that
49
+ * is one flat colour, or one whose subject matches its backdrop closely enough
50
+ * that nothing survives the threshold. The untrimmed image is used instead:
51
+ * worse framing beats no product.
52
+ */
53
+ const MIN_TRIMMED_EDGE = 16;
54
+
55
+ /** sharp's format name for a catalog MIME type (`jpg` is spelled `jpeg`). */
56
+ export function sharpFormatFor(contentType: string): string {
57
+ const extension = EXTENSION_BY_CONTENT_TYPE[contentType];
58
+ return extension === 'jpg' ? 'jpeg' : (extension ?? 'webp');
59
+ }
60
+
61
+ /**
62
+ * The animated read's header — one probe, reused for the two questions that have
63
+ * to be answered before anything is decoded.
64
+ *
65
+ * The ANIMATED read specifically: `sharp(bytes)` without `{ animated: true }`
66
+ * decodes frame one and hands back a perfectly good still, so a still read would
67
+ * report `pages: 1` for a GIF that has twenty and an animation would silently
68
+ * become five motionless WebPs.
69
+ */
70
+ async function animatedMetadata(
71
+ sharp: SharpModule,
72
+ bytes: Uint8Array,
73
+ ): Promise<SharpMetadata> {
74
+ return sharp(bytes, { animated: true }).metadata();
75
+ }
76
+
77
+ /** Does the source carry more than one frame? */
78
+ export async function isAnimatedSource(
79
+ sharp: SharpModule,
80
+ bytes: Uint8Array,
81
+ ): Promise<boolean> {
82
+ return ((await animatedMetadata(sharp, bytes)).pages ?? 1) > 1;
83
+ }
84
+
85
+ /**
86
+ * Would decoding this source cost more than `maxPixels`?
87
+ *
88
+ * The cut path needs its OWN answer to this, which is the whole point of the
89
+ * function existing here. `process` enforces the ceiling and refuses a
90
+ * decompression bomb — but the writer runs the two halves under one
91
+ * `Promise.all`, so the refusal used to arrive AFTER this path had already read
92
+ * the corner pixel, trimmed the full image and encoded five crops. An adversarial
93
+ * probe on a 30000×30000 declared source reached `extract`, `trim` and five
94
+ * `webp` encodes before the 413. The ceiling therefore bounded the status code
95
+ * and not the memory, which is the one thing it exists to bound.
96
+ *
97
+ * Answered from the header alone, before a single pixel is allocated.
98
+ *
99
+ * ## Why there is no `× pages` here, unlike in `process`
100
+ *
101
+ * This reads the ANIMATED metadata, and libvips models an animation as a vertical
102
+ * filmstrip: under `{ animated: true }` the reported `height` is **every frame
103
+ * stacked**, not one frame. Measured against real libvips 8.17.3 with a two-frame
104
+ * 64×64 GIF — the still read answers `height: 64, pages: 2`, the animated read
105
+ * answers `height: 128, pages: 2`. So `width × height` here ALREADY covers all
106
+ * frames, and multiplying by `pages` on top of it would over-count the cost by a
107
+ * factor of `pages` — refusing a modest animation as a decompression bomb.
108
+ *
109
+ * `rejectUnprocessable` in `sharp.ts` does multiply, and is right to: it reads the
110
+ * STILL metadata, where `height` is one frame and `pages` is the count.
111
+ *
112
+ * In practice this function only ever sees a still, because `cutRenditions`
113
+ * returns on `pages > 1` before calling it. The arithmetic is written to be correct
114
+ * either way rather than to depend on that ordering.
115
+ */
116
+ function exceedsPixelBudget(metadata: SharpMetadata, maxPixels: number): boolean {
117
+ const width = metadata.width ?? 0;
118
+ const height = metadata.height ?? 0;
119
+ if (width <= 0 || height <= 0) return true;
120
+ return width * height > maxPixels;
121
+ }
122
+
123
+ /**
124
+ * The photo's backdrop colour, read from its top-left pixel — the same pixel
125
+ * sharp's `trim` measures against, so the padding put back is by construction
126
+ * the colour that was taken away.
127
+ *
128
+ * Alpha is carried through: a PNG cut out on transparency trims to nothing and
129
+ * must be padded with nothing, not with white.
130
+ */
131
+ async function backdropColor(sharp: SharpModule, bytes: Uint8Array): Promise<Rgba> {
132
+ const raw = await sharp(bytes)
133
+ .extract({ left: 0, top: 0, width: 1, height: 1 })
134
+ .ensureAlpha()
135
+ .raw()
136
+ .toBuffer();
137
+ return {
138
+ r: raw[0] ?? 255,
139
+ g: raw[1] ?? 255,
140
+ b: raw[2] ?? 255,
141
+ alpha: (raw[3] ?? 255) / 255,
142
+ };
143
+ }
144
+
145
+ /**
146
+ * The product's own bounding box, as PNG bytes — lossless on purpose, because
147
+ * this buffer is re-encoded once per rendition and a lossy intermediate would
148
+ * compound its artifacts five times over.
149
+ *
150
+ * Returns the input unchanged whenever trimming cannot be trusted: sharp raises
151
+ * on an image it cannot trim at all, and a result that came back essentially
152
+ * empty means the subject matched the backdrop rather than that there was none.
153
+ */
154
+ async function trimToSubject(
155
+ sharp: SharpModule,
156
+ bytes: Uint8Array,
157
+ backdrop: Rgba,
158
+ ): Promise<Uint8Array> {
159
+ try {
160
+ const trimmed = await sharp(bytes)
161
+ .trim({ background: backdrop, threshold: TRIM_THRESHOLD })
162
+ .png()
163
+ .toBuffer();
164
+ const { width = 0, height = 0 } = await sharp(trimmed).metadata();
165
+ if (width < MIN_TRIMMED_EDGE || height < MIN_TRIMMED_EDGE) return bytes;
166
+ return trimmed;
167
+ } catch {
168
+ return bytes;
169
+ }
170
+ }
171
+
172
+ /** Resize the trimmed subject onto one rendition's canvas, or `null` on failure. */
173
+ async function renderOne(
174
+ sharp: SharpModule,
175
+ source: Uint8Array,
176
+ spec: RenditionSpec,
177
+ backdrop: Rgba,
178
+ ): Promise<Uint8Array | null> {
179
+ try {
180
+ return await (sharp(source) as SharpImage)
181
+ .resize({
182
+ width: spec.width,
183
+ height: spec.height,
184
+ // `contain`, so a tall bottle keeps its top and bottom; the canvas stays
185
+ // exactly spec.width × spec.height, which is what lets the srcset width
186
+ // descriptors be derived from the spec list alone.
187
+ fit: 'contain',
188
+ background: backdrop,
189
+ })
190
+ .webp({ quality: WEBP_QUALITY })
191
+ .toBuffer();
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Cut every requested size from `bytes`.
199
+ *
200
+ * `bytes` is the ORIGINAL upload rather than the already-downscaled object: a
201
+ * 1280px intermediate would be resampled a second time on the way to a 320px
202
+ * card, and the source is decoded here regardless.
203
+ *
204
+ * Never throws. A source sharp cannot process yields an empty list and the
205
+ * writer then stores the uncropped object alone.
206
+ */
207
+ export async function cutRenditions(
208
+ sharp: SharpModule,
209
+ bytes: Uint8Array,
210
+ specs: readonly RenditionSpec[],
211
+ maxPixels: number,
212
+ ): Promise<RenditionOutput[]> {
213
+ let source: Uint8Array;
214
+ let backdrop: Rgba;
215
+ try {
216
+ const metadata = await animatedMetadata(sharp, bytes);
217
+ if ((metadata.pages ?? 1) > 1) return [];
218
+ // Refused from the header, before `extract` or `trim` allocates anything. An
219
+ // empty list is the documented answer for "this source yields no crops", and
220
+ // `process` refuses the same source with the status the caller sees.
221
+ if (exceedsPixelBudget(metadata, maxPixels)) return [];
222
+ backdrop = await backdropColor(sharp, bytes);
223
+ source = await trimToSubject(sharp, bytes, backdrop);
224
+ } catch {
225
+ return [];
226
+ }
227
+
228
+ const outputs: RenditionOutput[] = [];
229
+ for (const spec of specs) {
230
+ const cut = await renderOne(sharp, source, spec, backdrop);
231
+ // One size failing is not a reason to keep the ones that worked: the srcset
232
+ // builder reads the same spec list either way, so a browser would be handed
233
+ // a key with no object behind it. A partial set is refused wholesale.
234
+ if (!cut) return [];
235
+ outputs.push({ spec, bytes: cut });
236
+ }
237
+ return outputs;
238
+ }
@@ -0,0 +1,142 @@
1
+ import type { StorageProblem } from '../../problems';
2
+ import type { ImagePipeline, ProcessResult } from './port';
3
+ import type { SharpMetadata, SharpModule } from './sharp-module';
4
+ import { cutRenditions, isAnimatedSource, sharpFormatFor } from './sharp-renditions';
5
+
6
+ /**
7
+ * The real pipeline: downscale, re-encode, and cut the crops — with `sharp`
8
+ * handed IN rather than imported (see `sharp-module.ts` for why).
9
+ *
10
+ * WHY THE SERVER RESIZES AT ALL, when a browser can do it for free. It can, and
11
+ * a browser-side resize is still worth having — the bytes never travel at full
12
+ * size. But it only covers callers that ARE browsers. An agent hands over
13
+ * whatever bytes it was given, with no device and no canvas, and until something
14
+ * resizes them a 2000px 1–2 MB PNG is served whole into a card ~165px wide. The
15
+ * choice is a dependency or unbounded bytes in the bucket, and the dependency is
16
+ * the host's to declare.
17
+ *
18
+ * WHAT IT WILL NOT DO, mirroring the browser profile's pass-throughs:
19
+ *
20
+ * - **Enlarge.** A 300px photo is stored at 300px.
21
+ * - **Return something bigger.** An already-optimized WebP can round-trip
22
+ * larger; the size comparison keeps the original when it does.
23
+ * - **Flatten an animation.** Animated sources are resized WITH their frames
24
+ * and re-encoded in their own container rather than becoming a still WebP.
25
+ *
26
+ * It deliberately does NOT crop the uncropped object. Aspect ratio is preserved
27
+ * on every path (`fit: 'inside'`), because that object is the one a ZOOM shows:
28
+ * the whole photograph, margins and all. The crops are a separate set.
29
+ */
30
+
31
+ export interface SharpPipelineConfig {
32
+ /** The `sharp` module itself. */
33
+ sharp: SharpModule;
34
+ /** Longest edge, in pixels, of the stored uncropped object. Default 1280. */
35
+ maxEdge?: number;
36
+ /** WebP quality, 0–100. Default 82. */
37
+ quality?: number;
38
+ /**
39
+ * Ceiling on the DECODED pixel count. A decode costs width × height × channels
40
+ * bytes, so a "decompression bomb" — a small file declaring enormous dimensions
41
+ * — is a way to exhaust the server's memory through an endpoint that otherwise
42
+ * looks like an 8 MB upload. The honest bound is on pixels, since that is what
43
+ * gets allocated. Default 50 megapixels.
44
+ */
45
+ maxPixels?: number;
46
+ }
47
+
48
+ /**
49
+ * 1280 is measured against the largest box the object is ever drawn into — a 4:3
50
+ * hero on a desktop viewport, with 2× headroom — not against the card, because a
51
+ * lightbox is exactly where somebody looks closely.
52
+ */
53
+ const DEFAULTS = { maxEdge: 1280, quality: 82, maxPixels: 50_000_000 } as const;
54
+
55
+ /** The refusals decided from the header alone. */
56
+ function rejectUnprocessable(
57
+ metadata: SharpMetadata,
58
+ maxPixels: number,
59
+ ): StorageProblem | null {
60
+ const width = metadata.width ?? 0;
61
+ const height = metadata.height ?? 0;
62
+ if (width <= 0 || height <= 0) return 'image_unreadable';
63
+ // `pages` multiplies the cost of an animation: a 1000×1000 GIF with 200 frames
64
+ // allocates as much as a 200-megapixel still.
65
+ if (width * height * (metadata.pages ?? 1) > maxPixels) {
66
+ return 'image_dimensions_too_large';
67
+ }
68
+ return null;
69
+ }
70
+
71
+ export function createSharpImagePipeline(config: SharpPipelineConfig): ImagePipeline {
72
+ const { sharp } = config;
73
+ const maxEdge = config.maxEdge ?? DEFAULTS.maxEdge;
74
+ const quality = config.quality ?? DEFAULTS.quality;
75
+ const maxPixels = config.maxPixels ?? DEFAULTS.maxPixels;
76
+
77
+ /** Probe the header, or `null` when the bytes are not a decodable image. */
78
+ async function readMetadata(bytes: Uint8Array): Promise<SharpMetadata | null> {
79
+ try {
80
+ return await sharp(bytes).metadata();
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ /** Resize and re-encode, or `null` if the codec refused partway through. */
87
+ async function encode(
88
+ bytes: Uint8Array,
89
+ animated: boolean,
90
+ declaredContentType: string,
91
+ ): Promise<Uint8Array | null> {
92
+ try {
93
+ const pipeline = sharp(bytes, animated ? { animated: true } : {}).resize({
94
+ width: maxEdge,
95
+ height: maxEdge,
96
+ fit: 'inside',
97
+ withoutEnlargement: true,
98
+ });
99
+ return animated
100
+ ? await pipeline.toFormat(sharpFormatFor(declaredContentType)).toBuffer()
101
+ : await pipeline.webp({ quality }).toBuffer();
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ return {
108
+ name: 'sharp',
109
+ cutsRenditions: true,
110
+ process: async (bytes, declaredContentType): Promise<ProcessResult> => {
111
+ const metadata = await readMetadata(bytes);
112
+ if (!metadata) return { ok: false, problem: 'image_unreadable' };
113
+ const problem = rejectUnprocessable(metadata, maxPixels);
114
+ if (problem) return { ok: false, problem };
115
+
116
+ // Asked of the ANIMATED read: a still decode reports pages = 1 for a GIF
117
+ // that has twenty, and flattening one is the failure this guards.
118
+ const animated = await isAnimatedSource(sharp, bytes);
119
+ const encoded = await encode(bytes, animated, declaredContentType);
120
+ if (!encoded) return { ok: false, problem: 'image_unreadable' };
121
+
122
+ // A re-encode that gained bytes is not an optimization. `>=` and not `>` so
123
+ // a tie keeps the known-good original.
124
+ if (encoded.byteLength >= bytes.byteLength) {
125
+ return { ok: true, image: { bytes, contentType: declaredContentType } };
126
+ }
127
+ // An animation keeps its own container; everything else becomes WebP, which
128
+ // is both the smallest of the accepted formats and what a browser-side
129
+ // resize already produces, so the two uploaders converge.
130
+ return {
131
+ ok: true,
132
+ image: {
133
+ bytes: encoded,
134
+ contentType: animated ? declaredContentType : 'image/webp',
135
+ },
136
+ };
137
+ },
138
+ // The ceiling goes to BOTH halves: the writer runs them together, so a bound
139
+ // only `process` knew about bounded nothing (see sharp-renditions.ts).
140
+ cut: (bytes, specs) => cutRenditions(sharp, bytes, specs, maxPixels),
141
+ };
142
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Reading an upload body without ever trusting its declared size.
3
+ *
4
+ * Rejects up front when `content-length` already exceeds the cap, then
5
+ * accumulates the stream chunk by chunk and CANCELS it the moment the running
6
+ * total crosses — so an oversize upload can never force the process to allocate
7
+ * the whole payload before the 413. A chunked request sends no length at all,
8
+ * which is why the incremental half is not redundant.
9
+ *
10
+ * `null` means "too large". A missing body reads as empty (0 bytes), which the
11
+ * caller refuses the same way it refuses any empty file.
12
+ */
13
+ export async function readBodyCapped(
14
+ request: Request,
15
+ maxBytes: number,
16
+ ): Promise<Uint8Array | null> {
17
+ const declaredLength = Number(request.headers.get('content-length'));
18
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) return null;
19
+ if (!request.body) return new Uint8Array(0);
20
+
21
+ const reader = request.body.getReader();
22
+ const chunks: Uint8Array[] = [];
23
+ let total = 0;
24
+ for (;;) {
25
+ const { done, value } = await reader.read();
26
+ if (done) break;
27
+ total += value.byteLength;
28
+ if (total > maxBytes) {
29
+ await reader.cancel();
30
+ return null;
31
+ }
32
+ chunks.push(value);
33
+ }
34
+
35
+ const bytes = new Uint8Array(total);
36
+ let offset = 0;
37
+ for (const chunk of chunks) {
38
+ bytes.set(chunk, offset);
39
+ offset += chunk.byteLength;
40
+ }
41
+ return bytes;
42
+ }
@@ -0,0 +1,240 @@
1
+ import { hasRenditions, keyInScope, parseObjectKey, renditionKey } from '../keys';
2
+ import type { RenditionSpec } from '../renditions';
3
+ import type { StorageDriver } from './driver';
4
+
5
+ /**
6
+ * Reclaiming the bytes behind a REPLACED object.
7
+ *
8
+ * When a photo is swapped, the superseded object used to stay in the bucket
9
+ * forever — nothing referenced it, nothing served it, nothing removed it. Every
10
+ * re-upload leaked one.
11
+ *
12
+ * WHY THIS IS NOT JUST `driver.delete(oldKey)`. A key can outlive the row that
13
+ * pointed at it, and some of those states are ones a person is ABOUT TO ACT ON:
14
+ * a duplicated row sharing one object, a soft-deleted row whose "restore" is
15
+ * expected to bring the photo back, a draft or a pending approval holding a key
16
+ * waiting to be published. Deleting the object would break the very next thing
17
+ * they do.
18
+ *
19
+ * Which states those are is the HOST'S question, not this package's — they are
20
+ * its tables. So they arrive as {@link StorageReferenceProbe}s: named predicates
21
+ * the reclaim consults before deleting anything. The package supplies the two
22
+ * things a host cannot get right by itself, and the reason this is not just a
23
+ * `delete` call:
24
+ *
25
+ * - the whole SET goes, not just the object the row named. Deleting one key
26
+ * would leak five crops per replaced photo — the same leak, one level down;
27
+ * - a key outside the caller's SCOPE is never touched, whatever the probes say.
28
+ * A probe answers "does anything of MINE reference this?", and that is true of
29
+ * every other tenant's objects too — which is exactly how a scope-blind
30
+ * reclaim deletes a neighbour's photo.
31
+ *
32
+ * Cleanup is best-effort BY CONSTRUCTION: it runs after the write it follows, and
33
+ * a failure is logged rather than raised. A store owner who successfully changed a
34
+ * photo must not see an error because a bucket delete timed out.
35
+ *
36
+ * ## The probe→purge window, stated plainly
37
+ *
38
+ * {@link deleteIfOrphaned} is read-validate-write: it consults every probe, then
39
+ * deletes. There is no claim-once and no transaction, so a concurrent request that
40
+ * ATTACHES the key between the last probe and the delete loses its object:
41
+ *
42
+ * 1. user B replaces product P's photo; the reclaim probes key `K` and finds
43
+ * nothing referencing it;
44
+ * 2. concurrently user A, whose tab still shows `K`, duplicates P — the duplicate
45
+ * row is written with `imageKey: K`;
46
+ * 3. the purge runs. `K` and its five crops are gone, and A's brand-new row points
47
+ * at six objects that do not exist.
48
+ *
49
+ * This is inherent to ANY probe-based orphan reclaim; closing it needs refcounting,
50
+ * which is a different design and a host-side one (the count would live in the
51
+ * host's tables, beside the keys). What the package can do is two things, and it
52
+ * does both:
53
+ *
54
+ * - the probes are read OUTSIDE the host's transaction, so the host has to order
55
+ * the call correctly — after commit, and after any copy-on-write path has
56
+ * landed. That rule is `ADOPTING.md` rule 3, and it is the actual fix;
57
+ * - the window is made DISCOVERABLE rather than silent (see `confirmStillOrphaned`).
58
+ * "The reclaim deleted the image I just attached" is otherwise a bug with no
59
+ * log line anywhere, which is the worst shape a data-loss bug can have.
60
+ */
61
+
62
+ /**
63
+ * One reason an object might still be needed, as a predicate over the host's own
64
+ * tables.
65
+ *
66
+ * `name` is not decoration: it is what the log line says when a reclaim is
67
+ * declined, which is the difference between "the bucket is growing" and "drafts
68
+ * are pinning objects".
69
+ */
70
+ export interface StorageReferenceProbe {
71
+ name: string;
72
+ /** Does anything in this store still reference `key`, within `scope`? */
73
+ referenced(scope: string, key: string): Promise<boolean>;
74
+ }
75
+
76
+ /** Where a reclaim's own failures go. */
77
+ export interface StorageLogger {
78
+ error(message: string): void;
79
+ }
80
+
81
+ export interface ReclaimDeps {
82
+ driver: StorageDriver;
83
+ probes: readonly StorageReferenceProbe[];
84
+ logger: StorageLogger;
85
+ unscopedKeys: 'accept' | 'reject';
86
+ specs: readonly RenditionSpec[];
87
+ keyPrefix: string;
88
+ }
89
+
90
+ /**
91
+ * Every object key a stored photo occupies: the key on the row plus the crops
92
+ * beside it — just the key itself for a photo stored alone.
93
+ *
94
+ * Read off the KEY, the same way the writer wrote it, so a reclaim cannot need a
95
+ * second record of what exists.
96
+ */
97
+ export function objectKeysFor(
98
+ key: string,
99
+ specs: readonly RenditionSpec[],
100
+ keyPrefix: string,
101
+ ): string[] {
102
+ if (!hasRenditions(key, keyPrefix)) return [key];
103
+ return [
104
+ key,
105
+ ...specs
106
+ .map((spec) => renditionKey(key, spec.name, keyPrefix))
107
+ .filter((sibling): sibling is string => sibling !== null),
108
+ ];
109
+ }
110
+
111
+ function errorText(error: unknown): string {
112
+ return error instanceof Error ? error.message : String(error);
113
+ }
114
+
115
+ /**
116
+ * Delete the object behind `key` if — and only if — nothing references it any
117
+ * more. Safe to call with a key still in use, one that never existed, and `null`.
118
+ *
119
+ * Never throws: callers invoke it after a successful write and must not have
120
+ * their response changed by its outcome.
121
+ */
122
+ export async function deleteIfOrphaned(
123
+ deps: ReclaimDeps,
124
+ scope: string,
125
+ key: string | null | undefined,
126
+ ): Promise<void> {
127
+ // A key this scheme did not mint is not ours to delete, and a malformed value
128
+ // must never reach a probe's query at all.
129
+ if (!key || !parseObjectKey(key, deps.keyPrefix)) return;
130
+ if (!keyInScope(key, scope, deps.unscopedKeys, deps.keyPrefix)) {
131
+ deps.logger.error(
132
+ `[storage] refused to reclaim ${key}: it does not belong to scope ${scope}`,
133
+ );
134
+ return;
135
+ }
136
+ try {
137
+ for (const probe of deps.probes) {
138
+ if (await probe.referenced(scope, key)) return;
139
+ }
140
+ await purge(deps, key);
141
+ await confirmStillOrphaned(deps, scope, key);
142
+ } catch (error) {
143
+ // The write this followed already succeeded. Logged through the host's own
144
+ // logger so a bucket that starts refusing deletes is discoverable rather than
145
+ // silently accumulating objects.
146
+ deps.logger.error(`[storage] could not reclaim ${key}: ${errorText(error)}`);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Re-ask the probes AFTER the purge, and log if the answer changed.
152
+ *
153
+ * This does NOT close the probe→purge window — nothing here can undelete an
154
+ * object, and pretending otherwise would be worse than the window itself. What it
155
+ * does is convert the failure from silent to reported: a row now points at six
156
+ * objects that no longer exist, and without this line the only trace anywhere is a
157
+ * store owner eventually noticing a broken image.
158
+ *
159
+ * That is the same bargain the rest of this file makes — a reclaim can only report
160
+ * — applied to the one hazard the design cannot remove.
161
+ *
162
+ * Cheap by construction, and deliberately so: it runs only when a purge ACTUALLY
163
+ * happened (the branch where the key was orphaned AND deleted, not the common one
164
+ * where a probe claimed it or the key was null), and not at all for a host that
165
+ * passes `references: []`, which has no window to detect.
166
+ */
167
+ async function confirmStillOrphaned(
168
+ deps: ReclaimDeps,
169
+ scope: string,
170
+ key: string,
171
+ ): Promise<void> {
172
+ for (const probe of deps.probes) {
173
+ if (await probe.referenced(scope, key)) {
174
+ deps.logger.error(
175
+ `[storage] reclaimed ${key} but ${probe.name} references it now: ` +
176
+ 'it was attached between the probe and the delete, so that row points at ' +
177
+ 'objects that no longer exist',
178
+ );
179
+ return;
180
+ }
181
+ }
182
+ }
183
+
184
+ /** Delete every member of `key`'s set, unconditionally. */
185
+ async function purge(deps: ReclaimDeps, key: string): Promise<void> {
186
+ await Promise.all(
187
+ objectKeysFor(key, deps.specs, deps.keyPrefix).map((object) =>
188
+ deps.driver.delete(object),
189
+ ),
190
+ );
191
+ }
192
+
193
+ /**
194
+ * Reclaim every key a write SUPERSEDED: those present before and absent after.
195
+ *
196
+ * Order is irrelevant and each is independent, so they run together. A key that
197
+ * appeared twice before the write is reclaimed once.
198
+ */
199
+ export async function deleteReplaced(
200
+ deps: ReclaimDeps,
201
+ scope: string,
202
+ before: readonly (string | null | undefined)[],
203
+ after: readonly (string | null | undefined)[],
204
+ ): Promise<void> {
205
+ const kept = new Set(after.filter((key): key is string => typeof key === 'string'));
206
+ const superseded = [
207
+ ...new Set(
208
+ before.filter((key): key is string => typeof key === 'string' && !kept.has(key)),
209
+ ),
210
+ ];
211
+ await Promise.all(superseded.map((key) => deleteIfOrphaned(deps, scope, key)));
212
+ }
213
+
214
+ /**
215
+ * Discard objects this request itself just minted — no probes consulted.
216
+ *
217
+ * For a write that fails AFTER storing its bytes (a spent quota, a duplicate
218
+ * name): nothing can reference a key nobody has been told about yet, so asking
219
+ * the host's tables about it would be a round trip whose answer is known. Still
220
+ * scope-checked, and still silent about its own failures.
221
+ */
222
+ export async function discardMinted(
223
+ deps: ReclaimDeps,
224
+ scope: string,
225
+ keys: readonly (string | null | undefined)[],
226
+ ): Promise<void> {
227
+ const own = keys.filter(
228
+ (key): key is string =>
229
+ typeof key === 'string' && keyInScope(key, scope, deps.unscopedKeys, deps.keyPrefix),
230
+ );
231
+ await Promise.all(
232
+ own.map(async (key) => {
233
+ try {
234
+ await purge(deps, key);
235
+ } catch (error) {
236
+ deps.logger.error(`[storage] could not discard ${key}: ${errorText(error)}`);
237
+ }
238
+ }),
239
+ );
240
+ }