diff --git a/.changeset/fifty-cases-rhyme.md b/.changeset/fifty-cases-rhyme.md new file mode 100644 index 00000000..d1f9f9f0 --- /dev/null +++ b/.changeset/fifty-cases-rhyme.md @@ -0,0 +1,11 @@ +--- +"@stackables/bridge-compiler": minor +"@stackables/bridge-parser": minor +"@stackables/bridge-core": minor +--- + +Multi-Level Control Flow (break N, continue N) + +When working with deeply nested arrays (e.g., mapping categories that contain lists of products), you may want an error deep inside the inner array to skip the outer array element. + +You can append a number to break or continue to specify how many loop levels the signal should pierce. \ No newline at end of file diff --git a/.changeset/many-beds-like.md b/.changeset/many-beds-like.md new file mode 100644 index 00000000..dc70b332 --- /dev/null +++ b/.changeset/many-beds-like.md @@ -0,0 +1,5 @@ +--- +"@stackables/bridge-graphql": minor +--- + +Support optional lookahead resolver with compiler diff --git a/packages/bridge-compiler/src/codegen.ts b/packages/bridge-compiler/src/codegen.ts index 363a1e03..d3f806ea 100644 --- a/packages/bridge-compiler/src/codegen.ts +++ b/packages/bridge-compiler/src/codegen.ts @@ -139,19 +139,41 @@ function hasCatchFallback(w: Wire): boolean { ); } +type DetectedControlFlow = { + kind: "break" | "continue" | "throw" | "panic"; + levels: number; +}; + /** Check if any wire in a set has a control flow instruction (break/continue/throw/panic). */ -function detectControlFlow( - wires: Wire[], -): "break" | "continue" | "throw" | "panic" | null { +function detectControlFlow(wires: Wire[]): DetectedControlFlow | null { for (const w of wires) { if ("fallbacks" in w && w.fallbacks) { for (const fb of w.fallbacks) { - if (fb.control) - return fb.control.kind as "break" | "continue" | "throw" | "panic"; + if (fb.control) { + const kind = fb.control.kind as + | "break" + | "continue" + | "throw" + | "panic"; + const levels = + kind === "break" || kind === "continue" + ? Math.max(1, Number((fb.control as any).levels) || 1) + : 1; + return { kind, levels }; + } } } if ("catchControl" in w && w.catchControl) { - return w.catchControl.kind as "break" | "continue" | "throw" | "panic"; + const kind = w.catchControl.kind as + | "break" + | "continue" + | "throw" + | "panic"; + const levels = + kind === "break" || kind === "continue" + ? Math.max(1, Number((w.catchControl as any).levels) || 1) + : 1; + return { kind, levels }; } } return null; @@ -648,6 +670,12 @@ class CodegenContext { ` const __ctx = { logger: __opts?.logger ?? {}, signal: __signal };`, ); lines.push(` const __trace = __opts?.__trace;`); + lines.push( + ` const __isLoopCtrl = (v) => (v?.__bridgeControl === "break" || v?.__bridgeControl === "continue") && Number.isInteger(v?.levels) && v.levels > 0;`, + ); + lines.push( + ` const __nextLoopCtrl = (v) => ({ __bridgeControl: v.__bridgeControl, levels: v.levels - 1 });`, + ); lines.push(` async function __call(fn, input, toolName) {`); lines.push(` if (__signal?.aborted) throw new __BridgeAbortError();`); lines.push(` const start = __trace ? performance.now() : 0;`); @@ -1386,6 +1414,8 @@ class CodegenContext { // Only check control flow on direct element wires, not sub-array element wires const directElemWires = elemWires.filter((w) => w.to.path.length === 1); const cf = detectControlFlow(directElemWires); + const anyCf = detectControlFlow(elemWires); + const requiresLabeledLoop = !cf && !!anyCf && anyCf.levels > 1; // Check if any element wire generates `await` (element-scoped tools or catch fallbacks) const needsAsync = elemWires.some((w) => this.wireNeedsAwait(w)); @@ -1401,20 +1431,27 @@ class CodegenContext { arrayIterators, 0, 4, - cf === "continue" ? "for-continue" : "break", + cf.kind === "continue" ? "for-continue" : "break", ) : ` _result.push(${this.buildElementBody(elemWires, arrayIterators, 0, 4)});`; lines.push(` const _result = [];`); - lines.push(` for (const _el0 of (${arrayExpr} ?? [])) {`); + lines.push(` __loop0: for (const _el0 of (${arrayExpr} ?? [])) {`); + lines.push(` try {`); for (const pl of preambleLines) { - lines.push(` ${pl}`); + lines.push(` ${pl}`); } - lines.push(body); + lines.push(` ${body.trimStart()}`); + lines.push(` } catch (_ctrl) {`); + lines.push( + ` if (__isLoopCtrl(_ctrl)) { if (_ctrl.levels > 1) throw __nextLoopCtrl(_ctrl); if (_ctrl.__bridgeControl === "break") break; continue; }`, + ); + lines.push(` throw _ctrl;`); + lines.push(` }`); lines.push(` }`); lines.push(` return _result;`); this.elementLocalVars.clear(); - } else if (cf === "continue") { + } else if (cf?.kind === "continue" && cf.levels === 1) { // Use flatMap — skip elements that trigger continue (sync only) const body = this.buildElementBodyWithControlFlow( elemWires, @@ -1426,18 +1463,35 @@ class CodegenContext { lines.push(` return (${arrayExpr} ?? []).flatMap((_el0) => {`); lines.push(body); lines.push(` });`); - } else if (cf === "break") { + } else if ( + cf?.kind === "break" || + cf?.kind === "continue" || + requiresLabeledLoop + ) { + // Use an explicit loop for: + // - direct break/continue control + // - nested multilevel control (e.g. break 2 / continue 2) that must + // escape from sub-array IIFEs through throw/catch propagation. // Use a loop with early break (sync) - const body = this.buildElementBodyWithControlFlow( - elemWires, - arrayIterators, - 0, - 4, - "break", - ); + const body = cf + ? this.buildElementBodyWithControlFlow( + elemWires, + arrayIterators, + 0, + 4, + cf.kind === "continue" ? "for-continue" : "break", + ) + : ` _result.push(${this.buildElementBody(elemWires, arrayIterators, 0, 4)});`; lines.push(` const _result = [];`); - lines.push(` for (const _el0 of (${arrayExpr} ?? [])) {`); - lines.push(body); + lines.push(` __loop0: for (const _el0 of (${arrayExpr} ?? [])) {`); + lines.push(` try {`); + lines.push(` ${body.trimStart()}`); + lines.push(` } catch (_ctrl) {`); + lines.push( + ` if (__isLoopCtrl(_ctrl)) { if (_ctrl.levels > 1) throw __nextLoopCtrl(_ctrl); if (_ctrl.__bridgeControl === "break") break; continue; }`, + ); + lines.push(` throw _ctrl;`); + lines.push(` }`); lines.push(` }`); lines.push(` return _result;`); } else { @@ -1560,6 +1614,8 @@ class CodegenContext { // Only check control flow on direct element wires (not sub-array element wires) const directShifted = shifted.filter((w) => w.to.path.length === 1); const cf = detectControlFlow(directShifted); + const anyCf = detectControlFlow(shifted); + const requiresLabeledLoop = !cf && !!anyCf && anyCf.levels > 1; // Check if any element wire generates `await` (element-scoped tools or catch fallbacks) const needsAsync = shifted.some((w) => this.wireNeedsAwait(w)); let mapExpr: string; @@ -1575,14 +1631,14 @@ class CodegenContext { arrayIterators, 0, 8, - cf === "continue" ? "for-continue" : "break", + cf.kind === "continue" ? "for-continue" : "break", ) : ` _result.push(${this.buildElementBody(shifted, arrayIterators, 0, 8)});`; const preamble = preambleLines.map((l) => ` ${l}`).join("\n"); - mapExpr = `await (async () => { const _src = ${arrayExpr}; if (_src == null) return null; const _result = []; for (const _el0 of _src) {\n${preamble}\n${asyncBody}\n } return _result; })()`; + mapExpr = `await (async () => { const _src = ${arrayExpr}; if (_src == null) return null; const _result = []; __loop0: for (const _el0 of _src) {\n try {\n${preamble}\n${asyncBody}\n } catch (_ctrl) { if (__isLoopCtrl(_ctrl)) { if (_ctrl.levels > 1) throw __nextLoopCtrl(_ctrl); if (_ctrl.__bridgeControl === "break") break; continue; } throw _ctrl; }\n } return _result; })()`; this.elementLocalVars.clear(); - } else if (cf === "continue") { + } else if (cf?.kind === "continue" && cf.levels === 1) { const cfBody = this.buildElementBodyWithControlFlow( shifted, arrayIterators, @@ -1591,15 +1647,23 @@ class CodegenContext { "continue", ); mapExpr = `((__s) => Array.isArray(__s) ? __s.flatMap((_el0) => {\n${cfBody}\n }) ?? null : null)(${arrayExpr})`; - } else if (cf === "break") { - const cfBody = this.buildElementBodyWithControlFlow( - shifted, - arrayIterators, - 0, - 8, - "break", - ); - mapExpr = `(() => { const _src = ${arrayExpr}; if (!Array.isArray(_src)) return null; const _result = []; for (const _el0 of _src) {\n${cfBody}\n } return _result; })()`; + } else if ( + cf?.kind === "break" || + cf?.kind === "continue" || + requiresLabeledLoop + ) { + // Same rationale as root array handling above: nested multilevel + // control requires for-loop + throw/catch propagation instead of map. + const loopBody = cf + ? this.buildElementBodyWithControlFlow( + shifted, + arrayIterators, + 0, + 8, + cf.kind === "continue" ? "for-continue" : "break", + ) + : ` _result.push(${this.buildElementBody(shifted, arrayIterators, 0, 8)});`; + mapExpr = `(() => { const _src = ${arrayExpr}; if (!Array.isArray(_src)) return null; const _result = []; __loop0: for (const _el0 of _src) {\n try {\n${loopBody}\n } catch (_ctrl) { if (__isLoopCtrl(_ctrl)) { if (_ctrl.levels > 1) throw __nextLoopCtrl(_ctrl); if (_ctrl.__bridgeControl === "break") break; continue; } throw _ctrl; }\n } return _result; })()`; } else { const body = this.buildElementBody(shifted, arrayIterators, 0, 6); mapExpr = `((__s) => Array.isArray(__s) ? __s.map((_el0) => (${body})) ?? null : null)(${arrayExpr})`; @@ -1752,11 +1816,11 @@ class CodegenContext { arrayIterators, depth + 1, indent + 4, - innerCf === "continue" ? "for-continue" : "break", + innerCf.kind === "continue" ? "for-continue" : "break", ) : `${" ".repeat(indent + 4)}_result.push(${this.buildElementBody(shifted, arrayIterators, depth + 1, indent + 4)});`; - mapExpr = `await (async () => { const _src = ${srcExpr}; if (!Array.isArray(_src)) return null; const _result = []; for (const ${innerElVar} of _src) {\n${innerBody}\n${" ".repeat(indent + 2)}} return _result; })()`; - } else if (innerCf === "continue") { + mapExpr = `await (async () => { const _src = ${srcExpr}; if (!Array.isArray(_src)) return null; const _result = []; __loop${depth + 1}: for (const ${innerElVar} of _src) {\n${" ".repeat(indent + 4)}try {\n${innerBody}\n${" ".repeat(indent + 4)}} catch (_ctrl) { if (__isLoopCtrl(_ctrl)) { if (_ctrl.levels > 1) throw __nextLoopCtrl(_ctrl); if (_ctrl.__bridgeControl === "break") break; continue; } throw _ctrl; }\n${" ".repeat(indent + 2)}} return _result; })()`; + } else if (innerCf?.kind === "continue" && innerCf.levels === 1) { const cfBody = this.buildElementBodyWithControlFlow( shifted, arrayIterators, @@ -1765,15 +1829,15 @@ class CodegenContext { "continue", ); mapExpr = `((__s) => Array.isArray(__s) ? __s.flatMap((${innerElVar}) => {\n${cfBody}\n${" ".repeat(indent + 2)}}) ?? null : null)(${srcExpr})`; - } else if (innerCf === "break") { + } else if (innerCf?.kind === "break" || innerCf?.kind === "continue") { const cfBody = this.buildElementBodyWithControlFlow( shifted, arrayIterators, depth + 1, indent + 4, - "break", + innerCf.kind === "continue" ? "for-continue" : "break", ); - mapExpr = `(() => { const _src = ${srcExpr}; if (!Array.isArray(_src)) return null; const _result = []; for (const ${innerElVar} of _src) {\n${cfBody}\n${" ".repeat(indent + 2)}} return _result; })()`; + mapExpr = `(() => { const _src = ${srcExpr}; if (!Array.isArray(_src)) return null; const _result = []; __loop${depth + 1}: for (const ${innerElVar} of _src) {\n${" ".repeat(indent + 4)}try {\n${cfBody}\n${" ".repeat(indent + 4)}} catch (_ctrl) { if (__isLoopCtrl(_ctrl)) { if (_ctrl.levels > 1) throw __nextLoopCtrl(_ctrl); if (_ctrl.__bridgeControl === "break") break; continue; } throw _ctrl; }\n${" ".repeat(indent + 2)}} return _result; })()`; } else { const innerBody = this.buildElementBody( shifted, @@ -1840,6 +1904,21 @@ class CodegenContext { controlWire.fallbacks?.some( (fb) => fb.type === "nullish" && fb.control != null, ) ?? false; + const ctrlFromFallback = controlWire.fallbacks?.find( + (fb) => fb.control != null, + )?.control; + const ctrl = ctrlFromFallback ?? controlWire.catchControl; + const controlKind = ctrl?.kind === "continue" ? "continue" : "break"; + const controlLevels = + ctrl && (ctrl.kind === "continue" || ctrl.kind === "break") + ? Math.max(1, Number(ctrl.levels) || 1) + : 1; + const controlStatement = + controlLevels > 1 + ? `throw { __bridgeControl: ${JSON.stringify(controlKind)}, levels: ${controlLevels} };` + : controlKind === "continue" + ? "continue;" + : "break;"; if (mode === "continue") { if (isNullish) { @@ -1852,16 +1931,16 @@ class CodegenContext { // mode === "for-continue" — same as break but uses native 'continue' keyword if (mode === "for-continue") { if (isNullish) { - return `${pad} if (${checkExpr} == null) continue;\n${pad} _result.push(${this.buildElementBody(elemWires, arrayIterators, depth, indent)});`; + return `${pad} if (${checkExpr} == null) ${controlStatement}\n${pad} _result.push(${this.buildElementBody(elemWires, arrayIterators, depth, indent)});`; } - return `${pad} if (!${checkExpr}) continue;\n${pad} _result.push(${this.buildElementBody(elemWires, arrayIterators, depth, indent)});`; + return `${pad} if (!${checkExpr}) ${controlStatement}\n${pad} _result.push(${this.buildElementBody(elemWires, arrayIterators, depth, indent)});`; } // mode === "break" if (isNullish) { - return `${pad} if (${checkExpr} == null) break;\n${pad} _result.push(${this.buildElementBody(elemWires, arrayIterators, depth, indent)});`; + return `${pad} if (${checkExpr} == null) ${controlStatement}\n${pad} _result.push(${this.buildElementBody(elemWires, arrayIterators, depth, indent)});`; } - return `${pad} if (!${checkExpr}) break;\n${pad} _result.push(${this.buildElementBody(elemWires, arrayIterators, depth, indent)});`; + return `${pad} if (!${checkExpr}) ${controlStatement}\n${pad} _result.push(${this.buildElementBody(elemWires, arrayIterators, depth, indent)});`; } // ── Wire → expression ──────────────────────────────────────────────────── diff --git a/packages/bridge-core/src/ExecutionTree.ts b/packages/bridge-core/src/ExecutionTree.ts index 2f37d157..0f6deb57 100644 --- a/packages/bridge-core/src/ExecutionTree.ts +++ b/packages/bridge-core/src/ExecutionTree.ts @@ -17,6 +17,7 @@ import { } from "./tracing.ts"; import type { Logger, + LoopControlSignal, MaybePromise, Path, TreeContext, @@ -27,6 +28,8 @@ import { BridgeAbortError, BridgePanicError, CONTINUE_SYM, + decrementLoopControl, + isLoopControlSignal, isPromise, MAX_EXECUTION_DEPTH, } from "./tree-types.ts"; @@ -371,8 +374,11 @@ export class ExecutionTree implements TreeContext { if (this.signal?.aborted) { throw new BridgeAbortError(); } - if (item === BREAK_SYM) break; - if (item === CONTINUE_SYM) continue; + if (isLoopControlSignal(item)) { + const ctrl = decrementLoopControl(item); + if (ctrl === BREAK_SYM) break; + if (ctrl === CONTINUE_SYM) continue; + } const s = this.shadow(); s.state[this.elementTrunkKey] = item; shadows.push(s); @@ -579,7 +585,7 @@ export class ExecutionTree implements TreeContext { const result = this.resolveWires(matches); if (!array) return result; const resolved = await result; - if (resolved === BREAK_SYM || resolved === CONTINUE_SYM) return []; + if (isLoopControlSignal(resolved)) return []; return this.createShadowArray(resolved as any[]); } @@ -936,7 +942,7 @@ export class ExecutionTree implements TreeContext { private materializeShadows( items: ExecutionTree[], pathPrefix: string[], - ): Promise { + ): Promise { return _materializeShadows(this, items, pathPrefix); } @@ -1004,7 +1010,7 @@ export class ExecutionTree implements TreeContext { // Array: create shadow trees for per-element resolution const resolved = await response; - if (resolved === BREAK_SYM || resolved === CONTINUE_SYM) return []; + if (isLoopControlSignal(resolved)) return []; return this.createShadowArray(resolved as any[]); } @@ -1018,7 +1024,7 @@ export class ExecutionTree implements TreeContext { const response = this.resolveWires(defineFieldWires); if (!array) return response; const resolved = await response; - if (resolved === BREAK_SYM || resolved === CONTINUE_SYM) return []; + if (isLoopControlSignal(resolved)) return []; return this.createShadowArray(resolved as any[]); } } diff --git a/packages/bridge-core/src/materializeShadows.ts b/packages/bridge-core/src/materializeShadows.ts index eb46da56..236834f7 100644 --- a/packages/bridge-core/src/materializeShadows.ts +++ b/packages/bridge-core/src/materializeShadows.ts @@ -11,7 +11,14 @@ import type { Wire } from "./types.ts"; import { SELF_MODULE } from "./types.ts"; import { setNested } from "./tree-utils.ts"; -import { isPromise, CONTINUE_SYM, BREAK_SYM } from "./tree-types.ts"; +import { + BREAK_SYM, + CONTINUE_SYM, + decrementLoopControl, + isLoopControlSignal, + isPromise, + type LoopControlSignal, +} from "./tree-types.ts"; import type { MaybePromise, Trunk } from "./tree-types.ts"; import { matchesRequestedFields } from "./requested-fields.ts"; @@ -113,7 +120,7 @@ export async function materializeShadows( host: MaterializerHost, items: MaterializableShadow[], pathPrefix: string[], -): Promise { +): Promise { const { directFields, deepPaths, wireGroupsByPath } = planShadowOutput( host, pathPrefix, @@ -170,18 +177,25 @@ export async function materializeShadows( : (rawValues as unknown[]); const finalResults: unknown[] = []; + let propagate: LoopControlSignal | undefined; for (let i = 0; i < items.length; i++) { const obj: Record = {}; let doBreak = false; let doSkip = false; for (let j = 0; j < nFields; j++) { const v = flatValues[i * nFields + j]; - if (v === BREAK_SYM) { - doBreak = true; - break; - } - if (v === CONTINUE_SYM) { - doSkip = true; + if (isLoopControlSignal(v)) { + if (v === BREAK_SYM) { + doBreak = true; + break; + } + if (v === CONTINUE_SYM) { + doSkip = true; + break; + } + doBreak = v.__bridgeControl === "break"; + doSkip = v.__bridgeControl === "continue"; + propagate = decrementLoopControl(v); break; } obj[directFieldArray[j]!] = v; @@ -190,6 +204,7 @@ export async function materializeShadows( if (doSkip) continue; finalResults.push(obj); } + if (propagate) return propagate; return finalResults; } @@ -253,8 +268,7 @@ export async function materializeShadows( await Promise.all(tasks); // Check if any field resolved to a sentinel — propagate it for (const v of Object.values(obj)) { - if (v === CONTINUE_SYM) return CONTINUE_SYM; - if (v === BREAK_SYM) return BREAK_SYM; + if (isLoopControlSignal(v)) return v; } return obj; }), @@ -263,8 +277,16 @@ export async function materializeShadows( // Filter sentinels from the final result const finalResults: unknown[] = []; for (const item of rawResults) { - if (item === BREAK_SYM) break; - if (item === CONTINUE_SYM) continue; + if (isLoopControlSignal(item)) { + if (item === BREAK_SYM) break; + if (item === CONTINUE_SYM) continue; + if (item.__bridgeControl === "break") { + return decrementLoopControl(item); + } + if (item.__bridgeControl === "continue") { + return decrementLoopControl(item); + } + } finalResults.push(item); } return finalResults; diff --git a/packages/bridge-core/src/tree-types.ts b/packages/bridge-core/src/tree-types.ts index 5e833f2b..36412723 100644 --- a/packages/bridge-core/src/tree-types.ts +++ b/packages/bridge-core/src/tree-types.ts @@ -42,6 +42,11 @@ export class BridgeTimeoutError extends Error { export const CONTINUE_SYM = Symbol.for("BRIDGE_CONTINUE"); /** Sentinel for `break` — halt array iteration */ export const BREAK_SYM = Symbol.for("BRIDGE_BREAK"); +/** Multi-level loop control signal used for break/continue N (N > 1). */ +export type LoopControlSignal = { + __bridgeControl: "break" | "continue"; + levels: number; +} | typeof BREAK_SYM | typeof CONTINUE_SYM; // ── Constants ─────────────────────────────────────────────────────────────── @@ -115,11 +120,51 @@ export function isFatalError(err: any): boolean { ); } +function controlLevels(ctrl: Extract): number { + const n = ctrl.levels; + return Number.isInteger(n) && (n as number) > 0 ? (n as number) : 1; +} + +/** True when `value` is a loop control signal (single- or multi-level). */ +export function isLoopControlSignal(value: unknown): value is typeof BREAK_SYM | typeof CONTINUE_SYM | LoopControlSignal { + if (value === BREAK_SYM || value === CONTINUE_SYM) return true; + if (typeof value !== "object" || value == null) return false; + const candidate = value as { __bridgeControl?: unknown; levels?: unknown }; + return ( + (candidate.__bridgeControl === "break" || + candidate.__bridgeControl === "continue") && + Number.isInteger(candidate.levels) && + (candidate.levels as number) > 0 + ); +} + +/** Decrement a loop control signal by one loop level. */ +export function decrementLoopControl( + value: typeof BREAK_SYM | typeof CONTINUE_SYM | LoopControlSignal, +): typeof BREAK_SYM | typeof CONTINUE_SYM | LoopControlSignal { + if (value === BREAK_SYM || value === CONTINUE_SYM) return value; + // After consuming one loop boundary: + // - levels=2 becomes a single-level symbol (break/continue current loop) + // - levels>2 stays a multi-level signal with levels-1 + if (value.levels <= 2) { + return value.__bridgeControl === "break" ? BREAK_SYM : CONTINUE_SYM; + } + return { __bridgeControl: value.__bridgeControl, levels: value.levels - 1 }; +} + /** Execute a control flow instruction, returning a sentinel or throwing. */ -export function applyControlFlow(ctrl: ControlFlowInstruction): symbol { +export function applyControlFlow( + ctrl: ControlFlowInstruction, +): symbol | LoopControlSignal { if (ctrl.kind === "throw") throw new Error(ctrl.message); if (ctrl.kind === "panic") throw new BridgePanicError(ctrl.message); - if (ctrl.kind === "continue") return CONTINUE_SYM; + if (ctrl.kind === "continue") { + const levels = controlLevels(ctrl); + return levels <= 1 + ? CONTINUE_SYM + : { __bridgeControl: "continue", levels }; + } /* ctrl.kind === "break" */ - return BREAK_SYM; + const levels = controlLevels(ctrl); + return levels <= 1 ? BREAK_SYM : { __bridgeControl: "break", levels }; } diff --git a/packages/bridge-core/src/types.ts b/packages/bridge-core/src/types.ts index d2524e76..810cf3ff 100644 --- a/packages/bridge-core/src/types.ts +++ b/packages/bridge-core/src/types.ts @@ -252,8 +252,8 @@ export type { export type ControlFlowInstruction = | { kind: "throw"; message: string } | { kind: "panic"; message: string } - | { kind: "continue" } - | { kind: "break" }; + | { kind: "continue"; levels?: number } + | { kind: "break"; levels?: number }; /** * Named constant definition — a reusable value defined in the bridge file. diff --git a/packages/bridge-core/test/resolve-wires-gates.test.ts b/packages/bridge-core/test/resolve-wires-gates.test.ts index 48c0f255..ab9bb231 100644 --- a/packages/bridge-core/test/resolve-wires-gates.test.ts +++ b/packages/bridge-core/test/resolve-wires-gates.test.ts @@ -5,7 +5,11 @@ */ import assert from "node:assert/strict"; import { describe, test } from "node:test"; -import { BREAK_SYM, CONTINUE_SYM } from "../src/tree-types.ts"; +import { + BREAK_SYM, + CONTINUE_SYM, + isLoopControlSignal, +} from "../src/tree-types.ts"; import { applyFallbackGates, applyCatchGate, @@ -101,6 +105,18 @@ describe("applyFallbackGates — falsy (||)", () => { assert.equal(await applyFallbackGates(ctx, w, false), BREAK_SYM); }); + test("falsy control kind=break with level 2 returns multi-level signal", async () => { + const ctx = makeCtx(); + const w = fromWire({ + fallbacks: [{ type: "falsy", control: { kind: "break", levels: 2 } }], + }); + const out = await applyFallbackGates(ctx, w, false); + assert.ok(isLoopControlSignal(out)); + assert.notEqual(out, BREAK_SYM); + assert.notEqual(out, CONTINUE_SYM); + assert.deepStrictEqual(out, { __bridgeControl: "break", levels: 2 }); + }); + test("falsy control kind=throw throws an error", async () => { const ctx = makeCtx(); const w = fromWire({ fallbacks: [{ type: "falsy", control: { kind: "throw", message: "boom" } }] }); @@ -261,6 +277,14 @@ describe("applyCatchGate", () => { assert.equal(await applyCatchGate(ctx, w), CONTINUE_SYM); }); + test("applies catchControl kind=continue with level 3", async () => { + const ctx = makeCtx(); + const w = fromWire({ catchControl: { kind: "continue", levels: 3 } }); + const out = await applyCatchGate(ctx, w); + assert.ok(isLoopControlSignal(out)); + assert.deepStrictEqual(out, { __bridgeControl: "continue", levels: 3 }); + }); + test("catchControl takes priority over catchFallbackRef", async () => { const ctx = makeCtx({ "m.backup": "should-not-be-used" }); const w = fromWire({ diff --git a/packages/bridge-graphql/src/bridge-asserts.ts b/packages/bridge-graphql/src/bridge-asserts.ts new file mode 100644 index 00000000..e20ffb64 --- /dev/null +++ b/packages/bridge-graphql/src/bridge-asserts.ts @@ -0,0 +1,89 @@ +import type { Bridge } from "@stackables/bridge-core"; + +/** + * Thrown when a bridge operation cannot be executed correctly using the + * field-by-field GraphQL resolver. + * + * `bridgeTransform` catches this error automatically and switches the affected + * operation to standalone execution mode, logging a warning. + * + * Additional incompatibility checks can be added to + * {@link assertBridgeGraphQLCompatible} — each one throws this error with a + * descriptive message and `bridgeTransform` handles them uniformly. + */ +export class BridgeGraphQLIncompatibleError extends Error { + constructor( + /** The affected operation in `"Type.field"` format. */ + public readonly operation: string, + message: string, + ) { + super(message); + this.name = "BridgeGraphQLIncompatibleError"; + } +} + +/** + * Assert that a bridge operation is compatible with field-by-field GraphQL + * execution. Throws {@link BridgeGraphQLIncompatibleError} for each detected + * incompatibility. + * + * `bridgeTransform` calls this for every bridge and catches the error to + * automatically fall back to standalone execution mode — no rethrow or message + * remapping needed; the error message is already the final warning text. + * + * **Currently detected incompatibilities:** + * + * - **Nested multilevel `break N` / `continue N`** — GraphQL resolves array + * elements field-by-field through independent resolver callbacks. A + * multilevel `LoopControlSignal` emitted deep inside an inner array element + * cannot propagate back out to the already-committed outer shadow array. + */ +export function assertBridgeGraphQLCompatible(bridge: Bridge): void { + const op = `${bridge.type}.${bridge.field}`; + + for (const wire of bridge.wires) { + if (wire.to.path.length <= 1) continue; + + const fallbacks = + "from" in wire + ? wire.fallbacks + : "cond" in wire + ? wire.fallbacks + : "condAnd" in wire + ? wire.fallbacks + : "condOr" in wire + ? wire.fallbacks + : undefined; + + const catchControl = + "from" in wire + ? wire.catchControl + : "cond" in wire + ? wire.catchControl + : "condAnd" in wire + ? wire.catchControl + : "condOr" in wire + ? wire.catchControl + : undefined; + + const isMultilevel = ( + ctrl: { kind: string; levels?: number } | undefined, + ) => + ctrl && + (ctrl.kind === "break" || ctrl.kind === "continue") && + (ctrl.levels ?? 1) > 1; + + if ( + fallbacks?.some((fb) => isMultilevel(fb.control)) || + isMultilevel(catchControl) + ) { + const path = wire.to.path.join("."); + throw new BridgeGraphQLIncompatibleError( + op, + `[bridge] ${op}: 'break N' / 'continue N' with N > 1 inside a nested ` + + `array element (path: ${path}) is not supported in ` + + `field-by-field GraphQL execution.`, + ); + } + } +} diff --git a/packages/bridge-graphql/src/bridge-transform.ts b/packages/bridge-graphql/src/bridge-transform.ts index 80768284..dc183a13 100644 --- a/packages/bridge-graphql/src/bridge-transform.ts +++ b/packages/bridge-graphql/src/bridge-transform.ts @@ -3,6 +3,9 @@ import { GraphQLList, GraphQLNonNull, type GraphQLSchema, + type GraphQLResolveInfo, + type SelectionNode, + Kind, defaultFieldResolver, getNamedType, isScalarType, @@ -10,20 +13,60 @@ import { import { ExecutionTree, TraceCollector, + executeBridge as executeBridgeDefault, resolveStd, checkHandleVersions, type Logger, type ToolTrace, type TraceLevel, + type ExecuteBridgeOptions, + type ExecuteBridgeResult, } from "@stackables/bridge-core"; import { std as bundledStd, STD_VERSION as BUNDLED_STD_VERSION, } from "@stackables/bridge-stdlib"; -import type { BridgeDocument, ToolMap } from "@stackables/bridge-core"; +import type { Bridge, BridgeDocument, ToolMap } from "@stackables/bridge-core"; import { SELF_MODULE } from "@stackables/bridge-core"; +import { + assertBridgeGraphQLCompatible, + BridgeGraphQLIncompatibleError, +} from "./bridge-asserts.ts"; export type { Logger }; +export { BridgeGraphQLIncompatibleError } from "./bridge-asserts.ts"; + +/** + * Extract leaf-level field paths from a GraphQL resolve info's selection set. + * Used to build requestedFields for standalone executeBridge calls. + */ +function collectRequestedFields(info: GraphQLResolveInfo): string[] { + const paths: string[] = []; + function walk(selections: readonly SelectionNode[], prefix: string): void { + for (const sel of selections) { + if (sel.kind === Kind.FIELD) { + const name = sel.name.value; + if (name.startsWith("__")) continue; + const path = prefix ? `${prefix}.${name}` : name; + if (sel.selectionSet) { + walk(sel.selectionSet.selections, path); + } else { + paths.push(path); + } + } else if (sel.kind === Kind.INLINE_FRAGMENT) { + walk(sel.selectionSet.selections, prefix); + } else if (sel.kind === Kind.FRAGMENT_SPREAD) { + const frag = info.fragments[sel.name.value]; + if (frag) walk(frag.selectionSet.selections, prefix); + } + } + } + const fieldNode = info.fieldNodes[0]; + if (fieldNode?.selectionSet) { + walk(fieldNode.selectionSet.selections, ""); + } + return paths; +} const noop = () => {}; const defaultLogger: Logger = { @@ -73,6 +116,24 @@ export type BridgeOptions = { * Default: 30. Increase for deeply nested array mappings. */ maxDepth?: number; + /** + * Override the standalone execution function. + * + * When provided, **all** bridge operations are executed through this function + * instead of the field-by-field GraphQL resolver. Operations that are + * incompatible with GraphQL execution (e.g. nested multilevel `break` / + * `continue`) also use this function as an automatic fallback. + * + * This allows plugging in the AOT compiler as the execution engine: + * ```ts + * import { executeBridge } from "@stackables/bridge-compiler"; + * bridgeTransform(schema, doc, { executeBridge }) + * ``` + * Defaults to the interpreter `executeBridge` from `@stackables/bridge-core`. + */ + executeBridge?: ( + options: ExecuteBridgeOptions, + ) => Promise; }; /** Document can be a static BridgeDocument or a function that selects per-request */ @@ -89,6 +150,12 @@ export function bridgeTransform( const contextMapper = options?.contextMapper; const traceLevel = options?.trace ?? "off"; const logger = options?.logger ?? defaultLogger; + const executeBridgeFn = options?.executeBridge ?? executeBridgeDefault; + // When an explicit executeBridge is provided, all operations use standalone mode. + const forceStandalone = !!options?.executeBridge; + + // Cache for standalone-op detection on dynamic documents (keyed by doc instance). + const standaloneOpsCache = new WeakMap>(); return mapSchema(schema, { [MapperKind.OBJECT_FIELD]: (fieldConfig, fieldName, typeName) => { @@ -110,6 +177,103 @@ export function bridgeTransform( const trunk = { module: SELF_MODULE, type: typeName, field: fieldName }; const { resolve = defaultFieldResolver } = fieldConfig; + // For static documents (or forceStandalone), the standalone decision is fully + // known at setup time — precompute it as a plain boolean so the resolver just + // reads a variable. For dynamic documents (document is a function) the actual + // doc instance isn't available until request time; detectForDynamic() handles + // that path with a per-doc-instance WeakMap cache. + function precomputeStandalone() { + if (forceStandalone) return true; + if (typeof document === "function") return null; // deferred to request time + const bridge = document.instructions.find( + (i) => + i.kind === "bridge" && + (i as Bridge).type === typeName && + (i as Bridge).field === fieldName, + ) as Bridge | undefined; + if (!bridge) return false; + try { + assertBridgeGraphQLCompatible(bridge); + return false; + } catch (e) { + if (e instanceof BridgeGraphQLIncompatibleError) { + logger.warn?.( + `${e.message} ` + + `Falling back to standalone execution mode. ` + + `In standalone mode errors affect the entire field result ` + + `rather than individual sub-fields.`, + ); + return true; + } + throw e; + } + } + + // Only used for dynamic documents (standalonePrecomputed === null). + function detectForDynamic(doc: BridgeDocument): boolean { + let ops = standaloneOpsCache.get(doc); + if (!ops) { + ops = new Set(); + for (const instr of doc.instructions) { + if (instr.kind !== "bridge") continue; + try { + assertBridgeGraphQLCompatible(instr as Bridge); + } catch (e) { + if (e instanceof BridgeGraphQLIncompatibleError) { + ops.add(e.operation); + logger.warn?.( + `${e.message} ` + + `Falling back to standalone execution mode. ` + + `In standalone mode errors affect the entire field result ` + + `rather than individual sub-fields.`, + ); + } else { + throw e; + } + } + } + standaloneOpsCache.set(doc, ops); + } + return ops.has(`${typeName}.${fieldName}`); + } + + // Standalone execution: runs the full bridge through executeBridge and + // returns the resolved data directly. GraphQL sub-field resolvers receive + // plain objects and fall through to the default field resolver. + // All errors surface as a single top-level field error rather than + // per-sub-field GraphQL errors. + async function resolveAsStandalone( + activeDoc: BridgeDocument, + bridgeContext: Record, + args: Record, + context: any, + info: GraphQLResolveInfo, + ): Promise { + const requestedFields = collectRequestedFields(info); + const { data, traces } = await executeBridgeFn({ + document: activeDoc, + operation: `${typeName}.${fieldName}`, + input: args, + context: bridgeContext, + tools: userTools, + ...(traceLevel !== "off" ? { trace: traceLevel } : {}), + logger, + ...(options?.toolTimeoutMs !== undefined + ? { toolTimeoutMs: options.toolTimeoutMs } + : {}), + ...(options?.maxDepth !== undefined + ? { maxDepth: options.maxDepth } + : {}), + ...(requestedFields.length > 0 ? { requestedFields } : {}), + }); + if (traceLevel !== "off") { + context.__bridgeTracer = { traces }; + } + return data; + } + + const standalonePrecomputed = precomputeStandalone(); + return { ...fieldConfig, resolve: async function ( @@ -163,6 +327,20 @@ export function bridgeTransform( ? contextMapper(context) : (context ?? {}); + // Standalone execution path — used when the operation is incompatible + // with field-by-field GraphQL resolution, or when an explicit + // executeBridge override has been provided. + if (standalonePrecomputed ?? detectForDynamic(activeDoc)) { + return resolveAsStandalone( + activeDoc, + bridgeContext, + args ?? {}, + context, + info, + ); + } + + // GraphQL field-by-field execution path via ExecutionTree. source = new ExecutionTree( trunk, activeDoc, @@ -171,10 +349,18 @@ export function bridgeTransform( ); source.logger = logger; - if (options?.toolTimeoutMs !== undefined && Number.isFinite(options.toolTimeoutMs) && options.toolTimeoutMs >= 0) { + if ( + options?.toolTimeoutMs !== undefined && + Number.isFinite(options.toolTimeoutMs) && + options.toolTimeoutMs >= 0 + ) { source.toolTimeoutMs = Math.floor(options.toolTimeoutMs); } - if (options?.maxDepth !== undefined && Number.isFinite(options.maxDepth) && options.maxDepth >= 0) { + if ( + options?.maxDepth !== undefined && + Number.isFinite(options.maxDepth) && + options.maxDepth >= 0 + ) { source.maxDepth = Math.floor(options.maxDepth); } diff --git a/packages/bridge-graphql/src/index.ts b/packages/bridge-graphql/src/index.ts index ae5fad36..b25636f4 100644 --- a/packages/bridge-graphql/src/index.ts +++ b/packages/bridge-graphql/src/index.ts @@ -11,5 +11,6 @@ export { bridgeTransform, getBridgeTraces, useBridgeTracing, + BridgeGraphQLIncompatibleError, } from "./bridge-transform.ts"; export type { BridgeOptions, DocumentSource } from "./bridge-transform.ts"; diff --git a/packages/bridge-graphql/test/executeGraph.test.ts b/packages/bridge-graphql/test/executeGraph.test.ts index f9d6fef2..6e6766f6 100644 --- a/packages/bridge-graphql/test/executeGraph.test.ts +++ b/packages/bridge-graphql/test/executeGraph.test.ts @@ -650,3 +650,88 @@ bridge Mutation.sendEmail { assert.equal(capturedParams.content, "Hi there"); // body -> content rename }); }); + +describe("executeGraph: multilevel break/continue in nested arrays", () => { + const catalogTypeDefs = /* GraphQL */ ` + type Item { + sku: String + price: Float + } + type Category { + name: String + items: [Item!]! + } + type Query { + processCatalog: [Category!]! + } + `; + + // continue 2 = skip the outer (category) element + // break 2 = stop iterating outer (category) array entirely + const catalogBridge = `version 1.5 +bridge Query.processCatalog { + with context as ctx + with output as o + + o <- ctx.catalog[] as cat { + .name <- cat.name + .items <- cat.items[] as item { + .sku <- item.sku ?? continue 2 + .price <- item.price ?? break 2 + } + } +}`; + + const catalog = [ + // sku present, price present → emitted + { name: "Summer", items: [{ sku: "A1", price: 10.0 }] }, + // sku null on first item → continue 2 → skip Winter + { name: "Winter", items: [{ sku: null, price: 5.0 }] }, + // price null on first item → break 2 → stop entire outer loop + { name: "Spring", items: [{ sku: "A3", price: null }] }, + // never reached + { name: "Autumn", items: [{ sku: "A4", price: 20.0 }] }, + ]; + + test("falls back to standalone execution mode with a warning", async () => { + const warnings: string[] = []; + const mockLogger = { + debug: () => {}, + info: () => {}, + warn: (msg: string) => warnings.push(msg), + error: () => {}, + }; + + const instructions = parseBridge(catalogBridge); + // Must NOT throw at setup time — fallback mode is used instead + const gateway = createGateway(catalogTypeDefs, instructions, { + logger: mockLogger, + context: { catalog }, + }); + + // Warning must be logged at setup time + assert.ok( + warnings.some((w) => w.includes("Query.processCatalog")), + `Expected a warning about Query.processCatalog, got: ${JSON.stringify(warnings)}`, + ); + assert.ok( + warnings.some((w) => w.includes("standalone")), + `Expected warning to mention standalone mode, got: ${JSON.stringify(warnings)}`, + ); + + const executor = buildHTTPExecutor({ fetch: gateway.fetch as any }); + const result: any = await executor({ + document: parse(`{ + processCatalog { + name + items { sku price } + } + }`), + }); + + // Summer passes; Winter skipped (continue 2); Spring triggers break 2 → only Summer + assert.deepStrictEqual(result.data.processCatalog, [ + { name: "Summer", items: [{ sku: "A1", price: 10.0 }] }, + ]); + }); +}); diff --git a/packages/bridge-parser/src/bridge-format.ts b/packages/bridge-parser/src/bridge-format.ts index 6f298a52..fdb7e00d 100644 --- a/packages/bridge-parser/src/bridge-format.ts +++ b/packages/bridge-parser/src/bridge-format.ts @@ -22,6 +22,7 @@ export function parseBridge(text: string): BridgeDocument { const BRIDGE_VERSION = "1.5"; const RESERVED_BARE_VALUE_KEYWORDS = new Set([ + // Declaration keywords "version", "bridge", "tool", @@ -36,20 +37,31 @@ const RESERVED_BARE_VALUE_KEYWORDS = new Set([ "alias", "on", "error", + "force", + "catch", + // Control flow "continue", "break", "throw", "panic", "if", "pipe", + // Boolean/logic operators + "and", + "or", + "not", ]); /** Serialize a ControlFlowInstruction to its textual form. */ function serializeControl(ctrl: ControlFlowInstruction): string { if (ctrl.kind === "throw") return `throw ${JSON.stringify(ctrl.message)}`; if (ctrl.kind === "panic") return `panic ${JSON.stringify(ctrl.message)}`; - if (ctrl.kind === "continue") return "continue"; - return "break"; + if (ctrl.kind === "continue") { + return ctrl.levels && ctrl.levels > 1 + ? `continue ${ctrl.levels}` + : "continue"; + } + return ctrl.levels && ctrl.levels > 1 ? `break ${ctrl.levels}` : "break"; } // ── Serializer ─────────────────────────────────────────────────────────────── @@ -855,12 +867,14 @@ function serializeBridgeBlock(bridge: Bridge): string { const fieldPath = ew.to.path.slice(pathDepth); const elemTo = "." + serPath(fieldPath); - const fallbackStr = (ew.fallbacks ?? []).map(f => { - const op = f.type === "falsy" ? "||" : "??"; - if (f.control) return ` ${op} ${serializeControl(f.control)}`; - if (f.ref) return ` ${op} ${sPipeOrRef(f.ref)}`; - return ` ${op} ${f.value}`; - }).join(""); + const fallbackStr = (ew.fallbacks ?? []) + .map((f) => { + const op = f.type === "falsy" ? "||" : "??"; + if (f.control) return ` ${op} ${serializeControl(f.control)}`; + if (f.ref) return ` ${op} ${sPipeOrRef(f.ref)}`; + return ` ${op} ${f.value}`; + }) + .join(""); const errf = "catchControl" in ew && ew.catchControl ? ` catch ${serializeControl(ew.catchControl)}` @@ -960,12 +974,14 @@ function serializeBridgeBlock(bridge: Bridge): string { const elseStr = w.elseRef ? sRef(w.elseRef, true) : (w.elseValue ?? "null"); - const fallbackStr = (w.fallbacks ?? []).map(f => { - const op = f.type === "falsy" ? "||" : "??"; - if (f.control) return ` ${op} ${serializeControl(f.control)}`; - if (f.ref) return ` ${op} ${sPipeOrRef(f.ref)}`; - return ` ${op} ${f.value}`; - }).join(""); + const fallbackStr = (w.fallbacks ?? []) + .map((f) => { + const op = f.type === "falsy" ? "||" : "??"; + if (f.control) return ` ${op} ${serializeControl(f.control)}`; + if (f.ref) return ` ${op} ${sPipeOrRef(f.ref)}`; + return ` ${op} ${f.value}`; + }) + .join(""); const errf = "catchControl" in w && w.catchControl ? ` catch ${serializeControl(w.catchControl)}` @@ -1028,12 +1044,14 @@ function serializeBridgeBlock(bridge: Bridge): string { } } const toStr = sRef(w.to, false); - const fallbackStr = (w.fallbacks ?? []).map(f => { - const op = f.type === "falsy" ? "||" : "??"; - if (f.control) return ` ${op} ${serializeControl(f.control)}`; - if (f.ref) return ` ${op} ${sPipeOrRef(f.ref)}`; - return ` ${op} ${f.value}`; - }).join(""); + const fallbackStr = (w.fallbacks ?? []) + .map((f) => { + const op = f.type === "falsy" ? "||" : "??"; + if (f.control) return ` ${op} ${serializeControl(f.control)}`; + if (f.ref) return ` ${op} ${sPipeOrRef(f.ref)}`; + return ` ${op} ${f.value}`; + }) + .join(""); const errf = "catchControl" in w && w.catchControl ? ` catch ${serializeControl(w.catchControl)}` @@ -1217,12 +1235,14 @@ function serializeBridgeBlock(bridge: Bridge): string { const exprStr = serializeExprTree(tk); if (exprStr) { const destStr = sRef(outWire.to, false); - const fallbackStr = (outWire.fallbacks ?? []).map(f => { - const op = f.type === "falsy" ? "||" : "??"; - if (f.control) return ` ${op} ${serializeControl(f.control)}`; - if (f.ref) return ` ${op} ${sPipeOrRef(f.ref)}`; - return ` ${op} ${f.value}`; - }).join(""); + const fallbackStr = (outWire.fallbacks ?? []) + .map((f) => { + const op = f.type === "falsy" ? "||" : "??"; + if (f.control) return ` ${op} ${serializeControl(f.control)}`; + if (f.ref) return ` ${op} ${sPipeOrRef(f.ref)}`; + return ` ${op} ${f.value}`; + }) + .join(""); const errf = "catchControl" in outWire && outWire.catchControl ? ` catch ${serializeControl(outWire.catchControl)}` @@ -1242,12 +1262,14 @@ function serializeBridgeBlock(bridge: Bridge): string { const templateStr = reconstructTemplateString(tk); if (templateStr) { const destStr = sRef(outWire.to, false); - const fallbackStr = (outWire.fallbacks ?? []).map(f => { - const op = f.type === "falsy" ? "||" : "??"; - if (f.control) return ` ${op} ${serializeControl(f.control)}`; - if (f.ref) return ` ${op} ${sPipeOrRef(f.ref)}`; - return ` ${op} ${f.value}`; - }).join(""); + const fallbackStr = (outWire.fallbacks ?? []) + .map((f) => { + const op = f.type === "falsy" ? "||" : "??"; + if (f.control) return ` ${op} ${serializeControl(f.control)}`; + if (f.ref) return ` ${op} ${sPipeOrRef(f.ref)}`; + return ` ${op} ${f.value}`; + }) + .join(""); const errf = "catchControl" in outWire && outWire.catchControl ? ` catch ${serializeControl(outWire.catchControl)}` @@ -1286,12 +1308,14 @@ function serializeBridgeBlock(bridge: Bridge): string { if (actualSourceRef && handleChain.length > 0) { const sourceStr = sRef(actualSourceRef, true); const destStr = sRef(outWire.to, false); - const fallbackStr = (outWire.fallbacks ?? []).map(f => { - const op = f.type === "falsy" ? "||" : "??"; - if (f.control) return ` ${op} ${serializeControl(f.control)}`; - if (f.ref) return ` ${op} ${sPipeOrRef(f.ref)}`; - return ` ${op} ${f.value}`; - }).join(""); + const fallbackStr = (outWire.fallbacks ?? []) + .map((f) => { + const op = f.type === "falsy" ? "||" : "??"; + if (f.control) return ` ${op} ${serializeControl(f.control)}`; + if (f.ref) return ` ${op} ${sPipeOrRef(f.ref)}`; + return ` ${op} ${f.value}`; + }) + .join(""); const errf = "catchControl" in outWire && outWire.catchControl ? ` catch ${serializeControl(outWire.catchControl)}` diff --git a/packages/bridge-parser/src/parser/parser.ts b/packages/bridge-parser/src/parser/parser.ts index 8211eb0c..c383e69e 100644 --- a/packages/bridge-parser/src/parser/parser.ts +++ b/packages/bridge-parser/src/parser/parser.ts @@ -825,8 +825,22 @@ class BridgeParser extends CstParser { this.CONSUME2(StringLiteral, { LABEL: "panicMsg" }); }, }, - { ALT: () => this.CONSUME(ContinueKw, { LABEL: "continueKw" }) }, - { ALT: () => this.CONSUME(BreakKw, { LABEL: "breakKw" }) }, + { + ALT: () => { + this.CONSUME(ContinueKw, { LABEL: "continueKw" }); + this.OPTION(() => { + this.CONSUME4(NumberLiteral, { LABEL: "continueLevel" }); + }); + }, + }, + { + ALT: () => { + this.CONSUME(BreakKw, { LABEL: "breakKw" }); + this.OPTION2(() => { + this.CONSUME5(NumberLiteral, { LABEL: "breakLevel" }); + }); + }, + }, { ALT: () => this.CONSUME3(StringLiteral, { LABEL: "stringLit" }) }, { ALT: () => this.CONSUME(NumberLiteral, { LABEL: "numberLit" }) }, { ALT: () => this.CONSUME(TrueLiteral, { LABEL: "trueLit" }) }, @@ -3528,8 +3542,24 @@ function buildBridgeBody( const msg = (c.panicMsg as IToken[])[0].image; return { control: { kind: "panic", message: JSON.parse(msg) } }; } - if (c.continueKw) return { control: { kind: "continue" } }; - if (c.breakKw) return { control: { kind: "break" } }; + if (c.continueKw) { + const raw = (c.continueLevel as IToken[] | undefined)?.[0]?.image; + if (!raw) return { control: { kind: "continue" } }; + const levels = Number(raw); + if (!Number.isInteger(levels) || levels < 1) { + throw new Error(`Line ${lineNum}: continue level must be a positive integer`); + } + return { control: { kind: "continue", levels } }; + } + if (c.breakKw) { + const raw = (c.breakLevel as IToken[] | undefined)?.[0]?.image; + if (!raw) return { control: { kind: "break" } }; + const levels = Number(raw); + if (!Number.isInteger(levels) || levels < 1) { + throw new Error(`Line ${lineNum}: break level must be a positive integer`); + } + return { control: { kind: "break", levels } }; + } if (c.stringLit) { const raw = (c.stringLit as IToken[])[0].image; const segs = parseTemplateString(raw.slice(1, -1)); diff --git a/packages/bridge/test/control-flow.test.ts b/packages/bridge/test/control-flow.test.ts index af5c8e24..3084535e 100644 --- a/packages/bridge/test/control-flow.test.ts +++ b/packages/bridge/test/control-flow.test.ts @@ -93,6 +93,41 @@ bridge Query.test { assert.deepStrictEqual(elemWire.fallbacks, [{ type: "nullish", control: { kind: "break" } }]); }); + test("break/continue with levels on ?? gate", () => { + const doc = parseBridge(`version 1.5 +bridge Query.test { + with api as a + with output as o + o <- a.orders[] as order { + .items <- order.items[] as item { + .sku <- item.sku ?? continue 2 + .price <- item.price ?? break 2 + } + } +}`); + const b = doc.instructions.find((i): i is Bridge => i.kind === "bridge")!; + const skuWire = b.wires.find( + (w): w is Extract => + "from" in w && + w.from.element === true && + w.to.path.join(".") === "items.sku", + ); + const priceWire = b.wires.find( + (w): w is Extract => + "from" in w && + w.from.element === true && + w.to.path.join(".") === "items.price", + ); + assert.ok(skuWire); + assert.ok(priceWire); + assert.deepStrictEqual(skuWire.fallbacks, [ + { type: "nullish", control: { kind: "continue", levels: 2 } }, + ]); + assert.deepStrictEqual(priceWire.fallbacks, [ + { type: "nullish", control: { kind: "break", levels: 2 } }, + ]); + }); + test("throw on catch gate", () => { const doc = parseBridge(`version 1.5 bridge Query.test { @@ -243,6 +278,49 @@ bridge Query.test { assert.ok(pullWire); assert.deepStrictEqual(pullWire.catchControl, { kind: "break" }); }); + + test("break/continue levels round-trip", () => { + const src = `version 1.5 + +bridge Query.test { + with api as a + with output as o + o <- a.orders[] as order { + .items <- order.items[] as item { + .sku <- item.sku ?? continue 2 + .price <- item.price ?? break 2 + } + } +}`; + const doc = parseBridge(src); + const out = serializeBridge(doc); + assert.ok(out.includes("?? continue 2")); + assert.ok(out.includes("?? break 2")); + const roundtripped = parseBridge(out); + const b = roundtripped.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + const skuWire = b.wires.find( + (w): w is Extract => + "from" in w && + w.from.element === true && + w.to.path.join(".") === "items.sku", + ); + const priceWire = b.wires.find( + (w): w is Extract => + "from" in w && + w.from.element === true && + w.to.path.join(".") === "items.price", + ); + assert.ok(skuWire); + assert.ok(priceWire); + assert.deepStrictEqual(skuWire.fallbacks, [ + { type: "nullish", control: { kind: "continue", levels: 2 } }, + ]); + assert.deepStrictEqual(priceWire.fallbacks, [ + { type: "nullish", control: { kind: "break", levels: 2 } }, + ]); + }); }); // ══════════════════════════════════════════════════════════════════════════════ @@ -468,6 +546,61 @@ bridge Query.test { }; assert.deepStrictEqual(data, []); }); + + test("continue 2 skips current parent element", async () => { + const src = `version 1.5 +bridge Query.test { + with api as a + with output as o + o <- a.orders[] as order { + .id <- order.id + .items <- order.items[] as item { + .sku <- item.sku ?? continue 2 + .price <- item.price + } + } +}`; + const tools = { + api: async () => ({ + orders: [ + { id: 1, items: [{ sku: "A", price: 10 }, { sku: null, price: 99 }] }, + { id: 2, items: [{ sku: "B", price: 20 }] }, + ], + }), + }; + const { data } = (await run(src, "Query.test", {}, tools)) as { + data: any[]; + }; + assert.deepStrictEqual(data, [{ id: 2, items: [{ sku: "B", price: 20 }] }]); + }); + + test("break 2 breaks out of parent loop", async () => { + const src = `version 1.5 +bridge Query.test { + with api as a + with output as o + o <- a.orders[] as order { + .id <- order.id + .items <- order.items[] as item { + .sku <- item.sku + .price <- item.price ?? break 2 + } + } +}`; + const tools = { + api: async () => ({ + orders: [ + { id: 1, items: [{ sku: "A", price: 10 }] }, + { id: 2, items: [{ sku: "B", price: null }, { sku: "C", price: 30 }] }, + { id: 3, items: [{ sku: "D", price: 40 }] }, + ], + }), + }; + const { data } = (await run(src, "Query.test", {}, tools)) as { + data: any[]; + }; + assert.deepStrictEqual(data, [{ id: 1, items: [{ sku: "A", price: 10 }] }]); + }); }); describe("AbortSignal", () => { diff --git a/packages/bridge/test/expressions.test.ts b/packages/bridge/test/expressions.test.ts index 7c541ddb..8af4cadd 100644 --- a/packages/bridge/test/expressions.test.ts +++ b/packages/bridge/test/expressions.test.ts @@ -1395,3 +1395,58 @@ bridge Query.test { assert.equal(data.result, false); }); }); + +describe("serializeBridge: keyword strings are quoted", () => { + // Regression: the serializer emitted bare keywords (or, and, not, force, + // catch, …) when those were stored as string constant values, producing + // output the parser rejected. + const keywords = [ + "or", + "and", + "not", + "version", + "bridge", + "tool", + "define", + "with", + "input", + "output", + "context", + "const", + "from", + "as", + "alias", + "on", + "error", + "force", + "catch", + "continue", + "break", + "throw", + "panic", + "if", + "pipe", + ]; + + for (const kw of keywords) { + test(`constant value "${kw}" round-trips through serializer`, () => { + const src = `version 1.5\nbridge Query.x {\n with output as o\n o.result = "${kw}"\n}`; + const doc = parseBridge(src); + const serialized = serializeBridge(doc); + // Must not contain a bare keyword (e.g. `= or` without quotes) + assert.ok( + !serialized.includes(`= ${kw}`), + `Expected "${kw}" to be quoted in: ${serialized}`, + ); + // And must re-parse cleanly + const reparsed = parseBridge(serialized); + const bridge = reparsed.instructions.find( + (i) => i.kind === "bridge", + ) as any; + const wire = bridge.wires.find( + (w: any) => "value" in w && w.to?.path?.[0] === "result", + ); + assert.equal(wire?.value, kw); + }); + } +}); 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 a5c3924e..1a9019d2 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 @@ -83,6 +83,34 @@ o.items <- searchApi.results[] as item { ``` +### Multi-Level Control Flow (`break N`, `continue N`) + +When working with deeply nested arrays (e.g., mapping categories that contain lists of products), you may want an error deep inside the inner array to skip the *outer* array element. + +You can append a number to `break` or `continue` to specify how many loop levels the signal should pierce. + +```bridge +o.catalogs <- api.catalogs[] as cat { + .id <- cat.id + + .products <- cat.products[] as prod { + .name <- prod.name + + # If a product has a fatal data corruption, skip the ENTIRE catalog. + # 'continue 1' would just skip this product. + # 'continue 2' skips this product AND the catalog it belongs to! + .sku <- prod.sku ?? continue 2 + } +} + +``` + +:::note[GraphQL execution mode — automatic standalone fallback] +`break N` / `continue N` with `N > 1` inside a **nested** array element cannot be executed correctly by the field-by-field GraphQL resolver. GraphQL resolves arrays through independent resolver callbacks, so a multilevel control signal emitted deep inside an inner element cannot propagate back to the outer shadow array. + +When `bridgeTransform` detects this pattern it **automatically falls back to standalone execution** for that operation. A `warn`-level log message is emitted at startup. In fallback mode the operation runs identically to `executeBridge` — multilevel control flow works correctly — but errors affect the entire field result rather than individual sub-fields (no per-field GraphQL error granularity). +::: + ## 3. Common Use Cases ### Use Case 1: Filtering an Array Before Mapping diff --git a/packages/playground/src/Playground.tsx b/packages/playground/src/Playground.tsx index a7acbd0f..cdb6104c 100644 --- a/packages/playground/src/Playground.tsx +++ b/packages/playground/src/Playground.tsx @@ -29,6 +29,23 @@ function ResizeHandle({ direction }: { direction: "horizontal" | "vertical" }) { ); } +// ── helpers ───────────────────────────────────────────────────────────────── +function contextIsFilled(context: string): boolean { + try { + const parsed = JSON.parse(context.trim()); + if ( + typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) + ) { + return Object.keys(parsed).length > 0; + } + } catch { + return true; // unparseable treated as non-empty + } + return false; +} + // ── query tab type ──────────────────────────────────────────────────────────── export type QueryTab = { id: string; @@ -55,6 +72,7 @@ type QueryTabBarProps = { runDisabled: boolean; running: boolean; showRunButton?: boolean; + contextFilled?: boolean; }; function QueryTabBar({ @@ -68,6 +86,7 @@ function QueryTabBar({ runDisabled, running, showRunButton = true, + contextFilled = false, }: QueryTabBarProps) { const isQueryTab = activeTabId !== "context"; const canRemove = queries.length > 1; @@ -89,13 +108,16 @@ function QueryTabBar({ {/* One tab per query */} @@ -400,6 +422,7 @@ export function Playground({ runDisabled={isActiveRunning || hasErrors} running={isActiveRunning} showRunButton={false} + contextFilled={contextIsFilled(context)} />
@@ -580,6 +603,7 @@ export function Playground({ onRun={onRun} runDisabled={isActiveRunning || hasErrors} running={isActiveRunning} + contextFilled={contextIsFilled(context)} /> diff --git a/packages/playground/src/engine.ts b/packages/playground/src/engine.ts index 881ec416..022c2e0c 100644 --- a/packages/playground/src/engine.ts +++ b/packages/playground/src/engine.ts @@ -14,6 +14,7 @@ export { prettyPrintToSource }; import type { BridgeDiagnostic, Bridge, + NodeRef, ToolTrace, Logger, CacheStore, @@ -393,16 +394,33 @@ export function extractInputSkeleton( // Exclude element wires (from array mappings like `c.field`) which also use // SELF_MODULE but have `element: true` — those are tool response fields, not inputs. const inputPaths: string[][] = []; - for (const wire of bridge.wires) { + + const collectRef = (ref: NodeRef | undefined) => { if ( - "from" in wire && - wire.from.module === "_" && - wire.from.type === type && - wire.from.field === field && - wire.from.path.length > 0 && - !wire.from.element + ref && + ref.module === "_" && + ref.type === type && + ref.field === field && + ref.path.length > 0 && + !ref.element ) { - inputPaths.push([...wire.from.path]); + inputPaths.push([...ref.path]); + } + }; + + for (const wire of bridge.wires) { + if ("from" in wire) { + collectRef(wire.from); + } else if ("cond" in wire) { + collectRef(wire.cond); + collectRef(wire.thenRef); + collectRef(wire.elseRef); + } else if ("condAnd" in wire) { + collectRef(wire.condAnd.leftRef); + collectRef(wire.condAnd.rightRef); + } else if ("condOr" in wire) { + collectRef(wire.condOr.leftRef); + collectRef(wire.condOr.rightRef); } } diff --git a/packages/playground/src/examples.ts b/packages/playground/src/examples.ts index bcb12fe0..31984bfa 100644 --- a/packages/playground/src/examples.ts +++ b/packages/playground/src/examples.ts @@ -880,4 +880,163 @@ bridge Query.evaluate { ], context: `{}`, }, + { + id: "control-flow-throw-panic", + name: "Control Flow (Throw/Panic)", + description: + "Use throw for recoverable validation failures and panic for unrecoverable fatal errors", + schema: ` +type Query { + validateProfile(name: String, fatal: Boolean): ValidationResult +} + +type ValidationResult { + name: String + status: String +} + `, + bridge: `version 1.5 + +bridge Query.validateProfile { + with input as i + with output as o + + o.name <- i.name || throw "name is required" + o.status <- i.fatal ? null : "ok" ?? panic "fatal validation error" +}`, + queries: [ + { + name: "Valid input", + query: `{ + validateProfile(name: "Ada", fatal: false) { + name + status + } +}`, + }, + { + name: "Throw: missing name", + query: `{ + validateProfile(fatal: false) { + name + status + } +}`, + }, + { + name: "Panic: fatal flag", + query: `{ + validateProfile(name: "Ada", fatal: true) { + name + status + } +}`, + }, + ], + standaloneQueries: [ + { + operation: "Query.validateProfile", + outputFields: "", + input: { name: "Ada", fatal: false }, + }, + { + operation: "Query.validateProfile", + outputFields: "", + input: { fatal: false }, + }, + { + operation: "Query.validateProfile", + outputFields: "", + input: { name: "Ada", fatal: true }, + }, + ], + context: `{}`, + }, + { + id: "control-flow-break-continue", + name: "Array Control Flow (Break/Continue)", + description: + "Use continue 2 / break 2 to skip or stop from a nested array by targeting the parent loop", + schema: ` +type Item { + sku: String + price: Float +} + +type Category { + name: String + items: [Item!]! +} + +type Query { + processCatalog: [Category!]! +} + `, + bridge: `version 1.5 + +bridge Query.processCatalog { + with context as ctx + with output as o + + o <- ctx.catalog[] as cat { + .name <- cat.name + .items <- cat.items[] as item { + .sku <- item.sku ?? continue 2 + .price <- item.price ?? break 2 + } + } +}`, + queries: [ + { + name: "Process catalog with break/continue", + query: `{ + processCatalog { + name + items { + sku + price + } + } +}`, + }, + ], + standaloneQueries: [ + { + operation: "Query.processCatalog", + outputFields: "", + input: {}, + }, + ], + context: `{ + "catalog": [ + { + "name": "Summer", + "items": [ + { "sku": "S-1", "price": 19.99 }, + { "sku": "S-2", "price": 29.99 } + ] + }, + { + "name": "Skip category with continue 2", + "items": [ + { "sku": null, "price": 999.99 }, + { "sku": "SHOULD-NOT-APPEAR", "price": 1.0 } + ] + }, + { + "name": "Stop all with break 2", + "items": [ + { "sku": "W-1", "price": null }, + { "sku": "W-3", "price": 49.99 } + ] + }, + { + "name": "Never reached after break 2", + "items": [ + { "sku": "N-1", "price": 10.0 } + ] + } + ] +}`, + }, ];