1import * as S3 from "@distilled.cloud/aws/s3";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Binding from "../Binding.ts";
5import type { RuntimeContext } from "../RuntimeContext.ts";
6import type { Bucket } from "./Bucket.ts";
7import { makeBucketBinding } from "./BucketBinding.ts";
8import type {
9 BucketCredentials,
10 BucketBody,
11 BucketError,
12 BucketObject,
13 PresignPutOptions,
14 PutOptions,
15} from "./BucketTypes.ts";
16import {
17 makeBucketAccess,
18 objectFrom,
19 toBucketError,
20 type BucketAccess,
21} from "./Internal/BucketClient.ts";
22
23export interface WriteBucket extends Binding.Service<
24 WriteBucket,
25 "Prisma.WriteBucket",
26 (bucket: Bucket) => Effect.Effect<WriteBucketClient>
27> {}
28
29/**
30 * Write-only client for a Prisma Object Store bucket. It deliberately exposes
31 * no read operations — see the role caveat on {@link WriteBucketBinding}.
32 */
33export interface WriteBucketClient {
34 /**
35 * Store an object, replacing any object already under the key.
36 */
37 put(
38 key: string,
39 value: BucketBody,
40 options?: PutOptions,
41 ): Effect.Effect<BucketObject, BucketError, RuntimeContext>;
42 /**
43 * Delete one key or a list of keys. Deleting a key that does not exist
44 * succeeds.
45 */
46 delete(
47 keys: string | string[],
48 ): Effect.Effect<void, BucketError, RuntimeContext>;
49 /**
50 * Mint a presigned upload URL, so a browser can write the object without
51 * credentials. Pure client-side SigV4 — no request is made to the store.
52 *
53 * When `contentType` is set the uploader must send exactly that
54 * `Content-Type`, because it is signed into the URL.
55 */
56 presignPut(
57 key: string,
58 options?: PresignPutOptions,
59 ): Effect.Effect<string, BucketError, RuntimeContext>;
60}
61
62/**
63 * Bind a Prisma Object Store {@link Bucket} to a Prisma Compute app, AWS
64 * Lambda Function, or Cloudflare Worker with write access, and obtain the
65 * typed runtime client.
66 *
67 * Binding creates a `Prisma.BucketAccessKey` for the bucket and carries its S3
68 * credentials into the host environment, so the caller never handles a
69 * credential themselves.
70 *
71 * Provide {@link WriteBucketBinding} on the host implementation.
72 *
73 * **Role caveat.** Prisma bucket keys carry one of two coarse roles, `read`
74 * and `read_write`; there is no write-only role. This binding therefore mints
75 * a `read_write` key, and the credential it puts in the host environment can
76 * also read. The write-only contract is enforced client-side —
77 * {@link WriteBucketClient} exposes no read operations — and becomes a
78 * server-side boundary if Prisma grows a write-only role.
79 *
80 * ### Binding a Bucket
81 * **Example:** Write objects from Prisma Compute
82 * ```typescript
83 * export default Prisma.Compute(
84 * "api",
85 * { project, main: import.meta.filename },
86 * Effect.gen(function* () {
87 * const uploads = yield* Prisma.WriteBucket(bucket);
88 *
89 * return {
90 * fetch: Effect.gen(function* () {
91 * yield* uploads.put("reports/2026.json", JSON.stringify({ ok: true }), {
92 * contentType: "application/json",
93 * });
94 * return yield* HttpServerResponse.empty({ status: 204 });
95 * }),
96 * };
97 * }).pipe(Effect.provide(Prisma.WriteBucketBinding)),
98 * );
99 * ```
100 *
101 * @binding
102 * @product Bucket
103 */
104export const WriteBucket = Binding.Service<WriteBucket>("Prisma.WriteBucket");
105
106/**
107 * Build the write operations over an already-resolved transport. Shared with
108 * {@link ReadWriteBucket} so both levels run the same code.
109 */
110export const writeBucketOperations = (
111 access: BucketAccess,
112): WriteBucketClient => ({
113 put: (key: string, value: BucketBody, options?: PutOptions) =>
114 access.bucketName.pipe(
115 Effect.flatMap((Bucket) =>
116 access.authorize(
117 S3.putObject({
118 Bucket,
119 Key: key,
120 Body: value,
121 ContentType: options?.contentType,
122 ContentLength: options?.contentLength,
123 CacheControl: options?.cacheControl,
124 ContentDisposition: options?.contentDisposition,
125 ContentEncoding: options?.contentEncoding,
126 Metadata: options?.metadata,
127 }),
128 ),
129 ),
130 Effect.map((response) =>
131 objectFrom(key, {
132 ETag: response.ETag,
133 ContentLength: options?.contentLength,
134 ContentType: options?.contentType,
135 Metadata: options?.metadata,
136 }),
137 ),
138 Effect.mapError(toBucketError),
139 ),
140 // Deletes are issued one key at a time rather than through the batch
141 // `DeleteObjects` operation, whose signed-payload digest requirement is not
142 // uniformly supported by S3-compatible stores.
143 delete: (keys: string | string[]) =>
144 access.bucketName.pipe(
145 Effect.flatMap((Bucket) =>
146 Effect.forEach(
147 typeof keys === "string" ? [keys] : keys,
148 (key) => access.authorize(S3.deleteObject({ Bucket, Key: key })),
149 { concurrency: 16, discard: true },
150 ),
151 ),
152 Effect.mapError(toBucketError),
153 ),
154 presignPut: (key: string, options?: PresignPutOptions) =>
155 access.presign({
156 method: "PUT",
157 key,
158 expiresIn: options?.expiresIn,
159 contentType: options?.contentType,
160 }),
161});
162
163/**
164 * Build a write-only bucket client from a bound bucket key's credentials.
165 */
166export const makeWriteBucketClient = (
167 credentials: BucketCredentials,
168): WriteBucketClient => writeBucketOperations(makeBucketAccess(credentials));
169
170/**
171 * Implementation layer for {@link WriteBucket}. Provide it on the host
172 * Function/Worker Effect:
173 *
174 * ```typescript
175 * Effect.gen(function* () {
176 * const uploads = yield* Prisma.WriteBucket(bucket);
177 * // ...
178 * }).pipe(Effect.provide(Prisma.WriteBucketBinding))
179 * ```
180 *
181 * Prisma bucket keys carry one of two coarse roles, `read` and `read_write`;
182 * there is no write-only role, so this binding mints a `read_write` key and
183 * the credential it carries into the host can also read. The write-only
184 * contract is enforced client-side by {@link WriteBucketClient}, which exposes
185 * no read operations, and becomes a server-side boundary if Prisma grows a
186 * write-only role.
187 */
188export const WriteBucketBinding = Layer.effect(
189 WriteBucket,
190 makeBucketBinding({
191 capability: "Write",
192 role: "read_write",
193 makeClient: makeWriteBucketClient,
194 }),
195);