1import * as Duration from "effect/Duration";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import type { Input } from "./Input.ts";
5import * as Output from "./Output.ts";
6import type { BindingNode } from "./Plan.ts";
7import type { ResourceBinding } from "./Resource.ts";
8import { isPlainData, isPrimitive, mapPlainData } from "./Util/data.ts";
9
10export type Diff = NoopDiff | UpdateDiff | ReplaceDiff;
11
12export interface NoopDiff {
13 action: "noop";
14 stables?: undefined;
15}
16
17export interface UpdateDiff {
18 action: "update";
19 /** properties that won't change as part of this update */
20 stables?: string[];
21}
22
23export interface ReplaceDiff {
24 action: "replace";
25 deleteFirst?: boolean;
26 stables?: undefined;
27}
28
29/**
30 * Returns true when `value` (or any nested leaf) is still an unresolved
31 * plan-time expression — i.e. an `Output`/`Expr` or an `Effect` that was
32 * not fully evaluated by `resolveInput` in Plan.ts.
33 *
34 * Use at the top of a provider `diff` to short-circuit before field access:
35 *
36 * ```ts
37 * if (!isResolved(news)) return undefined;
38 * const resolved = news as MyProps;
39 * ```
40 */
41export const hasUnresolvedInputs = <T>(value: Input<NoInfer<T>>): value is T =>
42 _hasUnresolved(value);
43
44export const isResolved = <T>(value: Input<T>): value is T =>
45 !_hasUnresolved(value);
46
47const _hasUnresolved = (
48 value: unknown,
49 seen: WeakSet<object> = new WeakSet(),
50): boolean => {
51 if (value == null || isPrimitive(value)) return false;
52 if (Output.isExpr(value) || Effect.isEffect(value)) return true;
53 // Only plain data is traversed; any other class instance (Layer, Context,
54 // Date, SDK objects) is a resolved leaf — see isPlainData (#1082).
55 if (isPlainData(value)) {
56 if (seen.has(value)) return false;
57 seen.add(value);
58 return Object.values(value).some((v) => _hasUnresolved(v, seen));
59 }
60 return false;
61};
62
63/**
64 * Deeply replace every unresolved plan-time expression (an `Output`/`Expr`
65 * or an un-evaluated `Effect`) with `undefined`, leaving resolved values
66 * (including opaque `Redacted`/`Duration` instances) intact.
67 *
68 * Persisted resource state must only ever hold plain data. Durable (JSON)
69 * state stores already enforce this implicitly — Output proxies are
70 * function-typed, so `JSON.stringify` silently drops them — but the
71 * in-memory store used by tests retains live proxies, which would later be
72 * fed back into provider lifecycle operations as `olds` after an
73 * interrupted apply (e.g. `read` during a destroy plan) and blow up on
74 * first string coercion. Sanitizing at the commit boundary keeps both
75 * store kinds consistent with the provider contract that `olds` is
76 * resolved `Props`.
77 */
78export const stripUnresolved = <T>(value: T): T => _stripUnresolved(value) as T;
79
80const _stripUnresolved = (
81 value: unknown,
82 // Ancestor-path cycle guard: a value on its own ancestor chain is cut to
83 // `undefined` (a cycle can never persist). Sync DFS, so add/delete around
84 // the recursion is race-free and keeps shared diamond references intact
85 // (#1082).
86 ancestors: WeakSet<object> = new WeakSet(),
87): unknown => {
88 if (value == null || isPrimitive(value)) return value;
89 if (Output.isExpr(value) || Effect.isEffect(value)) return undefined;
90 // Serializable leaves — the only class instances persisted state may
91 // carry (StateEncoding knows Redacted/Duration; Date JSON-encodes).
92 // Rebuilding them structurally would strip their prototype.
93 if (
94 Redacted.isRedacted(value) ||
95 Duration.isDuration(value) ||
96 value instanceof Date
97 ) {
98 return value;
99 }
100 if (isPlainData(value)) {
101 return mapPlainData(value, ancestors, (child) =>
102 _stripUnresolved(child, ancestors),
103 );
104 }
105 // Any other class instance (Layer, Context, SDK objects) is runtime-only
106 // wiring that can't round-trip through JSON — a beta.103 Context is even
107 // cyclic (#1082). Persisted state holds plain data only.
108 return undefined;
109};
110
111/**
112 * Deeply replace Effect-valued entries with `undefined`, leaving resolved
113 * values AND unresolved `Output`/`Expr`s intact.
114 *
115 * Effect-valued props — e.g. a tagged Worker class in `env` (the
116 * circular-bindings pattern) — can never be evaluated inside lifecycle
117 * operations, and {@link stripUnresolved} drops them from persisted state at
118 * the commit boundary. A provider `diff` that wants its structural change
119 * detection to still run despite them strips them first, so `isResolved`
120 * gates only on genuinely-unresolved Outputs (#874). The Effects' deploy-time
121 * identity is carried by the resolved binding data instead.
122 */
123export const stripEffects = <T>(value: T): T => _stripEffects(value) as T;
124
125const _stripEffects = (
126 value: unknown,
127 // Ancestor-path cycle guard — see _stripUnresolved (#1082).
128 ancestors: WeakSet<object> = new WeakSet(),
129): unknown => {
130 if (value == null || isPrimitive(value)) return value;
131 // Output proxies are left intact (so `isResolved` still sees them); they
132 // must be tested BEFORE `Effect.isEffect` because Output exprs are
133 // yieldable and would otherwise be misclassified as plain Effects.
134 if (Output.isExpr(value)) return value;
135 if (Effect.isEffect(value)) return undefined;
136 if (
137 Redacted.isRedacted(value) ||
138 Duration.isDuration(value) ||
139 value instanceof Date
140 ) {
141 return value;
142 }
143 if (isPlainData(value)) {
144 return mapPlainData(value, ancestors, (child) =>
145 _stripEffects(child, ancestors),
146 );
147 }
148 // Non-plain instances (Layer, Context, SDK objects) are dropped with
149 // Effects — same runtime-only rationale, and their internals may be
150 // cyclic (effect ≥4.0.0-beta.103's Context — #1082).
151 return undefined;
152};
153
154export const somePropsAreDifferent = <Props extends Record<string, any>>(
155 olds: Props,
156 news: Props,
157 props: (keyof Props)[],
158) => {
159 for (const prop of props) {
160 if (olds[prop] !== news[prop]) {
161 return true;
162 }
163 }
164 return false;
165};
166
167export const anyPropsAreDifferent = <Props extends Record<string, any>>(
168 olds: Props,
169 news: Props,
170) => {
171 for (const prop in olds) {
172 if (olds[prop] !== news[prop]) {
173 return true;
174 }
175 }
176 for (const prop in news) {
177 if (!(prop in olds)) {
178 return true;
179 }
180 }
181 return false;
182};
183
184export const havePropsChanged = <Props extends object>(
185 oldProps: Props | undefined,
186 newProps: Props,
187) =>
188 Output.hasOutputs(newProps) ||
189 // Compare both sides through `stripUnresolved` so the comparison is
190 // symmetric with the commit boundary: persisted props can never hold an
191 // Effect or Output expr (stripped at commit / silently dropped by JSON
192 // serialization), while desired props may still carry them — e.g. a tagged
193 // Worker class in `env` serializes via its `toJSON` to
194 // `{"_id":"Effect",...}` and would otherwise report a phantom change on
195 // every plan, forever (#874). Unresolved Outputs in `newProps` are already
196 // caught by the `hasOutputs` guard above, so stripping here never hides a
197 // real difference.
198 JSON.stringify(canonicalize(stripUnresolved(oldProps ?? {}), false)) !==
199 JSON.stringify(canonicalize(stripUnresolved(newProps ?? {}), false));
200
201export type DeepEqualOptions = {
202 /**
203 * When true, treat `null` and `undefined` as equivalent at any depth.
204 * Useful when comparing cloud-API responses (which often return `null`
205 * for unconfigured optional fields) against desired-state shapes built
206 * from `props?.x` (which leave the same fields `undefined`).
207 *
208 * @default false
209 */
210 stripNullish?: boolean;
211};
212
213/**
214 * Sort-keys deep equality for plain data (objects, arrays, primitives).
215 * Use in provider `diff` handlers instead of ad-hoc `JSON.stringify` comparisons.
216 *
217 * By default, `null` and `undefined` are treated as distinct. Pass
218 * `{ stripNullish: true }` to opt into treating them as equivalent.
219 */
220export const deepEqual = (
221 a: unknown,
222 b: unknown,
223 options?: DeepEqualOptions,
224): boolean =>
225 JSON.stringify(canonicalize(a, options?.stripNullish ?? false)) ===
226 JSON.stringify(canonicalize(b, options?.stripNullish ?? false));
227
228const canonicalize = (
229 value: unknown,
230 stripNullish: boolean,
231 // Ancestor-path cycle guard — see _stripUnresolved (#1082).
232 ancestors: WeakSet<object> = new WeakSet(),
233): unknown => {
234 if (stripNullish && value == null) return undefined;
235 if (Redacted.isRedacted(value)) {
236 return {
237 _tag: "Redacted",
238 value: Redacted.value(value),
239 };
240 }
241 // JSON-safe leaves compare by their serialized form (Duration/Date have
242 // toJSON).
243 if (Duration.isDuration(value) || value instanceof Date) return value;
244 if (isPlainData(value)) {
245 const rebuilt = mapPlainData(value, ancestors, (child) =>
246 canonicalize(child, stripNullish, ancestors),
247 );
248 if (rebuilt === undefined || Array.isArray(rebuilt)) return rebuilt;
249 // Deterministic key order for the JSON.stringify comparison. Filtering
250 // after the walk is JSON-equivalent to filtering before it —
251 // undefined-valued keys are dropped by JSON.stringify either way.
252 return Object.fromEntries(
253 Object.entries(rebuilt as Record<string, unknown>)
254 .filter(([, nested]) => !stripNullish || nested != null)
255 .sort(([a], [b]) => a.localeCompare(b)),
256 );
257 }
258 if (value && typeof value === "object") {
259 // Non-plain instances (Effect, Layer, Context, SDK objects) never
260 // canonicalize — walking them is unsafe (cyclic on effect
261 // ≥4.0.0-beta.103 — #1082) and comparing them is meaningless.
262 return undefined;
263 }
264 return value;
265};
266
267/**
268 * Deterministic ordering for binding rows.
269 *
270 * Bindings are registered by concurrently-built layers (a Function/Worker's
271 * capability layers run real IO — bundling, fs — before calling `host.bind`),
272 * so the registration order of `stack.bindings[fqn]` is not stable across
273 * deploys. `diffBindings` itself is sid-keyed and order-insensitive, but the
274 * row order flows into provider `diff`/`reconcile` inputs and persisted
275 * state — any consumer that hashes or deep-compares the binding array (e.g.
276 * a metadata hash over bindings) would churn on registration-order flips.
277 * Sorting by `sid` at every boundary makes binding rows stable.
278 */
279const bySid = (a: { sid: string }, b: { sid: string }): number =>
280 a.sid < b.sid ? -1 : a.sid > b.sid ? 1 : 0;
281
282export const sortBindings = <B extends { sid: string }>(bindings: B[]): B[] =>
283 [...bindings].sort(bySid);
284
285/**
286 * Collapse bindings that share the same `sid`, keeping the last occurrence,
287 * and return them in deterministic (sid-sorted) order.
288 *
289 * The same binding can be recorded more than once on a target resource — e.g.
290 * a KV namespace bound to both a Worker and a Workflow ends up pushed twice to
291 * `stack.bindings[fqn]`. `diffBindings` already collapses these implicitly via
292 * its `Map` keyed by `sid`, so the `reconcile` path never observes duplicates.
293 * Use this helper to give a provider's `diff` handler the same de-duplicated
294 * binding set, keeping plan-time hashing consistent with deploy-time.
295 */
296export const dedupeBindings = <B extends ResourceBinding>(bindings: B[]): B[] =>
297 sortBindings(Array.from(new Map(bindings.map((b) => [b.sid, b])).values()));
298
299export const diffBindings = (
300 oldBindings: ResourceBinding[],
301 newBindings: ResourceBinding[],
302): BindingNode[] => {
303 const oldMap = new Map(oldBindings.map((b) => [b.sid, b]));
304 const newMap = new Map(newBindings.map((b) => [b.sid, b]));
305 return sortBindings([
306 ...Array.from(oldMap)
307 .filter(([sid]) => !newMap.has(sid))
308 .map(([sid, old]) => ({
309 sid,
310 action: "delete" as const,
311 data: old.data,
312 })),
313 ...Array.from(newMap).map(([sid, binding]) => {
314 const old = oldMap.get(sid);
315 return {
316 sid,
317 action: (!old
318 ? "create"
319 : havePropsChanged(old.data, binding.data)
320 ? "update"
321 : "noop") as BindingNode["action"],
322 data: binding.data,
323 };
324 }),
325 ]);
326};