1import * as Neon from "@distilled.cloud/neon";
2import type { CORSRule } from "@distilled.cloud/aws/s3";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import { Unowned } from "../AdoptPolicy.ts";
7import { isResolved } from "../Diff.ts";
8import type { PropsInput } from "../Input.ts";
9import { createPhysicalName } from "../PhysicalName.ts";
10import * as Provider from "../Provider.ts";
11import { Resource } from "../Resource.ts";
12import { createInternalTags, diffTags, hasTags, tagRecord } from "../Tags.ts";
13import {
14 resolveBranchScope,
15 type BranchScope,
16 type ResolvedBranchScope,
17} from "./BranchScope.ts";
18import {
19 Credential,
20 validateCredential,
21 type CredentialAttributes,
22} from "./Credential.ts";
23import type { Providers } from "./Providers.ts";
24import { emptyStorageBucket, makeStorageClient } from "./Storage.ts";
25
26export type BucketProps = BranchScope & {
27 /** Bucket name, unique within this branch. Generated when omitted. */
28 name?: string;
29 /** Anonymous object GET/HEAD access; never grants anonymous listing or writes. @default "private" */
30 access?: "private" | "public_read";
31 /** S3 CORS rules. Omission removes managed CORS rules. */
32 cors?: CORSRule[];
33 /** User tags, merged with Alchemy ownership tags. */
34 tags?: Record<string, string>;
35 /** Empty objects and incomplete multipart uploads before deletion. @default false */
36 forceDestroy?: boolean;
37 /** Optional management credential; requires storage:read and storage:write on this branch or an ancestor. */
38 credential?: CredentialAttributes;
39};
40
41export interface BucketAttributes extends ResolvedBranchScope {
42 /** Bucket identity within the branch. */
43 bucketName: string;
44 /** Current anonymous object read policy. */
45 access: "private" | "public_read";
46 /** Branch-specific path-style S3 endpoint. */
47 endpoint: string;
48 /** S3 signing region. */
49 region: string;
50 /** Observed bucket tags, including ownership markers. */
51 tags: Record<string, string>;
52 /** Observed CORS rules. */
53 cors: CORSRule[];
54 /** Management credential for declarative objects and cleanup. Never bound into runtime readers. @internal */
55 credential: CredentialAttributes;
56}
57
58export interface Bucket extends Resource<
59 "Neon.Bucket",
60 BucketProps,
61 BucketAttributes,
62 never,
63 Providers
64> {}
65const BucketResource = Resource<Bucket>("Neon.Bucket");
66
67/**
68 * Branch-local S3 object storage. Writes do not change an ancestor's data.
69 * The management credential is tracked separately and revoked after bucket cleanup.
70 * Neon does not expose a supported visibility update API; changing access fails
71 * explicitly instead of replacing a populated bucket. Policies and ACL writes are
72 * not supported. A private bucket has no anonymously readable object URL.
73 * Adopting an inherited bucket materializes its configuration on the child branch
74 * without changing ancestor tags, CORS, or object data.
75 *
76 * ### Creating a Bucket
77 * **Example:** Private uploads with browser CORS
78 * ```typescript
79 * const uploads = yield* Neon.Bucket("Uploads", {
80 * branch,
81 * cors: [{ AllowedOrigins: ["https://app.example.com"], AllowedMethods: ["PUT", "GET"] }],
82 * });
83 * ```
84 *
85 * @resource
86 * @product Bucket
87 */
88export const Bucket: typeof BucketResource = Object.assign(
89 (
90 id: string,
91 props:
92 | PropsInput<BucketProps>
93 | Effect.Effect<PropsInput<BucketProps>, never, Providers>,
94 ) =>
95 BucketResource(
96 id,
97 Effect.gen(function* () {
98 const news = Effect.isEffect(props) ? yield* props : props;
99 const createCredential = yield* Credential;
100 const {
101 name: _name,
102 access: _access,
103 cors: _cors,
104 tags: _tags,
105 forceDestroy: _forceDestroy,
106 credential: suppliedCredential,
107 ...scope
108 } = news;
109 const credential =
110 suppliedCredential ??
111 (yield* createCredential(`${id}StorageCredential`, {
112 ...scope,
113 scopes: ["storage:read", "storage:write"],
114 }));
115 return { ...news, credential };
116 }),
117 ),
118 BucketResource,
119);
120
121export class UnsupportedBucketAccessUpdate extends Data.TaggedError(
122 "UnsupportedBucketAccessUpdate",
123)<{
124 message: string;
125}> {}
126export class BucketNotEmpty extends Data.TaggedError("BucketNotEmpty")<{
127 message: string;
128}> {}
129export class BucketConfigurationError extends Data.TaggedError(
130 "BucketConfigurationError",
131)<{ message: string }> {}
132
133const apiScope = (scope: ResolvedBranchScope) => ({
134 project_id: scope.projectId,
135 branch_id: scope.branchId,
136});
137const observe = Effect.fn(function* (scope: ResolvedBranchScope, name: string) {
138 const result = yield* Neon.listProjectBranchBuckets(apiScope(scope));
139 return result.buckets.find((bucket) => bucket.name === name);
140});
141
142export const bucketStorageClient = (bucket: BucketAttributes) =>
143 makeStorageClient(
144 {
145 endpoint: bucket.endpoint,
146 region: bucket.region,
147 accessKeyId: bucket.credential.tokenId,
148 secretAccessKey: bucket.credential.s3SecretAccessKey,
149 },
150 bucket.bucketName,
151 );
152
153const hydrate = Effect.fn(function* (
154 scope: ResolvedBranchScope,
155 bucket: Neon.Bucket,
156 credential: CredentialAttributes,
157) {
158 const storage = yield* Neon.getProjectBranchStorage(apiScope(scope));
159 const attrs = {
160 ...scope,
161 bucketName: bucket.name,
162 access: bucket.access_level,
163 endpoint: storage.s3_endpoint,
164 region: storage.region,
165 credential,
166 };
167 const client = yield* makeStorageClient(
168 {
169 endpoint: attrs.endpoint,
170 region: attrs.region,
171 accessKeyId: credential.tokenId,
172 secretAccessKey: credential.s3SecretAccessKey,
173 },
174 bucket.name,
175 );
176 const tags = tagRecord(
177 (yield* client.getTags().pipe(
178 Effect.retry({
179 while: (error) => error._tag === "NoSuchBucket",
180 schedule: Schedule.spaced("500 millis"),
181 times: 8,
182 }),
183 )).TagSet?.flatMap((tag) =>
184 tag.Key !== undefined && tag.Value !== undefined
185 ? [{ Key: tag.Key, Value: tag.Value }]
186 : [],
187 ),
188 );
189 const cors =
190 (yield* client.getCors().pipe(
191 Effect.retry({
192 while: (error) => error._tag === "NoSuchBucket",
193 schedule: Schedule.spaced("500 millis"),
194 times: 8,
195 }),
196 )).CORSRules ?? [];
197 return { ...attrs, tags, cors };
198});
199
200const credentialOf = Effect.fn(function* (
201 props: BucketProps,
202 output?: BucketAttributes,
203) {
204 const credential = props.credential ?? output?.credential;
205 if (!credential)
206 return yield* new BucketConfigurationError({
207 message: "Bucket management credential is missing",
208 });
209 return credential;
210});
211
212export const BucketProvider = () =>
213 Provider.succeed(Bucket, {
214 stables: ["projectId", "branchId", "bucketName", "endpoint", "region"],
215 diff: Effect.fn(function* ({ news, output }) {
216 if (!isResolved(news) || !output) return;
217 const scope = yield* resolveBranchScope(news);
218 if (
219 scope.projectId !== output.projectId ||
220 scope.branchId !== output.branchId ||
221 (news.name !== undefined && news.name !== output.bucketName)
222 )
223 return { action: "replace" };
224 }),
225 read: Effect.fn(function* ({ id, fqn, olds, output }) {
226 if (
227 !output &&
228 !olds?.project?.projectId &&
229 !(olds?.branch?.projectId && olds.branch.branchId)
230 )
231 return undefined;
232 const scope = output ?? (yield* resolveBranchScope(olds));
233 const name =
234 output?.bucketName ??
235 olds?.name ??
236 (yield* createPhysicalName({ id, maxLength: 63, lowercase: true }));
237 const bucket = yield* observe(scope, name);
238 if (!bucket) return undefined;
239 const credential = yield* credentialOf(olds, output);
240 const attrs = yield* hydrate(scope, bucket, credential);
241 const expected = {
242 ...(yield* createInternalTags(fqn)),
243 "alchemy::branch": scope.branchId,
244 };
245 return hasTags(expected, attrs.tags) ? attrs : Unowned(attrs);
246 }),
247 reconcile: Effect.fn(function* ({ id, fqn, news, output }) {
248 const scope = yield* resolveBranchScope(news);
249 const credential = yield* credentialOf(news, output);
250 yield* validateCredential(credential, scope, "storage:write");
251 yield* validateCredential(credential, scope, "storage:read");
252 const name =
253 news.name ??
254 output?.bucketName ??
255 (yield* createPhysicalName({ id, maxLength: 63, lowercase: true }));
256 let bucket = yield* observe(scope, name);
257 if (!bucket) {
258 bucket = (yield* Neon.createProjectBranchBucket({
259 ...apiScope(scope),
260 name,
261 access_level: news.access ?? "private",
262 })).bucket;
263 }
264 if (bucket.access_level !== (news.access ?? "private")) {
265 return yield* new UnsupportedBucketAccessUpdate({
266 message:
267 "Neon has no supported bucket access update operation; change access in the console or retain the existing access level",
268 });
269 }
270 const attrs = yield* hydrate(scope, bucket, credential);
271 const client = yield* bucketStorageClient(attrs);
272 const tags = {
273 ...news.tags,
274 ...(yield* createInternalTags(fqn)),
275 "alchemy::branch": scope.branchId,
276 };
277 const delta = diffTags(attrs.tags, tags);
278 if (delta.removed.length || delta.upsert.length)
279 yield* client.putTags(tags).pipe(
280 Effect.catchTag("NoSuchBucket", () =>
281 Effect.gen(function* () {
282 // Inherited buckets are readable before their branch-local configuration exists.
283 yield* Neon.createProjectBranchBucket({
284 ...apiScope(scope),
285 name,
286 access_level: news.access ?? "private",
287 });
288 yield* client.putTags(tags);
289 }),
290 ),
291 );
292 const cors = news.cors ?? [];
293 if (JSON.stringify(attrs.cors) !== JSON.stringify(cors)) {
294 if (cors.length) yield* client.putCors(cors);
295 else yield* client.deleteCors();
296 }
297 return yield* hydrate(scope, bucket, credential);
298 }),
299 delete: Effect.fn(function* ({ id, olds, output }) {
300 const scope = output ?? (yield* resolveBranchScope(olds));
301 const bucketName =
302 output?.bucketName ??
303 olds.name ??
304 (yield* createPhysicalName({ id, maxLength: 63, lowercase: true }));
305 const bucket = yield* observe(scope, bucketName);
306 if (!bucket) return;
307 const credential = yield* credentialOf(olds, output);
308 const storage = yield* Neon.getProjectBranchStorage(apiScope(scope));
309 const client = yield* makeStorageClient(
310 {
311 endpoint: storage.s3_endpoint,
312 region: storage.region,
313 accessKeyId: credential.tokenId,
314 secretAccessKey: credential.s3SecretAccessKey,
315 },
316 bucketName,
317 );
318 yield* client.deleteBucket().pipe(
319 Effect.catchTag("BucketNotEmpty", () =>
320 Effect.gen(function* () {
321 if (!olds.forceDestroy)
322 return yield* new BucketNotEmpty({
323 message:
324 "Bucket has objects or multipart uploads; set forceDestroy to explicitly empty it",
325 });
326 yield* emptyStorageBucket(client);
327 const objects = yield* client.list({ limit: 1 });
328 const uploads = yield* client.listMultipartUploads();
329 if (objects.Contents?.length || uploads.Uploads?.length) {
330 return yield* new BucketNotEmpty({
331 message:
332 "Bucket changed while being emptied; refusing deletion",
333 });
334 }
335 yield* client.deleteBucket();
336 }),
337 ),
338 );
339 }),
340 });