|
| 1 | +# Opaque Values, Closures, and ShadowRefs Documentation |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +This document explains how opaque values, closures, and shadowrefs work in the |
| 6 | +CommonTools builder system, with a focus on what should be seen in a shadowref. |
| 7 | + |
| 8 | +## Opaque Values and OpaqueRef |
| 9 | + |
| 10 | +### What are Opaque Values? |
| 11 | + |
| 12 | +Opaque values are wrapped values that represent future cells in the recipe |
| 13 | +system. They are created using the `opaqueRef()` function and serve as proxies |
| 14 | +for values that will eventually be materialized. |
| 15 | + |
| 16 | +### OpaqueRef Implementation |
| 17 | + |
| 18 | +From `packages/runner/src/builder/opaque-ref.ts`: |
| 19 | + |
| 20 | +```typescript |
| 21 | +export function opaqueRef<T>( |
| 22 | + value?: Opaque<T> | T, |
| 23 | + schema?: JSONSchema, |
| 24 | +): OpaqueRef<T>; |
| 25 | +``` |
| 26 | + |
| 27 | +Key features: |
| 28 | + |
| 29 | +- **Proxy-based**: Uses JavaScript Proxy to intercept property access |
| 30 | +- **Nested access**: Supports deep property access (e.g., `ref.a.b.c`) |
| 31 | +- **Methods**: Provides `.set()`, `.get()`, `.setDefault()`, `.setName()`, |
| 32 | + `.setSchema()` |
| 33 | +- **Frame tracking**: Each OpaqueRef is associated with a recipe frame |
| 34 | +- **Connection tracking**: Tracks which nodes use this ref |
| 35 | + |
| 36 | +### Internal Structure |
| 37 | + |
| 38 | +An OpaqueRef maintains: |
| 39 | + |
| 40 | +```typescript |
| 41 | +const store = { |
| 42 | + value, // The actual value |
| 43 | + defaultValue: undefined, // Default value if not set |
| 44 | + nodes: new Set<NodeRef>(), // Connected nodes |
| 45 | + frame: getTopFrame()!, // Recipe frame context |
| 46 | + name: undefined, // Optional name |
| 47 | + schema: schema, // JSON schema |
| 48 | +}; |
| 49 | +``` |
| 50 | + |
| 51 | +## Closures in Recipes |
| 52 | + |
| 53 | +### Recipe Closures |
| 54 | + |
| 55 | +Closures occur when recipes capture variables from their surrounding scope. This |
| 56 | +is particularly important for: |
| 57 | + |
| 58 | +- Map operations that use external factors |
| 59 | +- Lifted functions that reference outer variables |
| 60 | +- Computed values that depend on parent recipe context |
| 61 | + |
| 62 | +Example from tests: |
| 63 | + |
| 64 | +```typescript |
| 65 | +const doubleArray = recipe<{ values: number[]; factor: number }>( |
| 66 | + "Double numbers", |
| 67 | + ({ values, factor }) => { |
| 68 | + const doubled = values.map((x) => double({ x, factor })); |
| 69 | + return { doubled }; |
| 70 | + }, |
| 71 | +); |
| 72 | +``` |
| 73 | + |
| 74 | +### Unsafe Closures |
| 75 | + |
| 76 | +The system tracks "unsafe closures" - recipes that reference values from parent |
| 77 | +frames: |
| 78 | + |
| 79 | +- Marked with `unsafe_originalRecipe` symbol |
| 80 | +- Require special materialization handling |
| 81 | +- Must track parent recipe relationships |
| 82 | + |
| 83 | +## ShadowRefs |
| 84 | + |
| 85 | +### What is a ShadowRef? |
| 86 | + |
| 87 | +A ShadowRef is a reference to an OpaqueRef from a different (parent) frame. It |
| 88 | +acts as a cross-frame pointer to maintain referential integrity across recipe |
| 89 | +boundaries. |
| 90 | + |
| 91 | +### ShadowRef Structure |
| 92 | + |
| 93 | +From `packages/runner/src/builder/types.ts`: |
| 94 | + |
| 95 | +```typescript |
| 96 | +export type ShadowRef = { |
| 97 | + shadowOf: OpaqueRef<any> | ShadowRef; |
| 98 | +}; |
| 99 | +``` |
| 100 | + |
| 101 | +### When are ShadowRefs Created? |
| 102 | + |
| 103 | +ShadowRefs are created when: |
| 104 | + |
| 105 | +1. An OpaqueRef from a parent frame is referenced in a child frame |
| 106 | +2. During recipe factory creation when collecting cells and nodes |
| 107 | +3. When connecting nodes across frame boundaries |
| 108 | + |
| 109 | +From `packages/runner/src/builder/node-utils.ts`: |
| 110 | + |
| 111 | +```typescript |
| 112 | +if (value.export().frame !== node.frame) return createShadowRef(value); |
| 113 | +``` |
| 114 | + |
| 115 | +### What Should Be Seen in a ShadowRef? |
| 116 | + |
| 117 | +A ShadowRef should contain: |
| 118 | + |
| 119 | +1. **shadowOf property**: Reference to the original OpaqueRef (or another |
| 120 | + ShadowRef) |
| 121 | + - Points to the actual value holder |
| 122 | + - Maintains the chain back to the original ref |
| 123 | + |
| 124 | +2. **Cross-frame reference**: Indicates that the referenced value exists in a |
| 125 | + different recipe frame |
| 126 | + - Prevents direct cross-frame mutations |
| 127 | + - Ensures proper value propagation |
| 128 | + |
| 129 | +3. **Serialization format**: When serialized to JSON: |
| 130 | + |
| 131 | + ```typescript |
| 132 | + { |
| 133 | + $alias: { |
| 134 | + cell: shadowRef, // The shadow reference itself |
| 135 | + path: [...], // Path to the value |
| 136 | + schema: {...}, // Optional schema information |
| 137 | + rootSchema: {...} // Optional root schema |
| 138 | + } |
| 139 | + } |
| 140 | + ``` |
| 141 | + |
| 142 | +### When are ShadowRefs Dereferenced? |
| 143 | + |
| 144 | +ShadowRefs are dereferenced in several key scenarios: |
| 145 | + |
| 146 | +#### 1. During Recipe Factory Creation |
| 147 | + |
| 148 | +From `packages/runner/src/builder/recipe.ts`: |
| 149 | + |
| 150 | +```typescript |
| 151 | +if (isShadowRef(value)) { |
| 152 | + shadows.add(value); |
| 153 | + if ( |
| 154 | + isOpaqueRef(value.shadowOf) && |
| 155 | + value.shadowOf.export().frame === getTopFrame() |
| 156 | + ) { |
| 157 | + cells.add(value.shadowOf); // Dereference to add the actual cell |
| 158 | + } |
| 159 | +} |
| 160 | +``` |
| 161 | + |
| 162 | +**Purpose**: When collecting cells and nodes for a recipe, shadowrefs are |
| 163 | +dereferenced to: |
| 164 | + |
| 165 | +- Add the actual OpaqueRef to the cells collection if it belongs to the current |
| 166 | + frame |
| 167 | +- Ensure proper graph traversal and dependency tracking |
| 168 | +- Maintain the connection between shadowrefs and their target cells |
| 169 | + |
| 170 | +#### 2. During JSON Serialization |
| 171 | + |
| 172 | +From `packages/runner/src/builder/json-utils.ts`: |
| 173 | + |
| 174 | +```typescript |
| 175 | +if (isShadowRef(alias.cell)) { |
| 176 | + const cell = alias.cell.shadowOf; // Dereference to get the actual cell |
| 177 | + if (cell.export().frame !== getTopFrame()) { |
| 178 | + // Frame validation logic... |
| 179 | + } |
| 180 | + if (!paths.has(cell)) throw new Error(`Cell not found in paths`); |
| 181 | + return { |
| 182 | + $alias: { |
| 183 | + path: [...paths.get(cell)!, ...alias.path] as (string | number)[], |
| 184 | + }, |
| 185 | + } satisfies LegacyAlias; |
| 186 | +} |
| 187 | +``` |
| 188 | + |
| 189 | +**Purpose**: During serialization, shadowrefs are dereferenced to: |
| 190 | + |
| 191 | +- Access the actual OpaqueRef for path resolution |
| 192 | +- Validate frame relationships |
| 193 | +- Create proper alias structures with resolved paths |
| 194 | +- Ensure the serialized form correctly represents the cross-frame reference |
| 195 | + |
| 196 | +#### 3. During Node Connection |
| 197 | + |
| 198 | +From `packages/runner/src/builder/node-utils.ts`: |
| 199 | + |
| 200 | +```typescript |
| 201 | +if (isOpaqueRef(value)) { |
| 202 | + // Return shadow ref if this is a parent opaque ref. Note: No need to |
| 203 | + // connect to the cell. The connection is there to traverse the graph to |
| 204 | + // find all other nodes, but this points to the parent graph instead. |
| 205 | + if (value.export().frame !== node.frame) return createShadowRef(value); |
| 206 | + value.connect(node); |
| 207 | +} |
| 208 | +``` |
| 209 | + |
| 210 | +### ShadowRef Resolution During Recipe Instantiation |
| 211 | + |
| 212 | +When a recipe is instantiated and executed, shadowrefs are resolved through the |
| 213 | +`unsafe_materialize` mechanism. This happens when OpaqueRefs (including those |
| 214 | +referenced by shadowrefs) are accessed during recipe execution. |
| 215 | + |
| 216 | +#### The Resolution Process |
| 217 | + |
| 218 | +From `packages/runner/src/builder/opaque-ref.ts`: |
| 219 | + |
| 220 | +```typescript |
| 221 | +function unsafe_materialize( |
| 222 | + binding: { recipe: Recipe; path: PropertyKey[] } | undefined, |
| 223 | + path: PropertyKey[], |
| 224 | +) { |
| 225 | + if (!binding) throw new Error("Can't read value during recipe creation."); |
| 226 | + |
| 227 | + // Find first frame with unsafe binding |
| 228 | + let frame = getTopFrame(); |
| 229 | + let unsafe_binding: UnsafeBinding | undefined; |
| 230 | + while (frame && !unsafe_binding) { |
| 231 | + unsafe_binding = frame.unsafe_binding; |
| 232 | + frame = frame.parent; |
| 233 | + } |
| 234 | + |
| 235 | + // Walk up the chain until we find the original recipe |
| 236 | + while (unsafe_binding && unsafe_binding.parent?.recipe === binding.recipe) { |
| 237 | + unsafe_binding = unsafe_binding.parent; |
| 238 | + } |
| 239 | + |
| 240 | + if (!unsafe_binding) throw new Error("Can't find recipe in parent frames."); |
| 241 | + |
| 242 | + return unsafe_binding.materialize([...binding.path, ...path]); |
| 243 | +} |
| 244 | +``` |
| 245 | + |
| 246 | +#### How ShadowRef Resolution Works |
| 247 | + |
| 248 | +The resolution process follows this flow: |
| 249 | + |
| 250 | +1. **OpaqueRef Access**: When an OpaqueRef is accessed (via `.get()`, property |
| 251 | + access, or `Symbol.toPrimitive`), it calls `unsafe_materialize` |
| 252 | + |
| 253 | +2. **Frame Traversal**: `unsafe_materialize` walks up the frame stack to find |
| 254 | + the first frame with an `unsafe_binding` |
| 255 | + |
| 256 | +3. **Recipe Chain Resolution**: It then walks up the chain of |
| 257 | + `unsafe_binding.parent` references until it finds the original recipe that |
| 258 | + contains the shadowref |
| 259 | + |
| 260 | +4. **Materialization**: Finally, it calls |
| 261 | + `unsafe_binding.materialize([...binding.path, ...path])` which resolves the |
| 262 | + actual value |
| 263 | + |
| 264 | +#### Where UnsafeBinding is Set Up |
| 265 | + |
| 266 | +The `unsafe_binding` is created during recipe execution in |
| 267 | +`packages/runner/src/runner.ts`: |
| 268 | + |
| 269 | +```typescript |
| 270 | +const frame = pushFrameFromCause( |
| 271 | + { inputs, outputs, fn: fn.toString() }, |
| 272 | + { |
| 273 | + recipe, |
| 274 | + materialize: (path: readonly PropertyKey[]) => |
| 275 | + processCell.getAsQueryResult(path, tx), |
| 276 | + space: processCell.space, |
| 277 | + tx, |
| 278 | + } satisfies UnsafeBinding, |
| 279 | +); |
| 280 | +``` |
| 281 | + |
| 282 | +#### ShadowRef to Real Value Conversion |
| 283 | + |
| 284 | +During this process: |
| 285 | + |
| 286 | +- **ShadowRefs** → **OpaqueRefs** → **Real Values** |
| 287 | +- When a shadowref is accessed, `unsafe_materialize` follows the `shadowOf` |
| 288 | + chain |
| 289 | +- The `materialize` function uses `processCell.getAsQueryResult(path, tx)` to |
| 290 | + get the actual value |
| 291 | +- This resolves cross-frame references by accessing the actual cell data in the |
| 292 | + correct execution context |
| 293 | + |
| 294 | +#### When Resolution Occurs |
| 295 | + |
| 296 | +Shadowref resolution happens automatically when: |
| 297 | + |
| 298 | +1. **Property Access**: Accessing properties on OpaqueRefs that reference |
| 299 | + shadowrefs |
| 300 | +2. **Value Retrieval**: Calling `.get()` on OpaqueRefs |
| 301 | +3. **Primitive Conversion**: When OpaqueRefs are converted to primitives (via |
| 302 | + `Symbol.toPrimitive`) |
| 303 | +4. **Recipe Execution**: During the execution of recipes that contain shadowrefs |
| 304 | + |
| 305 | +This resolution is essential for: |
| 306 | + |
| 307 | +- **Value Access**: Converting cross-frame references into accessible values |
| 308 | +- **Reactivity**: Ensuring that changes to shadowrefs propagate correctly |
| 309 | +- **Execution Context**: Binding shadowrefs to the correct execution frame |
| 310 | +- **Data Flow**: Maintaining proper data flow across recipe boundaries |
| 311 | + |
| 312 | +### ShadowRef Usage in JSON Serialization |
| 313 | + |
| 314 | +From `packages/runner/src/builder/json-utils.ts`: |
| 315 | + |
| 316 | +- ShadowRefs are handled specially during serialization |
| 317 | +- They maintain the reference structure when converting to JSON |
| 318 | +- The system tracks paths to resolve shadow references correctly |
| 319 | + |
| 320 | +### Key Properties of ShadowRefs |
| 321 | + |
| 322 | +1. **Immutability**: ShadowRefs are read-only references |
| 323 | +2. **Frame safety**: Prevent direct cross-frame mutations |
| 324 | +3. **Path preservation**: Maintain the path to the original value |
| 325 | +4. **Type checking**: `isShadowRef()` function for runtime type checking |
| 326 | + |
| 327 | +## Nested Recipes and ShadowRef Resolution |
| 328 | + |
| 329 | +### The Issue with Nested Recipes |
| 330 | + |
| 331 | +When a recipe contains another recipe (nested recipes), a subtle issue can arise during JSON serialization: |
| 332 | + |
| 333 | +1. **Build Time**: `toJSONWithLegacyAliases` processes the outer recipe and converts shadowrefs to proper aliases |
| 334 | +2. **The Problem**: Nested recipes retain their original `toJSON()` method, which has a closure referencing the un-transformed recipe containing shadowrefs |
| 335 | +3. **Runtime**: When the nested recipe's `toJSON()` is called, it returns the original structure with shadowrefs, which the runtime cannot handle |
| 336 | + |
| 337 | +### Example |
| 338 | + |
| 339 | +```typescript |
| 340 | +const innerRecipe = recipe<{ x: number }>('Inner', ({ x }) => { |
| 341 | + // This recipe might capture variables from parent scope |
| 342 | + return { squared: x * x }; |
| 343 | +}); |
| 344 | + |
| 345 | +const outerRecipe = recipe<{ value: number }>('Outer', ({ value }) => { |
| 346 | + // When serialized, innerRecipe keeps its original toJSON method |
| 347 | + const nested = innerRecipe({ x: value }); |
| 348 | + return { nested }; |
| 349 | +}); |
| 350 | +``` |
| 351 | + |
| 352 | +### The Solution |
| 353 | + |
| 354 | +In `toJSONWithLegacyAliases`, nested recipes must be handled specially: |
| 355 | + |
| 356 | +```typescript |
| 357 | +if (isRecipe(value) && typeof value.toJSON === 'function') { |
| 358 | + // Call toJSON() to get the properly serialized version |
| 359 | + value = value.toJSON(); |
| 360 | +} |
| 361 | +// Then continue processing the serialized result |
| 362 | +``` |
| 363 | + |
| 364 | +This ensures that: |
| 365 | +- Shadowrefs are resolved during the build phase |
| 366 | +- Nested recipes don't keep their original `toJSON` method |
| 367 | +- The runtime never encounters shadowrefs |
| 368 | + |
| 369 | +### Key Insight |
| 370 | + |
| 371 | +**ShadowRefs should never reach the runtime**. They are build-time constructs that must be resolved during recipe serialization. The runtime only understands: |
| 372 | +- Numbers (for nested recipe references) |
| 373 | +- Entity IDs (in the format `{ "/": "..." }`) |
| 374 | +- Resolved cell references |
| 375 | + |
| 376 | +If shadowrefs appear at runtime, it indicates a serialization bug where the build-time resolution process was incomplete. |
| 377 | + |
| 378 | +## Summary |
| 379 | + |
| 380 | +- **OpaqueRef**: Proxy-based future value holders within a recipe frame |
| 381 | +- **Closures**: Captured variables requiring special handling for cross-recipe |
| 382 | + references |
| 383 | +- **ShadowRef**: Cross-frame references that maintain referential integrity |
| 384 | + |
| 385 | +ShadowRefs should be seen as lightweight pointers that: |
| 386 | + |
| 387 | +- Reference values from parent frames |
| 388 | +- Prevent direct cross-frame mutations |
| 389 | +- Preserve the connection to the original OpaqueRef |
| 390 | +- Enable proper serialization and deserialization of cross-frame references |
| 391 | +- Must be fully resolved before reaching runtime execution |
0 commit comments