@teleporthq/teleport-plugin-next-workflows 0.45.15 → 0.45.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/__tests__/cron-route-loop-templateparams.test.ts +5 -4
  2. package/__tests__/custom-js-loop-scope.test.ts +10 -5
  3. package/__tests__/loop-collection-unwrap.test.ts +6 -1
  4. package/__tests__/server-loop-body-branches.test.ts +188 -0
  5. package/__tests__/server-nested-loops.test.ts +392 -0
  6. package/__tests__/streaming-route-if-branch.test.ts +107 -0
  7. package/dist/cjs/api-route-generator.d.ts.map +1 -1
  8. package/dist/cjs/api-route-generator.js +57 -251
  9. package/dist/cjs/api-route-generator.js.map +1 -1
  10. package/dist/cjs/executor-generator.d.ts.map +1 -1
  11. package/dist/cjs/executor-generator.js +22 -0
  12. package/dist/cjs/executor-generator.js.map +1 -1
  13. package/dist/cjs/server-runtime-code.d.ts.map +1 -1
  14. package/dist/cjs/server-runtime-code.js +67 -0
  15. package/dist/cjs/server-runtime-code.js.map +1 -1
  16. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  17. package/dist/esm/api-route-generator.d.ts.map +1 -1
  18. package/dist/esm/api-route-generator.js +57 -251
  19. package/dist/esm/api-route-generator.js.map +1 -1
  20. package/dist/esm/executor-generator.d.ts.map +1 -1
  21. package/dist/esm/executor-generator.js +22 -0
  22. package/dist/esm/executor-generator.js.map +1 -1
  23. package/dist/esm/server-runtime-code.d.ts.map +1 -1
  24. package/dist/esm/server-runtime-code.js +67 -0
  25. package/dist/esm/server-runtime-code.js.map +1 -1
  26. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  27. package/package.json +2 -2
  28. package/src/api-route-generator.ts +57 -251
  29. package/src/executor-generator.ts +22 -0
  30. package/src/server-runtime-code.ts +67 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teleporthq/teleport-plugin-next-workflows",
3
- "version": "0.45.15",
3
+ "version": "0.45.16",
4
4
  "description": "A plugin for workflow code generation in Next.js projects.",
5
5
  "author": "teleportHQ",
6
6
  "license": "MIT",
@@ -69,5 +69,5 @@
69
69
  "@teleporthq/teleport-shared": "^0.45.0",
70
70
  "@teleporthq/teleport-types": "^0.45.0"
71
71
  },
72
- "gitHead": "c93e2bd998c78f667928f7a87b6bb5175de8fea1"
72
+ "gitHead": "ff4b04c211701b9bc5fdce1f32e42226241b0792"
73
73
  }
@@ -443,6 +443,8 @@ module.exports = async function handler(req, res) {
443
443
  // the caller as if that branch had run (a custom node answers the output
444
444
  // of the last node it ran).
445
445
  if (context.__skippedNodes && context.__skippedNodes[node.id]) continue;
446
+ // A node of a loop body ran inside its loop, an if-statement included.
447
+ if (context.__loopBodyNodeIds && context.__loopBodyNodeIds[node.id]) continue;
446
448
  const resolved = resolveNodeConfig(node, context);
447
449
  // Every data node lives in a SERVER segment, so this guard — which turns an
448
450
  // unresolved {{…}} into a validation error instead of letting the literal
@@ -458,119 +460,34 @@ module.exports = async function handler(req, res) {
458
460
  if (node.type === 'general-if-statement') {
459
461
  const condResult = utils.evaluateCondition(resolved, context, node.config);
460
462
  context[node.id] = { result: condResult };
461
- var skipHandle = condResult ? 'false' : 'true';
462
- var segEdgesIf = SEGMENT_CONFIG.edges || [];
463
- var skipEdge = segEdgesIf.find(function(e) { return e.source === node.id && e.sourceHandle === skipHandle; });
464
- if (skipEdge) {
465
- // BFS to collect all nodes in the non-taken branch
466
- var skipIds = {};
467
- var skipQueue = [skipEdge.target];
468
- while (skipQueue.length > 0) {
469
- var sid = skipQueue.shift();
470
- if (skipIds[sid] || sid === node.id) continue;
471
- skipIds[sid] = true;
472
- segEdgesIf.forEach(function(e) {
473
- if (e.source === sid && !skipIds[e.target] && e.target !== node.id) skipQueue.push(e.target);
474
- });
475
- }
476
- if (!context.__skippedNodes) context.__skippedNodes = {};
477
- Object.assign(context.__skippedNodes, skipIds);
478
- }
463
+ if (!context.__skippedNodes) context.__skippedNodes = {};
464
+ Object.assign(context.__skippedNodes, utils.nonTakenBranchNodeIds(SEGMENT_CONFIG.edges, node.id, condResult));
479
465
  continue;
480
466
  }
481
467
 
482
- // Loop nodes must be handled specially: run the loop body nodes once per
483
- // pass of the loop type (see createLoopSteps), then continue after the loop.
468
+ // A loop runs its body once per pass of its type, a loop inside the body
469
+ // included (see runServerLoop in the server runtime).
484
470
  if (node.type === 'general-loop') {
485
- const loopEdges = SEGMENT_CONFIG.edges.filter(function(e) { return e.source === node.id; });
486
- const loopBodyEdge = loopEdges.find(function(e) { return e.sourceHandle === 'loop'; });
487
- const exitEdge = loopEdges.find(function(e) { return e.sourceHandle === 'exit'; });
488
-
489
- // Collect loop body node IDs by following loop-body edges
490
- const bodyNodeIds = {};
491
- if (loopBodyEdge) {
492
- const queue = [loopBodyEdge.target];
493
- while (queue.length > 0) {
494
- const nid = queue.shift();
495
- if (bodyNodeIds[nid] || nid === node.id) continue;
496
- bodyNodeIds[nid] = true;
497
- SEGMENT_CONFIG.edges.forEach(function(e) {
498
- if (e.source === nid && (e.sourceHandle === 'loop-body-out' || !e.sourceHandle)) {
499
- if (e.target !== node.id) queue.push(e.target);
500
- }
501
- });
502
- }
503
- }
504
- const bodyNodes = sortedNodes.filter(function(n) { return bodyNodeIds[n.id]; });
505
- // For loopType=map, the schema (general-loop in node-context-schemas.json)
506
- // defines results as the array of last body node outputs (same as
507
- // Array.prototype.map). Pick the last body node so downstream nodes
508
- // (e.g. state-update-global-state) consume the transformed value
509
- // instead of the per-iteration scaffolding object.
510
- const __loopType = resolved.loopType || 'forEach';
511
- const __lastBodyNode = bodyNodes[bodyNodes.length - 1];
512
-
513
- // Resolve the collection to iterate over. unwrapWorkflowCollection
514
- // handles the common envelope shape that helper nodes return —
515
- // transform-array { result, ... }, integration-airtable { records, ... },
516
- // utility-semantic-search { matches, ... } — by unwrapping a single
517
- // array property when the upstream binding pointed at the parent node
518
- // instead of the inner field. See runtime-utils for the full rules.
519
- var collection = utils.unwrapWorkflowCollection(resolved.collection);
520
- var loopResults = [];
521
-
522
- // Track this loop's body scope so general-custom-js (and any handler
523
- // that consults the scope stack) can build the params / innerParams
524
- // arrays the workflow editor advertises: params = nodes BEFORE the
525
- // outermost loop, innerParamsN = nodes inside the loop at depth N.
526
- if (!context.__loopScopeStack) context.__loopScopeStack = [];
527
- context.__loopScopeStack.push({ loopNodeId: node.id, bodyNodeIds: bodyNodeIds });
528
- // Persistent registry of every loop scaffold node we've entered, so
529
- // a sibling that runs AFTER an inner loop completes still recognises
530
- // that inner loop id as scaffolding (not as a regular workflow node).
531
- if (!context.__loopNodeIds) context.__loopNodeIds = {};
532
- context.__loopNodeIds[node.id] = true;
533
-
534
- var loopSteps = utils.createLoopSteps(node, resolved, context, collection);
535
- for (var loopPass = loopSteps.next(context); loopPass; loopPass = loopSteps.next(context)) {
536
- context[loopPass.key] = loopPass.value;
537
- for (var bi = 0; bi < bodyNodes.length; bi++) {
538
- var bNode = bodyNodes[bi];
539
- var bResolved = resolveNodeConfig(bNode, context);
540
- bResolved.__nodeId = bNode.id;
541
- if (bNode.type === 'general-if-statement') {
542
- context[bNode.id] = { result: utils.evaluateCondition(bResolved, context, bNode.config) };
543
- continue;
544
- }
545
- var bHandler = nodeHandlers[bNode.type];
546
- if (!bHandler) continue;
547
- if (utils.isFireAndForgetNode(bNode)) {
548
- utils.registerPendingNodePromise(context, utils.startFireAndForgetNode(bNode, bHandler, bResolved, context));
549
- context[bNode.id] = null;
550
- continue;
551
- }
552
- var bResult = await bHandler(bResolved, context);
553
- if (bResult && (bResult.success === false || (typeof bResult.error === 'string' && bResult.error))) {
554
- throw new Error(bResult.error || 'Loop body node execution failed');
555
- }
556
- context[bNode.id] = bResult;
471
+ const bodyNodeIds = await utils.runServerLoop(node, resolved, sortedNodes, SEGMENT_CONFIG.edges, context, async function(bNode, bResolved) {
472
+ var bHandler = nodeHandlers[bNode.type];
473
+ if (!bHandler) return;
474
+ if (utils.isFireAndForgetNode(bNode)) {
475
+ utils.registerPendingNodePromise(context, utils.startFireAndForgetNode(bNode, bHandler, bResolved, context));
476
+ context[bNode.id] = null;
477
+ return;
557
478
  }
558
- if (__loopType === 'map') {
559
- loopResults.push(__lastBodyNode ? context[__lastBodyNode.id] : context[node.id]);
479
+ var bResult = await bHandler(bResolved, context);
480
+ if (bResult && (bResult.success === false || (typeof bResult.error === 'string' && bResult.error))) {
481
+ throw new Error(bResult.error || 'Loop body node execution failed');
560
482
  }
561
- }
562
-
563
- context.__loopScopeStack.pop();
564
-
565
- context[node.id] = { completed: true, iterations: loopSteps.iterations(), results: loopResults };
566
- // Store body node IDs so the outer loop can skip them
483
+ context[bNode.id] = bResult;
484
+ });
485
+ // The walk below must not run a body node again.
567
486
  if (!context.__loopBodyNodeIds) context.__loopBodyNodeIds = {};
568
487
  Object.assign(context.__loopBodyNodeIds, bodyNodeIds);
569
488
  continue;
570
489
  }
571
490
 
572
- // Skip nodes that were part of a loop body (already executed inside the loop)
573
- if (context.__loopBodyNodeIds && context.__loopBodyNodeIds[node.id]) continue;
574
491
 
575
492
  // Switch nodes evaluate a value, match a case, execute that branch, skip all others
576
493
  if (node.type === 'general-switch') {
@@ -866,6 +783,8 @@ module.exports = async function handler(req, res) {
866
783
  for (let i = 0; i < sortedNodes.length; i++) {
867
784
  const node = sortedNodes[i];
868
785
  if (executed[node.id]) continue;
786
+ // The branch an if-statement or a switch did not take never runs.
787
+ if (context.__skippedNodes && context.__skippedNodes[node.id]) continue;
869
788
  const resolved = resolveNodeConfig(node, context);
870
789
  // See the non-streaming segment above — without this the unresolved-token
871
790
  // guard never runs on the server, which is where every data node executes.
@@ -878,72 +797,27 @@ module.exports = async function handler(req, res) {
878
797
  const condResult = utils.evaluateCondition(resolved, context, node.config);
879
798
  context[node.id] = { result: condResult };
880
799
  executed[node.id] = true;
800
+ if (!context.__skippedNodes) context.__skippedNodes = {};
801
+ Object.assign(context.__skippedNodes, utils.nonTakenBranchNodeIds(SEGMENT_CONFIG.edges, node.id, condResult));
881
802
  if (streamStarted) {
882
803
  __streamNodeResult(res, node.id, context[node.id]);
883
804
  }
884
805
  continue;
885
806
  }
886
807
 
887
- // Loop nodes in streaming segments
808
+ // Loop nodes in streaming segments: same runner as the regular-segment
809
+ // loop above, a loop inside the body included.
888
810
  if (node.type === 'general-loop') {
889
- var ssLoopEdges = SEGMENT_CONFIG.edges.filter(function(e) { return e.source === node.id; });
890
- var ssLoopBodyEdge = ssLoopEdges.find(function(e) { return e.sourceHandle === 'loop'; });
891
- var ssBodyNodeIds = {};
892
- if (ssLoopBodyEdge) {
893
- var sslq = [ssLoopBodyEdge.target];
894
- while (sslq.length > 0) {
895
- var sslnid = sslq.shift();
896
- if (ssBodyNodeIds[sslnid] || sslnid === node.id) continue;
897
- ssBodyNodeIds[sslnid] = true;
898
- SEGMENT_CONFIG.edges.forEach(function(e) {
899
- if (e.source === sslnid && (e.sourceHandle === 'loop-body-out' || !e.sourceHandle)) {
900
- if (e.target !== node.id) sslq.push(e.target);
901
- }
902
- });
811
+ var ssBodyNodeIds = await utils.runServerLoop(node, resolved, sortedNodes, SEGMENT_CONFIG.edges, context, async function(ssbNode, ssbRes) {
812
+ var ssbHandler = nodeHandlers[ssbNode.type];
813
+ if (!ssbHandler) return;
814
+ if (utils.isFireAndForgetNode(ssbNode)) {
815
+ utils.registerPendingNodePromise(context, utils.startFireAndForgetNode(ssbNode, ssbHandler, ssbRes, context));
816
+ context[ssbNode.id] = null;
817
+ return;
903
818
  }
904
- }
905
- var ssBodyNodes = sortedNodes.filter(function(n) { return ssBodyNodeIds[n.id]; });
906
- // See the regular-segment loop above: loopType=map must publish the
907
- // last body node output as results[i] so the schema contract holds.
908
- var ssLoopType = resolved.loopType || 'forEach';
909
- var ssLastBodyNode = ssBodyNodes[ssBodyNodes.length - 1];
910
- // Same envelope unwrap as the regular-segment loop above.
911
- var ssLoopCollection = utils.unwrapWorkflowCollection(resolved.collection);
912
- var ssLoopResults = [];
913
- // Same loop-scope tracking as the regular-segment loop above. Custom-js
914
- // bodies can read context.__loopScopeStack to recover the params /
915
- // innerParams contract documented in workflow-utils.ts.
916
- if (!context.__loopScopeStack) context.__loopScopeStack = [];
917
- context.__loopScopeStack.push({ loopNodeId: node.id, bodyNodeIds: ssBodyNodeIds });
918
- if (!context.__loopNodeIds) context.__loopNodeIds = {};
919
- context.__loopNodeIds[node.id] = true;
920
- var ssLoopSteps = utils.createLoopSteps(node, resolved, context, ssLoopCollection);
921
- for (var ssLoopPass = ssLoopSteps.next(context); ssLoopPass; ssLoopPass = ssLoopSteps.next(context)) {
922
- context[ssLoopPass.key] = ssLoopPass.value;
923
- for (var ssbi = 0; ssbi < ssBodyNodes.length; ssbi++) {
924
- var ssbNode = ssBodyNodes[ssbi];
925
- var ssbRes = resolveNodeConfig(ssbNode, context);
926
- ssbRes.__nodeId = ssbNode.id;
927
- if (ssbNode.type === 'general-if-statement') {
928
- context[ssbNode.id] = { result: utils.evaluateCondition(ssbRes, context, ssbNode.config) };
929
- continue;
930
- }
931
- var ssbHandler = nodeHandlers[ssbNode.type];
932
- if (!ssbHandler) continue;
933
- if (utils.isFireAndForgetNode(ssbNode)) {
934
- utils.registerPendingNodePromise(context, utils.startFireAndForgetNode(ssbNode, ssbHandler, ssbRes, context));
935
- context[ssbNode.id] = null;
936
- continue;
937
- }
938
- var ssbResult = await ssbHandler(ssbRes, context);
939
- context[ssbNode.id] = ssbResult;
940
- }
941
- if (ssLoopType === 'map') {
942
- ssLoopResults.push(ssLastBodyNode ? context[ssLastBodyNode.id] : context[node.id]);
943
- }
944
- }
945
- context.__loopScopeStack.pop();
946
- context[node.id] = { completed: true, iterations: ssLoopSteps.iterations(), results: ssLoopResults };
819
+ context[ssbNode.id] = await ssbHandler(ssbRes, context);
820
+ });
947
821
  Object.keys(ssBodyNodeIds).forEach(function(k) { executed[k] = true; });
948
822
  executed[node.id] = true;
949
823
  if (streamStarted) {
@@ -1670,11 +1544,11 @@ const generateNodeExecutionLoop = (
1670
1544
  // top-level one does: its handler only hands back the marker.
1671
1545
  const loopBodyCustomNodeBlock = options?.hasCustomNodes
1672
1546
  ? `
1673
- if (bResult && bResult.__customNode) {
1674
- var bcnFn = typeof __customNodes !== 'undefined' ? __customNodes[bResult.customNodeId] : null;
1675
- context[bNode.id] = bcnFn ? await bcnFn(context, bResult.parameters, nodeHandlers) : null;
1676
- continue;
1677
- }`
1547
+ if (bResult && bResult.__customNode) {
1548
+ var bcnFn = typeof __customNodes !== 'undefined' ? __customNodes[bResult.customNodeId] : null;
1549
+ context[bNode.id] = bcnFn ? await bcnFn(context, bResult.parameters, nodeHandlers) : null;
1550
+ return;
1551
+ }`
1678
1552
  : ''
1679
1553
 
1680
1554
  return ` var sortedNodes = ${configVar}.nodes.slice().sort(function(a, b) { return a.stepNumber - b.stepNumber; });
@@ -1684,6 +1558,8 @@ ${resumeBlock}
1684
1558
  // Never resolved nor run on a non-taken branch, an if-statement included
1685
1559
  // (see the segment executor above).
1686
1560
  if (context.__skippedNodes && context.__skippedNodes[node.id]) continue;
1561
+ // A node of a loop body ran inside its loop, an if-statement included.
1562
+ if (context.__loopBodyNodeIds && context.__loopBodyNodeIds[node.id]) continue;
1687
1563
  var resolved = resolveNodeConfig(node, context);
1688
1564
  // See the other segment executors — the unresolved-token guard must run
1689
1565
  // wherever data nodes execute, and they all execute on the server.
@@ -1694,103 +1570,33 @@ ${resumeBlock}
1694
1570
  if (node.type === 'general-if-statement') {
1695
1571
  var condResult = utils.evaluateCondition(resolved, context, node.config);
1696
1572
  context[node.id] = { result: condResult };
1697
- var skipHandle = condResult ? 'false' : 'true';
1698
- var cfgEdges = ${configVar}.edges || [];
1699
- var skipEdge = cfgEdges.find(function(e) { return e.source === node.id && e.sourceHandle === skipHandle; });
1700
- if (skipEdge) {
1701
- var skipIds = {};
1702
- var skipQueue = [skipEdge.target];
1703
- while (skipQueue.length > 0) {
1704
- var sid = skipQueue.shift();
1705
- if (skipIds[sid] || sid === node.id) continue;
1706
- skipIds[sid] = true;
1707
- cfgEdges.forEach(function(e) {
1708
- if (e.source === sid && !skipIds[e.target] && e.target !== node.id) skipQueue.push(e.target);
1709
- });
1710
- }
1711
- if (!context.__skippedNodes) context.__skippedNodes = {};
1712
- Object.assign(context.__skippedNodes, skipIds);
1713
- }
1573
+ if (!context.__skippedNodes) context.__skippedNodes = {};
1574
+ Object.assign(context.__skippedNodes, utils.nonTakenBranchNodeIds(${configVar}.edges, node.id, condResult));
1714
1575
  continue;
1715
1576
  }
1716
1577
 
1578
+ // Same runner as the segment routes, a loop inside the body included
1579
+ // (see runServerLoop in the server runtime).
1717
1580
  if (node.type === 'general-loop') {
1718
- var loopEdges = ${configVar}.edges.filter(function(e) { return e.source === node.id; });
1719
- var loopBodyEdge = loopEdges.find(function(e) { return e.sourceHandle === 'loop'; });
1720
-
1721
- var bodyNodeIds = {};
1722
- if (loopBodyEdge) {
1723
- var lQueue = [loopBodyEdge.target];
1724
- while (lQueue.length > 0) {
1725
- var nid = lQueue.shift();
1726
- if (bodyNodeIds[nid] || nid === node.id) continue;
1727
- bodyNodeIds[nid] = true;
1728
- ${configVar}.edges.forEach(function(e) {
1729
- if (e.source === nid && (e.sourceHandle === 'loop-body-out' || !e.sourceHandle)) {
1730
- if (e.target !== node.id) lQueue.push(e.target);
1731
- }
1732
- });
1581
+ var bodyNodeIds = await utils.runServerLoop(node, resolved, sortedNodes, ${configVar}.edges, context, async function(bNode, bResolved) {
1582
+ var bHandler = nodeHandlers[bNode.type];
1583
+ if (!bHandler) return;
1584
+ if (utils.isFireAndForgetNode(bNode)) {
1585
+ utils.registerPendingNodePromise(context, utils.startFireAndForgetNode(bNode, bHandler, bResolved, context));
1586
+ context[bNode.id] = null;
1587
+ return;
1733
1588
  }
1734
- }
1735
- var bodyNodes = sortedNodes.filter(function(n) { return bodyNodeIds[n.id]; });
1736
- // For loopType=map, the schema (general-loop in node-context-schemas.json)
1737
- // defines results as the array of last body node outputs (same as
1738
- // Array.prototype.map). Pick the last body node so downstream nodes
1739
- // (e.g. state-update-global-state) consume the transformed value.
1740
- var __wfLoopType = resolved.loopType || 'forEach';
1741
- var __wfLastBodyNode = bodyNodes[bodyNodes.length - 1];
1742
-
1743
- // Same envelope unwrap as the regular-segment loop above.
1744
- var collection = utils.unwrapWorkflowCollection(resolved.collection);
1745
- var loopResults = [];
1746
-
1747
- // Same loop-scope tracking as the regular-segment loop above so
1748
- // general-custom-js inside the loop body sees the right params /
1749
- // innerParams. Required for webhook + cron routes.
1750
- if (!context.__loopScopeStack) context.__loopScopeStack = [];
1751
- context.__loopScopeStack.push({ loopNodeId: node.id, bodyNodeIds: bodyNodeIds });
1752
- if (!context.__loopNodeIds) context.__loopNodeIds = {};
1753
- context.__loopNodeIds[node.id] = true;
1754
-
1755
- var loopSteps = utils.createLoopSteps(node, resolved, context, collection);
1756
- for (var loopPass = loopSteps.next(context); loopPass; loopPass = loopSteps.next(context)) {
1757
- context[loopPass.key] = loopPass.value;
1758
- for (var bi = 0; bi < bodyNodes.length; bi++) {
1759
- var bNode = bodyNodes[bi];
1760
- var bResolved = resolveNodeConfig(bNode, context);
1761
- bResolved.__nodeId = bNode.id;
1762
- if (bNode.type === 'general-if-statement') {
1763
- context[bNode.id] = { result: utils.evaluateCondition(bResolved, context, bNode.config) };
1764
- continue;
1765
- }
1766
- var bHandler = nodeHandlers[bNode.type];
1767
- if (!bHandler) continue;
1768
- if (utils.isFireAndForgetNode(bNode)) {
1769
- utils.registerPendingNodePromise(context, utils.startFireAndForgetNode(bNode, bHandler, bResolved, context));
1770
- context[bNode.id] = null;
1771
- continue;
1772
- }
1773
- var bResult = await bHandler(bResolved, context);${loopBodyCustomNodeBlock}
1774
- if (bResult && (bResult.success === false || (typeof bResult.error === 'string' && bResult.error))) {
1775
- throw new Error(bResult.error || 'Loop body node execution failed');
1776
- }
1777
- context[bNode.id] = bResult;
1778
- }
1779
- if (__wfLoopType === 'map') {
1780
- loopResults.push(__wfLastBodyNode ? context[__wfLastBodyNode.id] : context[node.id]);
1589
+ var bResult = await bHandler(bResolved, context);${loopBodyCustomNodeBlock}
1590
+ if (bResult && (bResult.success === false || (typeof bResult.error === 'string' && bResult.error))) {
1591
+ throw new Error(bResult.error || 'Loop body node execution failed');
1781
1592
  }
1782
- }
1783
-
1784
- context.__loopScopeStack.pop();
1785
-
1786
- context[node.id] = { completed: true, iterations: loopSteps.iterations(), results: loopResults };
1593
+ context[bNode.id] = bResult;
1594
+ });
1787
1595
  if (!context.__loopBodyNodeIds) context.__loopBodyNodeIds = {};
1788
1596
  Object.assign(context.__loopBodyNodeIds, bodyNodeIds);
1789
1597
  continue;
1790
1598
  }
1791
1599
 
1792
- if (context.__loopBodyNodeIds && context.__loopBodyNodeIds[node.id]) continue;
1793
-
1794
1600
  if (node.type === 'general-switch') {
1795
1601
  // Only an authored case expression is compiled (see matchSwitchCase).
1796
1602
  var swMatchedCase = utils.matchSwitchCase(node.config, resolved, context);
@@ -215,6 +215,27 @@ function createLoopSteps(loopNode, config, context, collection) {
215
215
  };
216
216
  }
217
217
 
218
+ // The nodes of the branch an if-statement did NOT take: everything reachable
219
+ // from that branch's edge, never walking back through the if itself nor past
220
+ // stopAtId (the loop a body belongs to, whose loop-back edge leads out of it).
221
+ function nonTakenBranchNodeIds(edges, ifNodeId, conditionHeld, stopAtId) {
222
+ var skipHandle = conditionHeld ? 'false' : 'true';
223
+ var all = edges || [];
224
+ var skipEdge = all.find(function(e) { return e.source === ifNodeId && e.sourceHandle === skipHandle; });
225
+ var skipIds = {};
226
+ if (!skipEdge) return skipIds;
227
+ var queue = [skipEdge.target];
228
+ while (queue.length > 0) {
229
+ var id = queue.shift();
230
+ if (skipIds[id] || id === ifNodeId || id === stopAtId) continue;
231
+ skipIds[id] = true;
232
+ all.forEach(function(e) {
233
+ if (e.source === id && !skipIds[e.target]) queue.push(e.target);
234
+ });
235
+ }
236
+ return skipIds;
237
+ }
238
+
218
239
  function resolveCtxRef(ref, context) {
219
240
  var elem = context.triggerElement;
220
241
  if (!elem || typeof elem.getAttribute !== 'function') return undefined;
@@ -1855,6 +1876,7 @@ module.exports = {
1855
1876
  unwrapWorkflowCollection,
1856
1877
  loopIterationContext,
1857
1878
  createLoopSteps,
1879
+ nonTakenBranchNodeIds,
1858
1880
  snapshotIncomingContext,
1859
1881
  segmentReply,
1860
1882
  withoutCredentialColumns,
@@ -860,7 +860,74 @@ function handOffPendingNodePromises(context) {
860
860
  return Promise.resolve();
861
861
  }
862
862
 
863
+ // One loop of a server route, a loop inside its body included: every route
864
+ // kind runs its loops through here and differs only in runBodyNode(node,
865
+ // resolvedConfig), which runs one ordinary body node and stores its result.
866
+ // The body is walked in step order, once per pass of the loop type (see
867
+ // createLoopSteps). An if-statement skips the branch it does not take for
868
+ // that pass only; a loop found in the body runs all of its own passes on
869
+ // every pass of this one, and its body nodes belong to it alone.
870
+ // Returns every node id between the loop and its loop-back edge (the bodies
871
+ // of inner loops included), which the route must not run again.
872
+ async function runServerLoop(loopNode, config, sortedNodes, edges, context, runBodyNode) {
873
+ var bodyNodeIds = {};
874
+ utils.getLoopBodyNodeIds(loopNode.id, edges).forEach(function(id) { bodyNodeIds[id] = true; });
875
+ var innerLoopBodyIds = {};
876
+ sortedNodes.forEach(function(n) {
877
+ if (!bodyNodeIds[n.id] || n.type !== 'general-loop') return;
878
+ utils.getLoopBodyNodeIds(n.id, edges).forEach(function(id) { innerLoopBodyIds[id] = true; });
879
+ });
880
+ var bodyNodes = sortedNodes.filter(function(n) { return bodyNodeIds[n.id] && !innerLoopBodyIds[n.id]; });
881
+ // A map loop publishes the output of its last body node per pass, as
882
+ // Array.prototype.map does (general-loop in node-context-schemas.json).
883
+ var isMap = (config.loopType || 'forEach') === 'map';
884
+ var lastBodyNode = bodyNodes[bodyNodes.length - 1];
885
+ // A helper node's envelope ({ result }, { records }, { matches }) is
886
+ // unwrapped to the one array it holds.
887
+ var collection = utils.unwrapWorkflowCollection(config.collection);
888
+ var results = [];
889
+
890
+ // The scope stack lets custom JavaScript tell the nodes before the outermost
891
+ // loop (params) from those inside a loop at each depth (innerParams); the
892
+ // registry keeps a finished inner loop recognisable as scaffolding.
893
+ if (!context.__loopScopeStack) context.__loopScopeStack = [];
894
+ context.__loopScopeStack.push({ loopNodeId: loopNode.id, bodyNodeIds: bodyNodeIds });
895
+ if (!context.__loopNodeIds) context.__loopNodeIds = {};
896
+ context.__loopNodeIds[loopNode.id] = true;
897
+
898
+ var steps = utils.createLoopSteps(loopNode, config, context, collection);
899
+ for (var pass = steps.next(context); pass; pass = steps.next(context)) {
900
+ context[pass.key] = pass.value;
901
+ var skippedThisPass = {};
902
+ for (var i = 0; i < bodyNodes.length; i++) {
903
+ var bodyNode = bodyNodes[i];
904
+ if (skippedThisPass[bodyNode.id]) continue;
905
+ var resolved = utils.resolveNodeConfig(bodyNode, context);
906
+ resolved.__nodeId = bodyNode.id;
907
+ if (bodyNode.type === 'general-if-statement') {
908
+ var held = utils.evaluateCondition(resolved, context, bodyNode.config);
909
+ context[bodyNode.id] = { result: held };
910
+ Object.assign(skippedThisPass, utils.nonTakenBranchNodeIds(edges, bodyNode.id, held, loopNode.id));
911
+ continue;
912
+ }
913
+ if (bodyNode.type === 'general-loop') {
914
+ await runServerLoop(bodyNode, resolved, sortedNodes, edges, context, runBodyNode);
915
+ continue;
916
+ }
917
+ await runBodyNode(bodyNode, resolved);
918
+ }
919
+ if (isMap) {
920
+ results.push(lastBodyNode ? context[lastBodyNode.id] : context[loopNode.id]);
921
+ }
922
+ }
923
+
924
+ context.__loopScopeStack.pop();
925
+ context[loopNode.id] = { completed: true, iterations: steps.iterations(), results: results };
926
+ return bodyNodeIds;
927
+ }
928
+
863
929
  module.exports = Object.assign({}, utils, {
930
+ runServerLoop: runServerLoop,
864
931
  handOffPendingNodePromises: handOffPendingNodePromises,
865
932
  claimSegmentContext: claimSegmentContext,
866
933
  segmentReply: segmentReply,