| 1 | import * as Duration from "effect/Duration"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Redacted from "effect/Redacted"; |
| 4 | import type { Input } from "./Input.ts"; |
| 5 | import * as Output from "./Output.ts"; |
| 6 | import type { BindingNode } from "./Plan.ts"; |
| 7 | import type { ResourceBinding } from "./Resource.ts"; |
| 8 | import { isPlainData, isPrimitive, mapPlainData } from "./Util/data.ts"; |
| 9 | |
| 10 | export type Diff = NoopDiff | UpdateDiff | ReplaceDiff; |
| 11 | |
| 12 | export interface NoopDiff { |
| 13 | action: "noop"; |
| 14 | stables?: undefined; |
| 15 | } |
| 16 | |
| 17 | export interface UpdateDiff { |
| 18 | action: "update"; |
| 19 | /** properties that won't change as part of this update */ |
| 20 | stables?: string[]; |
| 21 | } |
| 22 | |
| 23 | export 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 | */ |
| 41 | export const hasUnresolvedInputs = <T>(value: Input<NoInfer<T>>): value is T => |
| 42 | _hasUnresolved(value); |
| 43 | |
| 44 | export const isResolved = <T>(value: Input<T>): value is T => |
| 45 | !_hasUnresolved(value); |
| 46 | |
| 47 | const _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 | */ |
| 78 | export const stripUnresolved = <T>(value: T): T => _stripUnresolved(value) as T; |
| 79 | |
| 80 | const _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 | */ |
| 123 | export const stripEffects = <T>(value: T): T => _stripEffects(value) as T; |
| 124 | |
| 125 | const _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 | |
| 154 | export 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 | |
| 167 | export 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 | |
| 184 | export 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 | |
| 201 | export 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 | */ |
| 220 | export 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 | |
| 228 | const 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 | */ |
| 279 | const bySid = (a: { sid: string }, b: { sid: string }): number => |
| 280 | a.sid < b.sid ? -1 : a.sid > b.sid ? 1 : 0; |
| 281 | |
| 282 | export 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 | */ |
| 296 | export const dedupeBindings = <B extends ResourceBinding>(bindings: B[]): B[] => |
| 297 | sortBindings(Array.from(new Map(bindings.map((b) => [b.sid, b])).values())); |
| 298 | |
| 299 | export 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 | }; |