| 1 | import * as S3 from "@distilled.cloud/aws/s3"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; |
| 4 | import * as Binding from "../Binding.ts"; |
| 5 | import type { RuntimeContext } from "../RuntimeContext.ts"; |
| 6 | import type { Bucket } from "./Bucket.ts"; |
| 7 | import { makeBucketBinding } from "./BucketBinding.ts"; |
| 8 | import type { |
| 9 | BucketCredentials, |
| 10 | BucketBody, |
| 11 | BucketError, |
| 12 | BucketObject, |
| 13 | PresignPutOptions, |
| 14 | PutOptions, |
| 15 | } from "./BucketTypes.ts"; |
| 16 | import { |
| 17 | makeBucketAccess, |
| 18 | objectFrom, |
| 19 | toBucketError, |
| 20 | type BucketAccess, |
| 21 | } from "./Internal/BucketClient.ts"; |
| 22 | |
| 23 | export 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 | */ |
| 33 | export 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 | */ |
| 104 | export 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 | */ |
| 110 | export 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 | */ |
| 166 | export 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 | */ |
| 188 | export const WriteBucketBinding = Layer.effect( |
| 189 | WriteBucket, |
| 190 | makeBucketBinding({ |
| 191 | capability: "Write", |
| 192 | role: "read_write", |
| 193 | makeClient: makeWriteBucketClient, |
| 194 | }), |
| 195 | ); |