1import * as Context from "effect/Context";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import { AlchemyContext } from "./AlchemyContext.ts";
6
7/**
8 * The mode a resource's provider operates in.
9 *
10 * - `"live"` — the provider converges real cloud state.
11 * - `"local"` — the provider emulates the resource on the developer's
12 * machine (`alchemy dev`), typically as a long-running process managed
13 * by the dev sidecar.
14 *
15 * The mode a resource was last reconciled with is persisted on its state
16 * row (`providerMode`). Switching a resource between modes is planned as a
17 * **replacement**: the new instance is created with the new mode's provider
18 * and the old instance is deleted with the provider of the mode that
19 * created it.
20 */
21export type ProviderMode = "live" | "local";
22
23/**
24 * ProviderModePolicy opts resources OUT of local emulation: when `true`,
25 * resources registered while it is in context resolve the **live**
26 * provider even during `alchemy dev`.
27 *
28 * Apply it with the {@link remote} combinator. During `alchemy deploy`
29 * everything is live anyway, so the policy only has an effect in dev.
30 *
31 * Tri-state `Context.Reference`: `undefined` (the default — no explicit
32 * decoration, inherit/run default), `true` (`remote()`), `false`
33 * (`remote(false)` — explicitly follow the run default again). The unset vs
34 * explicit distinction is load-bearing for conflict detection at
35 * registration (see `Resource.ts`).
36 */
37export const ProviderModePolicy = Context.Reference<boolean | undefined>(
38 "ProviderModePolicy",
39 { defaultValue: () => undefined },
40);
41
42/**
43 * The same resource (identified by FQN) was registered (`yield*`ed) from two
44 * places whose ambient {@link ProviderModePolicy} disagree — e.g. once inside
45 * `remote()` and once without it. Context-based decoration cannot decide which
46 * one wins, so the engine fails loudly instead of silently picking one.
47 *
48 * Fix: register the resource once and close over the returned value, or make
49 * both registration sites agree.
50 */
51export class ConflictingProviderModeError extends Data.TaggedError(
52 "ConflictingProviderModeError",
53)<{
54 message: string;
55 fqn: string;
56 /** The mode captured at the first registration site (undefined = default). */
57 existingMode: ProviderMode | undefined;
58 /** The explicit mode at the conflicting registration site. */
59 conflictingMode: ProviderMode | undefined;
60}> {}
61
62/**
63 * Prefix marking a locally-emulated resource's physical identity. Local
64 * providers fabricate ids/names with this prefix (`dev:<uuid>` queue and
65 * namespace ids, `dev:`-prefixed bucket names, ...) so a resource's
66 * persisted attributes reveal which runtime hosts it even without the
67 * `providerMode` stamp.
68 */
69export const LOCAL_ID_PREFIX = "dev:";
70
71/**
72 * Does a persisted attributes value carry a local identity marker — any
73 * string value (at any depth) with the {@link LOCAL_ID_PREFIX}? Persisted
74 * attrs are plain JSON (state commits strip unresolved values), so a
75 * structural scan is safe.
76 */
77export const hasLocalIdentity = (value: unknown): boolean => {
78 if (typeof value === "string") return value.startsWith(LOCAL_ID_PREFIX);
79 if (Array.isArray(value)) return value.some(hasLocalIdentity);
80 if (typeof value === "object" && value !== null) {
81 return Object.values(value).some(hasLocalIdentity);
82 }
83 return false;
84};
85
86/**
87 * The mode a persisted state row was actually reconciled with: its stamped
88 * `providerMode`, or — for legacy rows written before stamping existed —
89 * `"local"` when the persisted attributes carry a {@link LOCAL_ID_PREFIX}
90 * identity marker, `"live"` otherwise.
91 *
92 * An unstamped row without a marker predates provider modes or was written
93 * by a mode-agnostic provider — in both cases the write acted on the REAL
94 * cloud, so the row's physical resource is `"live"`. Assuming the current
95 * run's mode instead silently adopts a deployed cloud resource as a local
96 * instance during `alchemy dev`: the row noops (or restarts locally over
97 * the live attrs), gets re-stamped `"local"`, and the live resource becomes
98 * untracked — it is never deleted by a later destroy or replacement, and
99 * its deployed URL keeps serving.
100 *
101 * An unstamped row WITH a marker (e.g. a queue with a `dev:<uuid>` id
102 * written by `alchemy dev` on a pre-stamping version) is the mirror case:
103 * assuming `"live"` hands the `dev:` identity to the live provider, which
104 * sends it to the real cloud API (Cloudflare rejects it as a malformed
105 * parameter, permanently wedging the destroy). The marker proves the row
106 * was reconciled locally, so it is handled as `"local"`.
107 *
108 * For mode-agnostic providers the returned mode is harmless either way:
109 * `providerForMode` ignores the mode when the provider has a single
110 * implementation.
111 */
112export const stampedMode = (row: {
113 readonly providerMode?: ProviderMode | undefined;
114 readonly attr?: unknown;
115}): ProviderMode =>
116 row.providerMode ?? (hasLocalIdentity(row.attr) ? "local" : "live");
117
118/**
119 * Run the wrapped resources **remotely (against the real cloud) even during
120 * `alchemy dev`** — the opt-out from local emulation. During `alchemy deploy`
121 * this is a no-op (everything is remote).
122 *
123 * Captured at registration time like `adopt()` / `retain()`, so it can be
124 * applied to a single resource or a whole scope:
125 *
126 * ```ts
127 * // This queue talks to real Cloudflare even in dev
128 * const queue = yield* Queue("Jobs", {}).pipe(Alchemy.remote());
129 *
130 * // Everything in this scope runs live in dev
131 * yield* Effect.gen(function* () {
132 * const queue = yield* Queue("Jobs", {});
133 * const consumer = yield* Consumer("JobsConsumer", { queue });
134 * }).pipe(Alchemy.remote());
135 * ```
136 *
137 * `remote(false)` (or an `Effect<boolean>` resolving to false) removes the
138 * pin — the resource follows the run default again.
139 *
140 * Providers with a single implementation are mode-agnostic and already run
141 * live in dev; `remote()` on them is a no-op.
142 */
143export const remote: {
144 // Identity-typed so branded effect interfaces (e.g. a Worker-only
145 // binding's `BindingEffect`) survive the pipe with their brand intact.
146 (
147 enabled?: boolean,
148 ): <Eff extends Effect.Effect<any, any, any>>(effect: Eff) => Eff;
149 <R1 = never>(
150 enabled: Effect.Effect<boolean, never, R1>,
151 ): <A, E, R2 = never>(
152 effect: Effect.Effect<A, E, R2>,
153 ) => Effect.Effect<A, E, R1 | R2>;
154} = ((enabled: boolean | Effect.Effect<boolean, never, any> = true) =>
155 (eff: Effect.Effect<any, any, any>) =>
156 eff.pipe(
157 typeof enabled === "boolean"
158 ? Effect.provideService(ProviderModePolicy, enabled)
159 : Effect.provideServiceEffect(ProviderModePolicy, enabled),
160 )) as any;
161
162/**
163 * Resolve the run-level default provider mode:
164 *
165 * 1. An ambient {@link ProviderModePolicy} of `true` (e.g. `remote()` wrapped
166 * around a whole program) forces `"live"`.
167 * 2. Otherwise `AlchemyContext.dev` decides: `dev: true` → `"local"`.
168 * 3. Without an AlchemyContext (bare engine tests), default to `"live"`.
169 */
170export const defaultProviderMode: Effect.Effect<ProviderMode> = Effect.gen(
171 function* () {
172 if (yield* ProviderModePolicy) return "live" as const;
173 const ctx = yield* Effect.serviceOption(AlchemyContext);
174 return Option.match(ctx, {
175 onNone: () => "live" as const,
176 onSome: (c) => (c.dev ? ("local" as const) : ("live" as const)),
177 });
178 },
179);