@shaferllc/keel 0.66.0 → 0.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/AGENTS.md +167 -0
  2. package/README.md +30 -1
  3. package/bin/keel-mcp.mjs +9 -0
  4. package/dist/core/application.d.ts +5 -5
  5. package/dist/core/application.js +2 -2
  6. package/dist/core/cache.d.ts +82 -5
  7. package/dist/core/cache.js +181 -23
  8. package/dist/core/cli/stubs.d.ts +12 -0
  9. package/dist/core/cli/stubs.js +120 -0
  10. package/dist/core/events.d.ts +129 -5
  11. package/dist/core/events.js +165 -7
  12. package/dist/core/health.d.ts +141 -0
  13. package/dist/core/health.js +226 -0
  14. package/dist/core/helpers.d.ts +9 -3
  15. package/dist/core/helpers.js +11 -3
  16. package/dist/core/index.d.ts +15 -10
  17. package/dist/core/index.js +7 -4
  18. package/dist/core/lock.d.ts +139 -0
  19. package/dist/core/lock.js +215 -0
  20. package/dist/core/logger.d.ts +82 -4
  21. package/dist/core/logger.js +141 -23
  22. package/dist/core/mail.d.ts +128 -7
  23. package/dist/core/mail.js +264 -16
  24. package/dist/core/queue.d.ts +134 -9
  25. package/dist/core/queue.js +304 -14
  26. package/dist/core/storage.d.ts +159 -6
  27. package/dist/core/storage.js +287 -7
  28. package/dist/mcp/server.d.ts +19 -0
  29. package/dist/mcp/server.js +355 -0
  30. package/docs/ai-manifest.json +2472 -0
  31. package/docs/ai.md +128 -0
  32. package/docs/architecture.md +331 -0
  33. package/docs/authentication.md +453 -0
  34. package/docs/authorization.md +167 -0
  35. package/docs/broadcasting.md +137 -0
  36. package/docs/broker.md +500 -0
  37. package/docs/cache.md +558 -0
  38. package/docs/configuration.md +311 -0
  39. package/docs/console.md +356 -0
  40. package/docs/container.md +467 -0
  41. package/docs/controllers.md +265 -0
  42. package/docs/cors.md +51 -0
  43. package/docs/database.md +530 -0
  44. package/docs/debugging.md +129 -0
  45. package/docs/decorators.md +127 -0
  46. package/docs/errors.md +395 -0
  47. package/docs/events.md +496 -0
  48. package/docs/examples/architecture-app.ts +27 -0
  49. package/docs/examples/authentication.ts +61 -0
  50. package/docs/examples/authorization.ts +79 -0
  51. package/docs/examples/broadcasting.ts +60 -0
  52. package/docs/examples/broker-cache-validate.ts +34 -0
  53. package/docs/examples/broker-fault-tolerance.ts +29 -0
  54. package/docs/examples/broker-middleware.ts +27 -0
  55. package/docs/examples/broker.ts +203 -0
  56. package/docs/examples/cache.ts +222 -0
  57. package/docs/examples/configuration.ts +81 -0
  58. package/docs/examples/container.ts +134 -0
  59. package/docs/examples/controllers.ts +86 -0
  60. package/docs/examples/database.ts +118 -0
  61. package/docs/examples/debugging.ts +41 -0
  62. package/docs/examples/decorators.ts +40 -0
  63. package/docs/examples/errors.ts +121 -0
  64. package/docs/examples/events.ts +204 -0
  65. package/docs/examples/factories.ts +84 -0
  66. package/docs/examples/hashing.ts +71 -0
  67. package/docs/examples/health.ts +94 -0
  68. package/docs/examples/helpers.ts +171 -0
  69. package/docs/examples/hooks.ts +54 -0
  70. package/docs/examples/inertia.ts +81 -0
  71. package/docs/examples/locks.ts +120 -0
  72. package/docs/examples/logger.ts +92 -0
  73. package/docs/examples/mail.ts +160 -0
  74. package/docs/examples/middleware.ts +119 -0
  75. package/docs/examples/migrations.ts +126 -0
  76. package/docs/examples/models.ts +239 -0
  77. package/docs/examples/notification.ts +124 -0
  78. package/docs/examples/providers.ts +123 -0
  79. package/docs/examples/queues.ts +254 -0
  80. package/docs/examples/rate-limiting.ts +42 -0
  81. package/docs/examples/redis.ts +99 -0
  82. package/docs/examples/request-response.ts +197 -0
  83. package/docs/examples/routing.ts +186 -0
  84. package/docs/examples/scheduling.ts +62 -0
  85. package/docs/examples/sessions.ts +102 -0
  86. package/docs/examples/static-files.ts +63 -0
  87. package/docs/examples/storage.ts +132 -0
  88. package/docs/examples/templates.ts +58 -0
  89. package/docs/examples/testing.ts +66 -0
  90. package/docs/examples/transformer.ts +141 -0
  91. package/docs/examples/transformers.ts +49 -0
  92. package/docs/examples/url-builder.ts +86 -0
  93. package/docs/examples/validation.ts +102 -0
  94. package/docs/examples/views.tsx +62 -0
  95. package/docs/examples/vite.ts +106 -0
  96. package/docs/factories.md +166 -0
  97. package/docs/getting-started.md +290 -0
  98. package/docs/hashing.md +259 -0
  99. package/docs/health.md +225 -0
  100. package/docs/helpers.md +347 -0
  101. package/docs/hono.md +186 -0
  102. package/docs/hooks.md +118 -0
  103. package/docs/inertia.md +241 -0
  104. package/docs/locks.md +323 -0
  105. package/docs/logger.md +290 -0
  106. package/docs/mail.md +678 -0
  107. package/docs/middleware.md +425 -0
  108. package/docs/migrations.md +476 -0
  109. package/docs/models.md +810 -0
  110. package/docs/notifications.md +474 -0
  111. package/docs/providers.md +363 -0
  112. package/docs/queues.md +679 -0
  113. package/docs/rate-limiting.md +155 -0
  114. package/docs/redis.md +178 -0
  115. package/docs/request-response.md +953 -0
  116. package/docs/routing.md +804 -0
  117. package/docs/scheduling.md +110 -0
  118. package/docs/security.md +85 -0
  119. package/docs/sessions.md +354 -0
  120. package/docs/social-auth.md +174 -0
  121. package/docs/static-files.md +211 -0
  122. package/docs/storage.md +450 -0
  123. package/docs/templates.md +315 -0
  124. package/docs/testing.md +125 -0
  125. package/docs/transformers.md +381 -0
  126. package/docs/url-builder.md +295 -0
  127. package/docs/validation.md +288 -0
  128. package/docs/views.md +267 -0
  129. package/docs/vite.md +434 -0
  130. package/llms-full.txt +17694 -0
  131. package/llms.txt +116 -0
  132. package/package.json +26 -7
@@ -0,0 +1,450 @@
1
+ # Storage
2
+
3
+ File storage over a pluggable **disk** — like the database and mail layers, the
4
+ core imports no filesystem or SDK, so it runs on Node and the edge. Point a disk
5
+ at the local filesystem, S3, or a Cloudflare R2 binding; `MemoryDisk` is the
6
+ built-in default for tests.
7
+
8
+ ## Using it
9
+
10
+ Register a disk once (in a service provider), then reach it anywhere with
11
+ `storage()`:
12
+
13
+ ```ts
14
+ import { storage, setDisk, MemoryDisk } from "@shaferllc/keel/core";
15
+
16
+ setDisk(new MemoryDisk()); // swap for a local / S3 / R2 disk in production
17
+
18
+ await storage().put("avatars/1.png", bytes); // string | Uint8Array | ArrayBuffer
19
+ const bytes = await storage().get("avatars/1.png"); // Uint8Array | null
20
+ const text = await storage().getText("notes/todo.md"); // string | null
21
+ await storage().exists("avatars/1.png"); // boolean
22
+ await storage().delete("avatars/1.png");
23
+ const files = await storage().list("avatars/"); // paths under a prefix
24
+ const url = storage().url("avatars/1.png"); // a public URL for the object
25
+ ```
26
+
27
+ The default disk is a `MemoryDisk`, so `storage()` works out of the box in tests.
28
+
29
+ ## Writing files
30
+
31
+ The **content type is inferred from the extension**, so a `.png` lands in your
32
+ bucket as `image/png` rather than `application/octet-stream` — which is the
33
+ difference between a browser rendering the file and downloading it.
34
+
35
+ ```ts
36
+ await storage().put("avatars/1.png", bytes); // stored as image/png
37
+ ```
38
+
39
+ Pass `WriteOptions` to set it yourself, along with the rest of the object's
40
+ metadata:
41
+
42
+ ```ts
43
+ await storage().put("exports/report.csv", csv, {
44
+ contentType: "text/csv",
45
+ cacheControl: "public, max-age=3600",
46
+ visibility: "private", // needs a signed URL to read
47
+ metadata: { uploadedBy: "42" }, // arbitrary user metadata
48
+ });
49
+ ```
50
+
51
+ A disk that can't express one of these ignores it.
52
+
53
+ ## Inspecting, copying, moving
54
+
55
+ ```ts
56
+ const meta = await storage().metadata("avatars/1.png");
57
+ // { size, contentType, cacheControl, visibility, lastModified, metadata }
58
+
59
+ const size = await storage().size("avatars/1.png"); // number | null
60
+
61
+ await storage().copy("avatars/1.png", "avatars/1-backup.png");
62
+ await storage().move("tmp/upload.png", "avatars/2.png");
63
+ ```
64
+
65
+ `copy` and `move` use the backend's server-side operation when the disk provides
66
+ one, and fall back to read-then-write otherwise. `metadata` falls back to reading
67
+ the file and measuring it.
68
+
69
+ ## Signed URLs
70
+
71
+ `url()` is the *public* URL. For a private file, hand out a **temporary** one
72
+ instead:
73
+
74
+ ```ts
75
+ const url = await storage().signedUrl("invoices/42.pdf", { expiresIn: 300 });
76
+ // → /storage/invoices/42.pdf?expires=1752278400&signature=a3f1…
77
+ ```
78
+
79
+ How it's signed depends on the disk:
80
+
81
+ - **A disk with backend presigning** (S3, R2, GCS — see the recipes below) returns
82
+ the backend's own presigned URL. The file is served straight from the bucket;
83
+ your app isn't in the path at all.
84
+ - **Any other disk** gets a URL signed with `config('app.key')`, pointing at your
85
+ app. Serve those with `serveStorage({ signed: true })`.
86
+
87
+ Either way the URL expires, and tampering with the path invalidates it.
88
+
89
+ ### Serving files from a disk
90
+
91
+ `serveStorage()` is the middleware that makes app-signed URLs real — it serves a
92
+ disk's files over HTTP, verifying the signature when you ask it to. Requests that
93
+ don't match `basePath`, or that name a file the disk doesn't have, fall through
94
+ to your routes.
95
+
96
+ ```ts
97
+ // in a service provider's boot(), or your HTTP kernel's constructor
98
+ const kernel = this.app.make(HttpKernel);
99
+ kernel.use(serveStorage()); // public files under /storage
100
+ kernel.use(serveStorage({ basePath: "/private", signed: true }));
101
+ ```
102
+
103
+ In `signed` mode an unsigned or expired request gets a **403**. Files are sent
104
+ with their stored content type, an `ETag` (so conditional requests get a 304),
105
+ and their `Cache-Control`.
106
+
107
+ The signature covers the **path and query, not the host** — so the same signed URL
108
+ stays valid behind a CDN hostname, and you can't move a signature onto a different
109
+ file.
110
+
111
+ > **The disk's `url()` prefix and `basePath` must agree.** `signedUrl()` signs the
112
+ > path the *disk* reports, so if the disk hands out `/storage/…` while
113
+ > `serveStorage` listens on `/private`, every signed URL will 403. Give the disk
114
+ > the matching base — `new MemoryDisk("/private")`, or `localDisk("./storage",
115
+ > "/private")` — or keep both on the default `/storage`.
116
+
117
+ ## Direct browser uploads
118
+
119
+ Proxying a large upload through your app is exactly what you don't want on the
120
+ edge — a 50 MB video shouldn't stream through a Worker. A **signed upload URL** lets
121
+ the browser `PUT` the file straight to the bucket:
122
+
123
+ ```ts
124
+ // server
125
+ const url = await storage("r2").signedUploadUrl("uploads/clip.mp4", {
126
+ expiresIn: 600,
127
+ contentType: "video/mp4",
128
+ });
129
+ ```
130
+
131
+ ```ts
132
+ // browser
133
+ await fetch(url, {
134
+ method: "PUT",
135
+ body: file,
136
+ headers: { "Content-Type": "video/mp4" },
137
+ });
138
+ ```
139
+
140
+ Only the storage backend can accept such a write, so this needs a disk that
141
+ implements `signedUploadUrl` — there is no generic fallback, and calling it on one
142
+ that doesn't (the memory disk, say) throws a clear error rather than quietly
143
+ handing you a URL that won't work.
144
+
145
+ ## Testing
146
+
147
+ `fakeDisk()` swaps a disk for an in-memory one, so tests never touch a real
148
+ bucket, and gives you assertions over what was written. `restoreDisk()` puts the
149
+ real one back.
150
+
151
+ ```ts
152
+ import { fakeDisk, restoreDisk } from "@shaferllc/keel/core";
153
+
154
+ const disk = fakeDisk(); // or fakeDisk("r2") for a named disk
155
+
156
+ await request.post("/avatars", form);
157
+
158
+ await disk.assertExists("avatars/1.png");
159
+ await disk.assertMissing("avatars/2.png");
160
+ await disk.assertContents("notes/todo.md", "buy milk");
161
+ await disk.assertCount(1, "avatars/"); // files under a prefix
162
+
163
+ restoreDisk(); // no name → restore every faked disk
164
+ ```
165
+
166
+ Failed assertions throw with the path and what was actually there.
167
+
168
+ ## Multiple disks
169
+
170
+ Register disks by name and pick one with `storage(name)`:
171
+
172
+ ```ts
173
+ setDisk(localDisk("./storage"), "local");
174
+ setDisk(r2Disk(env.BUCKET), "r2");
175
+
176
+ await storage("local").put("cache/x", data);
177
+ await storage("r2").put("public/logo.svg", svg);
178
+ ```
179
+
180
+ ## Writing a disk
181
+
182
+ A disk is the `Disk` interface. Six methods are required — `put` / `get` /
183
+ `exists` / `delete` / `list` / `url` — and the rest are **optional capabilities**:
184
+ implement `metadata`, `copy`, `move`, `signedUrl`, or `signedUploadUrl` when your
185
+ backend can do better than the generic fallback, and `Storage` will use them.
186
+
187
+ ### Local filesystem (Node)
188
+
189
+ ```ts
190
+ import { mkdir, readFile, writeFile, rm, readdir, stat } from "node:fs/promises";
191
+ import { dirname, join } from "node:path";
192
+ import type { Disk } from "@shaferllc/keel/core";
193
+
194
+ const localDisk = (root: string, baseUrl = "/storage"): Disk => ({
195
+ async put(path, bytes) {
196
+ const full = join(root, path);
197
+ await mkdir(dirname(full), { recursive: true });
198
+ await writeFile(full, bytes);
199
+ },
200
+ async get(path) {
201
+ try {
202
+ return new Uint8Array(await readFile(join(root, path)));
203
+ } catch {
204
+ return null;
205
+ }
206
+ },
207
+ async exists(path) {
208
+ return (await this.get(path)) !== null;
209
+ },
210
+ async delete(path) {
211
+ await rm(join(root, path), { force: true });
212
+ },
213
+ async list(prefix = "") {
214
+ const all = await readdir(root, { recursive: true });
215
+ return all.filter((p) => p.startsWith(prefix));
216
+ },
217
+ async metadata(path) {
218
+ const s = await stat(join(root, path)).catch(() => null);
219
+ return s ? { size: s.size, lastModified: s.mtime } : null;
220
+ },
221
+ url: (path) => `${baseUrl}/${path}`,
222
+ });
223
+ ```
224
+
225
+ This disk has no backend presigning, so `signedUrl()` falls back to an app-signed
226
+ URL — pair it with `serveStorage({ signed: true })`.
227
+
228
+ ### Cloudflare R2 (edge, via the binding)
229
+
230
+ ```ts
231
+ import type { Disk } from "@shaferllc/keel/core";
232
+
233
+ const r2Disk = (bucket: R2Bucket, baseUrl: string): Disk => ({
234
+ async put(path, bytes, options) {
235
+ await bucket.put(path, bytes, {
236
+ httpMetadata: {
237
+ contentType: options?.contentType,
238
+ cacheControl: options?.cacheControl,
239
+ },
240
+ customMetadata: options?.metadata,
241
+ });
242
+ },
243
+ async get(path) {
244
+ const obj = await bucket.get(path);
245
+ return obj ? new Uint8Array(await obj.arrayBuffer()) : null;
246
+ },
247
+ async exists(path) {
248
+ return (await bucket.head(path)) !== null;
249
+ },
250
+ async delete(path) {
251
+ await bucket.delete(path);
252
+ },
253
+ async list(prefix) {
254
+ const { objects } = await bucket.list({ prefix });
255
+ return objects.map((o) => o.key);
256
+ },
257
+ async metadata(path) {
258
+ const obj = await bucket.head(path);
259
+ if (!obj) return null;
260
+ return {
261
+ size: obj.size,
262
+ contentType: obj.httpMetadata?.contentType,
263
+ cacheControl: obj.httpMetadata?.cacheControl,
264
+ lastModified: obj.uploaded,
265
+ metadata: obj.customMetadata,
266
+ };
267
+ },
268
+ url: (path) => `${baseUrl}/${path}`,
269
+ });
270
+ ```
271
+
272
+ The R2 binding writes through the Worker, so it can't presign. For direct browser
273
+ uploads, use R2's S3-compatible API below.
274
+
275
+ ### S3 / R2 presigned URLs
276
+
277
+ S3 and R2 speak the same SigV4-signed HTTP API, so one disk covers both, and it's
278
+ the disk that gives you `signedUrl` and `signedUploadUrl` for real. Sign with
279
+ [`aws4fetch`](https://github.com/mhart/aws4fetch) — a ~2 KB library that runs on
280
+ Workers because it's built on Web Crypto:
281
+
282
+ ```ts
283
+ import { AwsClient } from "aws4fetch";
284
+ import type { Disk } from "@shaferllc/keel/core";
285
+
286
+ const s3Disk = (options: {
287
+ endpoint: string; // https://<account>.r2.cloudflarestorage.com/<bucket>
288
+ accessKeyId: string;
289
+ secretAccessKey: string;
290
+ baseUrl: string; // your public bucket / CDN origin
291
+ }): Disk => {
292
+ const aws = new AwsClient({
293
+ accessKeyId: options.accessKeyId,
294
+ secretAccessKey: options.secretAccessKey,
295
+ service: "s3",
296
+ });
297
+ const object = (path: string) => `${options.endpoint}/${path}`;
298
+
299
+ /** A SigV4 URL with the credentials in the query string, valid for `expiresIn`. */
300
+ const presign = async (path: string, method: string, expiresIn: number, contentType?: string) => {
301
+ const url = new URL(object(path));
302
+ url.searchParams.set("X-Amz-Expires", String(expiresIn));
303
+ const signed = await aws.sign(new Request(url, { method, headers: contentType ? { "Content-Type": contentType } : {} }), {
304
+ aws: { signQuery: true },
305
+ });
306
+ return signed.url;
307
+ };
308
+
309
+ return {
310
+ async put(path, bytes, opts) {
311
+ await aws.fetch(object(path), {
312
+ method: "PUT",
313
+ body: bytes,
314
+ headers: {
315
+ "Content-Type": opts?.contentType ?? "application/octet-stream",
316
+ ...(opts?.cacheControl ? { "Cache-Control": opts.cacheControl } : {}),
317
+ },
318
+ });
319
+ },
320
+ async get(path) {
321
+ const res = await aws.fetch(object(path));
322
+ return res.ok ? new Uint8Array(await res.arrayBuffer()) : null;
323
+ },
324
+ async exists(path) {
325
+ return (await aws.fetch(object(path), { method: "HEAD" })).ok;
326
+ },
327
+ async delete(path) {
328
+ await aws.fetch(object(path), { method: "DELETE" });
329
+ },
330
+ async list(prefix = "") {
331
+ const url = new URL(options.endpoint);
332
+ url.searchParams.set("list-type", "2");
333
+ url.searchParams.set("prefix", prefix);
334
+ const xml = await (await aws.fetch(url)).text();
335
+ return [...xml.matchAll(/<Key>(.*?)<\/Key>/g)].map((m) => m[1]!);
336
+ },
337
+ async metadata(path) {
338
+ const res = await aws.fetch(object(path), { method: "HEAD" });
339
+ if (!res.ok) return null;
340
+ return {
341
+ size: Number(res.headers.get("Content-Length") ?? 0),
342
+ contentType: res.headers.get("Content-Type") ?? undefined,
343
+ cacheControl: res.headers.get("Cache-Control") ?? undefined,
344
+ lastModified: new Date(res.headers.get("Last-Modified") ?? Date.now()),
345
+ };
346
+ },
347
+ url: (path) => `${options.baseUrl}/${path}`,
348
+
349
+ // The capabilities that matter: the bucket signs, so the bytes skip your app.
350
+ signedUrl: (path, o) => presign(path, "GET", o?.expiresIn ?? 3600),
351
+ signedUploadUrl: (path, o) => presign(path, "PUT", o?.expiresIn ?? 3600, o?.contentType),
352
+ };
353
+ };
354
+ ```
355
+
356
+ ## API reference
357
+
358
+ ### `storage(name?)`
359
+
360
+ `storage(name?: string): Storage`
361
+
362
+ The default disk, or a named one registered with `setDisk(disk, name)`. Throws
363
+ for an unknown name.
364
+
365
+ ### `setDisk(disk, name?)`
366
+
367
+ `setDisk(disk: Disk, name?: string): Storage`
368
+
369
+ Registers a disk (default name `"default"`) and returns the wrapping `Storage`.
370
+
371
+ ### `Storage`
372
+
373
+ Wraps a `Disk`.
374
+
375
+ | Method | Signature |
376
+ |--------|-----------|
377
+ | `put` | `(path, contents: string \| Uint8Array \| ArrayBuffer, options?: WriteOptions) => Promise<void>` |
378
+ | `get` | `(path) => Promise<Uint8Array \| null>` |
379
+ | `getText` | `(path) => Promise<string \| null>` |
380
+ | `exists` / `delete` | `(path) => Promise<boolean>` / `Promise<void>` |
381
+ | `list` | `(prefix?) => Promise<string[]>` |
382
+ | `metadata` | `(path) => Promise<FileMetadata \| null>` |
383
+ | `size` | `(path) => Promise<number \| null>` |
384
+ | `copy` / `move` | `(from, to) => Promise<void>` |
385
+ | `url` | `(path) => string` — the public URL |
386
+ | `signedUrl` | `(path, options?: SignedFileOptions) => Promise<string>` |
387
+ | `signedUploadUrl` | `(path, options?: SignedUploadOptions) => Promise<string>` |
388
+ | `driver` | the underlying `Disk` |
389
+
390
+ ### `fakeDisk(name?)` / `restoreDisk(name?)`
391
+
392
+ `fakeDisk(name?: string): FakeStorage` swaps a disk for an in-memory
393
+ `FakeStorage`. `restoreDisk(name?)` puts the real one back — with no name, every
394
+ faked disk.
395
+
396
+ `FakeStorage` is a `Storage` plus `assertExists(path)`, `assertMissing(path)`,
397
+ `assertContents(path, text)`, and `assertCount(n, prefix?)`.
398
+
399
+ ### `serveStorage(options?)`
400
+
401
+ `serveStorage(options?: ServeStorageOptions): MiddlewareHandler`
402
+
403
+ Serves a disk's files over HTTP. Options: `disk` (name, default `"default"`),
404
+ `basePath` (default `"/storage"`), `signed` (require a valid signature, 403
405
+ otherwise), `maxAge` (`Cache-Control` seconds).
406
+
407
+ ### `signStorageUrl(url, expiresIn?)` / `verifyStorageUrl(url)`
408
+
409
+ `signStorageUrl(url: string, expiresIn?: number): Promise<string>` adds `expires`
410
+ and `signature` params, signed with `config('app.key')` (default one hour).
411
+ `verifyStorageUrl(url: string): Promise<boolean>` checks them. Signing covers the
412
+ path and query, not the host.
413
+
414
+ ### `contentTypeFor(path)`
415
+
416
+ `contentTypeFor(path: string): string` — the MIME type for a path's extension, or
417
+ `application/octet-stream`.
418
+
419
+ ### `MemoryDisk`
420
+
421
+ `class MemoryDisk implements Disk` — in-memory, the default and ideal for tests.
422
+ `new MemoryDisk(baseUrl?)` sets the `url()` prefix. Not shared across processes.
423
+
424
+ ### Interfaces & types
425
+
426
+ #### `Disk`
427
+
428
+ The driver seam. Required: `put` / `get` / `exists` / `delete` / `list` / `url`.
429
+ Optional capabilities: `metadata` / `copy` / `move` / `signedUrl` /
430
+ `signedUploadUrl`.
431
+
432
+ #### `WriteOptions`
433
+
434
+ `{ contentType?, cacheControl?, visibility?, metadata? }` — passed to `put`.
435
+
436
+ #### `FileMetadata`
437
+
438
+ `{ size, contentType?, cacheControl?, visibility?, lastModified?, metadata? }`.
439
+
440
+ #### `SignedFileOptions` / `SignedUploadOptions`
441
+
442
+ `{ expiresIn? }` (seconds, default 3600), plus `contentType?` for uploads.
443
+
444
+ #### `FileVisibility`
445
+
446
+ `type FileVisibility = "public" | "private"`.
447
+
448
+ #### `Contents`
449
+
450
+ `type Contents = string | Uint8Array | ArrayBuffer` — accepted by `put`.