1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as Effectable from "effect/Effectable";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import type { Pipeable } from "effect/Pipeable";
7import { AdoptPolicy } from "./AdoptPolicy.ts";
8import { toFqn } from "./FQN.ts";
9import type { Input, InputProps, PropsInput } from "./Input.ts";
10import { CurrentNamespace, type NamespaceNode } from "./Namespace.ts";
11import * as Output from "./Output.ts";
12import { Provider } from "./Provider.ts";
13import {
14 ConflictingProviderModeError,
15 ProviderModePolicy,
16 type ProviderMode,
17} from "./ProviderMode.ts";
18import { ref as makeRef } from "./Ref.ts";
19import { RemovalPolicy } from "./RemovalPolicy.ts";
20import { RenamePolicy } from "./Rename.ts";
21import { Self } from "./Self.ts";
22import { Stack } from "./Stack.ts";
23
24export type ResourceConstructor<R extends ResourceLike, Req = never> = {
25 Type: R["Type"];
26 Props: R["Props"];
27 <const Methods extends { [key: string]: any }>(
28 methods: Methods,
29 ): ResourceClassWithMethods<R, Methods>;
30 (
31 id: string,
32 // PropsInput distributes over union Props so discriminated-union
33 // resources keep the correlation between discriminant and payload.
34 ...args: {} extends R["Props"]
35 ? [props?: PropsInput<R["Props"]>]
36 : [props: PropsInput<R["Props"]>]
37 ): Effect.Effect<R, never, Req>;
38 <PropsReq = never>(
39 id: string,
40 props: Effect.Effect<InputProps<R["Props"]>, never, PropsReq>,
41 ): Effect.Effect<R, never, PropsReq | Req>;
42};
43
44export interface ResourceClassLike<R extends ResourceLike> {
45 Type: R["Type"];
46 Props: R["Props"];
47 Self: Self<R>;
48 Provider: Provider<R>;
49 /**
50 * Legacy type names this resource was previously registered under
51 * (see {@link ResourceOptions.aliases}). Copied onto the
52 * `ProviderService` by `Provider.succeed`/`Provider.effect` so provider
53 * lookup can resolve state persisted under a pre-rename type.
54 *
55 * `undefined` is accepted explicitly so `ResourceClass` (whose `Aliases`
56 * is `readonly string[] | undefined`) stays assignable to
57 * `ResourceClassLike` under `exactOptionalPropertyTypes`.
58 */
59 Aliases?: readonly string[] | undefined;
60}
61
62export type ResourceClass<R extends ResourceLike> = ResourceConstructor<
63 R,
64 R["Providers"] extends undefined ? Provider<R> : R["Providers"]
65> &
66 Effect.Effect<ResourceConstructor<R>> & {
67 Self: Self<R>;
68 Provider: Provider<R>;
69 Aliases: readonly string[] | undefined;
70 ref(
71 id: string,
72 options?: { stage?: string; stack?: string },
73 ): Effect.Effect<R>;
74 };
75
76export type ResourceClassWithMethods<
77 R extends ResourceLike,
78 Methods extends { [key: string]: any },
79> = ResourceConstructor<
80 R,
81 R["Providers"] extends undefined ? Provider<R> : R["Providers"]
82> &
83 Effect.Effect<ResourceConstructor<R>> & {
84 Self: Self<R>;
85 Provider: Provider<R>;
86 Aliases: readonly string[] | undefined;
87 ref(
88 id: string,
89 options?: { stage?: string; stack?: string },
90 ): Effect.Effect<R>;
91 } & Methods;
92
93export type LogicalId = string;
94
95export interface ResourceBinding<Data = any> {
96 sid: string;
97 data: Data;
98}
99
100export interface ResourceLike<
101 Type extends string = string,
102 Props extends object | undefined = any,
103 Attributes extends object = object,
104 Binding = any,
105 Providers = any,
106> {
107 /**
108 * Namespace containing this Resource.
109 */
110 Namespace: NamespaceNode | undefined;
111 /**
112 * Fully Qualified Name (namespace path + logical ID).
113 * Used as the unique key for state storage.
114 */
115 FQN: string;
116 /**
117 * Type of the Resource (e.g. AWS.Lambda.Function)
118 */
119 Type: Type;
120 /**
121 * Logical ID of the Resource (e.g. MyFunction)
122 */
123 LogicalId: LogicalId;
124 /**
125 * Properties of the Resource.
126 */
127 Props: Props;
128 /**
129 * Removal Policy of the Resource.
130 */
131 RemovalPolicy: RemovalPolicy["Service"];
132 /**
133 * Per-resource adoption policy captured from the ambient {@link AdoptPolicy}
134 * at registration time (e.g. via `.pipe(adopt(true))`). `undefined` means no
135 * resource-scoped override — the planner falls back to the stack/CLI default.
136 */
137 Adopt: boolean | undefined;
138 /**
139 * Per-resource provider mode captured from the ambient
140 * {@link ProviderModePolicy} at registration time. `"live"` when the
141 * resource was pinned via `.pipe(remote())` (opting out of local emulation
142 * during dev); `undefined` means the run default (`AlchemyContext.dev`).
143 */
144 Mode: ProviderMode | undefined;
145 /**
146 * Copied from {@link ResourceOptions.requiresImplementation} at
147 * registration: `true` for platform-typed resources, whose registrations
148 * must have resolved {@link Props} by plan time. `Plan.make` fails fast
149 * with {@link MissingImplementationError} when this is set and `Props`
150 * are still `undefined` after the whole program has evaluated — a bare
151 * tag was yielded but its `.make(props, impl)` Layer was never provided.
152 */
153 RequiresImplementation: boolean | undefined;
154 /**
155 * Former FQNs this resource's state may still be persisted under,
156 * captured from the ambient {@link RenamePolicy} at registration (via
157 * `.pipe(renamedFrom("OldId"))`) and resolved against the same namespace
158 * as the resource's own FQN. The planner migrates a state row found at a
159 * former FQN to {@link FQN} instead of planning a create+delete
160 * replacement — see `renamedFrom` in Rename.ts for the full semantics.
161 */
162 FormerFqns: readonly string[] | undefined;
163 /** @internal phantom */
164 Attributes: Attributes;
165 /** @internal phantom */
166 Binding: Binding;
167 /** @internal phantom */
168 Providers: Providers;
169}
170
171export const isResource = (value: any): value is ResourceLike => {
172 // Require the full resource identity (Type AND FQN), not just `Type`:
173 // user-authored prop objects legitimately carry a `Type` field (e.g.
174 // Amazon States Language states like `{ Type: "Pass", End: true }` in a
175 // Step Functions definition) and must not be mistaken for resources.
176 // Locally-declared resources always expose both as own keys; refs
177 // (`Resource.ref(...)`) deliberately report neither via `in` so they keep
178 // routing through Output resolution.
179 return (
180 typeof value === "object" &&
181 value !== null &&
182 "Type" in value &&
183 "FQN" in value
184 );
185};
186
187/**
188 * Does `value` reference an instance of the resource type `type` —
189 * either a locally-declared resource or a `Resource.ref(...)` to one?
190 *
191 * Two constraints that ad-hoc guards get wrong for refs, which resolve
192 * to Output-expression proxies:
193 *
194 * - Read `.Type` via property access (never `in`): the proxy answers
195 * property reads with statically-known values but deliberately does
196 * not report key existence (so {@link isResource} keeps routing refs
197 * through Output resolution instead of the upstream-node lookup).
198 * - Accept `typeof value === "function"`: the proxy's target is
199 * callable (it needs an `apply` trap), so refs are not `"object"`.
200 *
201 * Either mistake silently rejects refs — in a Worker `env` that
202 * degrades the binding to a plain JSON var.
203 */
204export const isResourceOfType = (value: unknown, type: string): boolean =>
205 (typeof value === "object" || typeof value === "function") &&
206 value !== null &&
207 (value as { Type?: unknown }).Type === type;
208
209export type Resource<
210 Type extends string = any,
211 Props extends object | undefined = any,
212 Attributes extends object = any,
213 Binding = never,
214 Providers = undefined,
215> = Pipeable &
216 ResourceLike<Type, Props, Attributes, Binding, Providers> & {
217 bind(sid: Input<string>, binding: Input<Binding>): Effect.Effect<void>;
218 bind(
219 template: TemplateStringsArray,
220 ...args: any[]
221 ): (binding: Input<Binding>) => Effect.Effect<void>;
222 } & {
223 [attr in keyof Attributes]-?: AttrOutput<Attributes[attr]>;
224 };
225
226/**
227 * Accessor type for one attribute. Pure object attributes upgrade to
228 * {@link Output.ObjectExpr} so nested access is typed —
229 * `hyperpod.instanceGroups.workers` — while primitives, unions with
230 * `undefined`, branded string unions (`"a" | (string & {})`), and arrays
231 * stay plain {@link Output.Output} (running them through `ToOutput` would
232 * classify the `string & {}` branch as an object and explode into
233 * String-method mapped types).
234 */
235type AttrOutput<A> = [A] extends [
236 string | number | boolean | bigint | null | undefined | Date | any[],
237]
238 ? Output.Output<A, never>
239 : [A] extends [Record<string, any>]
240 ? Output.ObjectExpr<A, never>
241 : Output.Output<A, never>;
242
243export interface ResourceOptions {
244 /**
245 * Default removal policy for this resource type when the caller has not
246 * explicitly provided one via `RemovalPolicy` / `destroy()` / `retain()`.
247 *
248 * Useful for resources that wrap unrecoverable real-world identifiers
249 * (DNS zones, customer accounts, etc.) where the safe default is to
250 * leave the cloud object alone on stack destroy.
251 *
252 * @default "destroy"
253 */
254 defaultRemovalPolicy?: RemovalPolicy["Service"];
255 /**
256 * Legacy type names this resource was previously registered under.
257 *
258 * When a resource type is renamed (e.g. `"Cloudflare.Queue"` →
259 * `"Cloudflare.Queues.Queue"`), state persisted under the old name must
260 * still resolve to this resource's provider. Listing the old names here
261 * makes provider lookup fall back from the legacy name to this type, so
262 * existing stacks keep planning, updating, and deleting cleanly across
263 * the rename. The state row migrates to the new type on its next write.
264 *
265 * ```ts
266 * export const Queue = Resource<Queue>("Cloudflare.Queues.Queue", {
267 * aliases: ["Cloudflare.Queue"],
268 * });
269 * ```
270 */
271 aliases?: string[];
272 /**
273 * Marks every registration of this type as requiring resolved props by
274 * plan time. Set by `Platform(...)` on its resource class: every
275 * legitimate platform construction (a `.make(props, impl)` Layer build,
276 * a tag declared with props, a plain `Worker("id", props)` call) produces
277 * defined `Props` — the only way a platform-typed registration reaches
278 * the planner with `Props === undefined` is a bare-tag FORWARD REFERENCE
279 * whose `.make` Layer never built. `Plan.make` fails fast with
280 * {@link MissingImplementationError} in that case, instead of letting a
281 * provider read `undefined` props. Plain (non-platform) resources leave
282 * this unset so a no-props reference yield (`yield* Queue("MyQueue")`)
283 * keeps planning as a noop.
284 */
285 requiresImplementation?: boolean;
286}
287
288/**
289 * A tagged platform resource declared with neither props nor an inline
290 * implementation was `yield*`ed, but its `.make(props, impl)` Layer never
291 * built.
292 *
293 * Such a tag carries no configuration on its own — props AND impl both live
294 * on the Layer — so its registration is a forward reference with `undefined`
295 * props that the Layer's build repairs (in either order; see the #874
296 * circular env-tag pattern). Props still `undefined` once the whole program
297 * has evaluated means the Layer was never provided; `Plan.make` fails fast
298 * with this error instead of letting the failure surface deep inside
299 * whichever provider first reads a prop (e.g. `TypeError: undefined is not
300 * an object (evaluating 'news.name')` in the Cloudflare Worker pre-create).
301 */
302export class MissingImplementationError extends Data.TaggedError(
303 "MissingImplementationError",
304)<{
305 message: string;
306 /** Resource type of the platform, e.g. `Cloudflare.Worker`. */
307 type: string;
308 /** Logical id of the tagged resource (its class name by convention). */
309 id: string;
310}> {}
311
312export const missingImplementation = (type: string, id: string) =>
313 new MissingImplementationError({
314 type,
315 id,
316 message: [
317 `${type}<${id}> was yielded without its implementation.`,
318 "",
319 `\`${id}\` is declared as a bare tag — no props, no inline implementation — so both come from its \`.make(...)\` Layer:`,
320 "",
321 ` export class ${id} extends ${type}<${id}>()("${id}") {}`,
322 ` export const ${id}Live = ${id}.make({ /* props */ }, Effect.gen(function* () { /* ... */ }));`,
323 "",
324 `That Layer is not in scope where \`${id}\` is yielded. Provide it to the Stack's program:`,
325 "",
326 " Alchemy.Stack(",
327 ` "my-stack",`,
328 " { providers, state },",
329 " Effect.gen(function* () {",
330 ` const instance = yield* ${id};`,
331 ` }).pipe(Effect.provide([${id}Live])),`,
332 " )",
333 "",
334 `If \`${id}\` is not Effect-native, declare it with props instead: \`()("${id}", { /* props */ })\`.`,
335 ].join("\n"),
336 });
337
338/**
339 * Creates a resource constructor for a concrete resource type.
340 *
341 * The returned constructor registers the resource on the current stack,
342 * resolves input props, exposes output attributes as `Output` expressions, and
343 * records bindings contributed by policies and event sources. Resource
344 * providers are attached separately through `.provider`.
345 */
346export function Resource<R extends ResourceLike>(
347 type: R["Type"],
348 options?: ResourceOptions,
349): ResourceClass<R> {
350 const defaultRemovalPolicy = options?.defaultRemovalPolicy ?? "destroy";
351 type Props = Input<R["Props"]>;
352 const self = Self<R>(type);
353 const constructor = (
354 id: string,
355 props: Props | Effect.Effect<Props> | undefined,
356 ) =>
357 Effect.gen(function* () {
358 const stack = yield* Stack;
359 const namespace = yield* CurrentNamespace;
360 const fqn = toFqn(namespace, id);
361
362 // `remote()` opts resources out of local emulation during dev. The
363 // captured Mode is either "live" (pinned) or undefined (run default).
364 // The Reference default is `undefined` — "no explicit decoration" —
365 // which is distinct from an explicit `remote(false)`.
366 const ambientPolicy = yield* ProviderModePolicy;
367 const ambientMode: ProviderMode | undefined = ambientPolicy
368 ? "live"
369 : undefined;
370
371 const existing = stack.resources[fqn];
372 if (existing) {
373 // A resource may be `yield*`ed from several places (idempotent
374 // registration). If a later site carries an *explicit* ambient
375 // ProviderModePolicy that disagrees with what the resource was
376 // registered with, the decorations are conflicting — fail loudly
377 // instead of silently picking one. A later site with NO ambient
378 // policy simply inherits the registered resource (the common
379 // "reference it from elsewhere" pattern).
380 if (ambientPolicy !== undefined && existing.Mode !== ambientMode) {
381 return yield* Effect.die(
382 new ConflictingProviderModeError({
383 message:
384 `Resource '${fqn}' was registered with provider mode ` +
385 `'${existing.Mode ?? "default"}' but is now being registered ` +
386 `with conflicting mode '${ambientMode ?? "default"}'. A ` +
387 "resource must resolve to a single provider mode: register " +
388 "it once and close over the returned value, or make both " +
389 "registration sites agree (e.g. wrap both in the same " +
390 "`remote()` scope).",
391 fqn,
392 existingMode: existing.Mode,
393 conflictingMode: ambientMode,
394 }),
395 );
396 }
397 // // TODO(sam): check if props are different and die
398 return existing;
399 }
400 const bind = (
401 ...args:
402 | [sid: string, data: R["Binding"]]
403 | [template: TemplateStringsArray, ...args: any[]]
404 ) =>
405 typeof args[0] === "string"
406 ? Effect.gen(function* () {
407 const [sid, data] = args as [sid: string, data: R["Binding"]];
408 (stack.bindings[fqn] ??= []).push({
409 sid,
410 data,
411 });
412 return undefined;
413 })
414 : (data: R["Binding"]) => {
415 const stringifyBindArg = (arg: any): string | undefined => {
416 if (arg === undefined) {
417 return undefined;
418 }
419
420 if (Array.isArray(arg)) {
421 return arg
422 .flatMap((item) => {
423 const stringified = stringifyBindArg(item);
424 return stringified === undefined ? [] : [stringified];
425 })
426 .join(", ");
427 }
428
429 if (
430 arg &&
431 (typeof arg === "object" || typeof arg === "function")
432 ) {
433 if ("LogicalId" in arg && typeof arg.LogicalId === "string") {
434 return arg.LogicalId;
435 }
436
437 if ("id" in arg && typeof arg.id === "string") {
438 return arg.id;
439 }
440 }
441
442 return String(arg);
443 };
444
445 return bind(
446 `${(args[0] as TemplateStringsArray)
447 .flatMap((text, i) => {
448 const stringified = stringifyBindArg(args[i + 1]);
449 return stringified !== undefined
450 ? [text, stringified]
451 : [text];
452 })
453 .join("")}`,
454 data,
455 );
456 };
457
458 const target: any = {
459 Type: type,
460 Namespace: namespace,
461 FQN: fqn,
462 LogicalId: id,
463 Props: props,
464 Provider: ProviderTag as Provider<any>,
465 RemovalPolicy: yield* Effect.serviceOption(RemovalPolicy).pipe(
466 Effect.map(Option.getOrElse(() => defaultRemovalPolicy)),
467 ),
468 Adopt: yield* Effect.serviceOption(AdoptPolicy).pipe(
469 Effect.map(Option.getOrUndefined),
470 ),
471 Mode: ambientMode,
472 RequiresImplementation: options?.requiresImplementation || undefined,
473 // Bare-string former ids resolve against the SAME namespace as the
474 // resource's own id, so `renamedFrom("Site/Worker")` declared at the
475 // caller's level claims `<callerNs>/Site/Worker`; the `{ fqn }` form
476 // is absolute (cross-namespace moves).
477 FormerFqns: yield* Effect.serviceOption(RenamePolicy).pipe(
478 Effect.map(
479 Option.match({
480 onNone: () => undefined,
481 onSome: (formerIds) =>
482 formerIds.map((formerId) =>
483 typeof formerId === "string"
484 ? toFqn(namespace, formerId)
485 : formerId.fqn,
486 ),
487 }),
488 ),
489 ),
490 bind,
491 toString(this: typeof target) {
492 return `Resource<${this.Type}>(${this.LogicalId})`;
493 },
494 [Symbol.toPrimitive](this: typeof target, hint: string) {
495 return hint === "number" ? NaN : this.toString();
496 },
497 };
498
499 const Resource: R = (stack.resources[fqn] = new Proxy(target, {
500 set: (t, prop, value) => {
501 t[prop as keyof typeof t] = value;
502 return true;
503 },
504 get: (t, prop) =>
505 typeof prop === "symbol" || prop in t
506 ? t[prop as keyof typeof t]
507 : new Output.PropExpr<any, string>(Output.of(Resource), prop),
508 })) as R;
509 Resource.Props = Effect.isEffect(props)
510 ? // @effect-diagnostics-next-line anyUnknownInErrorContext:off
511 yield* props.pipe(
512 Effect.provide(
513 Layer.mergeAll(
514 Layer.succeed(Self, Resource),
515 Layer.succeed(Self(type), Resource),
516 ),
517 ),
518 )
519 : props;
520 return Resource;
521 });
522
523 const ProviderTag = Provider(type);
524
525 const Service = {
526 /**
527 * Build a typed reference to a deployed instance of this resource
528 * — in the current stack/stage by default, or in another via
529 * `options`. Resolves to the same shape as `yield*
530 * MyResource("id", props)` so downstream code can read attributes
531 * (`ref.someAttr`) exactly the way it would for a locally-declared
532 * resource.
533 */
534 ref: (
535 id: string,
536 options?: { stage?: string; stack?: string },
537 ): Effect.Effect<R> =>
538 Effect.succeed(Output.of(makeRef<R>(id, options, type)) as unknown as R),
539
540 Type: type,
541 Provider: ProviderTag,
542 Self: self,
543 Aliases: options?.aliases,
544 };
545
546 const ResourceClass = Object.assign(
547 (...args: [id: string, props: R["Props"]] | [methods: object]) =>
548 typeof args[0] === "object"
549 ? Object.assign(ResourceClass, args[0])
550 : constructor(...(args as [string, R["Props"]])),
551 Service,
552 // Make the constructor itself a real Effect: `yield* MyResource` resolves
553 // to the constructor function (same as the old `asEffect()`), and
554 // `Effect.isEffect(MyResource)` is now true so `Effect.all`/`forEach` work.
555 Effectable.Prototype({
556 label: `Resource<${type}>`,
557 evaluate: () =>
558 Effect.succeed((id: string, props: R["Props"]) =>
559 constructor(id, props),
560 ),
561 }),
562 ) as any;
563
564 return ResourceClass;
565}