Skip to content

Commit b213e9f

Browse files
Copilotaarne
andauthored
Multilevel break/continue with GraphQL fallback to executeBridge (#104)
* Initial plan * feat(playground): add control flow examples for throw panic break continue Co-authored-by: aarne <82001+aarne@users.noreply.github.com> * feat: support multilevel break/continue and update playground examples Co-authored-by: aarne <82001+aarne@users.noreply.github.com> * merge main and resolve codegen conflict Co-authored-by: aarne <82001+aarne@users.noreply.github.com> * docs * Playground missing input * feat: add documentation for multi-level control flow (break N, continue N) * feat: add contextIsFilled helper and integrate into QueryTabBar and Playground components * feat: throw on multilevel break/continue in bridgeTransform * feat: enhance multilevel control flow handling in bridgeTransform with automatic fallback to standalone execution * feat: add BridgeGraphQLIncompatibleError and compatibility assertion for nested multilevel control flow in GraphQL execution * feat: add tests for keyword string serialization to prevent bare keywords in output --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: aarne <82001+aarne@users.noreply.github.com> Co-authored-by: Aarne Laur <aarne.laur@gmail.com>
1 parent fc6c619 commit b213e9f

20 files changed

Lines changed: 1145 additions & 121 deletions

File tree

.changeset/fifty-cases-rhyme.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@stackables/bridge-compiler": minor
3+
"@stackables/bridge-parser": minor
4+
"@stackables/bridge-core": minor
5+
---
6+
7+
Multi-Level Control Flow (break N, continue N)
8+
9+
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.
10+
11+
You can append a number to break or continue to specify how many loop levels the signal should pierce.

.changeset/many-beds-like.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@stackables/bridge-graphql": minor
3+
---
4+
5+
Support optional lookahead resolver with compiler

packages/bridge-compiler/src/codegen.ts

Lines changed: 122 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -139,19 +139,41 @@ function hasCatchFallback(w: Wire): boolean {
139139
);
140140
}
141141

142+
type DetectedControlFlow = {
143+
kind: "break" | "continue" | "throw" | "panic";
144+
levels: number;
145+
};
146+
142147
/** Check if any wire in a set has a control flow instruction (break/continue/throw/panic). */
143-
function detectControlFlow(
144-
wires: Wire[],
145-
): "break" | "continue" | "throw" | "panic" | null {
148+
function detectControlFlow(wires: Wire[]): DetectedControlFlow | null {
146149
for (const w of wires) {
147150
if ("fallbacks" in w && w.fallbacks) {
148151
for (const fb of w.fallbacks) {
149-
if (fb.control)
150-
return fb.control.kind as "break" | "continue" | "throw" | "panic";
152+
if (fb.control) {
153+
const kind = fb.control.kind as
154+
| "break"
155+
| "continue"
156+
| "throw"
157+
| "panic";
158+
const levels =
159+
kind === "break" || kind === "continue"
160+
? Math.max(1, Number((fb.control as any).levels) || 1)
161+
: 1;
162+
return { kind, levels };
163+
}
151164
}
152165
}
153166
if ("catchControl" in w && w.catchControl) {
154-
return w.catchControl.kind as "break" | "continue" | "throw" | "panic";
167+
const kind = w.catchControl.kind as
168+
| "break"
169+
| "continue"
170+
| "throw"
171+
| "panic";
172+
const levels =
173+
kind === "break" || kind === "continue"
174+
? Math.max(1, Number((w.catchControl as any).levels) || 1)
175+
: 1;
176+
return { kind, levels };
155177
}
156178
}
157179
return null;
@@ -648,6 +670,12 @@ class CodegenContext {
648670
` const __ctx = { logger: __opts?.logger ?? {}, signal: __signal };`,
649671
);
650672
lines.push(` const __trace = __opts?.__trace;`);
673+
lines.push(
674+
` const __isLoopCtrl = (v) => (v?.__bridgeControl === "break" || v?.__bridgeControl === "continue") && Number.isInteger(v?.levels) && v.levels > 0;`,
675+
);
676+
lines.push(
677+
` const __nextLoopCtrl = (v) => ({ __bridgeControl: v.__bridgeControl, levels: v.levels - 1 });`,
678+
);
651679
lines.push(` async function __call(fn, input, toolName) {`);
652680
lines.push(` if (__signal?.aborted) throw new __BridgeAbortError();`);
653681
lines.push(` const start = __trace ? performance.now() : 0;`);
@@ -1386,6 +1414,8 @@ class CodegenContext {
13861414
// Only check control flow on direct element wires, not sub-array element wires
13871415
const directElemWires = elemWires.filter((w) => w.to.path.length === 1);
13881416
const cf = detectControlFlow(directElemWires);
1417+
const anyCf = detectControlFlow(elemWires);
1418+
const requiresLabeledLoop = !cf && !!anyCf && anyCf.levels > 1;
13891419
// Check if any element wire generates `await` (element-scoped tools or catch fallbacks)
13901420
const needsAsync = elemWires.some((w) => this.wireNeedsAwait(w));
13911421

@@ -1401,20 +1431,27 @@ class CodegenContext {
14011431
arrayIterators,
14021432
0,
14031433
4,
1404-
cf === "continue" ? "for-continue" : "break",
1434+
cf.kind === "continue" ? "for-continue" : "break",
14051435
)
14061436
: ` _result.push(${this.buildElementBody(elemWires, arrayIterators, 0, 4)});`;
14071437

14081438
lines.push(` const _result = [];`);
1409-
lines.push(` for (const _el0 of (${arrayExpr} ?? [])) {`);
1439+
lines.push(` __loop0: for (const _el0 of (${arrayExpr} ?? [])) {`);
1440+
lines.push(` try {`);
14101441
for (const pl of preambleLines) {
1411-
lines.push(` ${pl}`);
1442+
lines.push(` ${pl}`);
14121443
}
1413-
lines.push(body);
1444+
lines.push(` ${body.trimStart()}`);
1445+
lines.push(` } catch (_ctrl) {`);
1446+
lines.push(
1447+
` if (__isLoopCtrl(_ctrl)) { if (_ctrl.levels > 1) throw __nextLoopCtrl(_ctrl); if (_ctrl.__bridgeControl === "break") break; continue; }`,
1448+
);
1449+
lines.push(` throw _ctrl;`);
1450+
lines.push(` }`);
14141451
lines.push(` }`);
14151452
lines.push(` return _result;`);
14161453
this.elementLocalVars.clear();
1417-
} else if (cf === "continue") {
1454+
} else if (cf?.kind === "continue" && cf.levels === 1) {
14181455
// Use flatMap — skip elements that trigger continue (sync only)
14191456
const body = this.buildElementBodyWithControlFlow(
14201457
elemWires,
@@ -1426,18 +1463,35 @@ class CodegenContext {
14261463
lines.push(` return (${arrayExpr} ?? []).flatMap((_el0) => {`);
14271464
lines.push(body);
14281465
lines.push(` });`);
1429-
} else if (cf === "break") {
1466+
} else if (
1467+
cf?.kind === "break" ||
1468+
cf?.kind === "continue" ||
1469+
requiresLabeledLoop
1470+
) {
1471+
// Use an explicit loop for:
1472+
// - direct break/continue control
1473+
// - nested multilevel control (e.g. break 2 / continue 2) that must
1474+
// escape from sub-array IIFEs through throw/catch propagation.
14301475
// Use a loop with early break (sync)
1431-
const body = this.buildElementBodyWithControlFlow(
1432-
elemWires,
1433-
arrayIterators,
1434-
0,
1435-
4,
1436-
"break",
1437-
);
1476+
const body = cf
1477+
? this.buildElementBodyWithControlFlow(
1478+
elemWires,
1479+
arrayIterators,
1480+
0,
1481+
4,
1482+
cf.kind === "continue" ? "for-continue" : "break",
1483+
)
1484+
: ` _result.push(${this.buildElementBody(elemWires, arrayIterators, 0, 4)});`;
14381485
lines.push(` const _result = [];`);
1439-
lines.push(` for (const _el0 of (${arrayExpr} ?? [])) {`);
1440-
lines.push(body);
1486+
lines.push(` __loop0: for (const _el0 of (${arrayExpr} ?? [])) {`);
1487+
lines.push(` try {`);
1488+
lines.push(` ${body.trimStart()}`);
1489+
lines.push(` } catch (_ctrl) {`);
1490+
lines.push(
1491+
` if (__isLoopCtrl(_ctrl)) { if (_ctrl.levels > 1) throw __nextLoopCtrl(_ctrl); if (_ctrl.__bridgeControl === "break") break; continue; }`,
1492+
);
1493+
lines.push(` throw _ctrl;`);
1494+
lines.push(` }`);
14411495
lines.push(` }`);
14421496
lines.push(` return _result;`);
14431497
} else {
@@ -1560,6 +1614,8 @@ class CodegenContext {
15601614
// Only check control flow on direct element wires (not sub-array element wires)
15611615
const directShifted = shifted.filter((w) => w.to.path.length === 1);
15621616
const cf = detectControlFlow(directShifted);
1617+
const anyCf = detectControlFlow(shifted);
1618+
const requiresLabeledLoop = !cf && !!anyCf && anyCf.levels > 1;
15631619
// Check if any element wire generates `await` (element-scoped tools or catch fallbacks)
15641620
const needsAsync = shifted.some((w) => this.wireNeedsAwait(w));
15651621
let mapExpr: string;
@@ -1575,14 +1631,14 @@ class CodegenContext {
15751631
arrayIterators,
15761632
0,
15771633
8,
1578-
cf === "continue" ? "for-continue" : "break",
1634+
cf.kind === "continue" ? "for-continue" : "break",
15791635
)
15801636
: ` _result.push(${this.buildElementBody(shifted, arrayIterators, 0, 8)});`;
15811637

15821638
const preamble = preambleLines.map((l) => ` ${l}`).join("\n");
1583-
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; })()`;
1639+
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; })()`;
15841640
this.elementLocalVars.clear();
1585-
} else if (cf === "continue") {
1641+
} else if (cf?.kind === "continue" && cf.levels === 1) {
15861642
const cfBody = this.buildElementBodyWithControlFlow(
15871643
shifted,
15881644
arrayIterators,
@@ -1591,15 +1647,23 @@ class CodegenContext {
15911647
"continue",
15921648
);
15931649
mapExpr = `((__s) => Array.isArray(__s) ? __s.flatMap((_el0) => {\n${cfBody}\n }) ?? null : null)(${arrayExpr})`;
1594-
} else if (cf === "break") {
1595-
const cfBody = this.buildElementBodyWithControlFlow(
1596-
shifted,
1597-
arrayIterators,
1598-
0,
1599-
8,
1600-
"break",
1601-
);
1602-
mapExpr = `(() => { const _src = ${arrayExpr}; if (!Array.isArray(_src)) return null; const _result = []; for (const _el0 of _src) {\n${cfBody}\n } return _result; })()`;
1650+
} else if (
1651+
cf?.kind === "break" ||
1652+
cf?.kind === "continue" ||
1653+
requiresLabeledLoop
1654+
) {
1655+
// Same rationale as root array handling above: nested multilevel
1656+
// control requires for-loop + throw/catch propagation instead of map.
1657+
const loopBody = cf
1658+
? this.buildElementBodyWithControlFlow(
1659+
shifted,
1660+
arrayIterators,
1661+
0,
1662+
8,
1663+
cf.kind === "continue" ? "for-continue" : "break",
1664+
)
1665+
: ` _result.push(${this.buildElementBody(shifted, arrayIterators, 0, 8)});`;
1666+
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; })()`;
16031667
} else {
16041668
const body = this.buildElementBody(shifted, arrayIterators, 0, 6);
16051669
mapExpr = `((__s) => Array.isArray(__s) ? __s.map((_el0) => (${body})) ?? null : null)(${arrayExpr})`;
@@ -1752,11 +1816,11 @@ class CodegenContext {
17521816
arrayIterators,
17531817
depth + 1,
17541818
indent + 4,
1755-
innerCf === "continue" ? "for-continue" : "break",
1819+
innerCf.kind === "continue" ? "for-continue" : "break",
17561820
)
17571821
: `${" ".repeat(indent + 4)}_result.push(${this.buildElementBody(shifted, arrayIterators, depth + 1, indent + 4)});`;
1758-
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; })()`;
1759-
} else if (innerCf === "continue") {
1822+
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; })()`;
1823+
} else if (innerCf?.kind === "continue" && innerCf.levels === 1) {
17601824
const cfBody = this.buildElementBodyWithControlFlow(
17611825
shifted,
17621826
arrayIterators,
@@ -1765,15 +1829,15 @@ class CodegenContext {
17651829
"continue",
17661830
);
17671831
mapExpr = `((__s) => Array.isArray(__s) ? __s.flatMap((${innerElVar}) => {\n${cfBody}\n${" ".repeat(indent + 2)}}) ?? null : null)(${srcExpr})`;
1768-
} else if (innerCf === "break") {
1832+
} else if (innerCf?.kind === "break" || innerCf?.kind === "continue") {
17691833
const cfBody = this.buildElementBodyWithControlFlow(
17701834
shifted,
17711835
arrayIterators,
17721836
depth + 1,
17731837
indent + 4,
1774-
"break",
1838+
innerCf.kind === "continue" ? "for-continue" : "break",
17751839
);
1776-
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; })()`;
1840+
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; })()`;
17771841
} else {
17781842
const innerBody = this.buildElementBody(
17791843
shifted,
@@ -1840,6 +1904,21 @@ class CodegenContext {
18401904
controlWire.fallbacks?.some(
18411905
(fb) => fb.type === "nullish" && fb.control != null,
18421906
) ?? false;
1907+
const ctrlFromFallback = controlWire.fallbacks?.find(
1908+
(fb) => fb.control != null,
1909+
)?.control;
1910+
const ctrl = ctrlFromFallback ?? controlWire.catchControl;
1911+
const controlKind = ctrl?.kind === "continue" ? "continue" : "break";
1912+
const controlLevels =
1913+
ctrl && (ctrl.kind === "continue" || ctrl.kind === "break")
1914+
? Math.max(1, Number(ctrl.levels) || 1)
1915+
: 1;
1916+
const controlStatement =
1917+
controlLevels > 1
1918+
? `throw { __bridgeControl: ${JSON.stringify(controlKind)}, levels: ${controlLevels} };`
1919+
: controlKind === "continue"
1920+
? "continue;"
1921+
: "break;";
18431922

18441923
if (mode === "continue") {
18451924
if (isNullish) {
@@ -1852,16 +1931,16 @@ class CodegenContext {
18521931
// mode === "for-continue" — same as break but uses native 'continue' keyword
18531932
if (mode === "for-continue") {
18541933
if (isNullish) {
1855-
return `${pad} if (${checkExpr} == null) continue;\n${pad} _result.push(${this.buildElementBody(elemWires, arrayIterators, depth, indent)});`;
1934+
return `${pad} if (${checkExpr} == null) ${controlStatement}\n${pad} _result.push(${this.buildElementBody(elemWires, arrayIterators, depth, indent)});`;
18561935
}
1857-
return `${pad} if (!${checkExpr}) continue;\n${pad} _result.push(${this.buildElementBody(elemWires, arrayIterators, depth, indent)});`;
1936+
return `${pad} if (!${checkExpr}) ${controlStatement}\n${pad} _result.push(${this.buildElementBody(elemWires, arrayIterators, depth, indent)});`;
18581937
}
18591938

18601939
// mode === "break"
18611940
if (isNullish) {
1862-
return `${pad} if (${checkExpr} == null) break;\n${pad} _result.push(${this.buildElementBody(elemWires, arrayIterators, depth, indent)});`;
1941+
return `${pad} if (${checkExpr} == null) ${controlStatement}\n${pad} _result.push(${this.buildElementBody(elemWires, arrayIterators, depth, indent)});`;
18631942
}
1864-
return `${pad} if (!${checkExpr}) break;\n${pad} _result.push(${this.buildElementBody(elemWires, arrayIterators, depth, indent)});`;
1943+
return `${pad} if (!${checkExpr}) ${controlStatement}\n${pad} _result.push(${this.buildElementBody(elemWires, arrayIterators, depth, indent)});`;
18651944
}
18661945

18671946
// ── Wire → expression ────────────────────────────────────────────────────

packages/bridge-core/src/ExecutionTree.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from "./tracing.ts";
1818
import type {
1919
Logger,
20+
LoopControlSignal,
2021
MaybePromise,
2122
Path,
2223
TreeContext,
@@ -27,6 +28,8 @@ import {
2728
BridgeAbortError,
2829
BridgePanicError,
2930
CONTINUE_SYM,
31+
decrementLoopControl,
32+
isLoopControlSignal,
3033
isPromise,
3134
MAX_EXECUTION_DEPTH,
3235
} from "./tree-types.ts";
@@ -371,8 +374,11 @@ export class ExecutionTree implements TreeContext {
371374
if (this.signal?.aborted) {
372375
throw new BridgeAbortError();
373376
}
374-
if (item === BREAK_SYM) break;
375-
if (item === CONTINUE_SYM) continue;
377+
if (isLoopControlSignal(item)) {
378+
const ctrl = decrementLoopControl(item);
379+
if (ctrl === BREAK_SYM) break;
380+
if (ctrl === CONTINUE_SYM) continue;
381+
}
376382
const s = this.shadow();
377383
s.state[this.elementTrunkKey] = item;
378384
shadows.push(s);
@@ -579,7 +585,7 @@ export class ExecutionTree implements TreeContext {
579585
const result = this.resolveWires(matches);
580586
if (!array) return result;
581587
const resolved = await result;
582-
if (resolved === BREAK_SYM || resolved === CONTINUE_SYM) return [];
588+
if (isLoopControlSignal(resolved)) return [];
583589
return this.createShadowArray(resolved as any[]);
584590
}
585591

@@ -936,7 +942,7 @@ export class ExecutionTree implements TreeContext {
936942
private materializeShadows(
937943
items: ExecutionTree[],
938944
pathPrefix: string[],
939-
): Promise<unknown[]> {
945+
): Promise<unknown[] | LoopControlSignal> {
940946
return _materializeShadows(this, items, pathPrefix);
941947
}
942948

@@ -1004,7 +1010,7 @@ export class ExecutionTree implements TreeContext {
10041010

10051011
// Array: create shadow trees for per-element resolution
10061012
const resolved = await response;
1007-
if (resolved === BREAK_SYM || resolved === CONTINUE_SYM) return [];
1013+
if (isLoopControlSignal(resolved)) return [];
10081014
return this.createShadowArray(resolved as any[]);
10091015
}
10101016

@@ -1018,7 +1024,7 @@ export class ExecutionTree implements TreeContext {
10181024
const response = this.resolveWires(defineFieldWires);
10191025
if (!array) return response;
10201026
const resolved = await response;
1021-
if (resolved === BREAK_SYM || resolved === CONTINUE_SYM) return [];
1027+
if (isLoopControlSignal(resolved)) return [];
10221028
return this.createShadowArray(resolved as any[]);
10231029
}
10241030
}

0 commit comments

Comments
 (0)