| 1 | import * as Context from "effect/Context"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; |
| 6 | import * as Predicate from "effect/Predicate"; |
| 7 | import type * as Stream from "effect/Stream"; |
| 8 | import type { Artifacts } from "./Artifacts.ts"; |
| 9 | import type { ScopedPlanStatusSession } from "./Report.ts"; |
| 10 | import type { Diff } from "./Diff.ts"; |
| 11 | import type { Input } from "./Input.ts"; |
| 12 | import type { InstanceId } from "./InstanceId.ts"; |
| 13 | import type { State } from "./State/State.ts"; |
| 14 | import type { Platform } from "./Platform.ts"; |
| 15 | import { defaultProviderMode, type ProviderMode } from "./ProviderMode.ts"; |
| 16 | import type { |
| 17 | ResourceBinding, |
| 18 | ResourceClass, |
| 19 | ResourceClassLike, |
| 20 | ResourceLike, |
| 21 | } from "./Resource.ts"; |
| 22 | |
| 23 | export interface Provider< |
| 24 | R extends ResourceLike = ResourceLike, |
| 25 | > extends Effect.Effect<ProviderService<R>, never, Provider<R>> { |
| 26 | asEffect: () => Effect.Effect<ProviderService<R>, never, Provider<R>>; |
| 27 | [Symbol.iterator]: () => Effect.EffectIterator<Provider<R>>; |
| 28 | of: < |
| 29 | ReadReq = never, |
| 30 | DiffReq = never, |
| 31 | PrecreateReq = never, |
| 32 | ReconcileReq = never, |
| 33 | DeleteReq = never, |
| 34 | TailReq = never, |
| 35 | LogsReq = never, |
| 36 | ListReq = never, |
| 37 | >( |
| 38 | service: Omit< |
| 39 | ProviderService< |
| 40 | R, |
| 41 | ReadReq, |
| 42 | DiffReq, |
| 43 | PrecreateReq, |
| 44 | ReconcileReq, |
| 45 | DeleteReq, |
| 46 | TailReq, |
| 47 | LogsReq, |
| 48 | ListReq |
| 49 | >, |
| 50 | "Type" |
| 51 | >, |
| 52 | ) => ProviderService< |
| 53 | R, |
| 54 | ReadReq, |
| 55 | DiffReq, |
| 56 | PrecreateReq, |
| 57 | ReconcileReq, |
| 58 | DeleteReq, |
| 59 | TailReq, |
| 60 | LogsReq, |
| 61 | ListReq |
| 62 | >; |
| 63 | } |
| 64 | |
| 65 | // Supplied by the engine to every lifecycle operation (the stack's `state` |
| 66 | // layer is merged into the context lifecycle ops run under), so they are not |
| 67 | // requirements of the provider layer itself. |
| 68 | type LifecycleServices = InstanceId | Artifacts | State; |
| 69 | |
| 70 | export const Provider = <R extends ResourceLike>( |
| 71 | type: R["Type"], |
| 72 | ): Provider<R> => |
| 73 | Context.Service<Provider<R>, ProviderService<R>>()(type) as any; |
| 74 | |
| 75 | type BindingData<Res extends ResourceLike> = [Res] extends [ |
| 76 | { Binding: infer B }, |
| 77 | ] |
| 78 | ? ResourceBinding<B>[] |
| 79 | : any[]; |
| 80 | |
| 81 | type Props<Res extends ResourceLike> = keyof Res["Props"] extends never |
| 82 | ? Res["Props"] | undefined |
| 83 | : Res["Props"]; |
| 84 | |
| 85 | export interface LogLine { |
| 86 | timestamp: Date; |
| 87 | message: string; |
| 88 | } |
| 89 | |
| 90 | export interface LogsInput { |
| 91 | since?: Date; |
| 92 | limit?: number; |
| 93 | } |
| 94 | |
| 95 | export interface ProviderService< |
| 96 | Res extends ResourceLike = ResourceLike, |
| 97 | ReadReq = never, |
| 98 | DiffReq = never, |
| 99 | PrecreateReq = never, |
| 100 | ReconcileReq = never, |
| 101 | DeleteReq = never, |
| 102 | TailReq = never, |
| 103 | LogsReq = never, |
| 104 | ListReq = never, |
| 105 | > { |
| 106 | /** |
| 107 | * The version of the provider. |
| 108 | * |
| 109 | * @default 0 |
| 110 | */ |
| 111 | version?: number; |
| 112 | /** |
| 113 | * Legacy type names this provider also answers to. Copied from the |
| 114 | * resource class's `aliases` option by {@link succeed}/{@link effect} so |
| 115 | * state persisted under a pre-rename type (e.g. `"Cloudflare.Queue"` |
| 116 | * before the `"Cloudflare.Queues.Queue"` rename) still resolves to this |
| 117 | * provider via {@link tryFindProviderByType}. |
| 118 | */ |
| 119 | aliases?: readonly string[]; |
| 120 | /** |
| 121 | * The {@link ProviderMode} this service operates in. |
| 122 | * |
| 123 | * Set by `ProviderLayer.dual` registrations: the delegating service |
| 124 | * registered in context carries the run's default mode, and each built |
| 125 | * variant carries its concrete mode. Mode-agnostic providers (plain |
| 126 | * {@link succeed}/{@link effect} registrations) leave it `undefined` — |
| 127 | * their resources never persist a mode and never replace on a mode |
| 128 | * switch. |
| 129 | */ |
| 130 | mode?: ProviderMode; |
| 131 | /** |
| 132 | * Lazily-built mode variants, present only for `ProviderLayer.dual` |
| 133 | * registrations. Each effect builds (once, memoized, into the stack's |
| 134 | * layer scope) the provider service for that mode — including any |
| 135 | * mode-specific dependency layers — and returns it. The engine resolves |
| 136 | * a concrete variant via {@link findProviderByType} with a mode so that |
| 137 | * e.g. a local-mode state row is always deleted by the local provider, |
| 138 | * even during a live deploy (and vice versa). |
| 139 | */ |
| 140 | modes?: { readonly [M in ProviderMode]: Effect.Effect<ProviderService<Res>> }; |
| 141 | /** |
| 142 | * Data-plane override context for this provider's **local** mode: a layer |
| 143 | * of cloud environment services (endpoint, credentials, region, |
| 144 | * environment) that points data-plane API calls at the local emulator — |
| 145 | * the same override the local lifecycle variant runs under (e.g. AWS's |
| 146 | * `flociServices()`). |
| 147 | * |
| 148 | * Set by `ProviderLayer.dual` registrations that pass `dataPlane`. |
| 149 | * Consumed by `Binding.Service`'s client wrapper (via |
| 150 | * {@link resolveLocalDataPlane}) so deploy-time binding invocations — |
| 151 | * inside an `Action` body or a plan-time `execute` — target whatever |
| 152 | * data plane the bound resource actually lives on: a local-mode resource |
| 153 | * routes to the emulator while an `Alchemy.remote()` resource keeps |
| 154 | * hitting the real cloud. Pass a module-memoized layer reference. |
| 155 | */ |
| 156 | localDataPlane?: () => Layer.Layer<any, any, never>; |
| 157 | /** |
| 158 | * Data-plane override context for this provider's **live** mode: the |
| 159 | * cloud environment captured at provider registration (credentials, |
| 160 | * region, endpoint). In an `alchemy dev` run the ambient environment IS |
| 161 | * the emulator, so an unwrapped live client would hit floci. Stamp this |
| 162 | * so `Alchemy.remote()` binding calls are provided the live chain |
| 163 | * closest, the inverse of {@link localDataPlane}. |
| 164 | */ |
| 165 | liveDataPlane?: () => Layer.Layer<any, any, never>; |
| 166 | /** |
| 167 | * Account-wide teardown (`alchemy unsafe nuke`) behaviour. Providers whose |
| 168 | * resources can't meaningfully be deleted opt out here so nuke doesn't |
| 169 | * report an endless "deleted but still there" loop. `read`/import are |
| 170 | * unaffected. |
| 171 | */ |
| 172 | nuke?: { |
| 173 | /** |
| 174 | * The resource is an account/zone **singleton setting** — always-present |
| 175 | * configuration (e.g. Bot Management, Email Routing, a zone's SSL |
| 176 | * settings) whose `delete` only *resets* it to defaults rather than |
| 177 | * removing a discrete resource. Skipped by nuke, since `list` always |
| 178 | * re-enumerates it and "deleting" it just resets config the operator |
| 179 | * never created. |
| 180 | */ |
| 181 | singleton?: boolean; |
| 182 | /** |
| 183 | * The resource is skipped by nuke for any other reason — typically because |
| 184 | * it can never actually be deleted (no delete API, like RealtimeKit Apps; |
| 185 | * or a registration that is never released, like Registrar Domains). Unlike |
| 186 | * {@link singleton}, these are ordinary multi-instance resources. |
| 187 | */ |
| 188 | skip?: boolean; |
| 189 | /** |
| 190 | * Provider IDs (picomatch globs, e.g. `"AWS.IAM.Role"` or `"AWS.EC2.*"`) |
| 191 | * whose resources this type's **cloud-side teardown** consumes — they |
| 192 | * must still exist while this type is deleting. `alchemy unsafe nuke` |
| 193 | * topologically orders deletion so every resource of this type is fully |
| 194 | * gone before any matching type starts deleting; if this type's deletes |
| 195 | * fail, the matching types are held back rather than deleted out from |
| 196 | * under an in-flight teardown. |
| 197 | * |
| 198 | * Example: SageMaker HyperPod deletes node ENIs by assuming the |
| 199 | * instance group's execution role — deleting the role (or the VPC) |
| 200 | * mid-teardown wedges the cluster in `Deleting` permanently, so |
| 201 | * `AWS.SageMaker.Cluster` declares `dependsOn: ["AWS.IAM.Role", ...]`. |
| 202 | * |
| 203 | * The constraint only takes effect when resources of this type are |
| 204 | * actually present in the nuke target set; declaration cycles collapse |
| 205 | * into a single concurrent wave (with a logged warning) instead of |
| 206 | * failing. |
| 207 | */ |
| 208 | dependsOn?: readonly string[]; |
| 209 | }; |
| 210 | /** |
| 211 | * Enumerates every existing resource of this type in the ambient scope |
| 212 | * (account / region / zone resolved from the environment services), and |
| 213 | * returns the full {@link ProviderService} `Attributes` shape for each — |
| 214 | * the same shape {@link read} produces, so each item is directly usable |
| 215 | * with {@link delete} without a follow-up read. |
| 216 | * |
| 217 | * This powers account-wide operations such as `alchemy nuke`, which lists |
| 218 | * everything and then deletes it. It takes no input and must paginate |
| 219 | * exhaustively so the returned array is complete. |
| 220 | * |
| 221 | * Resources with no native enumeration API (account/zone singletons, |
| 222 | * existence-only resources, sub-resources keyed entirely by a parent) |
| 223 | * should return an empty array rather than throwing. |
| 224 | */ |
| 225 | list(): Effect.Effect<Res["Attributes"][], any, ListReq>; |
| 226 | /** |
| 227 | * Returns a stream of log lines for a deployed resource. |
| 228 | * Used by `alchemy logs --tail` to stream real-time logs. |
| 229 | */ |
| 230 | tail?(input: { |
| 231 | id: string; |
| 232 | /** |
| 233 | * Fully-qualified name (namespace path + logical id, see `./FQN.ts`) — |
| 234 | * globally unique, so providers can stamp ownership metadata on cloud objects. |
| 235 | */ |
| 236 | fqn: string; |
| 237 | instanceId: string; |
| 238 | props: Props<Res>; |
| 239 | output: Res["Attributes"]; |
| 240 | }): Stream.Stream<LogLine, any, TailReq>; |
| 241 | /** |
| 242 | * Queries historical logs for a deployed resource. |
| 243 | * Used by `alchemy logs` to fetch past log entries. |
| 244 | */ |
| 245 | logs?(input: { |
| 246 | id: string; |
| 247 | /** |
| 248 | * Fully-qualified name (namespace path + logical id, see `./FQN.ts`) — |
| 249 | * globally unique, so providers can stamp ownership metadata on cloud objects. |
| 250 | */ |
| 251 | fqn: string; |
| 252 | instanceId: string; |
| 253 | props: Props<Res>; |
| 254 | output: Res["Attributes"]; |
| 255 | options: LogsInput; |
| 256 | }): Effect.Effect<LogLine[], any, LogsReq>; |
| 257 | // watch(); |
| 258 | // replace(): Effect.Effect<void, never, never>; |
| 259 | // different interface that is persistent, watching, reloads |
| 260 | // run?() {} |
| 261 | // branch?() {} |
| 262 | read?(input: { |
| 263 | id: string; |
| 264 | /** |
| 265 | * Fully-qualified name (namespace path + logical id, see `./FQN.ts`) — |
| 266 | * globally unique, so providers can stamp ownership metadata on cloud objects. |
| 267 | */ |
| 268 | fqn: string; |
| 269 | instanceId: string; |
| 270 | olds: Props<Res>; |
| 271 | // what is the ARN? |
| 272 | output: Res["Attributes"] | undefined; // current state -> synced state |
| 273 | }): Effect.Effect<Res["Attributes"] | undefined, any, ReadReq>; |
| 274 | /** |
| 275 | * Properties that are always stable across any update. |
| 276 | */ |
| 277 | stables?: Extract<keyof Res["Attributes"], string>[]; |
| 278 | diff?(input: { |
| 279 | id: string; |
| 280 | /** |
| 281 | * Fully-qualified name (namespace path + logical id, see `./FQN.ts`) — |
| 282 | * globally unique, so providers can stamp ownership metadata on cloud objects. |
| 283 | */ |
| 284 | fqn: string; |
| 285 | instanceId: string; |
| 286 | olds: Props<Res>; |
| 287 | // Note: we do not resolve (Res["Props"]) here because diff runs during plan |
| 288 | // -> we need a way for the diff handlers to work with Outputs |
| 289 | news: Input<Props<Res>>; |
| 290 | oldBindings: BindingData<Res>; |
| 291 | newBindings: Input<BindingData<Res>>; |
| 292 | output: Res["Attributes"] | undefined; |
| 293 | }): Effect.Effect<Diff | void, any, DiffReq>; |
| 294 | // dev?:() => Effect.Effect<void, any, DevReq>; |
| 295 | precreate?(input: { |
| 296 | id: string; |
| 297 | /** |
| 298 | * Fully-qualified name (namespace path + logical id, see `./FQN.ts`) — |
| 299 | * globally unique, so providers can stamp ownership metadata on cloud objects. |
| 300 | */ |
| 301 | fqn: string; |
| 302 | news: Props<Res>; |
| 303 | instanceId: string; |
| 304 | session: ScopedPlanStatusSession; |
| 305 | bindings: BindingData<Res>; |
| 306 | }): Effect.Effect<Res["Attributes"], any, PrecreateReq>; |
| 307 | /** |
| 308 | * Reconciles the desired state of a Resource with the live cloud state. |
| 309 | * |
| 310 | * This unified lifecycle method replaces the previous `create` and `update` |
| 311 | * pair. The engine dispatches `reconcile` for both intents — initial |
| 312 | * provisioning and subsequent updates — and providers must defensively |
| 313 | * handle every combination of inputs: |
| 314 | * |
| 315 | * - `output === undefined` and `olds === undefined` — first reconciliation |
| 316 | * for this logical resource. Treat as a create. Must remain idempotent |
| 317 | * because state persistence can fail after a successful API call. |
| 318 | * - `output !== undefined` and `olds === undefined` — engine adopted an |
| 319 | * existing cloud resource (via {@link read}). The provider has never |
| 320 | * written this resource through Alchemy before, so cannot rely on prior |
| 321 | * props as a baseline. |
| 322 | * - `output !== undefined` and `olds !== undefined` — standard update |
| 323 | * path with a known prior state. |
| 324 | * |
| 325 | * Ownership has already been verified upstream — by the time `reconcile` |
| 326 | * runs, the engine has confirmed (via `read` returning a non-`Unowned` |
| 327 | * value, or by writing the resource itself) that mutation is safe. |
| 328 | */ |
| 329 | reconcile(input: { |
| 330 | id: string; |
| 331 | /** |
| 332 | * Fully-qualified name (namespace path + logical id, see `./FQN.ts`) — |
| 333 | * globally unique, so providers can stamp ownership metadata on cloud objects. |
| 334 | */ |
| 335 | fqn: string; |
| 336 | instanceId: string; |
| 337 | news: Props<Res>; |
| 338 | olds: Props<Res> | undefined; |
| 339 | output: Res["Attributes"] | undefined; |
| 340 | session: ScopedPlanStatusSession; |
| 341 | bindings: BindingData<Res>; |
| 342 | }): Effect.Effect<Res["Attributes"], any, ReconcileReq>; |
| 343 | delete(input: { |
| 344 | id: string; |
| 345 | /** |
| 346 | * Fully-qualified name (namespace path + logical id, see `./FQN.ts`) — |
| 347 | * globally unique, so providers can stamp ownership metadata on cloud objects. |
| 348 | */ |
| 349 | fqn: string; |
| 350 | instanceId: string; |
| 351 | olds: Props<Res>; |
| 352 | output: Res["Attributes"]; |
| 353 | session: ScopedPlanStatusSession; |
| 354 | bindings: BindingData<Res>; |
| 355 | /** |
| 356 | * Set by account-wide teardown (`alchemy unsafe nuke`) to signal the |
| 357 | * operator explicitly requested destructive deletion. Nuke enumerates |
| 358 | * resources straight from the cloud, so `olds` carries Attributes rather |
| 359 | * than the originally-deployed Props — destructive prerequisites that a |
| 360 | * normal destroy gates behind a prop (e.g. emptying a non-empty S3 |
| 361 | * bucket behind `forceDestroy`) may be performed when this is set. |
| 362 | * Normal engine destroys never set it. |
| 363 | */ |
| 364 | force?: boolean; |
| 365 | }): Effect.Effect<void, any, DeleteReq>; |
| 366 | } |
| 367 | |
| 368 | /** |
| 369 | * The provider shape accepted by {@link effect} / {@link succeed}: identical |
| 370 | * to {@link ProviderService} except `list` is **optional**. Providers with no |
| 371 | * native enumeration API (local/dev providers, account singletons, |
| 372 | * sub-resources keyed entirely by a parent) simply omit it and get a safe |
| 373 | * `() => Effect.succeed([])` default. |
| 374 | * |
| 375 | * Making `list` optional at the constructor (rather than on |
| 376 | * {@link ProviderService} itself) keeps the engine's contract intact — every |
| 377 | * registered provider still has a callable `list` — while removing the |
| 378 | * type-inference trap where a missing required member silently defaults all |
| 379 | * `*Req` type params to `never` and produces baffling `R is not assignable |
| 380 | * to never` cascades at every other property. |
| 381 | */ |
| 382 | export type ProviderServiceInput< |
| 383 | Res extends ResourceLike = ResourceLike, |
| 384 | ReadReq = never, |
| 385 | DiffReq = never, |
| 386 | PrecreateReq = never, |
| 387 | ReconcileReq = never, |
| 388 | DeleteReq = never, |
| 389 | TailReq = never, |
| 390 | LogsReq = never, |
| 391 | ListReq = never, |
| 392 | > = Omit< |
| 393 | ProviderService< |
| 394 | Res, |
| 395 | ReadReq, |
| 396 | DiffReq, |
| 397 | PrecreateReq, |
| 398 | ReconcileReq, |
| 399 | DeleteReq, |
| 400 | TailReq, |
| 401 | LogsReq, |
| 402 | ListReq |
| 403 | >, |
| 404 | "list" |
| 405 | > & |
| 406 | Partial< |
| 407 | Pick< |
| 408 | ProviderService< |
| 409 | Res, |
| 410 | ReadReq, |
| 411 | DiffReq, |
| 412 | PrecreateReq, |
| 413 | ReconcileReq, |
| 414 | DeleteReq, |
| 415 | TailReq, |
| 416 | LogsReq, |
| 417 | ListReq |
| 418 | >, |
| 419 | "list" |
| 420 | > |
| 421 | >; |
| 422 | |
| 423 | const withDefaultList = <S extends { list?: unknown }>(service: S): S => |
| 424 | service.list ? service : { ...service, list: () => Effect.succeed([]) }; |
| 425 | |
| 426 | export const effect = < |
| 427 | R extends ResourceLike, |
| 428 | Req = never, |
| 429 | ReadReq = never, |
| 430 | DiffReq = never, |
| 431 | PrecreateReq = never, |
| 432 | ReconcileReq = never, |
| 433 | DeleteReq = never, |
| 434 | TailReq = never, |
| 435 | LogsReq = never, |
| 436 | ListReq = never, |
| 437 | >( |
| 438 | cls: ResourceClassLike<R> | Platform<R, any, any, any, any, any>, |
| 439 | eff: Effect.Effect< |
| 440 | ProviderServiceInput< |
| 441 | R, |
| 442 | ReadReq, |
| 443 | DiffReq, |
| 444 | PrecreateReq, |
| 445 | ReconcileReq, |
| 446 | DeleteReq, |
| 447 | TailReq, |
| 448 | LogsReq, |
| 449 | ListReq |
| 450 | >, |
| 451 | never, |
| 452 | Req |
| 453 | >, |
| 454 | ): Layer.Layer< |
| 455 | Provider<R>, |
| 456 | never, |
| 457 | Exclude< |
| 458 | Req | ReadReq | DiffReq | PrecreateReq | ReconcileReq | DeleteReq | ListReq, |
| 459 | LifecycleServices |
| 460 | > |
| 461 | > => |
| 462 | Layer.effect( |
| 463 | // @ts-expect-error |
| 464 | Provider(cls.Type), |
| 465 | Effect.map(eff, (service) => ({ |
| 466 | aliases: "Aliases" in cls ? cls.Aliases : undefined, |
| 467 | ...withDefaultList(service), |
| 468 | })), |
| 469 | ) as any; |
| 470 | |
| 471 | export const succeed = < |
| 472 | R extends ResourceLike, |
| 473 | ReadReq = never, |
| 474 | DiffReq = never, |
| 475 | PrecreateReq = never, |
| 476 | ReconcileReq = never, |
| 477 | DeleteReq = never, |
| 478 | TailReq = never, |
| 479 | LogsReq = never, |
| 480 | ListReq = never, |
| 481 | >( |
| 482 | cls: ResourceClass<R> | Platform<R, any, any, any, any, any>, |
| 483 | service: ProviderServiceInput< |
| 484 | R, |
| 485 | ReadReq, |
| 486 | DiffReq, |
| 487 | PrecreateReq, |
| 488 | ReconcileReq, |
| 489 | DeleteReq, |
| 490 | TailReq, |
| 491 | LogsReq, |
| 492 | ListReq |
| 493 | >, |
| 494 | ): Layer.Layer< |
| 495 | Provider<R>, |
| 496 | never, |
| 497 | Exclude< |
| 498 | ReadReq | DiffReq | PrecreateReq | ReconcileReq | DeleteReq | ListReq, |
| 499 | LifecycleServices |
| 500 | > |
| 501 | > => |
| 502 | // @ts-expect-error |
| 503 | Layer.succeed(Provider(cls.Type), { |
| 504 | aliases: "Aliases" in cls ? cls.Aliases : undefined, |
| 505 | ...withDefaultList(service), |
| 506 | }); |
| 507 | |
| 508 | export interface ProviderCollectionLike { |
| 509 | kind: "ProviderCollection"; |
| 510 | } |
| 511 | |
| 512 | export interface ProviderCollectionShape<Identifier extends string> |
| 513 | extends |
| 514 | Context.ServiceClass.Shape<Identifier, ProviderCollectionService>, |
| 515 | ProviderCollectionLike {} |
| 516 | |
| 517 | export interface ProviderCollection<Self, Identifier extends string> |
| 518 | extends |
| 519 | Context.Service<Self, ProviderCollectionService>, |
| 520 | ProviderCollectionLike { |
| 521 | readonly key: Identifier; |
| 522 | new (_: never): ProviderCollectionShape<Identifier>; |
| 523 | } |
| 524 | |
| 525 | export const ProviderCollection = |
| 526 | <Self>() => |
| 527 | <const ProviderId extends string>(id: ProviderId) => |
| 528 | Context.Service<Self, ProviderCollectionService>()( |
| 529 | id, |
| 530 | ) as ProviderCollection<Self, ProviderId>; |
| 531 | |
| 532 | export interface ProviderCollectionService { |
| 533 | kind: "ProviderCollection"; |
| 534 | get<Resource extends ResourceLike>( |
| 535 | service: string, |
| 536 | ): ProviderService<Resource> | undefined; |
| 537 | /** |
| 538 | * Every provider in this collection keyed by its resource type |
| 539 | * (e.g. `"Cloudflare.Worker"`). Used by account-wide operations such |
| 540 | * as `alchemy unsafe nuke` to enumerate and filter providers — the |
| 541 | * collection's closure-captured map would otherwise be unreachable. |
| 542 | */ |
| 543 | readonly providers: Record<string, ProviderService>; |
| 544 | } |
| 545 | |
| 546 | export const collection = < |
| 547 | R extends ResourceClassLike<any> | Platform<any, any, any, any, any, any>, |
| 548 | >( |
| 549 | resources: R[], |
| 550 | ): Effect.Effect< |
| 551 | ProviderCollectionService, |
| 552 | never, |
| 553 | R extends ResourceClass<infer R> | Platform<infer R, any, any, any, any, any> |
| 554 | ? Provider<R> |
| 555 | : never |
| 556 | > => |
| 557 | Effect.gen(function* () { |
| 558 | const context = yield* Effect.context(); |
| 559 | |
| 560 | const providers = Object.fromEntries( |
| 561 | yield* Effect.all( |
| 562 | resources.map((resource) => |
| 563 | "Provider" in resource |
| 564 | ? resource.Provider.pipe( |
| 565 | Effect.map((provider) => [resource.Type, provider] as const), |
| 566 | ) |
| 567 | : Effect.succeed([ |
| 568 | (resource as { key: string }).key, |
| 569 | context.mapUnsafe.get((resource as { key: string }).key), |
| 570 | ] as const), |
| 571 | ), |
| 572 | { concurrency: "unbounded" }, |
| 573 | ), |
| 574 | ); |
| 575 | |
| 576 | return { |
| 577 | kind: "ProviderCollection" as const, |
| 578 | get: (service: string) => providers[service], |
| 579 | providers, |
| 580 | }; |
| 581 | }) as any; |
| 582 | |
| 583 | export const isProviderCollectionService = ( |
| 584 | value: unknown, |
| 585 | ): value is ProviderCollectionService => { |
| 586 | return ( |
| 587 | Predicate.isObject(value) && |
| 588 | Predicate.hasProperty(value, "kind") && |
| 589 | value.kind === "ProviderCollection" |
| 590 | ); |
| 591 | }; |
| 592 | |
| 593 | /** |
| 594 | * Structural check for a {@link ProviderService} living in the Effect |
| 595 | * context. Providers are keyed by their canonical resource type; when |
| 596 | * searching for a legacy alias, the tag key won't match, so lookup has to |
| 597 | * recognize provider services by shape. |
| 598 | */ |
| 599 | export const isProviderService = (value: unknown): value is ProviderService => |
| 600 | Predicate.isObject(value) && |
| 601 | Predicate.hasProperty(value, "reconcile") && |
| 602 | Predicate.isFunction(value.reconcile) && |
| 603 | Predicate.hasProperty(value, "delete") && |
| 604 | Predicate.isFunction(value.delete); |
| 605 | |
| 606 | /** |
| 607 | * Resolve the concrete service for `mode` from a provider found in context. |
| 608 | * |
| 609 | * - `mode === undefined` → the service as registered (for dual |
| 610 | * registrations that is the delegating service for the run's default |
| 611 | * mode). |
| 612 | * - `mode` given and the provider carries {@link ProviderService.modes} → |
| 613 | * the (lazily built, memoized) variant for that mode. |
| 614 | * - `mode` given but the provider is mode-agnostic → the service itself: |
| 615 | * a single implementation satisfies every mode (hybrid), which is what |
| 616 | * lets live-only resources (e.g. R2 buckets) participate in dev runs. |
| 617 | */ |
| 618 | export const providerForMode = <R extends ResourceLike>( |
| 619 | service: ProviderService<R>, |
| 620 | mode: ProviderMode | undefined, |
| 621 | ): Effect.Effect<ProviderService<R>> => |
| 622 | mode !== undefined && service.modes !== undefined |
| 623 | ? service.modes[mode] |
| 624 | : Effect.succeed(service); |
| 625 | |
| 626 | /** |
| 627 | * Why a resource's deploy-time binding clients target the data plane they |
| 628 | * do — see {@link describeDataPlane}. |
| 629 | * |
| 630 | * - `local` — the resource runs on its provider's local emulator; `layer` |
| 631 | * is the override to provide closest around every client call. |
| 632 | * - `live` — the resource targets the real cloud: either pinned there |
| 633 | * (`Alchemy.remote()`) or the run is a plain deploy. `layer` is the |
| 634 | * registration-captured live environment, provided closest around every |
| 635 | * client call so a `remote()` resource still hits the real cloud when the |
| 636 | * ambient environment is the emulator. |
| 637 | * - `undeclared` — the resource resolves to local mode, but its provider |
| 638 | * is a dual registration without a {@link ProviderService.localDataPlane} |
| 639 | * (a process-hosted local variant with no API to route to, or a missing |
| 640 | * `dataPlane` on a hand-written `ProviderLayer.dual`). Clients fall back |
| 641 | * to the real cloud — reported by name so the two are never confused. |
| 642 | * - `agnostic` — the provider is mode-agnostic (plain registration); its |
| 643 | * resources live on the real cloud even in dev. |
| 644 | * - `unregistered` — no provider in context (bare runtime, unit tests). |
| 645 | */ |
| 646 | export type DataPlaneResolution = |
| 647 | | { readonly kind: "local"; readonly layer: Layer.Layer<any, any, never> } |
| 648 | | { |
| 649 | readonly kind: "live"; |
| 650 | readonly layer?: Layer.Layer<any, any, never> | undefined; |
| 651 | } |
| 652 | | { readonly kind: "undeclared"; readonly providerType: string } |
| 653 | | { readonly kind: "agnostic" } |
| 654 | | { readonly kind: "unregistered" }; |
| 655 | |
| 656 | /** |
| 657 | * Resolve where a resource's deploy-time binding clients should be routed: |
| 658 | * its registration-captured {@link ResourceLike.Mode} (`Alchemy.remote()` |
| 659 | * → `"live"`) or the run default (`alchemy dev` → `"local"`) — the same |
| 660 | * resolution `Plan.make` applies to lifecycle operations — combined with |
| 661 | * whether the provider registered a local data plane at all. |
| 662 | */ |
| 663 | export const describeDataPlane = (resource: { |
| 664 | readonly Type: string; |
| 665 | readonly Mode?: ProviderMode | undefined; |
| 666 | }): Effect.Effect<DataPlaneResolution> => |
| 667 | Effect.gen(function* () { |
| 668 | const found = yield* tryFindProviderRegistrationByType(resource.Type); |
| 669 | if (Option.isNone(found)) return { kind: "unregistered" as const }; |
| 670 | const provider = found.value; |
| 671 | if (provider.modes === undefined) return { kind: "agnostic" as const }; |
| 672 | const mode = resource.Mode ?? (yield* defaultProviderMode); |
| 673 | if (mode !== "local") { |
| 674 | const layer = provider.liveDataPlane?.(); |
| 675 | return layer !== undefined |
| 676 | ? { kind: "live" as const, layer } |
| 677 | : { kind: "live" as const }; |
| 678 | } |
| 679 | if (provider.localDataPlane === undefined) { |
| 680 | return { kind: "undeclared" as const, providerType: resource.Type }; |
| 681 | } |
| 682 | return { kind: "local" as const, layer: provider.localDataPlane() }; |
| 683 | }); |
| 684 | |
| 685 | /** |
| 686 | * The data-plane override layer for a resource, or `undefined` when its |
| 687 | * clients target the real cloud for any reason (see |
| 688 | * {@link describeDataPlane} for which). |
| 689 | */ |
| 690 | export const resolveLocalDataPlane = (resource: { |
| 691 | readonly Type: string; |
| 692 | readonly Mode?: ProviderMode | undefined; |
| 693 | }): Effect.Effect<Layer.Layer<any, any, never> | undefined> => |
| 694 | describeDataPlane(resource).pipe( |
| 695 | Effect.map((plane) => (plane.kind === "local" ? plane.layer : undefined)), |
| 696 | ); |
| 697 | |
| 698 | export const findProviderByType: { |
| 699 | <R extends ResourceLike>( |
| 700 | resourceType: R["Type"], |
| 701 | mode?: ProviderMode, |
| 702 | ): Effect.Effect<ProviderService<R>>; |
| 703 | } = ((type: string, mode?: ProviderMode) => |
| 704 | tryFindProviderByType(type, mode).pipe( |
| 705 | Effect.flatMap( |
| 706 | Option.match({ |
| 707 | onNone: () => Effect.die(`Provider not found for ${type}`), |
| 708 | onSome: (provider) => Effect.succeed(provider), |
| 709 | }), |
| 710 | ), |
| 711 | )) as any; |
| 712 | |
| 713 | /** |
| 714 | * Resolve the concrete provider for the requested or current run mode. |
| 715 | * Typed provider lookup by resource class (or {@link Platform}) value. Infers |
| 716 | * `R` from the class so `provider.list()` / `provider.read(...)` return the |
| 717 | * resource's `Attributes` shape — prefer this over {@link findProviderByType}, |
| 718 | * which only takes the type string. |
| 719 | */ |
| 720 | export const findProvider: { |
| 721 | <R extends ResourceLike>( |
| 722 | resource: ResourceClassLike<R> | Platform<R, any, any, any, any, any>, |
| 723 | mode?: ProviderMode, |
| 724 | ): Effect.Effect<ProviderService<R>>; |
| 725 | } = (resource: { Type?: string; key?: string }, mode?: ProviderMode) => |
| 726 | findProviderByType((resource.Type ?? resource.key) as string, mode) as any; |
| 727 | |
| 728 | /** |
| 729 | * A persisted state row references a resource type with no registered |
| 730 | * provider — the type was removed from the program (or renamed without an |
| 731 | * alias) while its state row still exists ("zombie" row). |
| 732 | * |
| 733 | * This is FATAL at plan time: the program and state fundamentally disagree, |
| 734 | * and without the provider the row's physical resource cannot be deleted |
| 735 | * anyway, so proceeding would only strand it silently. The plan dies with |
| 736 | * this error; nothing is deployed or destroyed until the provider is |
| 737 | * re-registered (or aliased), or the state row is cleared manually. |
| 738 | */ |
| 739 | export class MissingProviderError extends Data.TaggedError( |
| 740 | "MissingProviderError", |
| 741 | )<{ |
| 742 | message: string; |
| 743 | resourceType: string; |
| 744 | fqn: string; |
| 745 | }> {} |
| 746 | |
| 747 | /** Build the fatal plan-time error for a zombie state row. */ |
| 748 | export const missingProviderError = ( |
| 749 | resourceType: string, |
| 750 | fqn: string, |
| 751 | ): MissingProviderError => |
| 752 | new MissingProviderError({ |
| 753 | message: |
| 754 | `No provider is registered for resource type '${resourceType}' ` + |
| 755 | `(state row '${fqn}'). The type was removed from the program or ` + |
| 756 | "renamed without an alias. Re-register the provider (or add the " + |
| 757 | "old name to the resource's `aliases`) so this row can be " + |
| 758 | "destroyed, or clear the state row manually if the physical " + |
| 759 | "resource is already gone.", |
| 760 | resourceType, |
| 761 | fqn, |
| 762 | }); |
| 763 | |
| 764 | /** Resolve a concrete provider, using the current run's mode when omitted. */ |
| 765 | export const tryFindProviderByType = <R extends ResourceLike>( |
| 766 | resourceType: R["Type"], |
| 767 | mode?: ProviderMode, |
| 768 | ): Effect.Effect<Option.Option<ProviderService<R>>> => |
| 769 | Effect.gen(function* () { |
| 770 | const found = yield* tryFindProviderRegistrationByType<R>(resourceType); |
| 771 | if (Option.isNone(found)) return found; |
| 772 | return Option.some( |
| 773 | yield* providerForMode(found.value, mode ?? (yield* defaultProviderMode)), |
| 774 | ); |
| 775 | }); |
| 776 | |
| 777 | /** Inspect registration metadata without constructing either provider variant. */ |
| 778 | export const tryFindProviderRegistrationByType: { |
| 779 | <R extends ResourceLike>( |
| 780 | resourceType: R["Type"], |
| 781 | ): Effect.Effect<Option.Option<ProviderService<R>>>; |
| 782 | } = Effect.fn(function* <R extends ResourceLike>(resourceType: R["Type"]) { |
| 783 | const found = yield* Effect.gen(function* () { |
| 784 | const Tag = Provider<R>(resourceType) as unknown as Context.Service< |
| 785 | Provider<R>, |
| 786 | any |
| 787 | >; |
| 788 | const direct = yield* Effect.serviceOption(Tag); |
| 789 | if (Option.isSome(direct)) { |
| 790 | return direct; |
| 791 | } |
| 792 | |
| 793 | const context = yield* Effect.context<never>(); |
| 794 | for (const value of context.mapUnsafe.values()) { |
| 795 | if (isProviderCollectionService(value)) { |
| 796 | const provider = value.get(resourceType); |
| 797 | if (provider) { |
| 798 | return Option.some(provider); |
| 799 | } |
| 800 | } |
| 801 | } |
| 802 | |
| 803 | // State persisted before a type rename carries the legacy name, so no |
| 804 | // provider is keyed under it. Fall back to a provider that declares the |
| 805 | // name in its `aliases` — scanning both bare Provider layers and the |
| 806 | // members of every ProviderCollection. |
| 807 | for (const value of context.mapUnsafe.values()) { |
| 808 | if (isProviderCollectionService(value)) { |
| 809 | for (const provider of Object.values(value.providers)) { |
| 810 | if (provider.aliases?.includes(resourceType)) { |
| 811 | return Option.some(provider); |
| 812 | } |
| 813 | } |
| 814 | } else if ( |
| 815 | isProviderService(value) && |
| 816 | value.aliases?.includes(resourceType) |
| 817 | ) { |
| 818 | return Option.some(value); |
| 819 | } |
| 820 | } |
| 821 | return Option.none(); |
| 822 | }); |
| 823 | return found; |
| 824 | }) as any; |