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 BucketError,
11 BucketObject,
12 BucketObjectBody,
13 GetOptions,
14 ListOptions,
15 ListResult,
16 PresignGetOptions,
17} from "./BucketTypes.ts";
18import {
19 makeBucketAccess,
20 objectBodyFrom,
21 objectFrom,
22 objectFromListEntry,
23 rangeHeader,
24 toBucketError,
25 type BucketAccess,
26} from "./Internal/BucketClient.ts";
27
28export interface ReadBucket extends Binding.Service<
29 ReadBucket,
30 "Prisma.ReadBucket",
31 (bucket: Bucket) => Effect.Effect<ReadBucketClient>
32> {}
33
34/**
35 * Read-only client for a Prisma Object Store bucket. It deliberately exposes
36 * no write operations — see the role caveat on {@link ReadBucketBinding}.
37 */
38export interface ReadBucketClient {
39 /**
40 * Read an object's metadata without downloading it. Resolves `null` when the
41 * key does not exist.
42 */
43 head(
44 key: string,
45 ): Effect.Effect<BucketObject | null, BucketError, RuntimeContext>;
46 /**
47 * Read an object and its body. Resolves `null` when the key does not exist.
48 */
49 get(
50 key: string,
51 options?: GetOptions,
52 ): Effect.Effect<BucketObjectBody | null, BucketError, RuntimeContext>;
53 /**
54 * List one page of objects in the bucket.
55 */
56 list(
57 options?: ListOptions,
58 ): Effect.Effect<ListResult, BucketError, RuntimeContext>;
59 /**
60 * Mint a presigned download URL, so a browser can read the object without
61 * credentials. Pure client-side SigV4 — no request is made to the store.
62 */
63 presignGet(
64 key: string,
65 options?: PresignGetOptions,
66 ): Effect.Effect<string, BucketError, RuntimeContext>;
67}
68
69/**
70 * Bind a Prisma Object Store {@link Bucket} to a Prisma Compute app, AWS
71 * Lambda Function, or Cloudflare Worker with read-only access, and obtain the
72 * typed runtime client.
73 *
74 * Binding creates a read-scoped `Prisma.BucketAccessKey` for the bucket and carries
75 * its S3 credentials into the host environment, so the caller never handles a
76 * credential themselves.
77 *
78 * Provide {@link ReadBucketBinding} on the host implementation.
79 *
80 * Prisma bucket keys carry one of two coarse roles, `read` and `read_write`.
81 * This binding mints the `read` one, so the credential it puts in the host
82 * environment cannot write, and {@link ReadBucketClient} exposes no write
83 * operations either.
84 *
85 * ### Binding a Bucket
86 * **Example:** Read objects from Prisma Compute
87 * ```typescript
88 * export default Prisma.Compute(
89 * "api",
90 * { project, main: import.meta.filename },
91 * Effect.gen(function* () {
92 * const uploads = yield* Prisma.ReadBucket(bucket);
93 *
94 * return {
95 * fetch: Effect.gen(function* () {
96 * const object = yield* uploads.get("reports/2026.json");
97 * return yield* HttpServerResponse.json(
98 * object === null ? null : yield* object.json(),
99 * );
100 * }),
101 * };
102 * }).pipe(Effect.provide(Prisma.ReadBucketBinding)),
103 * );
104 * ```
105 *
106 * @binding
107 * @product Bucket
108 */
109export const ReadBucket = Binding.Service<ReadBucket>("Prisma.ReadBucket");
110
111/**
112 * Build the read operations over an already-resolved transport. Shared with
113 * {@link ReadWriteBucket} so both levels run the same code.
114 */
115export const readBucketOperations = (
116 access: BucketAccess,
117): ReadBucketClient => ({
118 head: (key: string) =>
119 access.bucketName.pipe(
120 Effect.flatMap((Bucket) =>
121 access.authorize(S3.headObject({ Bucket, Key: key })),
122 ),
123 Effect.map((response) => objectFrom(key, response)),
124 // A missing key is absence, not a failure — mirror the native bucket
125 // clients that resolve `null`.
126 Effect.catchTag("NotFound", () => Effect.succeed(null)),
127 Effect.mapError(toBucketError),
128 ),
129 get: (key: string, options?: GetOptions) =>
130 access.bucketName.pipe(
131 Effect.flatMap((Bucket) =>
132 access.authorize(
133 S3.getObject({
134 Bucket,
135 Key: key,
136 Range: rangeHeader(options?.range),
137 }),
138 ),
139 ),
140 Effect.map((response) => objectBodyFrom(key, response)),
141 Effect.catchTag("NoSuchKey", () => Effect.succeed(null)),
142 Effect.mapError(toBucketError),
143 ),
144 list: (options?: ListOptions) =>
145 access.bucketName.pipe(
146 Effect.flatMap((Bucket) =>
147 access.authorize(
148 S3.listObjectsV2({
149 Bucket,
150 Prefix: options?.prefix,
151 Delimiter: options?.delimiter,
152 ContinuationToken: options?.cursor,
153 MaxKeys: options?.limit,
154 StartAfter: options?.startAfter,
155 }),
156 ),
157 ),
158 Effect.map((response): ListResult => {
159 const objects = (response.Contents ?? []).map(objectFromListEntry);
160 const delimitedPrefixes = (response.CommonPrefixes ?? []).flatMap(
161 (prefix) => (prefix.Prefix === undefined ? [] : [prefix.Prefix]),
162 );
163 return response.IsTruncated && response.NextContinuationToken
164 ? {
165 objects,
166 delimitedPrefixes,
167 truncated: true,
168 cursor: response.NextContinuationToken,
169 }
170 : { objects, delimitedPrefixes, truncated: false };
171 }),
172 Effect.mapError(toBucketError),
173 ),
174 presignGet: (key: string, options?: PresignGetOptions) =>
175 access.presign({
176 method: "GET",
177 key,
178 expiresIn: options?.expiresIn,
179 responseContentType: options?.contentType,
180 }),
181});
182
183/**
184 * Build a read-only bucket client from a bound bucket key's credentials.
185 */
186export const makeReadBucketClient = (
187 credentials: BucketCredentials,
188): ReadBucketClient => readBucketOperations(makeBucketAccess(credentials));
189
190/**
191 * Implementation layer for {@link ReadBucket}. Provide it on the host
192 * Function/Worker Effect:
193 *
194 * ```typescript
195 * Effect.gen(function* () {
196 * const uploads = yield* Prisma.ReadBucket(bucket);
197 * // ...
198 * }).pipe(Effect.provide(Prisma.ReadBucketBinding))
199 * ```
200 *
201 * Prisma bucket keys have only `read` and `read_write` roles, and this binding
202 * mints the `read` one, so the credential it carries into the host cannot
203 * write.
204 */
205export const ReadBucketBinding = Layer.effect(
206 ReadBucket,
207 makeBucketBinding({
208 capability: "Read",
209 role: "read",
210 makeClient: makeReadBucketClient,
211 }),
212);