| 1 | import { projectBuckets } from "./GraphQL.ts"; |
| 2 | import { Query, type UnwrapPlan } from "@distilled.cloud/core/query"; |
| 3 | import { |
| 4 | Railway, |
| 5 | type Bucket as RailwayBucket, |
| 6 | type BucketS3CompatibleCredentials, |
| 7 | } from "@distilled.cloud/railway"; |
| 8 | import * as Data from "effect/Data"; |
| 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Redacted from "effect/Redacted"; |
| 11 | import * as Schedule from "effect/Schedule"; |
| 12 | import * as Stream from "effect/Stream"; |
| 13 | import { Unowned } from "../AdoptPolicy.ts"; |
| 14 | import { isResolved } from "../Diff.ts"; |
| 15 | import * as Provider from "../Provider.ts"; |
| 16 | import { Resource } from "../Resource.ts"; |
| 17 | import { |
| 18 | createRailwayName, |
| 19 | matchesAlchemyPhysicalName, |
| 20 | sanitizeRailwayName, |
| 21 | } from "./Metadata.ts"; |
| 22 | import { |
| 23 | ownedProjects, |
| 24 | projectEnvironmentIds, |
| 25 | type Project, |
| 26 | } from "./Project.ts"; |
| 27 | import type { Providers } from "./Providers.ts"; |
| 28 | import { withEnvironmentConfigLock } from "./transient.ts"; |
| 29 | |
| 30 | const 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 | }); |
| 37 | const 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 | }); |
| 47 | type CloudBucket = UnwrapPlan<ReturnType<typeof bucketFields>>; |
| 48 | type BucketS3CredentialsResultItem = UnwrapPlan< |
| 49 | ReturnType<typeof credentialsFields> |
| 50 | >; |
| 51 | |
| 52 | const readEnvironmentConfig = Query.fn((id: string, projectId: string) => ({ |
| 53 | config: Railway.environment({ id, projectId }).config, |
| 54 | })); |
| 55 | |
| 56 | const environmentPatchCommit = Query.fn( |
| 57 | (input: { |
| 58 | environmentId: string; |
| 59 | commitMessage: string; |
| 60 | patch: Record<string, unknown>; |
| 61 | }) => Railway.environmentPatchCommit(input), |
| 62 | ); |
| 63 | |
| 64 | const readBucketS3Credentials = Query.fn( |
| 65 | (input: { bucketId: string; environmentId: string; projectId: string }) => |
| 66 | Railway.bucketS3Credentials(input).pipe(Query.map(credentialsFields)), |
| 67 | ); |
| 68 | |
| 69 | const bucketCreate = Query.fn((input: { projectId: string; name: string }) => |
| 70 | bucketFields(Railway.bucketCreate({ input })), |
| 71 | ); |
| 72 | |
| 73 | const 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 | */ |
| 81 | type 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 | */ |
| 88 | export type BucketEnvironment = { |
| 89 | readonly environmentId: string; |
| 90 | }; |
| 91 | |
| 92 | /** Default Railway bucket region (US West). Changing region replaces. */ |
| 93 | export const DEFAULT_BUCKET_REGION = "sjc"; |
| 94 | |
| 95 | export 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 | |
| 123 | export 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 | |
| 162 | const 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 | |
| 184 | const 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 | */ |
| 304 | export 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 | |
| 312 | export class BucketNotCreated extends Data.TaggedError( |
| 313 | "Railway.BucketNotCreated", |
| 314 | )<{ |
| 315 | name: string; |
| 316 | projectId: string; |
| 317 | }> {} |
| 318 | |
| 319 | export class BucketProjectRequired extends Data.TaggedError( |
| 320 | "Railway.BucketProjectRequired", |
| 321 | )<{ |
| 322 | message: string; |
| 323 | }> {} |
| 324 | |
| 325 | export class BucketEnvironmentRequired extends Data.TaggedError( |
| 326 | "Railway.BucketEnvironmentRequired", |
| 327 | )<{ |
| 328 | message: string; |
| 329 | }> {} |
| 330 | |
| 331 | class BucketCredentialsPending extends Data.TaggedError( |
| 332 | "Railway.BucketCredentialsPending", |
| 333 | )<{ |
| 334 | bucketId: string; |
| 335 | }> {} |
| 336 | |
| 337 | class BucketDeployPending extends Data.TaggedError( |
| 338 | "Railway.BucketDeployPending", |
| 339 | )<{ |
| 340 | bucketId: string; |
| 341 | }> {} |
| 342 | |
| 343 | type BucketInstanceConfig = { |
| 344 | region?: string | null; |
| 345 | isDeleted?: boolean | null; |
| 346 | isCreated?: boolean | null; |
| 347 | }; |
| 348 | |
| 349 | type EnvironmentConfigShape = { |
| 350 | buckets?: Record<string, BucketInstanceConfig | null> | null; |
| 351 | }; |
| 352 | |
| 353 | const 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 | |
| 361 | const 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 | |
| 369 | const redact = ( |
| 370 | value: string | undefined, |
| 371 | ): Redacted.Redacted<string> | undefined => |
| 372 | value !== undefined && value.length > 0 ? Redacted.make(value) : undefined; |
| 373 | |
| 374 | const keepSecret = ( |
| 375 | next: Redacted.Redacted<string> | undefined, |
| 376 | previous: Redacted.Redacted<string> | undefined, |
| 377 | ): Redacted.Redacted<string> | undefined => next ?? previous; |
| 378 | |
| 379 | const 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 | |
| 394 | const 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 | |
| 403 | const isDeployed = (config: EnvironmentConfigShape, bucketId: string) => { |
| 404 | const row = instanceOf(config, bucketId); |
| 405 | return row !== undefined && row.isDeleted !== true; |
| 406 | }; |
| 407 | |
| 408 | const 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 | |
| 438 | const 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 | |
| 445 | const listProjectBuckets = (projectId: string) => |
| 446 | projectBuckets(projectId, bucketFields).pipe( |
| 447 | Effect.catchTag("RailwayNotFound", () => |
| 448 | Effect.succeed([] as CloudBucket[]), |
| 449 | ), |
| 450 | ); |
| 451 | |
| 452 | const findInProject = ( |
| 453 | projectId: string, |
| 454 | match: (bucket: CloudBucket) => boolean, |
| 455 | ) => |
| 456 | listProjectBuckets(projectId).pipe( |
| 457 | Effect.map((buckets) => buckets.find(match)), |
| 458 | ); |
| 459 | |
| 460 | const 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 | |
| 473 | const 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 | |
| 499 | const 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 | |
| 513 | const 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 | |
| 566 | const 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 | |
| 597 | const 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 | |
| 615 | export 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 | }); |