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