Skip to content

Commit 92a3bd4

Browse files
authored
Run queries returned by lift (#255)
* add stream() helper * run handler recipes that are more than single opaque refs * remove debugger calls * apply closure alias bumps to nested recipes * documented closure unwrapping a bit better * json serialize non-id cells as {"/": ""}, mostly for debugging * fix import in test * fix references with path when returning a recipe from a node * remove extra logging * lifted functions can now return recipes and they'll get executed! * added test to make sure we don't regenerate recipe when not necessary
1 parent 4eeb623 commit 92a3bd4

File tree

8 files changed

+174
-42
lines changed

8 files changed

+174
-42
lines changed

typescript/packages/common-builder/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export { opaqueRef as cell } from "./opaque-ref.js";
1+
export { opaqueRef as cell, stream } from "./opaque-ref.js";
22
export {
33
createNodeFactory,
44
derive,

typescript/packages/common-builder/src/opaque-ref.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,10 @@ export function opaqueRef<T>(value?: Opaque<T> | T): OpaqueRef<T> {
137137
return top;
138138
}
139139

140+
export function stream<T>(): OpaqueRef<T> {
141+
return opaqueRef<T>({ $stream: true } as T);
142+
}
143+
140144
export function createShadowRef(ref: OpaqueRef<any>): ShadowRef {
141145
return { shadowOf: ref };
142146
}

typescript/packages/common-runner/src/cell.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@ export function cell<T>(value?: T, cause?: any): CellImpl<T> {
376376
toJSON: () =>
377377
typeof entityId?.toJSON === "function"
378378
? entityId.toJSON()
379-
: (entityId as { "/": string }),
379+
: (entityId as { "/": string }) ?? { "/": "" },
380380
get value(): T {
381381
return value as T;
382382
},
@@ -772,7 +772,6 @@ export function createQueryResultProxy<T>(
772772
};
773773
ref.cell.send(value);
774774
ref.cell.sourceCell = valueCell;
775-
if (Array.isArray(valueCell.get())) debugger;
776775

777776
log?.writes.push(ref);
778777

@@ -835,7 +834,6 @@ function makeOpaqueRef(
835834
let ref = opaqueRefs.find((p) => arrayEqual(valuePath, p.path))?.opaqueRef;
836835
if (!ref) {
837836
ref = opaqueRef();
838-
for (const key of valuePath) ref = ref.key(key);
839837
ref.setPreExisting({ $alias: { cell: valueCell, path: valuePath } });
840838
opaqueRefs.push({ path: valuePath, opaqueRef: ref });
841839
}

typescript/packages/common-runner/src/runner.ts

Lines changed: 51 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
isModule,
99
isRecipe,
1010
isAlias,
11-
isOpaqueRef,
1211
isStreamAlias,
1312
popFrame,
1413
recipeFromFrame,
@@ -28,14 +27,15 @@ import {
2827
import { Action, schedule, addEventHandler } from "./scheduler.js";
2928
import {
3029
extractDefaultValues,
31-
mapBindingsToCell,
30+
unwrapOneLevelAndBindtoCell,
3231
findAllAliasedCells,
3332
followAliases,
3433
mergeObjects,
3534
sendValueToBinding,
3635
staticDataToNestedCells,
3736
deepCopy,
3837
unsafe_noteParentOnRecipes,
38+
containsOpaqueRef,
3939
} from "./utils.js";
4040
import { getModuleByRef } from "./module.js";
4141
import { type AddCancel, type Cancel, useCancelGroup } from "./cancel.js";
@@ -180,7 +180,9 @@ export function run<T, R = any>(
180180
});
181181

182182
// Send "query" to results to the result cell
183-
resultCell.send(mapBindingsToCell<R>(recipe.result as R, processCell));
183+
resultCell.send(
184+
unwrapOneLevelAndBindtoCell<R>(recipe.result as R, processCell),
185+
);
184186

185187
// [unsafe closures:] For recipes from closures, add a materialize factory
186188
if (recipe[unsafe_originalRecipe])
@@ -308,7 +310,7 @@ function instantiateJavaScriptNode(
308310
addCancel: AddCancel,
309311
recipe: Recipe,
310312
) {
311-
const inputs = mapBindingsToCell(
313+
const inputs = unwrapOneLevelAndBindtoCell(
312314
inputBindings as { [key: string]: any },
313315
processCell,
314316
);
@@ -318,7 +320,7 @@ function instantiateJavaScriptNode(
318320
// written.
319321
const reads = findAllAliasedCells(inputs, processCell);
320322

321-
const outputs = mapBindingsToCell(outputBindings, processCell);
323+
const outputs = unwrapOneLevelAndBindtoCell(outputBindings, processCell);
322324
const writes = findAllAliasedCells(outputs, processCell);
323325

324326
let fn = (
@@ -383,21 +385,14 @@ function instantiateJavaScriptNode(
383385
const result = fn(inputsCell.getAsQueryResult([]));
384386

385387
// If handler returns a graph created by builder, run it
386-
// TODO: Handle case where the result is a structure with possibly
387-
// multiple such nodes
388-
if (isOpaqueRef(result)) {
389-
const resultNode = result;
390-
391-
// Recipe that assigns the result of the returned node to "result"
388+
if (containsOpaqueRef(result)) {
392389
const resultRecipe = recipeFromFrame(
393390
"event handler result",
394391
undefined,
395-
() => ({
396-
result: resultNode,
397-
}),
392+
() => result,
398393
);
399394

400-
const resultCell = run(resultRecipe, {});
395+
const resultCell = run(resultRecipe);
401396
addCancel(cancels.get(resultCell));
402397
}
403398

@@ -411,6 +406,8 @@ function instantiateJavaScriptNode(
411406
const inputsCell = cell(inputs);
412407
inputsCell.freeze(); // Freezes the bindings, not aliased cells.
413408

409+
let resultCell: CellImpl<any> | undefined;
410+
414411
const action: Action = (log: ReactivityLog) => {
415412
const inputsProxy = inputsCell.getAsQueryResult([], log);
416413

@@ -420,9 +417,28 @@ function instantiateJavaScriptNode(
420417
processCell.getAsQueryResult(path, log),
421418
} satisfies UnsafeBinding);
422419
const result = fn(inputsProxy);
423-
popFrame(frame);
424420

425-
sendValueToBinding(processCell, outputs, result, log);
421+
if (containsOpaqueRef(result)) {
422+
const resultRecipe = recipeFromFrame(
423+
"action result",
424+
undefined,
425+
() => result,
426+
);
427+
428+
resultCell = run(resultRecipe, undefined, resultCell);
429+
addCancel(cancels.get(resultCell));
430+
431+
sendValueToBinding(
432+
processCell,
433+
outputs,
434+
{ cell: resultCell, path: [] },
435+
log,
436+
);
437+
} else {
438+
sendValueToBinding(processCell, outputs, result, log);
439+
}
440+
441+
popFrame(frame);
426442
};
427443

428444
addCancel(schedule(action, { reads, writes } satisfies ReactivityLog));
@@ -445,8 +461,14 @@ function instantiateRawNode(
445461
// Built-ins can define their own scheduling logic, so they'll
446462
// implement parts of the above themselves.
447463

448-
const mappedInputBindings = mapBindingsToCell(inputBindings, processCell);
449-
const mappedOutputBindings = mapBindingsToCell(outputBindings, processCell);
464+
const mappedInputBindings = unwrapOneLevelAndBindtoCell(
465+
inputBindings,
466+
processCell,
467+
);
468+
const mappedOutputBindings = unwrapOneLevelAndBindtoCell(
469+
outputBindings,
470+
processCell,
471+
);
450472

451473
// For `map` and future other node types that take closures, we need to
452474
// note the parent recipe on the closure recipes.
@@ -474,11 +496,11 @@ function instantiatePassthroughNode(
474496
processCell: CellImpl<any>,
475497
addCancel: AddCancel,
476498
) {
477-
const inputs = mapBindingsToCell(inputBindings, processCell);
499+
const inputs = unwrapOneLevelAndBindtoCell(inputBindings, processCell);
478500
const inputsCell = cell(inputs);
479501
const reads = findAllAliasedCells(inputs, processCell);
480502

481-
const outputs = mapBindingsToCell(outputBindings, processCell);
503+
const outputs = unwrapOneLevelAndBindtoCell(outputBindings, processCell);
482504
const writes = findAllAliasedCells(outputs, processCell);
483505

484506
const action: Action = (log: ReactivityLog) => {
@@ -496,12 +518,12 @@ function instantiateIsolatedNode(
496518
processCell: CellImpl<any>,
497519
addCancel: AddCancel,
498520
) {
499-
const inputs = mapBindingsToCell(inputBindings, processCell);
521+
const inputs = unwrapOneLevelAndBindtoCell(inputBindings, processCell);
500522
const reads = findAllAliasedCells(inputs, processCell);
501523
const inputsCell = cell(inputs);
502524
inputsCell.freeze();
503525

504-
const outputs = mapBindingsToCell(outputBindings, processCell);
526+
const outputs = unwrapOneLevelAndBindtoCell(outputBindings, processCell);
505527
const writes = findAllAliasedCells(outputs, processCell);
506528

507529
if (!isJavaScriptModuleDefinition(module.implementation))
@@ -561,14 +583,18 @@ function instantiateRecipeNode(
561583
addCancel: AddCancel,
562584
) {
563585
if (!isRecipe(module.implementation)) throw new Error(`Invalid recipe`);
564-
const inputs = mapBindingsToCell(inputBindings, processCell);
586+
const recipe = unwrapOneLevelAndBindtoCell(
587+
module.implementation,
588+
processCell,
589+
);
590+
const inputs = unwrapOneLevelAndBindtoCell(inputBindings, processCell);
565591
const resultCell = cell(undefined, {
566592
recipe: module.implementation,
567593
parent: processCell,
568594
inputBindings,
569595
outputBindings,
570596
});
571-
run(module.implementation, inputs, resultCell);
597+
run(recipe, inputs, resultCell);
572598
sendValueToBinding(processCell, outputBindings, {
573599
cell: resultCell,
574600
path: [],

typescript/packages/common-runner/src/utils.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
Recipe,
88
UnsafeBinding,
99
unsafe_materializeFactory,
10+
isOpaqueRef,
1011
} from "@commontools/common-builder";
1112
import {
1213
cell,
@@ -171,24 +172,50 @@ export function setNestedValue(
171172
return true;
172173
}
173174

174-
// Turn local aliases into explicit aliases to named cell.
175-
export function mapBindingsToCell<T>(binding: T, cell: CellImpl<any>): T {
175+
/**
176+
* Unwraps one level of aliases, and
177+
* - binds top-level aliases to passed cell
178+
* - reduces wrapping count of closure cells by one
179+
*
180+
* This is used for arguments to nodes (which can be recipes, e.g. for map) and
181+
* for the recipe in recipe nodes.
182+
*
183+
* An alias will go through these stages:
184+
* - { $alias: { cell: 1, path: ["a"] } }
185+
* = Nested two layers deep, an argment for a nested recipe
186+
* - { $alias: { path: ["a"] } }
187+
* = One layer deep, e.g. a recipe that will be passed to `run`
188+
* - { $alias: { cell: <cell>, path: ["a"] } }
189+
* = Unwrapped, executing the recipe
190+
*
191+
* @param binding - The binding to unwrap.
192+
* @param cell - The cell to bind to.
193+
* @returns The unwrapped binding.
194+
*/
195+
export function unwrapOneLevelAndBindtoCell<T>(
196+
binding: T,
197+
cell: CellImpl<any>,
198+
): T {
176199
function convert(binding: any, processStatic = false): any {
177200
if (isStatic(binding) && !processStatic)
178201
return markAsStatic(convert(binding, true));
179202
else if (isAlias(binding)) {
180203
if (typeof binding.$alias.cell === "number")
181204
if (binding.$alias.cell === 1)
205+
// Moved to the next-to-top level. Don't assign a cell, so that on
206+
// next unwrap, the right cell be assigned.
182207
return { $alias: { path: binding.$alias.path } };
183208
else
184209
return {
210+
// Otherwise decrease count by one
185211
$alias: {
186212
cell: binding.$alias.cell - 1,
187213
path: binding.$alias.path,
188214
},
189215
};
190216
else
191217
return {
218+
// Bind to passed cell, if there isn't already one
192219
$alias: {
193220
cell: binding.$alias.cell ?? cell,
194221
path: binding.$alias.path,
@@ -494,7 +521,6 @@ export function normalizeToCells(
494521
value[i] = { cell: cell(value[i]), path: [] } satisfies CellReference;
495522
value[i].cell.entityId = itemId;
496523
value[i].cell.sourceCell = parentCell;
497-
if (Array.isArray(parentCell.get())) debugger;
498524

499525
preceedingItemId = itemId;
500526
log?.writes.push(value[i]);
@@ -560,6 +586,13 @@ export function isEqualCellReferences(
560586
);
561587
}
562588

589+
export function containsOpaqueRef(value: any): boolean {
590+
if (isOpaqueRef(value)) return true;
591+
if (typeof value === "object" && value !== null)
592+
return Object.values(value).some(containsOpaqueRef);
593+
return false;
594+
}
595+
563596
export function deepCopy(value: any): any {
564597
if (isCell(value) || isRendererCell(value)) return value;
565598
if (typeof value === "object" && value !== null)

typescript/packages/common-runner/test/recipes.test.ts

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ describe("Recipe Runner", () => {
1515
},
1616
);
1717

18-
console.log("simpleRecipe", JSON.stringify(simpleRecipe.toJSON(), null, 2));
1918
const result = run(simpleRecipe, { value: 5 });
2019

2120
await idle();
@@ -198,11 +197,79 @@ describe("Recipe Runner", () => {
198197
await idle();
199198
expect(values).toEqual([
200199
[1, 1, 0],
201-
[3, 1, 0], // That's the first logger called again when counter changes
200+
// Next is the first logger called again when counter changes, since this
201+
// is now a long running charmlet:
202+
[3, 1, 0],
202203
[3, 2, 0],
203204
]);
204205
});
205206

207+
it("should handle recipes returned by lifted functions", async () => {
208+
const x = cell(2);
209+
const y = cell(3);
210+
211+
const runCounts = {
212+
multiply: 0,
213+
multiplyGenerator: 0,
214+
multiplyGenerator2: 0,
215+
};
216+
217+
const multiply = lift<{ x: number; y: number }>(({ x, y }) => {
218+
runCounts.multiply++;
219+
return x * y;
220+
});
221+
222+
const multiplyGenerator = lift<{ x: number; y: number }>((args) => {
223+
runCounts.multiplyGenerator++;
224+
return multiply(args);
225+
});
226+
227+
const multiplyGenerator2 = lift<{ x: number; y: number }>(({ x, y }) => {
228+
runCounts.multiplyGenerator2++;
229+
// Now passing literals, so will hardcode values in recipe and hence
230+
// re-run when values change
231+
return multiply({ x, y });
232+
});
233+
234+
const multiplyRecipe = recipe<{ x: number; y: number }>(
235+
"multiply",
236+
(args) => {
237+
return {
238+
result1: multiplyGenerator(args),
239+
result2: multiplyGenerator2(args),
240+
};
241+
},
242+
);
243+
244+
const result = run(multiplyRecipe, { x, y });
245+
246+
await idle();
247+
248+
expect(result.getAsQueryResult()).toMatchObject({
249+
result1: 6,
250+
result2: 6,
251+
});
252+
253+
expect(runCounts).toMatchObject({
254+
multiply: 2,
255+
multiplyGenerator: 1,
256+
multiplyGenerator2: 1,
257+
});
258+
259+
x.send(3);
260+
await idle();
261+
expect(result.getAsQueryResult()).toMatchObject({
262+
result1: 9,
263+
result2: 9,
264+
});
265+
266+
expect(runCounts).toMatchObject({
267+
multiply: 4,
268+
multiplyGenerator: 1, // Did not re-run, since we didn't read the values!
269+
multiplyGenerator2: 2,
270+
});
271+
});
272+
206273
it("should support referenced modules", async () => {
207274
addModuleByRef(
208275
"double",

0 commit comments

Comments
 (0)