1import type { WriteBucketClient } from "@/Prisma/WriteBucket.ts";
2import * as Effect from "effect/Effect";
3import type * as HttpServerRequest from "effect/http/HttpServerRequest";
4import * as HttpServerResponse from "effect/http/HttpServerResponse";
5
6/**
7 * Shared write-side routes so every method of {@link WriteBucketClient} is
8 * driven over `fetch`:
9 *
10 * - `PUT /put?key=` — `put(key, body)` (returns the object key to prove the
11 * call resolved a `BucketObject`). `contentType`, `metaKey` and `metaValue`
12 * travel as query params and are passed through as `PutOptions`, so the
13 * read side can observe them.
14 * - `DELETE /del?key=` — `delete(key)` (single).
15 * - `DELETE /del-many?keys=a,b,c` — `delete(keys)` (batch). Keys travel as a
16 * comma-separated query param rather than a request body because DELETE
17 * bodies are unreliable across `fetch`.
18 * - `GET /presign-put?key=` — `presignPut(key)`.
19 *
20 * Returns `undefined` when the path is not a write route so the caller can
21 * fall through (used by the read-write fixtures).
22 */
23export const writeRoutes = (
24 store: WriteBucketClient,
25 request: HttpServerRequest.HttpServerRequest,
26 url: URL,
27) =>
28 Effect.gen(function* () {
29 if (request.method === "PUT" && url.pathname === "/put") {
30 const key = url.searchParams.get("key") ?? "";
31 const metaKey = url.searchParams.get("metaKey");
32 const body = yield* request.text;
33 const object = yield* store
34 .put(key, body, {
35 contentType: url.searchParams.get("contentType") ?? undefined,
36 metadata: metaKey
37 ? { [metaKey]: url.searchParams.get("metaValue") ?? "" }
38 : undefined,
39 })
40 .pipe(Effect.orDie);
41 return yield* HttpServerResponse.json({ ok: true, key: object.key });
42 }
43 if (request.method === "DELETE" && url.pathname === "/del") {
44 const key = url.searchParams.get("key") ?? "";
45 yield* store.delete(key).pipe(Effect.orDie);
46 return yield* HttpServerResponse.json({ ok: true });
47 }
48 if (request.method === "DELETE" && url.pathname === "/del-many") {
49 const keys = (url.searchParams.get("keys") ?? "")
50 .split(",")
51 .filter((k) => k.length > 0);
52 yield* store.delete(keys).pipe(Effect.orDie);
53 return yield* HttpServerResponse.json({ ok: true });
54 }
55 // The app mints the URL from its bound credentials and hands it back; the
56 // test then uploads to that URL directly, carrying no credentials of its
57 // own.
58 if (request.method === "GET" && url.pathname === "/presign-put") {
59 const key = url.searchParams.get("key") ?? "";
60 const expiresIn = url.searchParams.get("expiresIn");
61 const contentType = url.searchParams.get("contentType");
62 const presigned = yield* store
63 .presignPut(key, {
64 expiresIn: expiresIn ? Number(expiresIn) : undefined,
65 contentType: contentType ?? undefined,
66 })
67 .pipe(Effect.orDie);
68 return yield* HttpServerResponse.json({ url: presigned });
69 }
70 return undefined;
71 });