Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 102 additions & 12 deletions packages/bridge-compiler/src/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<string, string>();
for (const inst of document.instructions) {
Expand Down Expand Up @@ -280,6 +308,8 @@ class CodegenContext {
private catchGuardedTools = new Set<string>();
/** Trunk keys of tools whose inputs depend on element wires (must be inlined in map callbacks). */
private elementScopedTools = new Set<string>();
/** Trunk keys of tools that should use memoized call wrapper (__callMemo). */
private memoizedTools = new Set<string>();
/** Trunk keys of tools that are only referenced in ternary branches (can be lazily evaluated). */
private ternaryOnlyTools = new Set<string>();
/** Map from element-scoped non-internal tool trunk key to loop-local variable name.
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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);
}
}
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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)};`,
);
}
}
Expand Down
19 changes: 17 additions & 2 deletions packages/bridge-compiler/src/execute-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -232,7 +235,19 @@ export async function executeBridge<T = unknown>(
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<T>(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"].
Expand Down
2 changes: 1 addition & 1 deletion packages/bridge-compiler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading
Loading