1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import { isResolved } from "../Diff.ts";
4import * as Provider from "../Provider.ts";
5import {
6 DEV_TIMESTAMP,
7 attrOrString,
8 devId,
9 devProvider,
10} from "./Internal/DevStub.ts";
11import * as ProviderLayer from "../Local/ProviderLayer.ts";
12import { Resource } from "../Resource.ts";
13import {
14 type GetBucketsResponse,
15 deleteBucket,
16 getBuckets,
17 getBucket,
18 createBucket,
19} from "@distilled.cloud/prisma/management";
20import { Retry } from "@distilled.cloud/prisma";
21import type { Project } from "./Project.ts";
22import type { Providers } from "./Providers.ts";
23import {
24 concreteIdsChanged,
25 isInputObject,
26 isPrismaDevId,
27 resolveProjectId,
28 unresolvedProjectIdOf,
29} from "./Refs.ts";
30import type { ObservedBucket } from "./Internal/Observed.ts";
31import { PrismaPaginationError } from "./Internal/Pagination.ts";
32
33export interface BucketProps {
34 /**
35 * Project ID or `project.projectId` output that owns this bucket.
36 */
37 project: string | Project;
38 /**
39 * Bucket display name. Prisma generates a name when omitted. The display
40 * name is not the provider-side S3 bucket name — S3 clients must use the
41 * `bucketName` attribute of `Prisma.BucketAccessKey`.
42 */
43 name?: string;
44 /**
45 * Branch ID to scope the bucket to, e.g. for per-branch preview storage.
46 */
47 branchId?: string;
48}
49
50export interface Bucket extends Resource<
51 "Prisma.Bucket",
52 BucketProps,
53 {
54 /**
55 * Prisma bucket ID.
56 */
57 bucketId: string;
58 /**
59 * Bucket display name. Not the provider-side S3 bucket name; S3 clients
60 * must use the `bucketName` attribute of `Prisma.BucketAccessKey`.
61 */
62 name: string;
63 /**
64 * Project ID that owns the bucket.
65 */
66 projectId: string;
67 /**
68 * ISO timestamp when the bucket was created.
69 */
70 createdAt: string;
71 },
72 never,
73 Providers
74> {}
75
76/**
77 * A Prisma Object Store bucket inside a Prisma project.
78 *
79 * Project, name, and branch changes replace the bucket because the
80 * Management API has no bucket update operation. Destroying this resource
81 * deletes the bucket, its objects, and any remaining access keys — the
82 * Management API cascades the deletion server-side.
83 *
84 * ### Creating a Bucket
85 * **Example:** Bucket in a project
86 * ```typescript
87 * const bucket = yield* Prisma.Bucket("uploads", {
88 * project,
89 * name: "uploads",
90 * });
91 * ```
92 *
93 * ### Accessing a Bucket
94 * **Example:** S3 credentials for a bucket
95 * ```typescript
96 * const key = yield* Prisma.BucketAccessKey("uploads-key", {
97 * bucket,
98 * role: "read_write",
99 * });
100 * ```
101 *
102 * @resource
103 * @product Bucket
104 */
105export const Bucket = Resource<Bucket>("Prisma.Bucket");
106
107/**
108 * The bucket the provider observed belongs to a different project than the
109 * one requested or persisted. Convergence and deletion both refuse rather
110 * than acting on a bucket that is not the one this resource manages.
111 */
112export class BucketProjectMismatchError extends Data.TaggedError(
113 "BucketProjectMismatchError",
114)<{
115 bucketId: string;
116 actualProjectId: string;
117 expectedProjectId: string;
118 message: string;
119}> {}
120
121const attrsFrom = (bucket: ObservedBucket): Bucket["Attributes"] => ({
122 bucketId: bucket.id,
123 name: bucket.name,
124 projectId: bucket.project.id,
125 createdAt: bucket.createdAt,
126});
127
128// Distilled emits the cursor-paginated list operations as plain ops, so
129// callers walk `pagination` themselves (see `src/Neon/Project.ts`).
130const listBuckets = () =>
131 Effect.gen(function* () {
132 const buckets: GetBucketsResponse["data"][number][] = [];
133 let cursor: string | undefined;
134 while (true) {
135 const page = yield* getBuckets(cursor === undefined ? {} : { cursor });
136 buckets.push(...page.data);
137 const nextCursor = page.pagination.nextCursor;
138 if (!page.pagination.hasMore) break;
139 if (nextCursor === null) {
140 return yield* Effect.fail(
141 new PrismaPaginationError({
142 message:
143 "Invalid Prisma Management API pagination response from getBuckets: hasMore was true without a non-empty nextCursor",
144 }),
145 );
146 }
147 cursor = nextCursor;
148 }
149 return buckets;
150 });
151
152const ProviderLive = () =>
153 Provider.effect(
154 Bucket,
155 Effect.gen(function* () {
156 return {
157 stables: ["bucketId"],
158 list: () =>
159 listBuckets().pipe(Effect.map((buckets) => buckets.map(attrsFrom))),
160 diff: Effect.fn(function* ({ olds, news, output }) {
161 if (!isInputObject(news)) return undefined;
162 if (isPrismaDevId(output?.bucketId)) {
163 return { action: "update" } as const;
164 }
165 const oldProjectId =
166 output?.projectId ?? unresolvedProjectIdOf(olds.project);
167 const newProjectId = isResolved(news.project)
168 ? unresolvedProjectIdOf(news.project)
169 : undefined;
170 if (concreteIdsChanged(oldProjectId, newProjectId)) {
171 return { action: "replace" } as const;
172 }
173 // Buckets have no update operation, so branch and name changes
174 // replace the bucket (and its contents) rather than converging.
175 if (
176 isResolved(news.branchId) &&
177 (news.branchId ?? undefined) !== (olds.branchId ?? undefined)
178 ) {
179 return { action: "replace" } as const;
180 }
181 if (
182 isResolved(news.name) &&
183 news.name !== undefined &&
184 news.name !== (output?.name ?? olds.name)
185 ) {
186 return { action: "replace" } as const;
187 }
188 return undefined;
189 }),
190 read: Effect.fn(function* ({ output }) {
191 const bucketId = isPrismaDevId(output?.bucketId)
192 ? undefined
193 : output?.bucketId;
194 if (!bucketId) return undefined;
195 const bucket = yield* getBucket({ bucketId }).pipe(
196 Effect.map((response) => response.data),
197 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
198 );
199 return bucket ? attrsFrom(bucket) : undefined;
200 }),
201 reconcile: Effect.fn(function* ({ news, output }) {
202 const projectId = yield* resolveProjectId(news.project);
203 const bucketId = isPrismaDevId(output?.bucketId)
204 ? undefined
205 : output?.bucketId;
206 const observed = bucketId
207 ? yield* getBucket({ bucketId }).pipe(
208 Effect.map((response) => response.data),
209 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
210 )
211 : undefined;
212 if (observed) {
213 if (observed.project.id !== projectId) {
214 return yield* new BucketProjectMismatchError({
215 bucketId: observed.id,
216 actualProjectId: observed.project.id,
217 expectedProjectId: projectId,
218 message: `Prisma bucket '${observed.id}' belongs to project '${observed.project.id}', not requested project '${projectId}'. Refusing to claim convergence; replace the bucket.`,
219 });
220 }
221 return attrsFrom(observed);
222 }
223 const created = yield* createBucket({
224 projectId,
225 ...(news.name === undefined ? {} : { name: news.name }),
226 ...(news.branchId === undefined || news.branchId === null
227 ? {}
228 : { branchId: news.branchId }),
229 }).pipe(
230 // A replayed create would make a second bucket; the retry policy
231 // cannot see the request, so opt out explicitly.
232 Retry.none,
233 Effect.map((response) => response.data),
234 );
235 return attrsFrom(created);
236 }),
237 delete: Effect.fn(function* ({ output }) {
238 if (isPrismaDevId(output.bucketId)) return;
239 const bucket = yield* getBucket({
240 bucketId: output.bucketId,
241 }).pipe(
242 Effect.map((response) => response.data),
243 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
244 );
245 if (!bucket) return;
246 if (bucket.project.id !== output.projectId) {
247 return yield* new BucketProjectMismatchError({
248 bucketId: bucket.id,
249 actualProjectId: bucket.project.id,
250 expectedProjectId: output.projectId,
251 message: `Prisma bucket '${bucket.id}' no longer matches persisted project '${output.projectId}'. Refusing to delete a mismatched bucket.`,
252 });
253 }
254 // Deletion cascades server-side: the Management API removes the
255 // bucket together with its objects and any remaining keys.
256 yield* deleteBucket({
257 bucketId: output.bucketId,
258 }).pipe(Effect.catchTag("NotFound", () => Effect.void));
259 }),
260 };
261 }),
262 );
263
264const ProviderLocal = () =>
265 devProvider(Bucket, ["bucketId"], ({ id, news }) => ({
266 bucketId: devId("bucket", id),
267 name: news.name ?? id,
268 projectId: attrOrString(news.project, "projectId") ?? devId("project", id),
269 createdAt: DEV_TIMESTAMP,
270 }));
271
272export const BucketProvider = () =>
273 ProviderLayer.dual(Bucket, {
274 local: () => ProviderLocal(),
275 live: () => ProviderLive(),
276 });