1import * as Context from "effect/Context";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4
5/**
6 * AdoptPolicy controls whether the engine should "adopt" pre-existing cloud
7 * resources that conflict with this stack instead of failing.
8 *
9 * The engine always calls a provider's `read` lifecycle operation when there
10 * is no prior state and `read` is implemented. The provider's job is to
11 * report two things:
12 *
13 * 1. Whether the resource exists (returns attributes vs. `undefined`).
14 * 2. Whether *we* own it (plain attributes vs. {@link Unowned}-branded).
15 *
16 * The engine then routes:
17 *
18 * | `read` outcome | `adopt: false` | `adopt: true` |
19 * | ------------------- | -------------- | ----------------- |
20 * | undefined | create | create |
21 * | owned (plain attrs) | silent adopt | silent adopt |
22 * | {@link Unowned} | fail | takeover (adopt) |
23 *
24 * "Owned" means a provider has affirmatively determined the resource was
25 * created by this stack/stage/logical-id (typically by inspecting tags or
26 * naming conventions). Resources without ownership semantics simply always
27 * return plain attrs — they are treated as owned by default, so silent
28 * adoption is the norm and `--adopt` is unnecessary.
29 *
30 * The policy can be overridden per-effect via {@link adopt} — most commonly
31 * applied at the resource or stack scope.
32 */
33export class AdoptPolicy extends Context.Service<AdoptPolicy, boolean>()(
34 "AdoptPolicy",
35) {}
36
37/**
38 * Engine-raised failure produced when `read` reports an existing resource
39 * marked as {@link Unowned} and `adopt` is `false`. Surface this to the user
40 * to indicate they should either re-tag the cloud resource, pick a different
41 * physical name, or re-run with `--adopt` (or `adopt(true)`) to force a
42 * takeover.
43 */
44export class OwnedBySomeoneElse extends Data.TaggedError("OwnedBySomeoneElse")<{
45 /** A human-readable description of why the resource cannot be adopted. */
46 message: string;
47 /** The Resource Type (e.g. `AWS.S3.Bucket`). */
48 resourceType?: string;
49 /** The logical ID of the conflicting resource. */
50 logicalId?: string;
51 /** The physical name/identifier of the conflicting resource. */
52 physicalName?: string;
53}> {}
54
55/**
56 * Private symbol used to brand attribute objects returned by `read` as
57 * "exists in cloud, but not owned by this stack/stage/logical-id".
58 *
59 * Deliberately *not* registered via `Symbol.for` — the brand is an
60 * engine-internal routing hint and must not be observable or
61 * forgeable from outside this module. The engine strips it before any
62 * state persistence (see {@link stripUnowned}) so it never leaks into
63 * the state store, JSON serialization, or downstream `Output`s.
64 */
65const UnownedTag: unique symbol = Symbol("alchemy/Unowned");
66
67/**
68 * Brand a `read` return value as belonging to a different owner.
69 *
70 * Use this in a provider's `read` implementation when the resource exists in
71 * the cloud but a positive ownership check (e.g. tag inspection) shows it
72 * was *not* created by this stack/stage/logical-id:
73 *
74 * ```ts
75 * read: Effect.fn(function* ({ id, output, olds }) {
76 * const settings = yield* getScriptSettings({ ... }).pipe(
77 * Effect.catchTag("WorkerNotFound", () => Effect.succeed(undefined)),
78 * );
79 * if (!settings) return undefined;
80 *
81 * const attrs = { workerName, ...buildAttrs(settings) };
82 * return hasAlchemyWorkerTags(id, settings.tags) ? attrs : Unowned(attrs);
83 * }),
84 * ```
85 *
86 * The returned value is structurally still `T` — there is no wrapper to
87 * unwrap. The brand is a non-enumerable symbol that the engine inspects
88 * during planning to decide whether to take over (`adopt: true`) or fail
89 * loudly (`adopt: false`).
90 *
91 * Resources without ownership semantics should simply return plain attrs —
92 * the engine treats them as owned and silent adoption is the default.
93 */
94export const Unowned: {
95 <T extends object>(attrs: T): T;
96 is: (value: unknown) => boolean;
97} = Object.assign(
98 <T extends object>(attrs: T): T => {
99 const cloned = { ...attrs } as T;
100 Object.defineProperty(cloned, UnownedTag, {
101 value: true,
102 enumerable: false,
103 writable: false,
104 configurable: false,
105 });
106 return cloned;
107 },
108 {
109 is: (value: unknown): boolean =>
110 typeof value === "object" &&
111 value !== null &&
112 (value as any)[UnownedTag] === true,
113 },
114);
115
116/**
117 * Strip the {@link Unowned} brand from an attributes object before persisting
118 * it — state should not carry per-deploy ownership-routing metadata.
119 *
120 * The brand is intentionally non-enumerable, so a plain spread already
121 * drops it; the additional `delete` is defense-in-depth in case a future
122 * change makes the descriptor enumerable. Always returns a fresh object
123 * (never mutates the input).
124 */
125export const stripUnowned = <T extends object>(attrs: T): T => {
126 const out: any = { ...attrs };
127 if (UnownedTag in out) delete out[UnownedTag];
128 return out;
129};
130
131export const adopt: {
132 (
133 enabled?: boolean,
134 ): <A, E, R = never>(
135 effect: Effect.Effect<A, E, R>,
136 ) => Effect.Effect<A, E, R>;
137 <R1 = never>(
138 enabled: Effect.Effect<boolean, never, R1>,
139 ): <A, E, R2 = never>(
140 effect: Effect.Effect<A, E, R2>,
141 ) => Effect.Effect<A, E, R1 | R2>;
142} = ((enabled: boolean | Effect.Effect<boolean, never, any> = true) =>
143 (eff: Effect.Effect<any, any, any>) =>
144 eff.pipe(
145 typeof enabled === "boolean"
146 ? Effect.provideService(AdoptPolicy, enabled ?? true)
147 : Effect.provideServiceEffect(AdoptPolicy, enabled),
148 )) as any;