1import { projectBuckets } from "./GraphQL.ts";
2import { Query, type UnwrapPlan } from "@distilled.cloud/core/query";
3import {
4 Railway,
5 type Bucket as RailwayBucket,
6 type BucketS3CompatibleCredentials,
7} from "@distilled.cloud/railway";
8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";
10import * as Redacted from "effect/Redacted";
11import * as Schedule from "effect/Schedule";
12import * as Stream from "effect/Stream";
13import { Unowned } from "../AdoptPolicy.ts";
14import { isResolved } from "../Diff.ts";
15import * as Provider from "../Provider.ts";
16import { Resource } from "../Resource.ts";
17import {
18 createRailwayName,
19 matchesAlchemyPhysicalName,
20 sanitizeRailwayName,
21} from "./Metadata.ts";
22import {
23 ownedProjects,
24 projectEnvironmentIds,
25 type Project,
26} from "./Project.ts";
27import type { Providers } from "./Providers.ts";
28import { withEnvironmentConfigLock } from "./transient.ts";
29
30const bucketFields = <E>(bucket: Query<RailwayBucket, E>) => ({
31 id: bucket.id,
32 name: bucket.name,
33 projectId: bucket.projectId,
34 createdAt: bucket.createdAt,
35 updatedAt: bucket.updatedAt,
36});
37const credentialsFields = <E>(
38 credentials: Query<BucketS3CompatibleCredentials, E>,
39) => ({
40 accessKeyId: credentials.accessKeyId,
41 bucketName: credentials.bucketName,
42 endpoint: credentials.endpoint,
43 region: credentials.region,
44 secretAccessKey: credentials.secretAccessKey,
45 urlStyle: credentials.urlStyle,
46});
47type CloudBucket = UnwrapPlan<ReturnType<typeof bucketFields>>;
48type BucketS3CredentialsResultItem = UnwrapPlan<
49 ReturnType<typeof credentialsFields>
50>;
51
52const readEnvironmentConfig = Query.fn((id: string, projectId: string) => ({
53 config: Railway.environment({ id, projectId }).config,
54}));
55
56const environmentPatchCommit = Query.fn(
57 (input: {
58 environmentId: string;
59 commitMessage: string;
60 patch: Record<string, unknown>;
61 }) => Railway.environmentPatchCommit(input),
62);
63
64const readBucketS3Credentials = Query.fn(
65 (input: { bucketId: string; environmentId: string; projectId: string }) =>
66 Railway.bucketS3Credentials(input).pipe(Query.map(credentialsFields)),
67);
68
69const bucketCreate = Query.fn((input: { projectId: string; name: string }) =>
70 bucketFields(Railway.bucketCreate({ input })),
71);
72
73const bucketUpdate = Query.fn((id: string, name: string) =>
74 bucketFields(Railway.bucketUpdate({ id, input: { name } })),
75);
76
77/**
78 * A resource-valued prop: the resource itself, or an Effect that produces
79 * it (so `yield* Project(...)` and `Project(...)` both type-check).
80 */
81type Ref<T> = T | Effect.Effect<T, never, Providers>;
82
83/**
84 * Environment identity a Bucket instance is deployed into. Accepts a
85 * `Railway.Project` (its primary environment), a `Railway.Environment`,
86 * or an `{ environmentId }` stub.
87 */
88export type BucketEnvironment = {
89 readonly environmentId: string;
90};
91
92/** Default Railway bucket region (US West). Changing region replaces. */
93export const DEFAULT_BUCKET_REGION = "sjc";
94
95export interface BucketProps {
96 /**
97 * Parent Railway Project. Changing it replaces the Bucket.
98 */
99 project: Ref<Project>;
100 /**
101 * Environment to deploy the bucket instance into. Accepts a
102 * `Railway.Project` (primary environment), a `Railway.Environment`, or
103 * `{ environmentId }`. Defaults to the project's primary environment.
104 * Changing it replaces the Bucket.
105 */
106 environment?: Ref<BucketEnvironment>;
107 /**
108 * Display name. Unique per project. If omitted, a unique name is
109 * generated from the stack, stage and logical ID. Changing it updates
110 * in place via `bucketUpdate`. The S3 API name is this plus a hash
111 * (`s3BucketName`).
112 */
113 name?: string;
114 /**
115 * Bucket region (`sjc`, `iad`, `ams`, `sin`). Cannot be changed after
116 * create.
117 *
118 * @default "sjc"
119 */
120 region?: string;
121}
122
123export type Bucket = Resource<
124 "Railway.Bucket",
125 BucketProps,
126 {
127 /** Railway bucket id. */
128 bucketId: string;
129 /** Display name (unique per project). */
130 name: string;
131 /** Parent Railway project id. */
132 projectId: string;
133 /** Environment the instance is deployed in. */
134 environmentId: string;
135 /** Railway region code (`sjc`, `iad`, `ams`, `sin`). */
136 region: string | undefined;
137 /** Globally unique S3 bucket name (`{name}-{hash}`). */
138 s3BucketName: string | undefined;
139 /** S3 API endpoint (`https://storage.railway.app`). */
140 endpoint: string | undefined;
141 /** S3 URL style (`virtual` or `path`). */
142 urlStyle: string | undefined;
143 /** S3 region reported by credentials (often `auto`). */
144 s3Region: string | undefined;
145 /**
146 * S3 access key id from `bucketS3Credentials`. Never logged.
147 */
148 accessKeyId: Redacted.Redacted<string> | undefined;
149 /**
150 * S3 secret access key from `bucketS3Credentials`. Never logged.
151 */
152 secretAccessKey: Redacted.Redacted<string> | undefined;
153 /** RFC3339 creation timestamp. */
154 createdAt: string;
155 /** RFC3339 update timestamp. */
156 updatedAt: string;
157 },
158 never,
159 Providers
160>;
161
162const resolveBucketProps = (
163 props: BucketProps | Effect.Effect<BucketProps, never, Providers>,
164): Effect.Effect<BucketProps, never, Providers> =>
165 Effect.gen(function* () {
166 const resolved = Effect.isEffect(props) ? yield* props : props;
167 if (globalThis.__ALCHEMY_RUNTIME__) return resolved;
168 const project = Effect.isEffect(resolved.project)
169 ? yield* resolved.project as Effect.Effect<Project, never, Providers>
170 : resolved.project;
171 const environment =
172 resolved.environment === undefined
173 ? undefined
174 : Effect.isEffect(resolved.environment)
175 ? yield* resolved.environment as Effect.Effect<
176 BucketEnvironment,
177 never,
178 Providers
179 >
180 : resolved.environment;
181 return { ...resolved, project, environment };
182 });
183
184const BucketResource = Resource<Bucket>("Railway.Bucket");
185
186/**
187 * A Railway.Bucket is S3-compatible object storage in a Project. Each
188 * environment gets its own isolated instance and credentials. Bind
189 * {@link PutObject} / {@link GetObject} (and the other S3 object ops) in
190 * Service init — Alchemy injects the endpoint and credentials.
191 *
192 * Railway has no labels. Ownership is stamped into the display name via
193 * `createPhysicalName`. `name` updates in place. Changing `project`,
194 * `environment`, or `region` replaces the Bucket.
195 *
196 * @see https://docs.railway.com/storage-buckets
197 *
198 * ### Create a Bucket
199 * Pass a Project. Alchemy generates a unique name and deploys to the
200 * project's primary environment in `sjc`.
201 *
202 * **Example:** Generated name
203 * ```typescript
204 * const site = yield* Railway.Project("Site");
205 * const data = yield* Railway.Bucket("Data", { project: site });
206 * ```
207 *
208 * :::caution[Changing `project` replaces the Bucket]
209 * The Bucket is created in the new Project. The old Bucket is deleted.
210 * :::
211 *
212 * ### A stable name
213 * Pass `name` when you need a stable display name. The S3 API name is
214 * this plus a short hash (`s3BucketName`).
215 *
216 * **Example:** Explicit name
217 * ```typescript
218 * const data = yield* Railway.Bucket("Data", {
219 * project: site,
220 * name: "uploads",
221 * });
222 * ```
223 *
224 * ### Region
225 * Omit `region` to use `sjc`. Changing it replaces the Bucket.
226 *
227 * **Example:** Pin a region
228 * ```typescript
229 * const data = yield* Railway.Bucket("Data", {
230 * project: site,
231 * region: "iad",
232 * });
233 * ```
234 *
235 * :::caution[Changing `region` replaces the Bucket]
236 * Railway cannot move a bucket. A new Bucket is created, then the old
237 * one is deleted.
238 * :::
239 *
240 * ### Environment
241 * Defaults to the Project's primary environment. Pass a
242 * `Railway.Environment` (or `{ environmentId }`) to target another one.
243 *
244 * **Example:** Extra environment
245 * ```typescript
246 * const staging = yield* Railway.Environment("Staging", { project: site });
247 * const data = yield* Railway.Bucket("StagingData", {
248 * project: site,
249 * environment: staging,
250 * });
251 * ```
252 *
253 * :::caution[Changing `environment` replaces the Bucket]
254 * The Bucket instance is created in the new environment. The old
255 * instance is deleted.
256 * :::
257 *
258 * ### Put and get objects
259 * Railway buckets speak the S3 API. Bind {@link PutObject} /
260 * {@link GetObject} in Service init.
261 *
262 * **Example:** Put an object from a Service
263 * ```typescript
264 * import * as HttpServerResponse from "effect/http/HttpServerResponse";
265 *
266 * export default class Api extends Railway.Service<Api>()(
267 * "Api",
268 * { project: Site, main: import.meta.url },
269 * Effect.gen(function* () {
270 * const putObject = yield* Railway.PutObject(Data);
271 * const getObject = yield* Railway.GetObject(Data);
272 *
273 * return {
274 * fetch: Effect.gen(function* () {
275 * yield* putObject({
276 * Key: "hello.txt",
277 * Body: "hello",
278 * ContentType: "text/plain",
279 * });
280 * const obj = yield* getObject({ Key: "hello.txt" });
281 * return HttpServerResponse.json({ ok: obj.ETag !== undefined });
282 * }),
283 * };
284 * }).pipe(Effect.provide([Railway.PutObjectHttp, Railway.GetObjectHttp])),
285 * ) {}
286 * ```
287 *
288 * ### Module-scope declarations
289 * Declare the Project once. Pass it into every child. Resource-valued
290 * props accept the resource or an Effect producing it.
291 *
292 * **Example:** Module-scope Bucket
293 * ```typescript
294 * // src/data.ts
295 * import * as Railway from "alchemy/Railway";
296 *
297 * export const Site = Railway.Project("Site");
298 * export const Data = Railway.Bucket("Data", { project: Site });
299 * ```
300 *
301 * @resource
302 * @product Bucket
303 */
304export const Bucket: typeof BucketResource = Object.assign(
305 (
306 id: string,
307 props: BucketProps | Effect.Effect<BucketProps, never, Providers>,
308 ) => BucketResource(id, resolveBucketProps(props)),
309 BucketResource,
310);
311
312export class BucketNotCreated extends Data.TaggedError(
313 "Railway.BucketNotCreated",
314)<{
315 name: string;
316 projectId: string;
317}> {}
318
319export class BucketProjectRequired extends Data.TaggedError(
320 "Railway.BucketProjectRequired",
321)<{
322 message: string;
323}> {}
324
325export class BucketEnvironmentRequired extends Data.TaggedError(
326 "Railway.BucketEnvironmentRequired",
327)<{
328 message: string;
329}> {}
330
331class BucketCredentialsPending extends Data.TaggedError(
332 "Railway.BucketCredentialsPending",
333)<{
334 bucketId: string;
335}> {}
336
337class BucketDeployPending extends Data.TaggedError(
338 "Railway.BucketDeployPending",
339)<{
340 bucketId: string;
341}> {}
342
343type BucketInstanceConfig = {
344 region?: string | null;
345 isDeleted?: boolean | null;
346 isCreated?: boolean | null;
347};
348
349type EnvironmentConfigShape = {
350 buckets?: Record<string, BucketInstanceConfig | null> | null;
351};
352
353const projectIdOf = (value: unknown): string | undefined => {
354 if (value === null || typeof value !== "object") return undefined;
355 const rec = value as { projectId?: unknown };
356 return typeof rec.projectId === "string" && rec.projectId.length > 0
357 ? rec.projectId
358 : undefined;
359};
360
361const environmentIdOf = (value: unknown): string | undefined => {
362 if (value === null || typeof value !== "object") return undefined;
363 const rec = value as { environmentId?: unknown };
364 return typeof rec.environmentId === "string" && rec.environmentId.length > 0
365 ? rec.environmentId
366 : undefined;
367};
368
369const redact = (
370 value: string | undefined,
371): Redacted.Redacted<string> | undefined =>
372 value !== undefined && value.length > 0 ? Redacted.make(value) : undefined;
373
374const keepSecret = (
375 next: Redacted.Redacted<string> | undefined,
376 previous: Redacted.Redacted<string> | undefined,
377): Redacted.Redacted<string> | undefined => next ?? previous;
378
379const parseEnvironmentConfig = (value: unknown): EnvironmentConfigShape => {
380 if (value === null || typeof value !== "object" || Array.isArray(value)) {
381 return {};
382 }
383 const buckets = (value as { buckets?: unknown }).buckets;
384 if (
385 buckets === null ||
386 typeof buckets !== "object" ||
387 Array.isArray(buckets)
388 ) {
389 return {};
390 }
391 return { buckets: buckets as Record<string, BucketInstanceConfig | null> };
392};
393
394const instanceOf = (
395 config: EnvironmentConfigShape,
396 bucketId: string,
397): BucketInstanceConfig | undefined => {
398 const row = config.buckets?.[bucketId];
399 if (row === undefined || row === null) return undefined;
400 return row;
401};
402
403const isDeployed = (config: EnvironmentConfigShape, bucketId: string) => {
404 const row = instanceOf(config, bucketId);
405 return row !== undefined && row.isDeleted !== true;
406};
407
408const toAttrs = (
409 bucket: CloudBucket,
410 extra: {
411 environmentId: string;
412 region?: string;
413 credentials?: BucketS3CredentialsResultItem;
414 previous?: Bucket["Attributes"];
415 },
416): Bucket["Attributes"] => ({
417 bucketId: bucket.id,
418 name: bucket.name,
419 projectId: bucket.projectId,
420 environmentId: extra.environmentId,
421 region: extra.region ?? extra.previous?.region,
422 s3BucketName: extra.credentials?.bucketName ?? extra.previous?.s3BucketName,
423 endpoint: extra.credentials?.endpoint ?? extra.previous?.endpoint,
424 urlStyle: extra.credentials?.urlStyle ?? extra.previous?.urlStyle,
425 s3Region: extra.credentials?.region ?? extra.previous?.s3Region,
426 accessKeyId: keepSecret(
427 redact(extra.credentials?.accessKeyId),
428 extra.previous?.accessKeyId,
429 ),
430 secretAccessKey: keepSecret(
431 redact(extra.credentials?.secretAccessKey),
432 extra.previous?.secretAccessKey,
433 ),
434 createdAt: bucket.createdAt,
435 updatedAt: bucket.updatedAt,
436});
437
438const resolveName = (id: string, name: string | undefined, existing?: string) =>
439 Effect.gen(function* () {
440 if (name !== undefined) return sanitizeRailwayName(name);
441 if (existing !== undefined) return existing;
442 return yield* createRailwayName(id);
443 });
444
445const listProjectBuckets = (projectId: string) =>
446 projectBuckets(projectId, bucketFields).pipe(
447 Effect.catchTag("RailwayNotFound", () =>
448 Effect.succeed([] as CloudBucket[]),
449 ),
450 );
451
452const findInProject = (
453 projectId: string,
454 match: (bucket: CloudBucket) => boolean,
455) =>
456 listProjectBuckets(projectId).pipe(
457 Effect.map((buckets) => buckets.find(match)),
458 );
459
460const getEnvironmentConfig = (environmentId: string, projectId: string) =>
461 readEnvironmentConfig(environmentId, projectId).pipe(
462 Effect.map((env) => parseEnvironmentConfig(env.config)),
463 Effect.retry({
464 while: (e) => e._tag === "RailwayNotFound",
465 times: 8,
466 schedule: Schedule.spaced("1 second"),
467 }),
468 Effect.catchTag("RailwayNotFound", () =>
469 Effect.succeed({} as EnvironmentConfigShape),
470 ),
471 );
472
473const environmentIdsOf = (project: {
474 projectId: string;
475 environmentId: string;
476}) =>
477 Query.items(
478 Railway.environments({ projectId: project.projectId, first: 50 }).pipe(
479 Query.map((env) => ({ id: env.id, deletedAt: env.deletedAt })),
480 ),
481 ).pipe(
482 Stream.filter((env) => env.deletedAt == null),
483 Stream.map((env) => env.id),
484 Stream.runCollect,
485 Effect.map((ids) => {
486 const set = new Set(Array.from(ids));
487 if (project.environmentId.length > 0) {
488 set.add(project.environmentId);
489 }
490 return Array.from(set);
491 }),
492 Effect.catchTag("RailwayNotFound", () =>
493 Effect.succeed(
494 project.environmentId.length > 0 ? [project.environmentId] : [],
495 ),
496 ),
497 );
498
499const commitBucketPatch = (input: {
500 environmentId: string;
501 commitMessage: string;
502 patch: Record<string, unknown>;
503}) =>
504 withEnvironmentConfigLock(
505 input.environmentId,
506 environmentPatchCommit({
507 environmentId: input.environmentId,
508 commitMessage: input.commitMessage,
509 patch: input.patch,
510 }),
511 );
512
513const ensureDeployed = Effect.fn(function* (input: {
514 bucketId: string;
515 environmentId: string;
516 projectId: string;
517 region: string;
518 name: string;
519}) {
520 const config = yield* getEnvironmentConfig(
521 input.environmentId,
522 input.projectId,
523 );
524 if (isDeployed(config, input.bucketId)) {
525 return instanceOf(config, input.bucketId);
526 }
527 yield* commitBucketPatch({
528 environmentId: input.environmentId,
529 commitMessage: `Alchemy: create bucket ${input.name}`,
530 patch: {
531 buckets: {
532 [input.bucketId]: {
533 region: input.region,
534 isCreated: true,
535 },
536 },
537 },
538 }).pipe(
539 Effect.retry({
540 while: (e) => e._tag === "RailwayNotFound",
541 times: 8,
542 schedule: Schedule.spaced("1 second"),
543 }),
544 );
545 const synced = yield* getEnvironmentConfig(
546 input.environmentId,
547 input.projectId,
548 ).pipe(
549 Effect.flatMap((next) => {
550 if (!isDeployed(next, input.bucketId)) {
551 return Effect.fail(
552 new BucketDeployPending({ bucketId: input.bucketId }),
553 );
554 }
555 return Effect.succeed(instanceOf(next, input.bucketId));
556 }),
557 Effect.retry({
558 while: (e) => e._tag === "Railway.BucketDeployPending",
559 times: 8,
560 schedule: Schedule.spaced("1 second"),
561 }),
562 );
563 return synced;
564});
565
566const fetchCredentials = (input: {
567 bucketId: string;
568 environmentId: string;
569 projectId: string;
570}) =>
571 readBucketS3Credentials({
572 bucketId: input.bucketId,
573 environmentId: input.environmentId,
574 projectId: input.projectId,
575 }).pipe(
576 Effect.flatMap((items) => {
577 const first = items[0];
578 if (first === undefined) {
579 return Effect.fail(
580 new BucketCredentialsPending({ bucketId: input.bucketId }),
581 );
582 }
583 return Effect.succeed(first);
584 }),
585 Effect.catchTag(
586 ["RailwayNotFound", "RailwayBucketCredentialsNotReady"],
587 () =>
588 Effect.fail(new BucketCredentialsPending({ bucketId: input.bucketId })),
589 ),
590 Effect.retry({
591 while: (e) => e._tag === "Railway.BucketCredentialsPending",
592 times: 4,
593 schedule: Schedule.spaced("2 seconds"),
594 }),
595 );
596
597const waitUntilGone = (input: {
598 bucketId: string;
599 projectId: string;
600 environmentId: string;
601}) =>
602 getEnvironmentConfig(input.environmentId, input.projectId).pipe(
603 Effect.flatMap((config) =>
604 !isDeployed(config, input.bucketId)
605 ? Effect.void
606 : Effect.fail(new BucketDeployPending({ bucketId: input.bucketId })),
607 ),
608 Effect.retry({
609 schedule: Schedule.spaced("1 second"),
610 while: (error) => error._tag === "Railway.BucketDeployPending",
611 times: 8,
612 }),
613 );
614
615export const BucketProvider = () =>
616 Provider.succeed(Bucket, {
617 stables: ["bucketId", "projectId", "environmentId", "createdAt"],
618 nuke: { dependsOn: ["Railway.Project"] },
619
620 diff: Effect.fn(function* ({ news, output }) {
621 if (news === undefined || !isResolved(news)) return undefined;
622 if (output === undefined) return undefined;
623 const nextProject = projectIdOf(news.project);
624 const projectChanged =
625 nextProject !== undefined && nextProject !== output.projectId;
626 const nextEnv = environmentIdOf(news.environment);
627 const environmentChanged =
628 nextEnv !== undefined && nextEnv !== output.environmentId;
629 const desiredRegion = news.region ?? DEFAULT_BUCKET_REGION;
630 const regionChanged =
631 output.region !== undefined && desiredRegion !== output.region;
632 if (projectChanged || environmentChanged || regionChanged) {
633 return { action: "replace" as const };
634 }
635 return undefined;
636 }),
637
638 read: Effect.fn(function* ({ id, olds, output }) {
639 const projectId =
640 output?.projectId ??
641 (olds !== undefined ? projectIdOf(olds.project) : undefined);
642 const environmentId =
643 output?.environmentId ??
644 (olds !== undefined
645 ? (environmentIdOf(olds.environment) ?? environmentIdOf(olds.project))
646 : undefined);
647 const name = yield* resolveName(id, olds?.name, output?.name);
648 if (projectId === undefined) return undefined;
649
650 const found =
651 (output?.bucketId !== undefined && output.bucketId.length > 0
652 ? yield* findInProject(
653 projectId,
654 (bucket) => bucket.id === output.bucketId,
655 )
656 : undefined) ??
657 (yield* findInProject(projectId, (bucket) => bucket.name === name));
658 if (found === undefined) return undefined;
659
660 const envId = environmentId ?? output?.environmentId ?? "";
661 const config =
662 envId.length > 0
663 ? yield* getEnvironmentConfig(envId, projectId)
664 : ({} as EnvironmentConfigShape);
665 if (envId.length > 0 && !isDeployed(config, found.id)) {
666 return undefined;
667 }
668 const attrs = toAttrs(found, {
669 environmentId: envId,
670 region: instanceOf(config, found.id)?.region ?? output?.region,
671 previous: output,
672 });
673 if (output !== undefined) return attrs;
674 return matchesAlchemyPhysicalName(found.name) ? attrs : Unowned(attrs);
675 }),
676
677 list: Effect.fn(function* () {
678 const projects = yield* ownedProjects();
679 const rows = yield* Effect.forEach(projects, (project) =>
680 Effect.gen(function* () {
681 const envIds = yield* projectEnvironmentIds(project);
682 const buckets = yield* listProjectBuckets(project.projectId);
683 const nested = yield* Effect.forEach(envIds, (environmentId) =>
684 getEnvironmentConfig(environmentId, project.projectId).pipe(
685 Effect.map((config) =>
686 buckets
687 .filter(
688 (bucket) =>
689 matchesAlchemyPhysicalName(bucket.name) &&
690 isDeployed(config, bucket.id),
691 )
692 .map((bucket) =>
693 toAttrs(bucket, {
694 environmentId,
695 region:
696 instanceOf(config, bucket.id)?.region ?? undefined,
697 }),
698 ),
699 ),
700 ),
701 );
702 return nested.flat();
703 }),
704 );
705 const seen = new Set<string>();
706 const unique: Bucket["Attributes"][] = [];
707 for (const row of rows.flat()) {
708 const key = `${row.bucketId}:${row.environmentId}`;
709 if (seen.has(key)) continue;
710 seen.add(key);
711 unique.push(row);
712 }
713 return unique;
714 }),
715
716 reconcile: Effect.fn(function* ({ id, news, output }) {
717 const props = news ?? ({} as BucketProps);
718 const projectId = projectIdOf(props.project) ?? output?.projectId;
719 if (projectId === undefined) {
720 return yield* new BucketProjectRequired({
721 message: "Bucket requires a resolved Railway.Project",
722 });
723 }
724 const environmentId =
725 environmentIdOf(props.environment) ??
726 environmentIdOf(props.project) ??
727 output?.environmentId;
728 if (environmentId === undefined) {
729 return yield* new BucketEnvironmentRequired({
730 message:
731 "Bucket requires a Railway environment (pass environment or a Project with environmentId)",
732 });
733 }
734 const name = yield* resolveName(id, props.name, output?.name);
735 const region = props.region ?? output?.region ?? DEFAULT_BUCKET_REGION;
736
737 let current =
738 output?.bucketId !== undefined && output.bucketId.length > 0
739 ? yield* findInProject(
740 projectId,
741 (bucket) => bucket.id === output.bucketId,
742 )
743 : undefined;
744 if (current === undefined) {
745 current = yield* findInProject(
746 projectId,
747 (bucket) => bucket.name === name,
748 );
749 }
750
751 if (current === undefined) {
752 const created = yield* bucketCreate({ projectId, name }).pipe(
753 Effect.retry({
754 while: (e) => e._tag === "RailwayNotFound",
755 times: 8,
756 schedule: Schedule.spaced("1 second"),
757 }),
758 Effect.catchTag("RailwayValidationError", () =>
759 Effect.succeed(undefined),
760 ),
761 );
762 current =
763 created ??
764 (yield* findInProject(projectId, (bucket) => bucket.name === name));
765 }
766
767 if (current === undefined) {
768 return yield* new BucketNotCreated({ name, projectId });
769 }
770
771 if (current.name !== name) {
772 current = yield* bucketUpdate(current.id, name);
773 }
774
775 const deployed = yield* ensureDeployed({
776 bucketId: current.id,
777 environmentId,
778 projectId,
779 region,
780 name,
781 });
782
783 const credentials = yield* fetchCredentials({
784 bucketId: current.id,
785 environmentId,
786 projectId,
787 });
788
789 return toAttrs(current, {
790 environmentId,
791 region: deployed?.region ?? region,
792 credentials,
793 previous: output,
794 });
795 }),
796
797 delete: Effect.fn(function* ({ output }) {
798 const bucketId = output.bucketId;
799 const environmentId = output.environmentId;
800 const projectId = output.projectId;
801 if (bucketId.length === 0 || environmentId.length === 0) return;
802 yield* commitBucketPatch({
803 environmentId,
804 commitMessage: `Alchemy: delete bucket ${output.name}`,
805 patch: {
806 buckets: {
807 [bucketId]: { isDeleted: true },
808 },
809 },
810 }).pipe(Effect.catchTag("RailwayNotFound", () => Effect.void));
811 if (projectId.length > 0) {
812 yield* waitUntilGone({ bucketId, projectId, environmentId });
813 }
814 }),
815 });