Skip to content
Source
Sign inSign up

Provider.ts

837 lines30.7 KB

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