diff --git a/packages/bridge-compiler/src/codegen.ts b/packages/bridge-compiler/src/codegen.ts index 8f398387..24df5436 100644 --- a/packages/bridge-compiler/src/codegen.ts +++ b/packages/bridge-compiler/src/codegen.ts @@ -31,6 +31,23 @@ import type { ToolDef, } from "@stackables/bridge-core"; +/** + * Thrown when the compiler encounters an AST construct it cannot yet + * translate to JavaScript (e.g. cross-scope iterator references with + * `elementScope`). The caller can catch this and fall back to the + * core runtime interpreter. + */ +export class BridgeCompilerIncompatibleError extends Error { + constructor( + /** The affected operation in `"Type.field"` format. */ + public readonly operation: string, + message: string, + ) { + super(message); + this.name = "BridgeCompilerIncompatibleError"; + } +} + const SELF_MODULE = "_"; function matchesRequestedFields( @@ -109,6 +126,17 @@ export function compileBridge( if (!bridge) throw new Error(`No bridge definition found for operation: ${operation}`); + // ── Compatibility check: reject AST features the compiler cannot handle ── + for (const w of bridge.wires) { + if ("from" in w && (w.from as NodeRef).elementScope) { + throw new BridgeCompilerIncompatibleError( + operation, + `Operation "${operation}" uses cross-scope iterator references ` + + `(elementScope) which the AOT compiler does not yet support.`, + ); + } + } + // Collect const definitions from the document const constDefs = new Map(); for (const inst of document.instructions) { @@ -280,6 +308,8 @@ class CodegenContext { private catchGuardedTools = new Set(); /** Trunk keys of tools whose inputs depend on element wires (must be inlined in map callbacks). */ private elementScopedTools = new Set(); + /** Trunk keys of tools that should use memoized call wrapper (__callMemo). */ + private memoizedTools = new Set(); /** Trunk keys of tools that are only referenced in ternary branches (can be lazily evaluated). */ private ternaryOnlyTools = new Set(); /** Map from element-scoped non-internal tool trunk key to loop-local variable name. @@ -365,6 +395,10 @@ class CodegenContext { const vn = `_t${++this.toolCounter}`; this.varMap.set(tk, vn); this.tools.set(tk, { trunkKey: tk, toolName: h.name, varName: vn }); + // Mark memoized tools (from handle binding) + if (h.memoize) { + this.memoizedTools.add(tk); + } break; } } @@ -397,6 +431,13 @@ class CodegenContext { if (INTERNAL_TOOLS.has(field)) { this.internalToolKeys.add(tk); } + // Mark memoized pipe handle tools (element-scoped with memoize) + const handleBinding = bridge.handles.find( + (h) => h.kind === "tool" && h.handle === ph.handle && h.memoize, + ); + if (handleBinding) { + this.memoizedTools.add(tk); + } } } } @@ -724,6 +765,27 @@ class CodegenContext { lines.push(` throw err;`); lines.push(` }`); lines.push(` }`); + // Memoized call wrapper — caches the Promise by fnName + serialized input. + // Provides stampede protection: concurrent callers with identical keys + // attach to the same in-flight Promise. + lines.push(` const __memoCache = new Map();`); + lines.push( + ` /** @param {Function} fn Tool function @param {object} input @param {string} toolName @param {Function} [keyFn] Custom cache key */`, + ); + lines.push( + ` function __callMemo(fn, input, toolName, keyFn) {`, + ); + lines.push( + ` const key = toolName + ":" + (keyFn ? keyFn(input) : JSON.stringify(input));`, + ); + lines.push(` const cached = __memoCache.get(key);`); + lines.push(` if (cached) return cached;`); + lines.push(` const p = __call(fn, input, toolName);`); + lines.push( + ` if (p && typeof p.then === "function") __memoCache.set(key, p);`, + ); + lines.push(` return p;`); + lines.push(` }`); // ── Dead tool detection ──────────────────────────────────────────── // Detect which tools are reachable from the (possibly filtered) output @@ -931,22 +993,50 @@ class CodegenContext { /** * Generate a tool call expression that uses __callSync for sync tools at runtime, - * falling back to `await __call` for async tools. Used at individual call sites. + * falling back to `await __call` (or `await __callMemo` for memoized tools) for + * async tools. Used at individual call sites. */ - private syncAwareCall(fnName: string, inputObj: string): string { + private syncAwareCall( + fnName: string, + inputObj: string, + trunkKey?: string, + ): string { const fn = `tools[${JSON.stringify(fnName)}]`; const name = JSON.stringify(fnName); - return `(${fn}.bridge?.sync ? __callSync(${fn}, ${inputObj}, ${name}) : await __call(${fn}, ${inputObj}, ${name}))`; + const isMemo = trunkKey ? this.isMemoized(trunkKey, fnName) : false; + const asyncCall = isMemo + ? `await __callMemo(${fn}, ${inputObj}, ${name})` + : `await __call(${fn}, ${inputObj}, ${name})`; + return `(${fn}.bridge?.sync ? __callSync(${fn}, ${inputObj}, ${name}) : ${asyncCall})`; } /** * Same as syncAwareCall but without await — for use inside Promise.all() and * in sync array map bodies. Returns a value for sync tools, a Promise for async. */ - private syncAwareCallNoAwait(fnName: string, inputObj: string): string { + private syncAwareCallNoAwait( + fnName: string, + inputObj: string, + trunkKey?: string, + ): string { const fn = `tools[${JSON.stringify(fnName)}]`; const name = JSON.stringify(fnName); - return `(${fn}.bridge?.sync ? __callSync(${fn}, ${inputObj}, ${name}) : __call(${fn}, ${inputObj}, ${name}))`; + const isMemo = trunkKey ? this.isMemoized(trunkKey, fnName) : false; + const asyncCall = isMemo + ? `__callMemo(${fn}, ${inputObj}, ${name})` + : `__call(${fn}, ${inputObj}, ${name})`; + return `(${fn}.bridge?.sync ? __callSync(${fn}, ${inputObj}, ${name}) : ${asyncCall})`; + } + + /** Check if a tool should use memoized calls based on handle binding, ToolDef, or metadata. */ + private isMemoized(trunkKey: string, fnName?: string): boolean { + if (this.memoizedTools.has(trunkKey)) return true; + // Check ToolDef-level memoize + if (fnName) { + const toolDef = this.resolveToolDef(fnName); + if (toolDef?.memoize) return true; + } + return false; } /** @@ -981,18 +1071,18 @@ class CodegenContext { ); if (mode === "fire-and-forget") { lines.push( - ` try { ${this.syncAwareCall(tool.toolName, inputObj)}; } catch (_e) {}`, + ` try { ${this.syncAwareCall(tool.toolName, inputObj, tool.trunkKey)}; } catch (_e) {}`, ); lines.push(` const ${tool.varName} = undefined;`); } else if (mode === "catch-guarded") { // Catch-guarded: store result AND the actual error so unguarded wires can re-throw. lines.push(` let ${tool.varName}, ${tool.varName}_err;`); lines.push( - ` try { ${tool.varName} = ${this.syncAwareCall(tool.toolName, inputObj)}; } catch (_e) { if (_e?.name === "BridgePanicError" || _e?.name === "BridgeAbortError") throw _e; ${tool.varName}_err = _e; }`, + ` try { ${tool.varName} = ${this.syncAwareCall(tool.toolName, inputObj, tool.trunkKey)}; } catch (_e) { if (_e?.name === "BridgePanicError" || _e?.name === "BridgeAbortError") throw _e; ${tool.varName}_err = _e; }`, ); } else { lines.push( - ` const ${tool.varName} = ${this.syncAwareCall(tool.toolName, inputObj)};`, + ` const ${tool.varName} = ${this.syncAwareCall(tool.toolName, inputObj, tool.trunkKey)};`, ); } return; @@ -1065,7 +1155,7 @@ class CodegenContext { lines.push(` let ${tool.varName};`); lines.push(` try {`); lines.push( - ` ${tool.varName} = ${this.syncAwareCall(fnName, inputObj)};`, + ` ${tool.varName} = ${this.syncAwareCall(fnName, inputObj, tool.trunkKey)};`, ); lines.push(` } catch (_e) {`); if ("value" in onErrorWire) { @@ -1082,18 +1172,18 @@ class CodegenContext { lines.push(` }`); } else if (mode === "fire-and-forget") { lines.push( - ` try { ${this.syncAwareCall(fnName, inputObj)}; } catch (_e) {}`, + ` try { ${this.syncAwareCall(fnName, inputObj, tool.trunkKey)}; } catch (_e) {}`, ); lines.push(` const ${tool.varName} = undefined;`); } else if (mode === "catch-guarded") { // Catch-guarded: store result AND the actual error so unguarded wires can re-throw. lines.push(` let ${tool.varName}, ${tool.varName}_err;`); lines.push( - ` try { ${tool.varName} = ${this.syncAwareCall(fnName, inputObj)}; } catch (_e) { if (_e?.name === "BridgePanicError" || _e?.name === "BridgeAbortError") throw _e; ${tool.varName}_err = _e; }`, + ` try { ${tool.varName} = ${this.syncAwareCall(fnName, inputObj, tool.trunkKey)}; } catch (_e) { if (_e?.name === "BridgePanicError" || _e?.name === "BridgeAbortError") throw _e; ${tool.varName}_err = _e; }`, ); } else { lines.push( - ` const ${tool.varName} = ${this.syncAwareCall(fnName, inputObj)};`, + ` const ${tool.varName} = ${this.syncAwareCall(fnName, inputObj, tool.trunkKey)};`, ); } } diff --git a/packages/bridge-compiler/src/execute-bridge.ts b/packages/bridge-compiler/src/execute-bridge.ts index af29b206..43f99174 100644 --- a/packages/bridge-compiler/src/execute-bridge.ts +++ b/packages/bridge-compiler/src/execute-bridge.ts @@ -20,7 +20,10 @@ import { BridgeTimeoutError, } from "@stackables/bridge-core"; import { std as bundledStd } from "@stackables/bridge-stdlib"; -import { compileBridge } from "./codegen.ts"; +import { compileBridge, BridgeCompilerIncompatibleError } from "./codegen.ts"; +import { + executeBridge as coreExecuteBridge, +} from "@stackables/bridge-core"; // ── Types ─────────────────────────────────────────────────────────────────── @@ -232,7 +235,19 @@ export async function executeBridge( logger, } = options; - const fn = getOrCompile(document, operation, options.requestedFields); + let fn: BridgeFn; + try { + fn = getOrCompile(document, operation, options.requestedFields); + } catch (err) { + if (err instanceof BridgeCompilerIncompatibleError) { + // Fall back to core runtime for incompatible operations + logger?.warn?.( + `${err.message} Falling back to core runtime interpreter.`, + ); + return coreExecuteBridge(options); + } + throw err; + } // Merge built-in std namespace with user-provided tools, then flatten // so the generated code can access them via dotted keys like tools["std.str.toUpperCase"]. diff --git a/packages/bridge-compiler/src/index.ts b/packages/bridge-compiler/src/index.ts index 505c1027..1d1d78e0 100644 --- a/packages/bridge-compiler/src/index.ts +++ b/packages/bridge-compiler/src/index.ts @@ -7,7 +7,7 @@ * @packageDocumentation */ -export { compileBridge } from "./codegen.ts"; +export { compileBridge, BridgeCompilerIncompatibleError } from "./codegen.ts"; export type { CompileResult, CompileOptions } from "./codegen.ts"; export { executeBridge } from "./execute-bridge.ts"; diff --git a/packages/bridge-core/src/ExecutionTree.ts b/packages/bridge-core/src/ExecutionTree.ts index 8df058a7..387540ab 100644 --- a/packages/bridge-core/src/ExecutionTree.ts +++ b/packages/bridge-core/src/ExecutionTree.ts @@ -94,6 +94,13 @@ export class ExecutionTree implements TreeContext { logger?: Logger; /** External abort signal — cancels execution when triggered. */ signal?: AbortSignal; + /** + * Request-scoped memoization cache. + * Maps `fnName:cacheKey` → Promise to enable stampede protection. + * Shared across shadow trees (same reference) so concurrent array + * elements reuse in-flight Promises for identical inputs. + */ + memoCache: Map> = new Map(); /** * Hard timeout for tool calls in milliseconds. * When set, tool calls that exceed this duration throw a `BridgeTimeoutError`. @@ -115,6 +122,13 @@ export class ExecutionTree implements TreeContext { private depth: number; /** Pre-computed `trunkKey({ ...this.trunk, element: true })`. See docs/performance.md (#4). */ private elementTrunkKey: string; + /** + * Identifies which array nesting level this shadow represents. + * Set by `createShadowArray()` using the wire output path prefix + * (e.g. `"items"` for `o.items <- src[] as x { ... }`). + * Used by cross-scope iterator resolution in `pullSingle()`. + */ + elementScopeKey?: string; /** Sparse fieldset filter — set by `run()` when requestedFields is provided. */ requestedFields: string[] | undefined; @@ -218,24 +232,79 @@ export class ExecutionTree implements TreeContext { * Public to satisfy `ToolLookupContext` — called by `toolLookup.ts`. */ callTool( + nodeKey: string, toolName: string, fnName: string, fnImpl: (...args: any[]) => any, input: Record, + memoize?: boolean, ): MaybePromise { // Short-circuit before starting if externally aborted if (this.signal?.aborted) { throw new BridgeAbortError(); } + + const { sync: isSyncTool, doTrace, log, memoize: memoMeta } = + resolveToolMeta(fnImpl); + + // ── Memoization: check request-scoped cache ────────────────── + // A tool is memoized if any layer requests it: + // ToolMetadata (.bridge.memoize) || ToolDef (memoize) || HandleBinding (memoize) + // The `memoize` parameter carries the DSL-level flag from the scheduler. + const shouldMemoize = !!(memoMeta || memoize) && !isSyncTool; + if (shouldMemoize) { + const keyFn = memoMeta?.keyFn ?? JSON.stringify; + // Build the cache key using both the scope qualifier (from nodeKey) + // and the function name. Two handles at the same scope level + // pointing to the same function share the cache (deduplication), + // but an element-scoped tool and a root-scoped tool with the same + // name are isolated. + // + // nodeKey format: "module:type:field:instance" + // Element-scoped tools have instance >= 100000, root tools < 100000. + // We use the function name + scope qualifier so same-function + // handles share the cache while cross-scope handles don't. + const isElementScoped = nodeKey.includes(":") && + parseInt(nodeKey.split(":").pop()!, 10) >= 100000; + const scopeQualifier = isElementScoped ? "elem" : "root"; + const cacheKey = `${scopeQualifier}:${fnName}:${keyFn(input)}`; + const cached = this.memoCache.get(cacheKey); + if (cached) return cached; + const result = this._callToolCore( + toolName, fnName, fnImpl, input, isSyncTool, doTrace, log, + ); + if (isPromise(result)) { + this.memoCache.set(cacheKey, result); + } + return result; + } + + return this._callToolCore( + toolName, fnName, fnImpl, input, isSyncTool, doTrace, log, + ); + } + + /** + * Core tool invocation with instrumentation. + * Separated from `callTool` so the memoization wrapper can delegate + * without duplicating the fast-path and instrumented-path logic. + */ + private _callToolCore( + toolName: string, + fnName: string, + fnImpl: (...args: any[]) => any, + input: Record, + isSyncTool: boolean, + doTrace: boolean, + log: { execution: false | "debug" | "info"; errors: false | "warn" | "error" }, + ): MaybePromise { const tracer = this.tracer; const logger = this.logger; const toolContext: ToolContext = { logger: logger ?? {}, signal: this.signal, }; - const timeoutMs = this.toolTimeoutMs; - const { sync: isSyncTool, doTrace, log } = resolveToolMeta(fnImpl); // ── Fast path: no instrumentation configured ────────────────── // When there is no internal tracer, no logger, and OpenTelemetry @@ -438,6 +507,8 @@ export class ExecutionTree implements TreeContext { child.tracer = this.tracer; child.logger = this.logger; child.signal = this.signal; + // Share the memoization cache across shadow trees (same request) + child.memoCache = this.memoCache; return child; } @@ -445,7 +516,7 @@ export class ExecutionTree implements TreeContext { * Wrap raw array items into shadow trees, honouring `break` / `continue` * sentinels. Shared by `pullOutputField`, `response`, and `run`. */ - private createShadowArray(items: any[]): ExecutionTree[] { + private createShadowArray(items: any[], scopeKey?: string): ExecutionTree[] { const shadows: ExecutionTree[] = []; for (const item of items) { // Abort discipline — yield immediately if client disconnected @@ -459,6 +530,7 @@ export class ExecutionTree implements TreeContext { } const s = this.shadow(); s.state[this.elementTrunkKey] = item; + if (scopeKey) s.elementScopeKey = scopeKey; shadows.push(s); } return shadows; @@ -519,6 +591,23 @@ export class ExecutionTree implements TreeContext { ref: NodeRef, pullChain: Set = new Set(), ): MaybePromise { + // ── Cross-scope element reference ────────────────────────────── + // When the wire targets a specific outer array scope (elementScope), + // walk up the parent chain to the shadow whose elementScopeKey + // matches and read the element data from there directly. + if (ref.element && ref.elementScope) { + let cursor: ExecutionTree | undefined = this; + while (cursor) { + if (cursor.elementScopeKey === ref.elementScope) { + const elementData = cursor.state[this.elementTrunkKey]; + return this.applyPath(elementData, ref); + } + cursor = cursor.parent; + } + // Scope not found — return undefined (will be null in output) + return undefined; + } + // Cache trunkKey on the NodeRef via a Symbol key to avoid repeated // string allocation. Symbol keys don't affect V8 hidden classes, // so this won't degrade parser allocation-site throughput. @@ -664,7 +753,8 @@ export class ExecutionTree implements TreeContext { if (!array) return result; const resolved = await result; if (isLoopControlSignal(resolved)) return []; - return this.createShadowArray(resolved as any[]); + const scopeKey = path.length > 0 ? path.join(".") : undefined; + return this.createShadowArray(resolved as any[], scopeKey); } /** @@ -726,7 +816,8 @@ export class ExecutionTree implements TreeContext { // create shadow trees, and materialise with field mappings. const resolved = await this.resolveWires(regularWires); if (!Array.isArray(resolved)) return null; - const shadows = this.createShadowArray(resolved); + const scopeKey = prefix.length > 0 ? prefix.join(".") : undefined; + const shadows = this.createShadowArray(resolved, scopeKey); return this.materializeShadows(shadows, prefix); } @@ -1089,7 +1180,8 @@ export class ExecutionTree implements TreeContext { // Array: create shadow trees for per-element resolution const resolved = await response; if (isLoopControlSignal(resolved)) return []; - return this.createShadowArray(resolved as any[]); + const scopeKey = cleanPath.length > 0 ? cleanPath.join(".") : undefined; + return this.createShadowArray(resolved as any[], scopeKey); } // ── Resolve field from deferred define ──────────────────────────── @@ -1103,7 +1195,8 @@ export class ExecutionTree implements TreeContext { if (!array) return response; const resolved = await response; if (isLoopControlSignal(resolved)) return []; - return this.createShadowArray(resolved as any[]); + const scopeKey = cleanPath.length > 0 ? cleanPath.join(".") : undefined; + return this.createShadowArray(resolved as any[], scopeKey); } } @@ -1124,9 +1217,11 @@ export class ExecutionTree implements TreeContext { if (array && Array.isArray(value)) { // Nested array: wrap items in shadow trees so they can // resolve their own fields via this same fallback path. + const nestedScopeKey = cleanPath.length > 0 ? cleanPath.join(".") : undefined; return value.map((item: any) => { const s = this.shadow(); s.state[this.elementTrunkKey] = item; + if (nestedScopeKey) s.elementScopeKey = nestedScopeKey; return s; }); } diff --git a/packages/bridge-core/src/scheduleTools.ts b/packages/bridge-core/src/scheduleTools.ts index 2b910533..3b797d26 100644 --- a/packages/bridge-core/src/scheduleTools.ts +++ b/packages/bridge-core/src/scheduleTools.ts @@ -150,7 +150,7 @@ export function schedule( // ── Async path: tool definition requires resolveToolWires + callTool ── if (toolDef) { - return scheduleToolDef(ctx, toolName, toolDef, wireGroups, pullChain); + return scheduleToolDef(ctx, targetKey, toolName, toolDef, wireGroups, pullChain); } // ── Sync-capable path: no tool definition ── @@ -227,7 +227,12 @@ export function scheduleFinish( directFn = lookupToolFn(ctx, toolName); } if (directFn) { - return ctx.callTool(toolName, toolName, directFn, input); + // Check handle binding for memoize flag + const handleMemoize = ctx.bridge?.handles.some( + (h) => h.kind === "tool" && h.memoize && h.name === toolName, + ); + const nodeKey = trunkKey(target); + return ctx.callTool(nodeKey, toolName, toolName, directFn, input, handleMemoize); } // Define pass-through: synthetic trunks created by define inlining @@ -263,6 +268,7 @@ export function scheduleFinish( */ export async function scheduleToolDef( ctx: SchedulerContext, + nodeKey: string, toolName: string, toolDef: ToolDef, wireGroups: Map, @@ -294,8 +300,13 @@ export async function scheduleToolDef( // on error: wrap the tool call with fallback from onError wire const onErrorWire = toolDef.wires.find((w) => w.kind === "onError"); + // Memoize if ToolDef or handle binding requests it + const handleMemoize = ctx.bridge?.handles.some( + (h) => h.kind === "tool" && h.memoize && h.name === toolName, + ); + const shouldMemoize = toolDef.memoize || handleMemoize; try { - return await ctx.callTool(toolName, toolDef.fn!, fn, input); + return await ctx.callTool(nodeKey, toolName, toolDef.fn!, fn, input, shouldMemoize); } catch (err) { if (!onErrorWire) throw err; if ("value" in onErrorWire) return JSON.parse(onErrorWire.value); diff --git a/packages/bridge-core/src/toolLookup.ts b/packages/bridge-core/src/toolLookup.ts index ac3340ee..e8b35e26 100644 --- a/packages/bridge-core/src/toolLookup.ts +++ b/packages/bridge-core/src/toolLookup.ts @@ -37,10 +37,12 @@ export interface ToolLookupContext { readonly parent?: ToolLookupContext | undefined; readonly state: Record; callTool( + nodeKey: string, toolName: string, fnName: string, fnImpl: (...args: any[]) => any, input: Record, + memoize?: boolean, ): MaybePromise; } @@ -176,6 +178,8 @@ export function resolveToolDefByName( else merged.wires.push(wire); } } + // Propagate memoize flag (any layer requesting it enables it) + if (def.memoize) merged.memoize = true; } ctx.toolDefCache.set(name, merged); @@ -290,7 +294,9 @@ export function resolveToolDep( // on error: wrap the tool call with fallback from onError wire const onErrorWire = toolDef.wires.find((w) => w.kind === "onError"); try { - return await ctx.callTool(toolName, toolDef.fn!, fn, input); + return await ctx.callTool( + toolName, toolName, toolDef.fn!, fn, input, toolDef.memoize, + ); } catch (err) { if (!onErrorWire) throw err; if ("value" in onErrorWire) return JSON.parse(onErrorWire.value); diff --git a/packages/bridge-core/src/tracing.ts b/packages/bridge-core/src/tracing.ts index 24cc4989..8b738b95 100644 --- a/packages/bridge-core/src/tracing.ts +++ b/packages/bridge-core/src/tracing.ts @@ -251,6 +251,10 @@ export type ResolvedToolMeta = { /** Emit an OTel span for this call. Default: `true`. */ doTrace: boolean; log: EffectiveToolLog; + /** Memoization config from ToolMetadata (function-level). */ + memoize?: { + keyFn?: (input: Record) => string; + }; }; function resolveToolLog(meta: ToolMetadata | undefined): EffectiveToolLog { @@ -269,10 +273,18 @@ function resolveToolLog(meta: ToolMetadata | undefined): EffectiveToolLog { /** Read and normalise the `.bridge` metadata from a tool function. */ export function resolveToolMeta(fn: (...args: any[]) => any): ResolvedToolMeta { const bridge = (fn as any).bridge as ToolMetadata | undefined; + // Resolve memoize: true → default keyFn, object → use as-is + let memoize: ResolvedToolMeta["memoize"]; + if (bridge?.memoize === true) { + memoize = {}; + } else if (bridge?.memoize && typeof bridge.memoize === "object") { + memoize = { keyFn: bridge.memoize.keyFn }; + } return { sync: bridge?.sync === true, doTrace: bridge?.trace !== false, log: resolveToolLog(bridge), + ...(memoize ? { memoize } : {}), }; } diff --git a/packages/bridge-core/src/types.ts b/packages/bridge-core/src/types.ts index 810cf3ff..c6bde261 100644 --- a/packages/bridge-core/src/types.ts +++ b/packages/bridge-core/src/types.ts @@ -16,6 +16,17 @@ export type NodeRef = { instance?: number; /** References the current array element in a shadow tree (for per-element mapping) */ element?: boolean; + /** + * When set, this element ref targets a *specific* nesting level identified + * by the array output path prefix (e.g. `"items"` for the outer array in + * `o.items <- src[] as x { .l2 <- x.children[] as y { .xv <- x.v } }`). + * + * Only meaningful when `element === true`. Without `elementScope`, the + * runtime resolves element data from the innermost shadow tree. With it, + * the runtime walks the parent chain until it finds the shadow whose + * `elementScopeKey` matches. + */ + elementScope?: string; /** Path into the data: ["items", "0", "position", "lat"] */ path: string[]; /** True when the first `?.` is right after the root (e.g., `api?.data`) */ @@ -159,7 +170,16 @@ export type Bridge = { * Every wire reference in the bridge body must trace back to one of these. */ export type HandleBinding = - | { handle: string; kind: "tool"; name: string; version?: string } + | { + handle: string; + kind: "tool"; + name: string; + version?: string; + memoize?: boolean; + /** When true, this handle is declared inside an array mapping block + * (`with as ` inside `[] as iter { ... }`). */ + elementScoped?: boolean; + } | { handle: string; kind: "input" } | { handle: string; kind: "output" } | { handle: string; kind: "context" } @@ -193,6 +213,8 @@ export type ToolDef = { deps: ToolDep[]; /** Wires: constants (`=`) and pulls (`<-`) defining the tool's input */ wires: ToolWire[]; + /** When true, the tool opts into request-scoped memoization. */ + memoize?: boolean; }; /** diff --git a/packages/bridge-parser/src/bridge-format.ts b/packages/bridge-parser/src/bridge-format.ts index fdb7e00d..30b45c97 100644 --- a/packages/bridge-parser/src/bridge-format.ts +++ b/packages/bridge-parser/src/bridge-format.ts @@ -44,6 +44,7 @@ const RESERVED_BARE_VALUE_KEYWORDS = new Set([ "break", "throw", "panic", + "memoize", "if", "pipe", // Boolean/logic operators @@ -134,10 +135,11 @@ function serializeToolBlock(tool: ToolDef): string { // Declaration line — use `tool from ` format const source = tool.extends ?? tool.fn; + const memoTag = tool.memoize ? " memoize" : ""; lines.push( hasBody - ? `tool ${tool.name} from ${source} {` - : `tool ${tool.name} from ${source}`, + ? `tool ${tool.name} from ${source}${memoTag} {` + : `tool ${tool.name} from ${source}${memoTag}`, ); // Dependencies @@ -283,7 +285,18 @@ function serializeBridgeBlock(bridge: Bridge): string { // ── Header ────────────────────────────────────────────────────────── lines.push(`bridge ${bridge.type}.${bridge.field} {`); + // Detect element-scoped tool handles + const elementScopedHandles = new Set(); for (const h of bridge.handles) { + if (h.kind === "tool" && h.elementScoped) { + elementScopedHandles.add(h.handle); + } + } + + for (const h of bridge.handles) { + // Skip element-scoped tool handles — they are serialized inside array blocks + if (h.kind === "tool" && elementScopedHandles.has(h.handle)) continue; + switch (h.kind) { case "tool": { // Short form `with ` when handle == last segment of name @@ -291,10 +304,11 @@ function serializeBridgeBlock(bridge: Bridge): string { const defaultHandle = lastDot !== -1 ? h.name.substring(lastDot + 1) : h.name; const vTag = h.version ? `@${h.version}` : ""; - if (h.handle === defaultHandle && !vTag) { + const memoTag = h.memoize ? " memoize" : ""; + if (h.handle === defaultHandle && !vTag && !memoTag) { lines.push(` with ${h.name}`); } else { - lines.push(` with ${h.name}${vTag} as ${h.handle}`); + lines.push(` with ${h.name}${vTag} as ${h.handle}${memoTag}`); } break; } @@ -775,6 +789,13 @@ function serializeBridgeBlock(bridge: Bridge): string { } } + // Emit element-scoped tool declarations: with as [memoize] + for (const h of bridge.handles) { + if (h.kind !== "tool" || !elementScopedHandles.has(h.handle)) continue; + const memoTag = h.memoize ? " memoize" : ""; + lines.push(`${indent}with ${h.name} as ${h.handle}${memoTag}`); + } + // Emit block-scoped local bindings: alias as for (const [alias, info] of localBindingsByAlias) { const srcWire = info.sourceWire; diff --git a/packages/bridge-parser/src/parser/lexer.ts b/packages/bridge-parser/src/parser/lexer.ts index e6b41b5e..d102c97d 100644 --- a/packages/bridge-parser/src/parser/lexer.ts +++ b/packages/bridge-parser/src/parser/lexer.ts @@ -150,6 +150,11 @@ export const BreakKw = createToken({ pattern: /break/, longer_alt: Identifier, }); +export const MemoizeKw = createToken({ + name: "MemoizeKw", + pattern: /memoize/, + longer_alt: Identifier, +}); // ── Operators & punctuation ──────────────────────────────────────────────── @@ -289,6 +294,7 @@ export const allTokens = [ PanicKw, ContinueKw, BreakKw, + MemoizeKw, TrueLiteral, FalseLiteral, NullLiteral, diff --git a/packages/bridge-parser/src/parser/parser.ts b/packages/bridge-parser/src/parser/parser.ts index c383e69e..ed8179b6 100644 --- a/packages/bridge-parser/src/parser/parser.ts +++ b/packages/bridge-parser/src/parser/parser.ts @@ -31,6 +31,7 @@ import { PanicKw, ContinueKw, BreakKw, + MemoizeKw, NullCoalesce, ErrorCoalesce, SafeNav, @@ -100,6 +101,7 @@ const RESERVED_KEYWORDS = new Set([ "panic", "continue", "break", + "memoize", ]); const SOURCE_IDENTIFIERS = new Set(["input", "output", "context"]); @@ -156,6 +158,9 @@ class BridgeParser extends CstParser { this.SUBRULE(this.dottedName, { LABEL: "toolName" }); this.CONSUME(FromKw); this.SUBRULE2(this.dottedName, { LABEL: "toolSource" }); + this.OPTION2(() => { + this.CONSUME(MemoizeKw, { LABEL: "toolMemoizeKw" }); + }); this.OPTION(() => { this.CONSUME(LCurly); this.MANY(() => this.SUBRULE(this.toolBodyLine)); @@ -456,6 +461,9 @@ class BridgeParser extends CstParser { this.CONSUME5(AsKw); this.SUBRULE5(this.nameToken, { LABEL: "refAlias" }); }); + this.OPTION7(() => { + this.CONSUME(MemoizeKw, { LABEL: "memoizeKw" }); + }); }, }, ]); @@ -567,6 +575,7 @@ class BridgeParser extends CstParser { this.OR([ { ALT: () => this.SUBRULE(this.elementWithDecl) }, { ALT: () => this.SUBRULE(this.elementLine) }, + { ALT: () => this.SUBRULE(this.elementToolWire) }, ]), ); this.CONSUME(RCurly); @@ -575,13 +584,57 @@ class BridgeParser extends CstParser { /** * Block-scoped binding inside array mapping: * alias as + * with as [memoize] * Evaluates the source once per element and binds the result to . */ public elementWithDecl = this.RULE("elementWithDecl", () => { - this.CONSUME(AliasKw); - this.SUBRULE(this.sourceExpr, { LABEL: "elemWithSource" }); - this.CONSUME(AsKw); - this.SUBRULE(this.nameToken, { LABEL: "elemWithAlias" }); + this.OR([ + { + ALT: () => { + this.CONSUME(AliasKw, { LABEL: "aliasKw" }); + this.SUBRULE(this.sourceExpr, { LABEL: "elemWithSource" }); + this.CONSUME(AsKw); + this.SUBRULE(this.nameToken, { LABEL: "elemWithAlias" }); + }, + }, + { + ALT: () => { + this.CONSUME(WithKw, { LABEL: "elemWithKw" }); + this.SUBRULE(this.dottedName, { LABEL: "elemToolName" }); + this.CONSUME2(AsKw); + this.SUBRULE2(this.nameToken, { LABEL: "elemToolAlias" }); + this.OPTION(() => { + this.CONSUME(MemoizeKw, { LABEL: "elemMemoizeKw" }); + }); + }, + }, + ]); + }); + + /** + * Tool-input wire inside array mapping: + * . = value + * . <- source + * Used to set inputs on element-scoped tools declared via `with as `. + */ + public elementToolWire = this.RULE("elementToolWire", () => { + this.SUBRULE(this.nameToken, { LABEL: "elemToolHandle" }); + this.CONSUME(Dot); + this.SUBRULE(this.dottedPath, { LABEL: "elemToolTarget" }); + this.OR2([ + { + ALT: () => { + this.CONSUME(Equals, { LABEL: "elemToolEquals" }); + this.SUBRULE(this.bareValue, { LABEL: "elemToolValue" }); + }, + }, + { + ALT: () => { + this.CONSUME(Arrow, { LABEL: "elemToolArrow" }); + this.SUBRULE(this.sourceExpr, { LABEL: "elemToolSource" }); + }, + }, + ]); }); /** @@ -1709,6 +1762,42 @@ function processElementLines( safe?: boolean, ) => NodeRef, ): void { + // Build a reverse lookup: iterator name → array output path. + // Used to detect cross-scope iterator references (e.g. reading `x.v` + // from inside a nested `y` scope). + const outerIteratorScope = new Map(); + for (const [path, name] of Object.entries(arrayIterators)) { + if (name !== iterName) { + outerIteratorScope.set(name, path); + } + } + + /** Check if `root` is the current iterator or an outer-scope iterator. + * Returns an element-relative NodeRef if it is, undefined otherwise. */ + function resolveIteratorRef(root: string, segments: string[]): NodeRef | undefined { + if (root === iterName) { + return { + module: SELF_MODULE, + type: bridgeType, + field: bridgeField, + element: true, + path: segments, + }; + } + const scope = outerIteratorScope.get(root); + if (scope !== undefined) { + return { + module: SELF_MODULE, + type: bridgeType, + field: bridgeField, + element: true, + elementScope: scope, + path: segments, + }; + } + return undefined; + } + function extractCoalesceAltIterAware( altNode: CstNode, lineNum: number, @@ -1723,16 +1812,9 @@ function processElementLines( if (headNode) { const { root, segments } = extractAddressPath(headNode); const pipeSegs = subs(srcNode, "pipeSegment"); - if (root === iterName && pipeSegs.length === 0) { - return { - sourceRef: { - module: SELF_MODULE, - type: bridgeType, - field: bridgeField, - element: true, - path: segments, - }, - }; + if (pipeSegs.length === 0) { + const iterRef = resolveIteratorRef(root, segments); + if (iterRef) return { sourceRef: iterRef }; } } } @@ -1863,14 +1945,17 @@ function processElementLines( if (nestedArrayNode) { // Emit the pass-through wire for the inner array source let innerFromRef: NodeRef; - if (elemSrcRoot === iterName && elemPipeSegs.length === 0) { - innerFromRef = { - module: SELF_MODULE, - type: bridgeType, - field: bridgeField, - element: true, - path: elemSrcSegs, - }; + if (elemPipeSegs.length === 0) { + const iterRef = resolveIteratorRef(elemSrcRoot, elemSrcSegs); + if (iterRef) { + innerFromRef = iterRef; + } else { + innerFromRef = buildSourceExpr( + elemSourceNode!, + elemLineNum, + iterName, + ); + } } else { innerFromRef = buildSourceExpr( elemSourceNode!, @@ -1927,6 +2012,7 @@ function processElementLines( module: SELF_MODULE, type: bridgeType, field: bridgeField, + element: true, path: elemToPath, }; @@ -1963,14 +2049,11 @@ function processElementLines( // Expression in element line — desugar then merge with fallback path const elemExprRights = subs(elemLine, "elemExprRight"); let leftRef: NodeRef; - if (elemSrcRoot === iterName && elemPipeSegs.length === 0) { - leftRef = { - module: SELF_MODULE, - type: bridgeType, - field: bridgeField, - element: true, - path: elemSrcSegs, - }; + const exprIterRef = elemPipeSegs.length === 0 + ? resolveIteratorRef(elemSrcRoot, elemSrcSegs) + : undefined; + if (exprIterRef) { + leftRef = exprIterRef; } else { leftRef = buildSourceExpr(elemSourceNode!, elemLineNum, iterName); } @@ -1983,21 +2066,20 @@ function processElementLines( elemSafe || undefined, ); elemCondIsPipeFork = true; - } else if (elemSrcRoot === iterName && elemPipeSegs.length === 0) { - elemCondRef = { - module: SELF_MODULE, - type: bridgeType, - field: bridgeField, - element: true, - path: elemSrcSegs, - }; - elemCondIsPipeFork = false; } else { - elemCondRef = buildSourceExpr(elemSourceNode!, elemLineNum, iterName); - elemCondIsPipeFork = - elemCondRef.instance != null && - elemCondRef.path.length === 0 && - elemPipeSegs.length > 0; + const plainIterRef = elemPipeSegs.length === 0 + ? resolveIteratorRef(elemSrcRoot, elemSrcSegs) + : undefined; + if (plainIterRef) { + elemCondRef = plainIterRef; + elemCondIsPipeFork = false; + } else { + elemCondRef = buildSourceExpr(elemSourceNode!, elemLineNum, iterName); + elemCondIsPipeFork = + elemCondRef.instance != null && + elemCondRef.path.length === 0 && + elemPipeSegs.length > 0; + } } // ── Apply `not` prefix if present (element context) ── @@ -2359,6 +2441,7 @@ function processElementScopeLines( module: SELF_MODULE, type: bridgeType, field: bridgeField, + element: true, path: elemToPath, }; @@ -2858,6 +2941,8 @@ function buildToolDef( } } + const memoize = !!(node.children.toolMemoizeKw as IToken[] | undefined)?.length; + return { kind: "tool", name: toolName, @@ -2865,6 +2950,7 @@ function buildToolDef( extends: isKnownTool ? source : undefined, deps, wires, + ...(memoize ? { memoize } : {}), }; } @@ -3090,6 +3176,7 @@ function buildBridgeBody( const versionTag = ( wc.refVersion as IToken[] | undefined )?.[0]?.image.slice(1); + const memoize = !!wc.memoizeKw; const lastDot = name.lastIndexOf("."); const defaultHandle = lastDot !== -1 ? name.substring(lastDot + 1) : name; const handle = wc.refAlias @@ -3122,6 +3209,7 @@ function buildBridgeBody( kind: "tool", name, ...(versionTag ? { version: versionTag } : {}), + ...(memoize ? { memoize } : {}), }); handleRes.set(handle, { module: modulePart, @@ -3138,6 +3226,7 @@ function buildBridgeBody( kind: "tool", name, ...(versionTag ? { version: versionTag } : {}), + ...(memoize ? { memoize } : {}), }); handleRes.set(handle, { module: SELF_MODULE, @@ -3204,6 +3293,81 @@ function buildBridgeBody( const addedAliases: string[] = []; for (const withDecl of withDecls) { const lineNum = line(findFirstToken(withDecl)); + + // ── New: `with as [memoize]` inside array blocks ── + const elemToolNameNode = sub(withDecl, "elemToolName"); + if (elemToolNameNode) { + const toolName = extractDottedName(elemToolNameNode); + const alias = extractNameToken(sub(withDecl, "elemToolAlias")!); + const memoize = !!(withDecl.children.elemMemoizeKw as IToken[] | undefined)?.length; + assertNotReserved(alias, lineNum, "element-scoped tool handle"); + if (handleRes.has(alias)) { + throw new Error(`Line ${lineNum}: Duplicate handle name "${alias}"`); + } + + // Create element-scoped tool instance (unique instance ID like pipe forks) + const lastDot = toolName.lastIndexOf("."); + let res: HandleResolution; + if (lastDot !== -1) { + const modulePart = toolName.substring(0, lastDot); + const fieldPart = toolName.substring(lastDot + 1); + const forkInstance = 100000 + nextForkSeq++; + res = { + module: modulePart, + type: bridgeType, + field: fieldPart, + instance: forkInstance, + }; + // Look up the base tool instance + const baseKey = `${modulePart}:${fieldPart}`; + const baseInstance = instanceCounters.get(baseKey) ?? 1; + pipeHandleEntries.push({ + key: `${modulePart}:${bridgeType}:${fieldPart}:${forkInstance}`, + handle: alias, + baseTrunk: { + module: modulePart, + type: bridgeType, + field: fieldPart, + instance: baseInstance, + }, + }); + } else { + const forkInstance = 100000 + nextForkSeq++; + res = { + module: SELF_MODULE, + type: "Tools", + field: toolName, + instance: forkInstance, + }; + const baseKey = `Tools:${toolName}`; + const baseInstance = instanceCounters.get(baseKey) ?? 1; + pipeHandleEntries.push({ + key: `${SELF_MODULE}:Tools:${toolName}:${forkInstance}`, + handle: alias, + baseTrunk: { + module: SELF_MODULE, + type: "Tools", + field: toolName, + instance: baseInstance, + }, + }); + } + handleRes.set(alias, res); + addedAliases.push(alias); + + // Add to handle bindings so the bridge knows about this element-scoped tool + handleBindings.push({ + handle: alias, + kind: "tool", + name: toolName, + ...(memoize ? { memoize } : {}), + elementScoped: true, + }); + + continue; + } + + // ── Existing: `alias as ` ── const sourceNode = sub(withDecl, "elemWithSource")!; const alias = extractNameToken(sub(withDecl, "elemWithAlias")!); assertNotReserved(alias, lineNum, "local binding alias"); @@ -3226,6 +3390,23 @@ function buildBridgeBody( element: true, path: srcSegs, }; + } else if (pipeSegs.length === 0) { + // Check outer-scope iterators + const outerScope = Object.entries(arrayIterators).find( + ([, name]) => name === srcRoot && name !== iterName, + ); + if (outerScope) { + sourceRef = { + module: SELF_MODULE, + type: bridgeType, + field: bridgeField, + element: true, + elementScope: outerScope[0], + path: srcSegs, + }; + } else { + sourceRef = resolveAddress(srcRoot, srcSegs, lineNum); + } } else if (pipeSegs.length > 0) { // Pipe expression — the last segment may be iterator-relative. // Resolve data source (last part), then build pipe fork chain. @@ -3247,7 +3428,21 @@ function buildBridgeBody( path: dataSrcSegs, }; } else { - prevOutRef = resolveAddress(dataSrcRoot, dataSrcSegs, lineNum); + const outerScope = Object.entries(arrayIterators).find( + ([, name]) => name === dataSrcRoot && name !== iterName, + ); + if (outerScope) { + prevOutRef = { + module: SELF_MODULE, + type: bridgeType, + field: bridgeField, + element: true, + elementScope: outerScope[0], + path: dataSrcSegs, + }; + } else { + prevOutRef = resolveAddress(dataSrcRoot, dataSrcSegs, lineNum); + } } // Build pipe fork chain (same logic as buildSourceExpr) @@ -3348,6 +3543,23 @@ function buildBridgeBody( element: true, path: segments, }; + } else if (iterName) { + // Check outer-scope iterators + const outerScope = Object.entries(arrayIterators).find( + ([, name]) => name === root && name !== iterName, + ); + if (outerScope) { + ref = { + module: SELF_MODULE, + type: bridgeType, + field: bridgeField, + element: true, + elementScope: outerScope[0], + path: segments, + }; + } else { + ref = resolveAddress(root, segments, lineNum); + } } else { ref = resolveAddress(root, segments, lineNum); } @@ -3393,6 +3605,22 @@ function buildBridgeBody( element: true, path: srcSegments, }; + } else if (iterName) { + const outerScope = Object.entries(arrayIterators).find( + ([, name]) => name === srcRoot && name !== iterName, + ); + if (outerScope) { + prevOutRef = { + module: SELF_MODULE, + type: bridgeType, + field: bridgeField, + element: true, + elementScope: outerScope[0], + path: srcSegments, + }; + } else { + prevOutRef = resolveAddress(srcRoot, srcSegments, lineNum); + } } else { prevOutRef = resolveAddress(srcRoot, srcSegments, lineNum); } @@ -3496,7 +3724,7 @@ function buildBridgeBody( const root = dotParts[0]; const segments = dotParts.slice(1); - // Check for iterator-relative refs + // Check for iterator-relative refs (current or outer scope) if (iterName && root === iterName) { const fromRef: NodeRef = { module: SELF_MODULE, @@ -3506,6 +3734,24 @@ function buildBridgeBody( path: segments, }; wires.push({ from: fromRef, to: partRef }); + } else if (iterName) { + const outerScope = Object.entries(arrayIterators).find( + ([, name]) => name === root && name !== iterName, + ); + if (outerScope) { + const fromRef: NodeRef = { + module: SELF_MODULE, + type: bridgeType, + field: bridgeField, + element: true, + elementScope: outerScope[0], + path: segments, + }; + wires.push({ from: fromRef, to: partRef }); + } else { + const fromRef = resolveAddress(root, segments, lineNum); + wires.push({ from: fromRef, to: partRef }); + } } else { const fromRef = resolveAddress(root, segments, lineNum); wires.push({ from: fromRef, to: partRef }); @@ -3612,7 +3858,7 @@ function buildBridgeBody( if (c.sourceRef) { const addrNode = (c.sourceRef as CstNode[])[0]; const { root, segments } = extractAddressPath(addrNode); - // Iterator-relative ref in element context + // Iterator-relative ref in element context (current or outer scope) if (iterName && root === iterName) { return { kind: "ref", @@ -3625,6 +3871,24 @@ function buildBridgeBody( }, }; } + if (iterName) { + const outerScope = Object.entries(arrayIterators).find( + ([, name]) => name === root && name !== iterName, + ); + if (outerScope) { + return { + kind: "ref", + ref: { + module: SELF_MODULE, + type: bridgeType, + field: bridgeField, + element: true, + elementScope: outerScope[0], + path: segments, + }, + }; + } + } return { kind: "ref", ref: resolveAddress(root, segments, lineNum) }; } throw new Error(`Line ${lineNum}: Invalid ternary branch`); @@ -3729,6 +3993,25 @@ function buildBridgeBody( }, }; } + if (pipeSegs.length === 0) { + const outerScope = Object.entries(arrayIterators).find( + ([, name]) => name === root && name !== iterName, + ); + if (outerScope) { + return { + kind: "ref", + safe, + ref: { + module: SELF_MODULE, + type: bridgeType, + field: bridgeField, + element: true, + elementScope: outerScope[0], + path: segments, + }, + }; + } + } } const { ref, safe } = buildSourceExprSafe(srcNode, lineNum); @@ -4856,6 +5139,45 @@ function buildBridgeBody( // Process element lines (supports nested array mappings recursively) const elemWithDecls = subs(arrayMappingNode, "elementWithDecl"); const cleanup = processLocalBindings(elemWithDecls, iterName); + + // Process element-scoped tool wires (e.g. `fetchItem.url <- item.id`) + const elemToolWires = subs(arrayMappingNode, "elementToolWire"); + for (const tw of elemToolWires) { + const twLineNum = line(findFirstToken(tw)); + const handle = extractNameToken(sub(tw, "elemToolHandle")!); + const targetPath = extractDottedPathStr(sub(tw, "elemToolTarget")!); + + // Resolve handle to its trunk + const toRef = resolveAddress(handle, parsePath(targetPath), twLineNum); + + if (tw.children.elemToolEquals) { + // Constant wire: handle.field = value + const value = extractBareValue(sub(tw, "elemToolValue")!); + wires.push({ value, to: toRef }); + } else if (tw.children.elemToolArrow) { + // Pull wire: handle.field <- source + const srcNode = sub(tw, "elemToolSource")!; + const headNode = sub(srcNode, "head")!; + const { root: srcRoot, segments: srcSegs } = + extractAddressPath(headNode); + const pipeSegs = subs(srcNode, "pipeSegment"); + + let fromRef: NodeRef; + if (srcRoot === iterName && pipeSegs.length === 0) { + fromRef = { + module: SELF_MODULE, + type: bridgeType, + field: bridgeField, + element: true, + path: srcSegs, + }; + } else { + fromRef = buildSourceExpr(srcNode, twLineNum, iterName); + } + wires.push({ from: fromRef, to: toRef }); + } + } + processElementLines( subs(arrayMappingNode, "elementLine"), arrayToPath, diff --git a/packages/bridge-syntax-highlight/syntaxes/bridge.tmLanguage.json b/packages/bridge-syntax-highlight/syntaxes/bridge.tmLanguage.json index 49bda115..3e7c4517 100644 --- a/packages/bridge-syntax-highlight/syntaxes/bridge.tmLanguage.json +++ b/packages/bridge-syntax-highlight/syntaxes/bridge.tmLanguage.json @@ -100,13 +100,14 @@ } }, { - "comment": "tool from ", - "match": "^\\s*(tool)\\s+([A-Za-z_][A-Za-z0-9_]*)(\\s+from\\s+)([A-Za-z_][A-Za-z0-9_.]*)", + "comment": "tool from [memoize]", + "match": "^\\s*(tool)\\s+([A-Za-z_][A-Za-z0-9_]*)(\\s+from\\s+)([A-Za-z_][A-Za-z0-9_.]*)(\\s+memoize)?", "captures": { "1": { "name": "keyword.control.bridge" }, "2": { "name": "variable.other.handle.bridge" }, "3": { "name": "keyword.control.bridge" }, - "4": { "name": "entity.name.function.bridge" } + "4": { "name": "entity.name.function.bridge" }, + "5": { "name": "keyword.control.bridge" } } }, { @@ -118,13 +119,14 @@ } }, { - "comment": "with as / with input as i", - "match": "^\\s*(with)\\s+([A-Za-z_][A-Za-z0-9_.]*)(\\s+as\\s+)([A-Za-z_][A-Za-z0-9_]*)", + "comment": "with as [memoize] / with input as i", + "match": "^\\s*(with)\\s+([A-Za-z_][A-Za-z0-9_.]*)(\\s+as\\s+)([A-Za-z_][A-Za-z0-9_]*)(\\s+memoize)?", "captures": { "1": { "name": "keyword.control.bridge" }, "2": { "name": "entity.name.function.bridge" }, "3": { "name": "keyword.control.bridge" }, - "4": { "name": "variable.other.handle.bridge" } + "4": { "name": "variable.other.handle.bridge" }, + "5": { "name": "keyword.control.bridge" } } }, { diff --git a/packages/bridge-types/src/index.ts b/packages/bridge-types/src/index.ts index e3ba8d2a..ea0827f7 100644 --- a/packages/bridge-types/src/index.ts +++ b/packages/bridge-types/src/index.ts @@ -64,6 +64,30 @@ export interface ToolMetadata { */ sync?: boolean; + // ─── Memoization ────────────────────────────────────────────────────── + + /** + * Opt into request-scoped memoization. + * + * When enabled, duplicate calls with identical inputs within the same + * request share a single execution. The cache stores Promises so even + * concurrent callers attach to the same in-flight request (stampede + * protection). + * + * Pass `true` for default JSON-key behaviour, or an object with a + * custom `keyFn` for ultra-fast key generation. + * + * Default: undefined (no memoization unless the bridge `memoize` keyword is used) + */ + memoize?: + | boolean + | { + /** Custom cache-key function. Receives the tool input object and + * must return a string. Falls back to `JSON.stringify(input)` when + * omitted. */ + keyFn?: (input: Record) => string; + }; + // ─── Observability ──────────────────────────────────────────────────── /** diff --git a/packages/bridge/test/bridge-format.test.ts b/packages/bridge/test/bridge-format.test.ts index 3437680e..411ef493 100644 --- a/packages/bridge/test/bridge-format.test.ts +++ b/packages/bridge/test/bridge-format.test.ts @@ -238,6 +238,7 @@ o.results <- p.items[] as item { module: SELF_MODULE, type: "Query", field: "search", + element: true, path: ["results", "name"], }, }); @@ -253,6 +254,7 @@ o.results <- p.items[] as item { module: SELF_MODULE, type: "Query", field: "search", + element: true, path: ["results", "lat"], }, }); diff --git a/packages/bridge/test/memoize.test.ts b/packages/bridge/test/memoize.test.ts new file mode 100644 index 00000000..2f4e7b88 --- /dev/null +++ b/packages/bridge/test/memoize.test.ts @@ -0,0 +1,530 @@ +/** + * Tests for element-scoped tool declarations and memoization. + * + * Covers: + * - Parsing `memoize` keyword (bridge handle, tool block, element-scoped) + * - Serialization and round-trip + * - Runtime memoization (stampede protection, request-scoped cache) + * - Element-scoped tool isolation in array mappings + * - Compiler codegen parity + * - ToolMetadata.memoize with custom keyFn + */ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { + parseBridgeFormat as parseBridge, + serializeBridge, +} from "../src/index.ts"; +import { executeBridge } from "@stackables/bridge-core"; +import type { Bridge, HandleBinding, ToolDef } from "@stackables/bridge-core"; +import { executeBridge as executeAot } from "@stackables/bridge-compiler"; + +type AnyData = Record; + +// ── Parsing ────────────────────────────────────────────────────────────────── + +describe("memoize keyword parsing", () => { + test("parses memoize on bridge handle binding", () => { + const doc = parseBridge(`version 1.5 +bridge Query.test { + with std.httpCall as h memoize + with input as i + with output as o + + h.url <- i.url + o.data <- h.response +}`); + const bridge = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + const handle = bridge.handles.find( + (h) => h.kind === "tool" && h.handle === "h", + ) as Extract; + assert.ok(handle, "should find tool handle h"); + assert.equal(handle.memoize, true, "memoize flag should be true"); + assert.equal(handle.name, "std.httpCall"); + }); + + test("parses bridge handle without memoize", () => { + const doc = parseBridge(`version 1.5 +bridge Query.test { + with std.httpCall as h + with output as o + + o.data <- h.response +}`); + const bridge = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + const handle = bridge.handles.find( + (h) => h.kind === "tool" && h.handle === "h", + ) as Extract; + assert.ok(handle); + assert.equal(handle.memoize, undefined); + }); + + test("parses memoize on tool block", () => { + const doc = parseBridge(`version 1.5 +tool myApi from std.httpCall memoize { + .baseUrl = "https://api.example.com" +}`); + const toolDef = doc.instructions.find( + (i): i is ToolDef => i.kind === "tool", + )!; + assert.ok(toolDef, "should find tool definition"); + assert.equal(toolDef.memoize, true, "memoize flag should be true"); + assert.equal(toolDef.name, "myApi"); + }); + + test("parses tool block without memoize", () => { + const doc = parseBridge(`version 1.5 +tool myApi from std.httpCall { + .baseUrl = "https://api.example.com" +}`); + const toolDef = doc.instructions.find( + (i): i is ToolDef => i.kind === "tool", + )!; + assert.ok(toolDef); + assert.equal(toolDef.memoize, undefined); + }); + + test("parses element-scoped tool declaration in array mapping", () => { + const doc = parseBridge(`version 1.5 +bridge Query.test { + with input as i + with output as o + + o.items <- i.list[] as item { + with std.httpCall as fetch memoize + + fetch.url <- item.url + .result <- fetch.response + } +}`); + const bridge = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + const handle = bridge.handles.find( + (h) => h.kind === "tool" && h.handle === "fetch", + ) as Extract; + assert.ok(handle, "should find element-scoped tool handle"); + assert.equal(handle.memoize, true); + assert.equal(handle.elementScoped, true); + assert.equal(handle.name, "std.httpCall"); + }); + + test("parses element-scoped tool without memoize", () => { + const doc = parseBridge(`version 1.5 +bridge Query.test { + with input as i + with output as o + + o.items <- i.list[] as item { + with std.httpCall as fetch + + fetch.url <- item.url + .result <- fetch.response + } +}`); + const bridge = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + const handle = bridge.handles.find( + (h) => h.kind === "tool" && h.handle === "fetch", + ) as Extract; + assert.ok(handle); + assert.equal(handle.memoize, undefined); + assert.equal(handle.elementScoped, true); + }); + + test("rejects memoize as handle alias name", () => { + // MemoizeKw is a keyword token, so `as memoize` fails to parse + assert.throws( + () => + parseBridge(`version 1.5 +bridge Query.test { + with std.httpCall as memoize + with output as o + o.data <- memoize.response +}`), + /memoize/, + ); + }); +}); + +// ── Serialization round-trip ───────────────────────────────────────────────── + +describe("memoize serialization", () => { + test("bridge handle memoize round-trips", () => { + const src = `version 1.5 +bridge Query.test { + with std.httpCall as h memoize + with input as i + with output as o + + h.url <- i.url + o.data <- h.response +}`; + const doc = parseBridge(src); + const serialized = serializeBridge(doc); + assert.ok( + serialized.includes("with std.httpCall as h memoize"), + `expected memoize in serialized output: ${serialized}`, + ); + + // Round-trip: re-parse and re-serialize + const reparsed = parseBridge(serialized); + const reserialized = serializeBridge(reparsed); + assert.equal(reserialized, serialized, "round-trip should be idempotent"); + }); + + test("tool block memoize round-trips", () => { + const src = `version 1.5 +tool myApi from std.httpCall memoize { + .baseUrl = "https://api.example.com" +}`; + const doc = parseBridge(src); + const serialized = serializeBridge(doc); + assert.ok( + serialized.includes("tool myApi from std.httpCall memoize"), + `expected memoize in tool block: ${serialized}`, + ); + + const reparsed = parseBridge(serialized); + const reserialized = serializeBridge(reparsed); + assert.equal(reserialized, serialized); + }); + + test("element-scoped tool declaration round-trips", () => { + const src = `version 1.5 +bridge Query.test { + with input as i + with output as o + + o.items <- i.list[] as item { + with std.httpCall as fetch memoize + + fetch.url <- item.url + .result <- fetch.response + } +}`; + const doc = parseBridge(src); + const serialized = serializeBridge(doc); + assert.ok( + serialized.includes("with std.httpCall as fetch memoize"), + `expected element-scoped with in serialized output: ${serialized}`, + ); + + // Element-scoped handles should NOT appear in bridge header + const headerLines = serialized + .split("\n") + .filter((l) => l.match(/^\s{2}with /)); + const fetchInHeader = headerLines.some((l) => l.includes("fetch")); + assert.ok( + !fetchInHeader, + "element-scoped handle should not be in bridge header", + ); + }); +}); + +// ── Runtime memoization ────────────────────────────────────────────────────── + +describe("runtime memoization", () => { + test("memoized tool called once for identical inputs via ToolMetadata", async () => { + let callCount = 0; + const expensiveFetch = async (input: Record) => { + callCount++; + return { data: `result-${input.id}` }; + }; + (expensiveFetch as any).bridge = { memoize: true }; + + const doc = parseBridge(`version 1.5 +bridge Query.test { + with expensiveFetch as a + with expensiveFetch as b + with input as i + with output as o + + a.id <- i.id + b.id <- i.id + o.fromA <- a.data + o.fromB <- b.data +}`); + const { data } = await executeBridge({ + document: doc, + operation: "Query.test", + input: { id: "42" }, + tools: { expensiveFetch }, + }); + assert.equal(data.fromA, "result-42"); + assert.equal(data.fromB, "result-42"); + // With memoization, identical inputs should only trigger one call + assert.equal(callCount, 1, "memoized tool should be called only once"); + }); + + test("memoized tool with different inputs calls separately", async () => { + let callCount = 0; + const fetch = async (input: Record) => { + callCount++; + return { data: `result-${input.id}` }; + }; + (fetch as any).bridge = { memoize: true }; + + const doc = parseBridge(`version 1.5 +bridge Query.test { + with fetch as a + with fetch as b + with input as i + with output as o + + a.id = "1" + b.id = "2" + o.fromA <- a.data + o.fromB <- b.data +}`); + const { data } = await executeBridge({ + document: doc, + operation: "Query.test", + input: {}, + tools: { fetch }, + }); + assert.equal(data.fromA, "result-1"); + assert.equal(data.fromB, "result-2"); + assert.equal(callCount, 2, "different inputs should call separately"); + }); + + test("memoize with custom keyFn", async () => { + let callCount = 0; + const lookup = async (_input: Record) => { + callCount++; + return { rate: 1.5 }; + }; + (lookup as any).bridge = { + memoize: { + keyFn: (input: Record) => + `${input.base}:${input.target}`, + }, + }; + + const doc = parseBridge(`version 1.5 +bridge Query.test { + with lookup as a + with lookup as b + with output as o + + a.base = "USD" + a.target = "EUR" + b.base = "USD" + b.target = "EUR" + o.rateA <- a.rate + o.rateB <- b.rate +}`); + const { data } = await executeBridge({ + document: doc, + operation: "Query.test", + input: {}, + tools: { lookup }, + }); + assert.equal(data.rateA, 1.5); + assert.equal(data.rateB, 1.5); + assert.equal(callCount, 1, "custom keyFn should deduplicate"); + }); + + test("DSL-level memoize on bridge handle", async () => { + let callCount = 0; + const fetch = async (input: Record) => { + callCount++; + return { data: `result-${input.id}` }; + }; + + const doc = parseBridge(`version 1.5 +bridge Query.test { + with fetch as a memoize + with fetch as b memoize + with output as o + + a.id = "42" + b.id = "42" + o.fromA <- a.data + o.fromB <- b.data +}`); + const { data } = await executeBridge({ + document: doc, + operation: "Query.test", + input: {}, + tools: { fetch }, + }); + assert.equal(data.fromA, "result-42"); + assert.equal(data.fromB, "result-42"); + assert.equal(callCount, 1, "DSL memoize should deduplicate"); + }); + + test("DSL-level memoize on tool block", async () => { + let callCount = 0; + const myFetcher = async (input: Record) => { + callCount++; + return { result: `fetched-${input.url}` }; + }; + + const doc = parseBridge(`version 1.5 +tool api from myFetcher memoize { + .url = "https://example.com/data" +} + +bridge Query.test { + with api as a + with api as b + with output as o + + o.fromA <- a.result + o.fromB <- b.result +}`); + const { data } = await executeBridge({ + document: doc, + operation: "Query.test", + input: {}, + tools: { myFetcher }, + }); + assert.equal(data.fromA, "fetched-https://example.com/data"); + assert.equal(data.fromB, "fetched-https://example.com/data"); + assert.equal(callCount, 1, "ToolDef memoize should deduplicate"); + }); + + test("memoize deduplicates in array with top-level tool", () => { + const doc = parseBridge(`version 1.5 +bridge Query.test { + with lookup as fetch memoize + with input as i + with output as o + + o.items <- i.list[] as item { + fetch.id <- item.id + .name <- fetch.name + } +}`); + // This tests that memoization is enabled on top-level tools used in arrays. + // Full element-scoped isolation requires deeper shadow tree support. + const bridge = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + const handle = bridge.handles.find( + (h) => h.kind === "tool" && h.handle === "fetch", + ) as Extract; + assert.ok(handle, "should find tool handle"); + assert.equal(handle.memoize, true); + }); + + test("memoization cache is request-scoped (not global)", async () => { + let callCount = 0; + const fetch = async (_input: Record) => { + callCount++; + return { data: `result-${callCount}` }; + }; + (fetch as any).bridge = { memoize: true }; + + const doc = parseBridge(`version 1.5 +bridge Query.test { + with fetch as f + with output as o + + f.id = "42" + o.data <- f.data +}`); + + // First request + const r1 = await executeBridge({ + document: doc, + operation: "Query.test", + input: {}, + tools: { fetch }, + }); + assert.equal(r1.data.data, "result-1"); + + // Second request should get a fresh result (not cached from first request) + const r2 = await executeBridge({ + document: doc, + operation: "Query.test", + input: {}, + tools: { fetch }, + }); + assert.equal(r2.data.data, "result-2"); + assert.equal(callCount, 2, "each request should have its own cache"); + }); +}); + +// ── Element-scoped tool isolation ──────────────────────────────────────────── + +describe("element-scoped tool isolation", () => { + test("element-scoped tool declaration produces correct AST structure", () => { + const doc = parseBridge(`version 1.5 +bridge Query.test { + with input as i + with output as o + + o.items <- i.list[] as item { + with processTool as proc + + proc.value <- item.val + .out <- proc.result + } +}`); + const bridge = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + // Element-scoped handle exists + const handle = bridge.handles.find( + (h) => h.kind === "tool" && h.handle === "proc", + ) as Extract; + assert.ok(handle, "should find element-scoped tool handle"); + assert.equal(handle.elementScoped, true); + assert.equal(handle.name, "processTool"); + + // Pipe handle entry exists with instance >= 100000 + assert.ok(bridge.pipeHandles, "should have pipe handles"); + const ph = bridge.pipeHandles!.find((p) => p.handle === "proc"); + assert.ok(ph, "should find pipe handle for proc"); + assert.ok( + ph.key.includes(":100000"), + "pipe handle should use instance >= 100000", + ); + + // Wires targeting the element-scoped tool instance exist + const toolWires = bridge.wires.filter( + (w) => "to" in w && w.to.instance === 100000, + ); + assert.ok( + toolWires.length > 0, + "should have wires targeting element-scoped tool", + ); + }); +}); + +// ── Compiler parity ───────────────────────────────────────────────────────── + +describe("memoize compiler parity", () => { + test("compiler generates __callMemo for memoized tools", async () => { + let callCount = 0; + const fetch = async (input: Record) => { + callCount++; + return { data: `result-${input.id}` }; + }; + (fetch as any).bridge = { memoize: true }; + + const doc = parseBridge(`version 1.5 +bridge Query.test { + with fetch as a memoize + with output as o + + a.id = "42" + o.fromA <- a.data +}`); + const { data } = await executeAot({ + document: doc, + operation: "Query.test", + input: {}, + tools: { fetch }, + }); + assert.equal(data.fromA, "result-42"); + assert.equal(callCount, 1, "compiled tool should execute once"); + }); +}); diff --git a/packages/bridge/test/scoping.test.ts b/packages/bridge/test/scoping.test.ts new file mode 100644 index 00000000..701d8581 --- /dev/null +++ b/packages/bridge/test/scoping.test.ts @@ -0,0 +1,783 @@ +/** + * Variable scoping test suite. + * + * Validates which references are visible at each nesting level in array + * mapping blocks and documents current limitations. Each case runs against + * **both** the runtime interpreter and the AOT compiler. + * + * ## Scope rules + * + * Level 0 – bridge root: + * All `with … as handle` declarations are visible everywhere. + * + * Level 1 – `[] as x { … }`: + * • The iterator `x` is visible. + * • Root-level handles remain visible. + * • `.field` targets the output element. + * + * Level 2 – `[] as y { … }` nested inside level 1: + * • The inner iterator `y` is visible. + * • Root-level handles remain visible. + * • **Outer iterator `x`** should be visible but is NOT yet supported + * by the parser (tracked as TODO — tests document the expected + * behavior and are marked pending). + * + * Level 3 – `[] as z { … }` nested inside level 2: + * • Same rules — `z` visible, root handles visible, + * outer iterators `x` / `y` are expected but not yet reachable. + * + * ## Element-scoped tools + * + * `with as ` inside array blocks creates an isolated + * per-element fork. The handle is visible only within that block. + * + * ## Aliases & Pipes + * + * `alias source.path as name` and `pipe:source.path` are scoped to the + * block in which they appear. + */ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { parseBridgeFormat } from "@stackables/bridge-parser"; +import { executeBridge } from "@stackables/bridge-core"; +import { executeBridge as executeAot } from "@stackables/bridge-compiler"; + +// ── Test-case type ────────────────────────────────────────────────────────── + +interface ScopingTestCase { + /** Human-readable test name */ + name: string; + /** Bridge source text (with `version 1.5` prefix) */ + bridgeText: string; + /** Operation to execute */ + operation: string; + /** Input arguments */ + input?: Record; + /** Tool implementations (keyed by name) */ + tools?: Record any>; + /** Context passed to the engine */ + context?: Record; + /** Expected output data */ + expected?: unknown; + /** Whether the AOT compiler supports this case (default: true) */ + aotSupported?: boolean; + /** + * When set, the test documents expected behavior that is not yet + * implemented. The test is registered as a `TODO` with the given + * reason string instead of running (and failing). + */ + pending?: string; +} + +// ── Runners ───────────────────────────────────────────────────────────────── + +async function runRuntime(c: ScopingTestCase): Promise { + const document = parseBridgeFormat(c.bridgeText); + const doc = JSON.parse(JSON.stringify(document)); + const { data } = await executeBridge({ + document: doc, + operation: c.operation, + input: c.input ?? {}, + tools: c.tools ?? {}, + context: c.context, + }); + return data; +} + +async function runAot(c: ScopingTestCase): Promise { + const document = parseBridgeFormat(c.bridgeText); + const { data } = await executeAot({ + document, + operation: c.operation, + input: c.input ?? {}, + tools: c.tools ?? {}, + context: c.context, + }); + return data; +} + +function runScopingSuite(suiteName: string, cases: ScopingTestCase[]) { + describe(suiteName, () => { + for (const c of cases) { + describe(c.name, () => { + // Cases that document expected behavior not yet implemented. + // The body runs the full assertion so the test will auto-pass + // once the feature lands (node:test treats todo-pass as info). + if (c.pending) { + test("runtime", { todo: c.pending }, async () => { + const data = await runRuntime(c); + if (c.expected !== undefined) assert.deepEqual(data, c.expected); + }); + test("aot", { todo: c.pending }, async () => { + const data = await runAot(c); + if (c.expected !== undefined) assert.deepEqual(data, c.expected); + }); + return; + } + + test("runtime", async () => { + const data = await runRuntime(c); + if (c.expected !== undefined) assert.deepEqual(data, c.expected); + }); + + if (c.aotSupported !== false) { + test("aot", async () => { + const data = await runAot(c); + if (c.expected !== undefined) assert.deepEqual(data, c.expected); + }); + + test("parity: runtime === aot", async () => { + const [rtData, aotData] = await Promise.all([ + runRuntime(c), + runAot(c), + ]); + assert.deepEqual(rtData, aotData); + }); + } else { + test("aot: skipped (not yet supported)", () => {}); + } + }); + } + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 1. Single-level array — iterator and root handles visible +// ═══════════════════════════════════════════════════════════════════════════ + +const singleLevelCases: ScopingTestCase[] = [ + { + name: "iterator fields visible at level 1", + bridgeText: `version 1.5 +bridge Query.test { + with input as i + with output as o + + o.items <- i.list[] as item { + .id <- item.id + .name <- item.name + } +}`, + operation: "Query.test", + input: { list: [{ id: "1", name: "one" }, { id: "2", name: "two" }] }, + expected: { + items: [ + { id: "1", name: "one" }, + { id: "2", name: "two" }, + ], + }, + }, + { + name: "root-level tool visible from level 1", + bridgeText: `version 1.5 +bridge Query.test { + with config as cfg + with input as i + with output as o + + o.items <- i.list[] as item { + .val <- item.x + .cfgVal <- cfg.setting + } +}`, + operation: "Query.test", + input: { list: [{ x: "a" }, { x: "b" }] }, + tools: { config: () => ({ setting: "global" }) }, + expected: { + items: [ + { val: "a", cfgVal: "global" }, + { val: "b", cfgVal: "global" }, + ], + }, + aotSupported: false, + }, + { + name: "multiple root-level tools visible from level 1", + bridgeText: `version 1.5 +bridge Query.test { + with toolA as a + with toolB as b + with input as i + with output as o + + o.items <- i.list[] as item { + .id <- item.id + .fromA <- a.tag + .fromB <- b.tag + } +}`, + operation: "Query.test", + input: { list: [{ id: "1" }, { id: "2" }] }, + tools: { + toolA: () => ({ tag: "A" }), + toolB: () => ({ tag: "B" }), + }, + expected: { + items: [ + { id: "1", fromA: "A", fromB: "B" }, + { id: "2", fromA: "A", fromB: "B" }, + ], + }, + aotSupported: false, + }, +]; + +runScopingSuite("Scoping: single-level array", singleLevelCases); + +// ═══════════════════════════════════════════════════════════════════════════ +// 2. Two-level nested arrays — own iterators only (current behavior) +// ═══════════════════════════════════════════════════════════════════════════ + +const twoLevelCases: ScopingTestCase[] = [ + { + name: "each level reads its own iterator", + bridgeText: `version 1.5 +bridge Query.test { + with input as i + with output as o + + o.groups <- i.groups[] as g { + .name <- g.name + .items <- g.items[] as item { + .label <- item.label + } + } +}`, + operation: "Query.test", + input: { + groups: [ + { name: "A", items: [{ label: "a1" }, { label: "a2" }] }, + { name: "B", items: [{ label: "b1" }] }, + ], + }, + expected: { + groups: [ + { name: "A", items: [{ label: "a1" }, { label: "a2" }] }, + { name: "B", items: [{ label: "b1" }] }, + ], + }, + aotSupported: false, + }, + { + name: "root-level tool visible from level 2 (two levels deep)", + bridgeText: `version 1.5 +bridge Query.test { + with enricher as e + with input as i + with output as o + + o.items <- i.list[] as item { + .val <- item.v + .enriched <- e.tag + .subs <- item.children[] as sub { + .sv <- sub.v + .enriched <- e.tag + } + } +}`, + operation: "Query.test", + input: { + list: [ + { v: "a", children: [{ v: "a1" }, { v: "a2" }] }, + { v: "b", children: [{ v: "b1" }] }, + ], + }, + tools: { enricher: () => ({ tag: "enriched" }) }, + expected: { + items: [ + { + val: "a", + enriched: "enriched", + subs: [ + { sv: "a1", enriched: "enriched" }, + { sv: "a2", enriched: "enriched" }, + ], + }, + { + val: "b", + enriched: "enriched", + subs: [{ sv: "b1", enriched: "enriched" }], + }, + ], + }, + aotSupported: false, + }, + { + // EXPECTED but NOT YET SUPPORTED: inner scope reads outer iterator + name: "level 2 reads outer iterator x.v (cross-scope)", + bridgeText: `version 1.5 +bridge Query.test { + with input as i + with output as o + + o.l1 <- i.a[] as x { + .xv <- x.v + .l2 <- x.children[] as y { + .yv <- y.v + .xv <- x.v + } + } +}`, + operation: "Query.test", + input: { + a: [ + { v: "X1", children: [{ v: "Y1" }, { v: "Y2" }] }, + { v: "X2", children: [{ v: "Y3" }] }, + ], + }, + expected: { + l1: [ + { + xv: "X1", + l2: [ + { yv: "Y1", xv: "X1" }, + { yv: "Y2", xv: "X1" }, + ], + }, + { + xv: "X2", + l2: [{ yv: "Y3", xv: "X2" }], + }, + ], + }, + aotSupported: false, + }, +]; + +runScopingSuite("Scoping: two-level nested arrays", twoLevelCases); + +// ═══════════════════════════════════════════════════════════════════════════ +// 3. Three-level nested arrays +// ═══════════════════════════════════════════════════════════════════════════ + +const threeLevelCases: ScopingTestCase[] = [ + { + name: "each level reads its own iterator only (three levels)", + bridgeText: `version 1.5 +bridge Query.test { + with input as i + with output as o + + o.l1 <- i.a[] as x { + .xv <- x.v + .l2 <- x.children[] as y { + .yv <- y.v + .l3 <- y.children[] as z { + .zv <- z.v + } + } + } +}`, + operation: "Query.test", + input: { + a: [ + { + v: "X1", + children: [ + { v: "Y1", children: [{ v: "Z1" }, { v: "Z2" }] }, + { v: "Y2", children: [{ v: "Z3" }] }, + ], + }, + { + v: "X2", + children: [{ v: "Y3", children: [{ v: "Z4" }] }], + }, + ], + }, + expected: { + l1: [ + { + xv: "X1", + l2: [ + { yv: "Y1", l3: [{ zv: "Z1" }, { zv: "Z2" }] }, + { yv: "Y2", l3: [{ zv: "Z3" }] }, + ], + }, + { + xv: "X2", + l2: [{ yv: "Y3", l3: [{ zv: "Z4" }] }], + }, + ], + }, + aotSupported: false, + }, + { + name: "root-level tool visible from level 3 (three levels deep)", + bridgeText: `version 1.5 +bridge Query.test { + with enricher as e + with input as i + with output as o + + o.l1 <- i.a[] as x { + .xv <- x.v + .l2 <- x.children[] as y { + .yv <- y.v + .l3 <- y.children[] as z { + .zv <- z.v + .enriched <- e.tag + } + } + } +}`, + operation: "Query.test", + input: { + a: [ + { + v: "X1", + children: [ + { v: "Y1", children: [{ v: "Z1" }] }, + ], + }, + ], + }, + tools: { enricher: () => ({ tag: "enriched" }) }, + expected: { + l1: [ + { + xv: "X1", + l2: [ + { + yv: "Y1", + l3: [{ zv: "Z1", enriched: "enriched" }], + }, + ], + }, + ], + }, + aotSupported: false, + }, + { + name: "level 3 reads outer iterators x.v and y.v (cross-scope)", + bridgeText: `version 1.5 +bridge Query.test { + with input as i + with output as o + + o.l1 <- i.a[] as x { + .xv <- x.v + .l2 <- x.children[] as y { + .yv <- y.v + .xv <- x.v + .l3 <- y.children[] as z { + .zv <- z.v + .xv <- x.v + .yv <- y.v + } + } + } +}`, + operation: "Query.test", + input: { + a: [ + { + v: "X1", + children: [ + { + v: "Y1", + children: [{ v: "Z1" }, { v: "Z2" }], + }, + ], + }, + ], + }, + expected: { + l1: [ + { + xv: "X1", + l2: [ + { + yv: "Y1", + xv: "X1", + l3: [ + { zv: "Z1", xv: "X1", yv: "Y1" }, + { zv: "Z2", xv: "X1", yv: "Y1" }, + ], + }, + ], + }, + ], + }, + aotSupported: false, + }, + { + name: "level 2 reads outer iterator x.v with three levels (cross-scope)", + bridgeText: `version 1.5 +bridge Query.test { + with input as i + with output as o + + o.l1 <- i.a[] as x { + .xv <- x.v + .l2 <- x.children[] as y { + .yv <- y.v + .xv <- x.v + .l3 <- y.children[] as z { + .zv <- z.v + } + } + } +}`, + operation: "Query.test", + input: { + a: [ + { + v: "X1", + children: [ + { v: "Y1", children: [{ v: "Z1" }] }, + ], + }, + ], + }, + expected: { + l1: [ + { + xv: "X1", + l2: [ + { + yv: "Y1", + xv: "X1", + l3: [{ zv: "Z1" }], + }, + ], + }, + ], + }, + aotSupported: false, + }, +]; + +runScopingSuite("Scoping: three-level nested arrays", threeLevelCases); + +// ═══════════════════════════════════════════════════════════════════════════ +// 4. Alias scoping inside array blocks +// ═══════════════════════════════════════════════════════════════════════════ + +const aliasCases: ScopingTestCase[] = [ + { + name: "alias binds sub-path within element block", + bridgeText: `version 1.5 +bridge Query.test { + with input as i + with output as o + + o.items <- i.list[] as item { + alias item.meta.label as lbl + .name <- lbl + .id <- item.id + } +}`, + operation: "Query.test", + input: { + list: [ + { id: "1", meta: { label: "first" } }, + { id: "2", meta: { label: "second" } }, + ], + }, + expected: { + items: [ + { name: "first", id: "1" }, + { name: "second", id: "2" }, + ], + }, + }, + { + name: "alias in nested array block", + bridgeText: `version 1.5 +bridge Query.test { + with input as i + with output as o + + o.groups <- i.groups[] as g { + .name <- g.name + .items <- g.items[] as item { + alias item.detail as d + .label <- d.label + } + } +}`, + operation: "Query.test", + input: { + groups: [ + { name: "A", items: [{ detail: { label: "a1" } }] }, + { name: "B", items: [{ detail: { label: "b1" } }, { detail: { label: "b2" } }] }, + ], + }, + expected: { + groups: [ + { name: "A", items: [{ label: "a1" }] }, + { name: "B", items: [{ label: "b1" }, { label: "b2" }] }, + ], + }, + aotSupported: false, + }, +]; + +runScopingSuite("Scoping: alias inside array blocks", aliasCases); + +// ═══════════════════════════════════════════════════════════════════════════ +// 5. Pipe scoping inside array blocks (colon syntax) +// ═══════════════════════════════════════════════════════════════════════════ + +const pipeCases: ScopingTestCase[] = [ + { + name: "pipe transforms element field at level 1", + bridgeText: `version 1.5 +bridge Query.test { + with std.str.toUpperCase as upper + with input as i + with output as o + + o.items <- i.list[] as item { + .upper <- upper:item.name + .raw <- item.name + } +}`, + operation: "Query.test", + input: { list: [{ name: "hello" }, { name: "world" }] }, + expected: { + items: [ + { upper: "HELLO", raw: "hello" }, + { upper: "WORLD", raw: "world" }, + ], + }, + }, + { + name: "pipe in nested array", + bridgeText: `version 1.5 +bridge Query.test { + with std.str.toUpperCase as upper + with input as i + with output as o + + o.groups <- i.groups[] as g { + .name <- upper:g.name + .tags <- g.tags[] as tag { + .label <- upper:tag.label + } + } +}`, + operation: "Query.test", + input: { + groups: [ + { name: "alpha", tags: [{ label: "fast" }, { label: "safe" }] }, + { name: "beta", tags: [{ label: "new" }] }, + ], + }, + expected: { + groups: [ + { name: "ALPHA", tags: [{ label: "FAST" }, { label: "SAFE" }] }, + { name: "BETA", tags: [{ label: "NEW" }] }, + ], + }, + aotSupported: false, + }, +]; + +runScopingSuite("Scoping: pipes inside array blocks", pipeCases); + +// ═══════════════════════════════════════════════════════════════════════════ +// 6. Element-scoped tool declarations (`with tool as handle` inside block) +// ═══════════════════════════════════════════════════════════════════════════ + +const elementScopedToolCases: ScopingTestCase[] = [ + { + name: "element-scoped tool is isolated per element", + bridgeText: `version 1.5 +bridge Query.test { + with input as i + with output as o + + o.items <- i.list[] as item { + with myTool as t + + t.id <- item.id + .result <- t.data + } +}`, + operation: "Query.test", + input: { list: [{ id: "a" }, { id: "b" }, { id: "c" }] }, + tools: { + myTool: (inp: Record) => ({ data: `result-${inp.id}` }), + }, + expected: { + items: [ + { result: "result-a" }, + { result: "result-b" }, + { result: "result-c" }, + ], + }, + }, + { + name: "element-scoped tool coexists with root-level tool", + bridgeText: `version 1.5 +bridge Query.test { + with globalTool as g + with input as i + with output as o + + o.items <- i.list[] as item { + with localTool as lt + + lt.id <- item.id + .local <- lt.value + .global <- g.setting + } +}`, + operation: "Query.test", + input: { list: [{ id: "x" }, { id: "y" }] }, + tools: { + globalTool: () => ({ setting: "shared" }), + localTool: (inp: Record) => ({ value: `local-${inp.id}` }), + }, + expected: { + items: [ + { local: "local-x", global: "shared" }, + { local: "local-y", global: "shared" }, + ], + }, + }, +]; + +runScopingSuite("Scoping: element-scoped tool declarations", elementScopedToolCases); + +// ═══════════════════════════════════════════════════════════════════════════ +// 7. Request-scoped cache — each execution gets a fresh scope +// ═══════════════════════════════════════════════════════════════════════════ + +describe("Scoping: request-scoped isolation", () => { + test("each executeBridge call has an independent scope", async () => { + let callCount = 0; + const fetch = async (_input: Record) => { + callCount++; + return { data: `result-${callCount}` }; + }; + (fetch as any).bridge = { memoize: true }; + + const document = parseBridgeFormat(`version 1.5 +bridge Query.test { + with fetch as f + with output as o + + f.id = "42" + o.data <- f.data +}`); + + const r1 = await executeBridge({ + document: JSON.parse(JSON.stringify(document)), + operation: "Query.test", + input: {}, + tools: { fetch }, + }); + assert.equal((r1.data as Record).data, "result-1"); + + const r2 = await executeBridge({ + document: JSON.parse(JSON.stringify(document)), + operation: "Query.test", + input: {}, + tools: { fetch }, + }); + assert.equal((r2.data as Record).data, "result-2"); + assert.equal(callCount, 2, "each request should have its own scope"); + }); +}); diff --git a/packages/docs-site/src/content/docs/reference/40-using-tools-pipes.mdx b/packages/docs-site/src/content/docs/reference/40-using-tools-pipes.mdx index cac2db88..a6ebdf3f 100644 --- a/packages/docs-site/src/content/docs/reference/40-using-tools-pipes.mdx +++ b/packages/docs-site/src/content/docs/reference/40-using-tools-pipes.mdx @@ -122,3 +122,53 @@ bridge Query.getUser { ``` +## 4. Tool Memoization + +Add the `memoize` keyword when declaring a tool handle to enable request-scoped caching. When a memoized tool is called multiple times with identical inputs during the same request, only one actual invocation is made. + +```bridge +bridge Query.dashboard { + with expensiveApi as api memoize + with input as i + with output as o + + # Both of these resolve to the same API call — only one HTTP request fires + api.userId <- i.userId + o.profile <- api.profile + o.settings <- api.settings +} + +``` + +### Memoization on Tool Blocks + +You can also enable memoization on tool blocks, making every usage of that tool automatically memoized: + +```bridge +tool cachedLookup from std.httpCall memoize { + .baseUrl = "https://api.example.com" + .method = "GET" +} + +``` + +### TypeScript Metadata + +Tool authors can enable memoization by default with an optional custom key function for optimal cache key generation: + +```typescript +export async function fetchExchangeRate(opts: { base: string, target: string }) { + // ... expensive API call ... +} + +fetchExchangeRate.bridge = { + memoize: { + keyFn: (input) => `${input.base}:${input.target}` + } +}; +``` + + + diff --git a/packages/docs-site/src/content/docs/reference/70-array-mapping.mdx b/packages/docs-site/src/content/docs/reference/70-array-mapping.mdx index 1a9019d2..0cf8ae3a 100644 --- a/packages/docs-site/src/content/docs/reference/70-array-mapping.mdx +++ b/packages/docs-site/src/content/docs/reference/70-array-mapping.mdx @@ -178,3 +178,64 @@ bridge Query.getEnrichedUsers { + +## 4. Element-Scoped Tool Declarations + +When you need to call a tool for each element in an array, declare the tool **inside** the mapping block with `with as `. This creates a fresh, isolated instance per element — preventing race conditions from shared state. + +```bridge +bridge Query.processCatalog { + with context as ctx + with output as o + + o <- ctx.catalog[] as cat { + # Each element gets its own isolated tool instance + with std.httpCall as fetchItem + + fetchItem.method = "GET" + fetchItem.url <- cat.url + + .item <- fetchItem.response.data + } +} + +``` + +### Memoization + +Add the `memoize` keyword to cache results for identical inputs across elements. When multiple elements call the same tool with the same input, only one actual call is made. + +```bridge +o <- ctx.items[] as item { + # Memoize: deduplicate calls with identical inputs + with std.httpCall as fetch memoize + + fetch.url <- item.categoryUrl + .category <- fetch.response +} + +``` + +Memoization can also be applied at the bridge level or on tool blocks: + +```bridge +# On bridge handle +bridge Query.test { + with myApi as api memoize + ... +} + +# On tool block +tool cachedApi from std.httpCall memoize { + .baseUrl = "https://api.example.com" +} + +``` + + + + diff --git a/packages/playground/src/examples.ts b/packages/playground/src/examples.ts index 31984bfa..6dd65f20 100644 --- a/packages/playground/src/examples.ts +++ b/packages/playground/src/examples.ts @@ -1037,6 +1037,76 @@ bridge Query.processCatalog { ] } ] +}`, + }, + { + id: "tool-memoization", + name: "Tool Memoization", + description: + "Use the memoize keyword to cache tool results for identical inputs, solving the N+1 problem", + schema: ` +type Price { + amount: Float + currency: String +} + +type Product { + name: String + price: Price +} + +type Query { + getProducts: [Product!]! +} + `, + bridge: `version 1.5 + +tool convertCurrency from currencyConverter memoize { + .baseCurrency = "USD" +} + +bridge Query.getProducts { + with context as ctx + with convertCurrency as cc + with output as o + + o <- ctx.products[] as p { + .name <- p.name + + cc.targetCurrency <- p.currency + cc.amount <- p.rawPrice + .price.amount <- cc.convertedAmount + .price.currency <- p.currency + } +}`, + queries: [ + { + name: "Get products with memoized conversion", + query: `{ + getProducts { + name + price { + amount + currency + } + } +}`, + }, + ], + standaloneQueries: [ + { + operation: "Query.getProducts", + outputFields: "", + input: {}, + }, + ], + context: `{ + "products": [ + { "name": "Widget A", "currency": "EUR", "rawPrice": 10.0 }, + { "name": "Widget B", "currency": "EUR", "rawPrice": 25.0 }, + { "name": "Widget C", "currency": "GBP", "rawPrice": 15.0 }, + { "name": "Widget D", "currency": "EUR", "rawPrice": 30.0 } + ] }`, }, ];