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 { Artifacts } from "./Artifacts.ts";
9import type { ScopedPlanStatusSession } from "./Report.ts";
10import type { Diff } from "./Diff.ts";
11import type { Input } from "./Input.ts";
12import type { InstanceId } from "./InstanceId.ts";
13import type { State } from "./State/State.ts";
14import type { Platform } from "./Platform.ts";
15import { defaultProviderMode, type ProviderMode } from "./ProviderMode.ts";
16import type {
17 ResourceBinding,
18 ResourceClass,
19 ResourceClassLike,
20 ResourceLike,
21} from "./Resource.ts";
22
23export 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.
68type LifecycleServices = InstanceId | Artifacts | State;
69
70export const Provider = <R extends ResourceLike>(
71 type: R["Type"],
72): Provider<R> =>
73 Context.Service<Provider<R>, ProviderService<R>>()(type) as any;
74
75type BindingData<Res extends ResourceLike> = [Res] extends [
76 { Binding: infer B },
77]
78 ? ResourceBinding<B>[]
79 : any[];
80
81type Props<Res extends ResourceLike> = keyof Res["Props"] extends never
82 ? Res["Props"] | undefined
83 : Res["Props"];
84
85export interface LogLine {
86 timestamp: Date;
87 message: string;
88}
89
90export interface LogsInput {
91 since?: Date;
92 limit?: number;
93}
94
95export 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 */
382export 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
423const withDefaultList = <S extends { list?: unknown }>(service: S): S =>
424 service.list ? service : { ...service, list: () => Effect.succeed([]) };
425
426export 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
471export 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
508export interface ProviderCollectionLike {
509 kind: "ProviderCollection";
510}
511
512export interface ProviderCollectionShape<Identifier extends string>
513 extends
514 Context.ServiceClass.Shape<Identifier, ProviderCollectionService>,
515 ProviderCollectionLike {}
516
517export 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
525export const ProviderCollection =
526 <Self>() =>
527 <const ProviderId extends string>(id: ProviderId) =>
528 Context.Service<Self, ProviderCollectionService>()(
529 id,
530 ) as ProviderCollection<Self, ProviderId>;
531
532export 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
546export 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
583export 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 */
599export 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 */
618export 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 */
646export 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 */
663export 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 */
690export 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
698export 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 */
720export 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 */
739export 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. */
748export 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. */
765export 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. */
778export 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;