Skip to content

Commit 9e9413e

Browse files
authored
feat(runner): change immutable cells to use data: URIs (#1449)
feat(runner): change immutable cells to use data: URIs Also requires preserving functions through serialization - Convert immutable cells to data URIs - Add FunctionCache class to avoid re-evaluating JavaScript functions - Improve data link resolution with proper path handling - Update link parsing to support redirect overwrites - Enhance URI utilities to handle data URI references - Added tests for data: URI unrolling when writing links with them - AI generated shadowrefs documentation
1 parent d5ce747 commit 9e9413e

File tree

11 files changed

+1003
-21
lines changed

11 files changed

+1003
-21
lines changed
Lines changed: 391 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,391 @@
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

Comments
 (0)