@bian-womp/spark-graph 0.3.12 → 0.3.14

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.
package/lib/esm/index.js CHANGED
@@ -444,6 +444,7 @@ Registry.idCounter = 0;
444
444
 
445
445
  /**
446
446
  * Graph component - manages nodes, edges, and handle resolution
447
+ * This is the ONLY place where nodes, edges, and resolvedByNode are directly updated.
447
448
  */
448
449
  class Graph {
449
450
  constructor(registry) {
@@ -452,46 +453,423 @@ class Graph {
452
453
  this.edges = [];
453
454
  this.resolvedByNode = new Map();
454
455
  }
455
- // Node accessors
456
+ // ==================== Node Accessors ====================
457
+ /**
458
+ * Get a node by ID (readonly to prevent accidental modifications)
459
+ */
456
460
  getNode(nodeId) {
457
461
  return this.nodes.get(nodeId);
458
462
  }
459
- getNodes() {
460
- return this.nodes;
463
+ /**
464
+ * Get a node by ID (mutable, for internal use only)
465
+ * @internal
466
+ */
467
+ getNodeMutable(nodeId) {
468
+ return this.nodes.get(nodeId);
469
+ }
470
+ /**
471
+ * Iterate over all nodes safely (readonly to prevent accidental modifications)
472
+ */
473
+ forEachNode(callback) {
474
+ for (const [nodeId, node] of this.nodes.entries()) {
475
+ callback(node, nodeId);
476
+ }
477
+ }
478
+ /**
479
+ * Get all node IDs
480
+ */
481
+ getNodeIds() {
482
+ return Array.from(this.nodes.keys());
483
+ }
484
+ /**
485
+ * Check if a node exists
486
+ */
487
+ hasNode(nodeId) {
488
+ return this.nodes.has(nodeId);
461
489
  }
490
+ // ==================== Node Mutators ====================
491
+ /**
492
+ * Set a node (creates or replaces)
493
+ */
462
494
  setNode(nodeId, node) {
463
495
  this.nodes.set(nodeId, node);
464
496
  }
497
+ /**
498
+ * Delete a node
499
+ */
465
500
  deleteNode(nodeId) {
466
501
  this.nodes.delete(nodeId);
467
502
  }
468
- hasNode(nodeId) {
469
- return this.nodes.has(nodeId);
503
+ // ==================== Node Property Updates ====================
504
+ /**
505
+ * Update node inputs
506
+ */
507
+ updateNodeInput(nodeId, handle, value) {
508
+ const node = this.getNodeMutable(nodeId);
509
+ if (!node)
510
+ return;
511
+ if (value === undefined) {
512
+ delete node.inputs[handle];
513
+ }
514
+ else {
515
+ node.inputs[handle] = value;
516
+ }
517
+ }
518
+ /**
519
+ * Delete a node input handle
520
+ */
521
+ deleteNodeInput(nodeId, handle) {
522
+ const node = this.nodes.get(nodeId);
523
+ if (!node)
524
+ return;
525
+ delete node.inputs[handle];
526
+ }
527
+ /**
528
+ * Update node outputs
529
+ */
530
+ updateNodeOutput(nodeId, handle, value) {
531
+ const node = this.nodes.get(nodeId);
532
+ if (!node)
533
+ return;
534
+ node.outputs[handle] = value;
535
+ }
536
+ /**
537
+ * Update node state
538
+ */
539
+ updateNodeState(nodeId, updates) {
540
+ const node = this.nodes.get(nodeId);
541
+ if (!node)
542
+ return;
543
+ Object.assign(node.state, updates);
544
+ }
545
+ /**
546
+ * Update node params
547
+ */
548
+ updateNodeParams(nodeId, params) {
549
+ const node = this.nodes.get(nodeId);
550
+ if (!node)
551
+ return;
552
+ node.params = params;
553
+ }
554
+ /**
555
+ * Update node policy
556
+ */
557
+ updateNodePolicy(nodeId, policy) {
558
+ const node = this.nodes.get(nodeId);
559
+ if (!node)
560
+ return;
561
+ node.policy = { ...node.policy, ...policy };
562
+ }
563
+ /**
564
+ * Update node stats
565
+ */
566
+ updateNodeStats(nodeId, updates) {
567
+ const node = this.nodes.get(nodeId);
568
+ if (!node)
569
+ return;
570
+ Object.assign(node.stats, updates);
571
+ }
572
+ /**
573
+ * Increment node runSeq
574
+ */
575
+ incrementNodeRunSeq(nodeId) {
576
+ const node = this.nodes.get(nodeId);
577
+ if (!node)
578
+ return 0;
579
+ node.runSeq += 1;
580
+ return node.runSeq;
581
+ }
582
+ /**
583
+ * Set node latestRunId
584
+ */
585
+ setNodeLatestRunId(nodeId, runId) {
586
+ const node = this.nodes.get(nodeId);
587
+ if (!node)
588
+ return;
589
+ node.latestRunId = runId;
590
+ }
591
+ /**
592
+ * Set node lastScheduledAt
593
+ */
594
+ setNodeLastScheduledAt(nodeId, timestamp) {
595
+ const node = this.nodes.get(nodeId);
596
+ if (!node)
597
+ return;
598
+ node.lastScheduledAt = timestamp;
599
+ }
600
+ /**
601
+ * Update node lastInputAt timestamp for a handle
602
+ */
603
+ updateNodeLastInputAt(nodeId, handle, timestamp) {
604
+ const node = this.nodes.get(nodeId);
605
+ if (!node)
606
+ return;
607
+ if (!node.lastInputAt) {
608
+ node.lastInputAt = {};
609
+ }
610
+ node.lastInputAt[handle] = timestamp;
611
+ }
612
+ /**
613
+ * Set node lastSuccessAt timestamp
614
+ */
615
+ setNodeLastSuccessAt(nodeId, timestamp) {
616
+ const node = this.nodes.get(nodeId);
617
+ if (!node)
618
+ return;
619
+ node.lastSuccessAt = timestamp;
620
+ }
621
+ // ==================== Node Queue Operations ====================
622
+ /**
623
+ * Add item to node queue
624
+ */
625
+ addToNodeQueue(nodeId, item) {
626
+ const node = this.nodes.get(nodeId);
627
+ if (!node)
628
+ return;
629
+ node.queue.push(item);
630
+ }
631
+ /**
632
+ * Remove first item from node queue
633
+ */
634
+ shiftNodeQueue(nodeId) {
635
+ const node = this.nodes.get(nodeId);
636
+ if (!node)
637
+ return undefined;
638
+ return node.queue.shift();
639
+ }
640
+ /**
641
+ * Clear node queue
642
+ */
643
+ clearNodeQueue(nodeId) {
644
+ const node = this.nodes.get(nodeId);
645
+ if (!node)
646
+ return;
647
+ node.queue = [];
648
+ }
649
+ /**
650
+ * Replace node queue
651
+ */
652
+ replaceNodeQueue(nodeId, items) {
653
+ const node = this.nodes.get(nodeId);
654
+ if (!node)
655
+ return;
656
+ node.queue = items;
657
+ }
658
+ // ==================== Node Controller Operations ====================
659
+ /**
660
+ * Add controller to node
661
+ */
662
+ addNodeController(nodeId, controller, runId) {
663
+ const node = this.nodes.get(nodeId);
664
+ if (!node)
665
+ return;
666
+ node.activeControllers.add(controller);
667
+ node.controllerRunIds.set(controller, runId);
668
+ }
669
+ /**
670
+ * Remove controller from node
671
+ */
672
+ removeNodeController(nodeId, controller) {
673
+ const node = this.nodes.get(nodeId);
674
+ if (!node)
675
+ return;
676
+ node.activeControllers.delete(controller);
677
+ node.controllerRunIds.delete(controller);
678
+ }
679
+ /**
680
+ * Clear all controllers from node
681
+ */
682
+ clearNodeControllers(nodeId) {
683
+ const node = this.nodes.get(nodeId);
684
+ if (!node)
685
+ return;
686
+ node.activeControllers.clear();
687
+ node.controllerRunIds.clear();
688
+ }
689
+ /**
690
+ * Get all controllers for a node
691
+ */
692
+ getNodeControllers(nodeId) {
693
+ const node = this.nodes.get(nodeId);
694
+ if (!node)
695
+ return new Set();
696
+ return new Set(node.activeControllers);
697
+ }
698
+ // ==================== Node Run Context Operations ====================
699
+ /**
700
+ * Add run context ID to node
701
+ */
702
+ addNodeRunContextId(nodeId, runContextId) {
703
+ const node = this.nodes.get(nodeId);
704
+ if (!node)
705
+ return;
706
+ node.activeRunContextIds.add(runContextId);
707
+ }
708
+ /**
709
+ * Add multiple run context IDs to node
710
+ */
711
+ addNodeRunContextIds(nodeId, runContextIds) {
712
+ const node = this.nodes.get(nodeId);
713
+ if (!node)
714
+ return;
715
+ for (const id of runContextIds) {
716
+ node.activeRunContextIds.add(id);
717
+ }
718
+ }
719
+ /**
720
+ * Remove run context ID from node
721
+ */
722
+ removeNodeRunContextId(nodeId, runContextId) {
723
+ const node = this.nodes.get(nodeId);
724
+ if (!node)
725
+ return;
726
+ node.activeRunContextIds.delete(runContextId);
727
+ }
728
+ /**
729
+ * Clear all run context IDs from node
730
+ */
731
+ clearNodeRunContextIds(nodeId) {
732
+ const node = this.nodes.get(nodeId);
733
+ if (!node)
734
+ return;
735
+ node.activeRunContextIds.clear();
736
+ }
737
+ /**
738
+ * Get run context IDs for a node
739
+ */
740
+ getNodeRunContextIds(nodeId) {
741
+ const node = this.nodes.get(nodeId);
742
+ if (!node)
743
+ return new Set();
744
+ return new Set(node.activeRunContextIds);
745
+ }
746
+ // ==================== Node Snapshot Cancelled Run IDs ====================
747
+ /**
748
+ * Add snapshot cancelled run ID to node
749
+ */
750
+ addSnapshotCancelledRunId(nodeId, runId) {
751
+ const node = this.nodes.get(nodeId);
752
+ if (!node)
753
+ return;
754
+ if (!node.snapshotCancelledRunIds) {
755
+ node.snapshotCancelledRunIds = new Set();
756
+ }
757
+ node.snapshotCancelledRunIds.add(runId);
758
+ }
759
+ // ==================== Edge Accessors ====================
760
+ /**
761
+ * Iterate over all edges safely
762
+ */
763
+ forEachEdge(callback) {
764
+ this.edges.forEach(callback);
470
765
  }
471
- // Edge accessors
472
- getEdges() {
473
- return this.edges;
766
+ /**
767
+ * Find edges matching a predicate
768
+ */
769
+ findEdges(predicate) {
770
+ return this.edges.filter(predicate);
771
+ }
772
+ /**
773
+ * Get edges by source node and handle
774
+ */
775
+ getEdgesBySource(srcNodeId, srcHandle) {
776
+ return this.edges.filter((e) => e.source.nodeId === srcNodeId && e.source.handle === srcHandle);
777
+ }
778
+ /**
779
+ * Get edges by target node and handle
780
+ */
781
+ getEdgesByTarget(targetNodeId, targetHandle) {
782
+ return this.edges.filter((e) => e.target.nodeId === targetNodeId && e.target.handle === targetHandle);
783
+ }
784
+ /**
785
+ * Get inbound edges for a node
786
+ */
787
+ getInboundEdges(nodeId) {
788
+ return this.edges.filter((e) => e.target.nodeId === nodeId);
474
789
  }
790
+ /**
791
+ * Get outbound edges for a node
792
+ */
793
+ getOutboundEdges(nodeId) {
794
+ return this.edges.filter((e) => e.source.nodeId === nodeId);
795
+ }
796
+ // ==================== Edge Mutators ====================
797
+ /**
798
+ * Set all edges (replaces existing)
799
+ */
475
800
  setEdges(edges) {
476
- this.edges = edges;
801
+ this.edges = [...edges];
477
802
  }
478
- // Registry accessor
803
+ /**
804
+ * Update an edge by ID
805
+ */
806
+ updateEdge(edgeId, updates) {
807
+ const edge = this.edges.find((e) => e.id === edgeId);
808
+ if (!edge)
809
+ return;
810
+ Object.assign(edge, updates);
811
+ }
812
+ /**
813
+ * Update edge properties (convert, convertAsync, types, etc.)
814
+ */
815
+ updateEdgeProperties(edgeId, updates) {
816
+ const edge = this.edges.find((e) => e.id === edgeId);
817
+ if (!edge)
818
+ return;
819
+ if (updates.effectiveTypeId !== undefined) {
820
+ edge.effectiveTypeId = updates.effectiveTypeId;
821
+ }
822
+ if (updates.dstDeclared !== undefined) {
823
+ edge.dstDeclared = updates.dstDeclared;
824
+ }
825
+ if (updates.srcUnionTypes !== undefined) {
826
+ edge.srcUnionTypes = updates.srcUnionTypes;
827
+ }
828
+ if (updates.convert !== undefined) {
829
+ edge.convert = updates.convert;
830
+ }
831
+ if (updates.convertAsync !== undefined) {
832
+ edge.convertAsync = updates.convertAsync;
833
+ }
834
+ }
835
+ /**
836
+ * Update edge stats
837
+ */
838
+ updateEdgeStats(edgeId, updates) {
839
+ const edge = this.edges.find((e) => e.id === edgeId);
840
+ if (!edge)
841
+ return;
842
+ Object.assign(edge.stats, updates);
843
+ }
844
+ /**
845
+ * Get edge by ID
846
+ */
847
+ getEdge(edgeId) {
848
+ return this.edges.find((e) => e.id === edgeId);
849
+ }
850
+ // ==================== Registry Accessors ====================
479
851
  getRegistry() {
480
852
  return this.registry;
481
853
  }
482
854
  setRegistry(registry) {
483
855
  this.registry = registry;
484
856
  }
485
- // Resolved handles accessors
857
+ // ==================== Resolved Handles Accessors ====================
486
858
  getResolvedHandles(nodeId) {
487
859
  return this.resolvedByNode.get(nodeId);
488
860
  }
489
861
  setResolvedHandles(nodeId, handles) {
490
862
  this.resolvedByNode.set(nodeId, handles);
491
863
  }
492
- getResolvedHandlesMap() {
493
- return this.resolvedByNode;
864
+ /**
865
+ * Iterate over resolved handles safely
866
+ */
867
+ forEachResolvedHandles(callback) {
868
+ for (const [nodeId, handles] of this.resolvedByNode.entries()) {
869
+ callback(handles, nodeId);
870
+ }
494
871
  }
872
+ // ==================== Utility Methods ====================
495
873
  /**
496
874
  * Check if all inbound edges for a node have values
497
875
  */
@@ -508,7 +886,10 @@ class Graph {
508
886
  }
509
887
  return true;
510
888
  }
511
- // Clear all data
889
+ // ==================== Clear Operations ====================
890
+ /**
891
+ * Clear all data
892
+ */
512
893
  clear() {
513
894
  this.nodes.clear();
514
895
  this.edges = [];
@@ -648,9 +1029,9 @@ class RunContextManager {
648
1029
  return; // Still has pending work
649
1030
  }
650
1031
  // Clean up activeRunContexts from all nodes
651
- for (const node of this.graph.getNodes().values()) {
652
- node.activeRunContextIds.delete(id);
653
- }
1032
+ this.graph.forEachNode((node) => {
1033
+ this.graph.removeNodeRunContextId(node.nodeId, id);
1034
+ });
654
1035
  this.runContexts.delete(id);
655
1036
  if (ctx.resolve)
656
1037
  ctx.resolve();
@@ -673,7 +1054,7 @@ class RunContextManager {
673
1054
  if (visited.has(cur))
674
1055
  continue;
675
1056
  visited.add(cur);
676
- for (const e of this.graph.getEdges()) {
1057
+ this.graph.forEachEdge((e) => {
677
1058
  if (e.source.nodeId === cur) {
678
1059
  const targetId = e.target.nodeId;
679
1060
  if (!visited.has(targetId)) {
@@ -681,7 +1062,7 @@ class RunContextManager {
681
1062
  queue.push(targetId);
682
1063
  }
683
1064
  }
684
- }
1065
+ });
685
1066
  }
686
1067
  }
687
1068
  // Mark nodes as cancelled in all run-contexts
@@ -692,9 +1073,7 @@ class RunContextManager {
692
1073
  }
693
1074
  // Clear activeRunContexts for cancelled nodes
694
1075
  for (const id of toCancel) {
695
- const node = this.graph.getNode(id);
696
- if (node)
697
- node.activeRunContextIds.clear();
1076
+ this.graph.clearNodeRunContextIds(id);
698
1077
  }
699
1078
  }
700
1079
  /**
@@ -972,13 +1351,14 @@ class HandleResolver {
972
1351
  if (!node)
973
1352
  return;
974
1353
  // Track resolver start for all active run-contexts
975
- if (node.activeRunContextIds && node.activeRunContextIds.size > 0) {
976
- for (const runContextId of node.activeRunContextIds) {
1354
+ const activeRunContextIds = this.graph.getNodeRunContextIds(nodeId);
1355
+ if (activeRunContextIds.size > 0) {
1356
+ for (const runContextId of activeRunContextIds) {
977
1357
  this.runContextManager.startHandleResolution(runContextId, nodeId);
978
1358
  }
979
1359
  }
980
1360
  setTimeout(() => {
981
- void this.recomputeHandlesForNode(nodeId, node.activeRunContextIds);
1361
+ void this.recomputeHandlesForNode(nodeId, activeRunContextIds.size > 0 ? activeRunContextIds : undefined);
982
1362
  }, 0);
983
1363
  }
984
1364
  // Update resolved handles for a single node and refresh edge converters/types that touch it
@@ -989,30 +1369,33 @@ class HandleResolver {
989
1369
  if (!node)
990
1370
  return;
991
1371
  this.graph.setResolvedHandles(nodeId, handles);
992
- const edges = this.graph.getEdges();
993
- const resolvedByNode = this.graph.getResolvedHandlesMap();
994
- for (const e of edges) {
1372
+ const resolvedByNode = new Map();
1373
+ this.graph.forEachResolvedHandles((handles, nodeId) => {
1374
+ resolvedByNode.set(nodeId, handles);
1375
+ });
1376
+ const registry = this.registry; // Store for use in callback
1377
+ this.graph.forEachEdge((e, _index) => {
995
1378
  // Only update edges that touch the changed node
996
1379
  const touchesChangedNode = e.source.nodeId === nodeId || e.target.nodeId === nodeId;
997
1380
  if (!touchesChangedNode)
998
- continue;
1381
+ return;
999
1382
  const srcNode = this.graph.getNode(e.source.nodeId);
1000
1383
  const dstNode = this.graph.getNode(e.target.nodeId);
1001
1384
  const oldDstDeclared = e.dstDeclared;
1002
1385
  // Extract edge types using shared helper (handles both source and target updates)
1003
1386
  const { srcDeclared, dstDeclared, effectiveTypeId } = extractEdgeTypes(e.source.nodeId, e.source.handle, e.target.nodeId, e.target.handle, resolvedByNode, e.typeId);
1004
- // Update edge properties
1005
- if (!e.typeId) {
1006
- e.effectiveTypeId = effectiveTypeId;
1007
- }
1008
- e.dstDeclared = dstDeclared;
1009
- e.srcUnionTypes = Array.isArray(srcDeclared)
1010
- ? [...srcDeclared]
1011
- : undefined;
1012
1387
  // Update converters
1013
- const conv = buildEdgeConverters(srcDeclared, dstDeclared, this.registry, `updateNodeHandles: ${srcNode?.typeId || ""}.${e.source.nodeId}.${e.source.handle} -> ${dstNode?.typeId || ""}.${e.target.nodeId}.${e.target.handle}`);
1014
- e.convert = conv.convert;
1015
- e.convertAsync = conv.convertAsync;
1388
+ const conv = buildEdgeConverters(srcDeclared, dstDeclared, registry, `updateNodeHandles: ${srcNode?.typeId || ""}.${e.source.nodeId}.${e.source.handle} -> ${dstNode?.typeId || ""}.${e.target.nodeId}.${e.target.handle}`);
1389
+ // Update edge properties via Graph
1390
+ this.graph.updateEdgeProperties(e.id, {
1391
+ effectiveTypeId: !e.typeId ? effectiveTypeId : undefined,
1392
+ dstDeclared,
1393
+ srcUnionTypes: Array.isArray(srcDeclared)
1394
+ ? [...srcDeclared]
1395
+ : undefined,
1396
+ convert: conv.convert,
1397
+ convertAsync: conv.convertAsync,
1398
+ });
1016
1399
  if (e.target.nodeId === nodeId &&
1017
1400
  oldDstDeclared === undefined &&
1018
1401
  dstDeclared !== undefined) {
@@ -1020,11 +1403,12 @@ class HandleResolver {
1020
1403
  if (srcNode) {
1021
1404
  const srcValue = srcNode.outputs[e.source.handle];
1022
1405
  if (srcValue !== undefined) {
1023
- this.edgePropagator.propagate(e.source.nodeId, e.source.handle, srcValue, srcNode.activeRunContextIds);
1406
+ const activeRunContextIds = this.graph.getNodeRunContextIds(e.source.nodeId);
1407
+ this.edgePropagator.propagate(e.source.nodeId, e.source.handle, srcValue, activeRunContextIds.size > 0 ? activeRunContextIds : undefined);
1024
1408
  }
1025
1409
  }
1026
1410
  }
1027
- }
1411
+ });
1028
1412
  this.edgePropagator.invalidateDownstream(nodeId);
1029
1413
  }
1030
1414
  /**
@@ -1163,12 +1547,11 @@ class EdgePropagator {
1163
1547
  * Set source output value and emit event
1164
1548
  */
1165
1549
  setSourceOutput(srcNodeId, srcHandle, value) {
1166
- const srcNode = this.graph.getNode(srcNodeId);
1167
- if (!srcNode) {
1550
+ if (!this.graph.hasNode(srcNodeId)) {
1168
1551
  // Node was removed (e.g., graph updated) but an async emit arrived late; ignore
1169
1552
  return false;
1170
1553
  }
1171
- srcNode.outputs[srcHandle] = value;
1554
+ this.graph.updateNodeOutput(srcNodeId, srcHandle, value);
1172
1555
  this.eventEmitter.emit("value", {
1173
1556
  nodeId: srcNodeId,
1174
1557
  handle: srcHandle,
@@ -1182,8 +1565,7 @@ class EdgePropagator {
1182
1565
  * Find all outgoing edges from a source node handle
1183
1566
  */
1184
1567
  findOutgoingEdges(srcNodeId, srcHandle) {
1185
- const edges = this.graph.getEdges();
1186
- return edges.filter((e) => e.source.nodeId === srcNodeId && e.source.handle === srcHandle);
1568
+ return this.graph.getEdgesBySource(srcNodeId, srcHandle);
1187
1569
  }
1188
1570
  /**
1189
1571
  * Propagate value to a single edge
@@ -1282,9 +1664,12 @@ class EdgePropagator {
1282
1664
  });
1283
1665
  const controller = new AbortController();
1284
1666
  const startAt = Date.now();
1285
- edge.stats.runs += 1;
1286
- edge.stats.inFlight = true;
1287
- edge.stats.progress = 0;
1667
+ const currentStats = edge.stats;
1668
+ this.graph.updateEdgeStats(edge.id, {
1669
+ runs: currentStats.runs + 1,
1670
+ inFlight: true,
1671
+ progress: 0,
1672
+ });
1288
1673
  edge
1289
1674
  .convertAsync(value, controller.signal)
1290
1675
  .then((converted) => {
@@ -1340,10 +1725,7 @@ class EdgePropagator {
1340
1725
  else if (shouldSetValue && !valueChanged) {
1341
1726
  // Even if value didn't change, update timestamp if we're forcing execution
1342
1727
  const now = Date.now();
1343
- if (!dstNode.lastInputAt) {
1344
- dstNode.lastInputAt = {};
1345
- }
1346
- dstNode.lastInputAt[edge.target.handle] = now;
1728
+ this.graph.updateNodeLastInputAt(edge.target.nodeId, edge.target.handle, now);
1347
1729
  }
1348
1730
  // Schedule downstream execution
1349
1731
  this.executeDownstream(edge.target.nodeId, effectiveRunContexts);
@@ -1369,15 +1751,12 @@ class EdgePropagator {
1369
1751
  }
1370
1752
  forHandle.set(edge.id, toArray(value));
1371
1753
  // Merge all parts for this handle
1372
- const edges = this.graph.getEdges();
1754
+ const targetEdges = this.graph.getEdgesByTarget(edge.target.nodeId, edge.target.handle);
1373
1755
  const merged = [];
1374
- for (const ed of edges) {
1375
- if (ed.target.nodeId === edge.target.nodeId &&
1376
- ed.target.handle === edge.target.handle) {
1377
- const part = forHandle.get(ed.id);
1378
- if (part && part.length)
1379
- merged.push(...part);
1380
- }
1756
+ for (const ed of targetEdges) {
1757
+ const part = forHandle.get(ed.id);
1758
+ if (part && part.length)
1759
+ merged.push(...part);
1381
1760
  }
1382
1761
  return merged;
1383
1762
  }
@@ -1402,12 +1781,8 @@ class EdgePropagator {
1402
1781
  */
1403
1782
  setTargetInput(edge, dstNode, value) {
1404
1783
  const now = Date.now();
1405
- dstNode.inputs[edge.target.handle] = value;
1406
- // Track when this input was set
1407
- if (!dstNode.lastInputAt) {
1408
- dstNode.lastInputAt = {};
1409
- }
1410
- dstNode.lastInputAt[edge.target.handle] = now;
1784
+ this.graph.updateNodeInput(edge.target.nodeId, edge.target.handle, value);
1785
+ this.graph.updateNodeLastInputAt(edge.target.nodeId, edge.target.handle, now);
1411
1786
  this.eventEmitter.emit("value", {
1412
1787
  nodeId: edge.target.nodeId,
1413
1788
  handle: edge.target.handle,
@@ -1447,9 +1822,13 @@ class EdgePropagator {
1447
1822
  * Update edge stats on successful conversion
1448
1823
  */
1449
1824
  updateEdgeStatsOnSuccess(edge, startAt) {
1450
- edge.stats.inFlight = false;
1451
1825
  const duration = Date.now() - startAt;
1452
- edge.stats.lastDurationMs = duration;
1826
+ this.graph.updateEdgeStats(edge.id, {
1827
+ inFlight: false,
1828
+ lastDurationMs: duration,
1829
+ lastEndAt: Date.now(),
1830
+ lastError: undefined,
1831
+ });
1453
1832
  this.eventEmitter.emit("stats", {
1454
1833
  kind: "edge-done",
1455
1834
  edgeId: edge.id,
@@ -1458,15 +1837,15 @@ class EdgePropagator {
1458
1837
  target: { nodeId: edge.target.nodeId, handle: edge.target.handle },
1459
1838
  durationMs: duration,
1460
1839
  });
1461
- edge.stats.lastEndAt = Date.now();
1462
- edge.stats.lastError = undefined;
1463
1840
  }
1464
1841
  /**
1465
1842
  * Handle edge conversion error
1466
1843
  */
1467
1844
  handleEdgeConversionError(edge, err) {
1468
- edge.stats.inFlight = false;
1469
- edge.stats.lastError = err;
1845
+ this.graph.updateEdgeStats(edge.id, {
1846
+ inFlight: false,
1847
+ lastError: err,
1848
+ });
1470
1849
  this.eventEmitter.emit("error", {
1471
1850
  kind: "edge-convert",
1472
1851
  edgeId: edge.id,
@@ -1499,10 +1878,11 @@ class EdgePropagator {
1499
1878
  ? new Set(Object.keys(resolved.outputs))
1500
1879
  : new Set();
1501
1880
  // Use node's activeRunContexts to propagate to new nodes that were added
1881
+ const activeRunContextIds = this.graph.getNodeRunContextIds(nodeId);
1502
1882
  for (const [handle, value] of Object.entries(node.outputs)) {
1503
1883
  // Only re-emit if this handle is still valid
1504
1884
  if (validOutputHandles.has(handle)) {
1505
- this.propagate(nodeId, handle, value, node.activeRunContextIds);
1885
+ this.propagate(nodeId, handle, value, activeRunContextIds.size > 0 ? activeRunContextIds : undefined);
1506
1886
  }
1507
1887
  }
1508
1888
  }
@@ -1563,10 +1943,8 @@ class NodeExecutor {
1563
1943
  // Start with real inputs only (no defaults)
1564
1944
  const effective = { ...node.inputs };
1565
1945
  // Build set of inbound handles (wired inputs)
1566
- const edges = this.graph.getEdges();
1567
- const inbound = new Set(edges
1568
- .filter((e) => e.target.nodeId === nodeId)
1569
- .map((e) => e.target.handle));
1946
+ const inboundEdges = this.graph.getInboundEdges(nodeId);
1947
+ const inbound = new Set(inboundEdges.map((e) => e.target.handle));
1570
1948
  // Apply defaults only for:
1571
1949
  // 1. Unbound handles that have no explicit value
1572
1950
  // 2. Static handles (not dynamically resolved)
@@ -1594,7 +1972,9 @@ class NodeExecutor {
1594
1972
  });
1595
1973
  const reportProgress = options?.reportProgress ??
1596
1974
  ((p) => {
1597
- node.stats.progress = Math.max(0, Math.min(1, Number(p) || 0));
1975
+ this.graph.updateNodeStats(nodeId, {
1976
+ progress: Math.max(0, Math.min(1, Number(p) || 0)),
1977
+ });
1598
1978
  });
1599
1979
  // Create log function that respects node's logLevel
1600
1980
  const log = (level, message, context) => {
@@ -1628,7 +2008,7 @@ class NodeExecutor {
1628
2008
  return {
1629
2009
  nodeId,
1630
2010
  state: node.state,
1631
- setState: (next) => Object.assign(node.state, next),
2011
+ setState: (next) => this.graph.updateNodeState(nodeId, next),
1632
2012
  emit: emitHandler,
1633
2013
  invalidateDownstream: () => {
1634
2014
  this.edgePropagator.invalidateDownstream(nodeId);
@@ -1688,10 +2068,12 @@ class NodeExecutor {
1688
2068
  if (this.runtime.isPaused())
1689
2069
  return;
1690
2070
  // Attach run-context IDs if provided
1691
- this.attachRunContexts(node, runContextIds);
2071
+ if (runContextIds) {
2072
+ this.graph.addNodeRunContextIds(nodeId, runContextIds);
2073
+ }
1692
2074
  // Handle debouncing
1693
2075
  const now = Date.now();
1694
- if (this.shouldDebounce(node, now)) {
2076
+ if (this.shouldDebounce(nodeId, node, now)) {
1695
2077
  this.handleDebouncedSchedule(node, nodeId, now);
1696
2078
  return;
1697
2079
  }
@@ -1700,44 +2082,35 @@ class NodeExecutor {
1700
2082
  // Route to appropriate concurrency handler
1701
2083
  this.routeToConcurrencyHandler(node, nodeId, executionPlan);
1702
2084
  }
1703
- /**
1704
- * Attach run-context IDs to the node
1705
- */
1706
- attachRunContexts(node, runContextIds) {
1707
- if (!runContextIds)
1708
- return;
1709
- node.activeRunContextIds = new Set([
1710
- ...node.activeRunContextIds,
1711
- ...runContextIds,
1712
- ]);
1713
- }
1714
2085
  /**
1715
2086
  * Check if execution should be debounced
1716
2087
  */
1717
- shouldDebounce(node, now) {
2088
+ shouldDebounce(nodeId, node, now) {
1718
2089
  const policy = node.policy ?? {};
2090
+ const lastScheduledAt = node.lastScheduledAt;
1719
2091
  return !!(policy.debounceMs &&
1720
- node.lastScheduledAt &&
1721
- now - node.lastScheduledAt < policy.debounceMs);
2092
+ lastScheduledAt &&
2093
+ now - lastScheduledAt < policy.debounceMs);
1722
2094
  }
1723
2095
  /**
1724
2096
  * Handle debounced scheduling by replacing the latest queued item
1725
2097
  */
1726
2098
  handleDebouncedSchedule(node, nodeId, now) {
1727
2099
  const effectiveInputs = this.getEffectiveInputs(nodeId);
1728
- node.queue.splice(0, node.queue.length);
1729
- node.runSeq += 1;
1730
- const rid = `${nodeId}:${node.runSeq}:${now}`;
1731
- node.queue.push({ runId: rid, inputs: effectiveInputs });
2100
+ const runSeq = this.graph.incrementNodeRunSeq(nodeId);
2101
+ const rid = `${nodeId}:${runSeq}:${now}`;
2102
+ this.graph.replaceNodeQueue(nodeId, [
2103
+ { runId: rid, inputs: effectiveInputs },
2104
+ ]);
1732
2105
  }
1733
2106
  /**
1734
2107
  * Prepare execution plan with all necessary information
1735
2108
  */
1736
2109
  prepareExecutionPlan(node, nodeId, runContextIds, now) {
1737
- node.lastScheduledAt = now;
1738
- node.runSeq += 1;
1739
- const runId = `${nodeId}:${node.runSeq}:${now}`;
1740
- node.latestRunId = runId;
2110
+ this.graph.setNodeLastScheduledAt(nodeId, now);
2111
+ const runSeq = this.graph.incrementNodeRunSeq(nodeId);
2112
+ const runId = `${nodeId}:${runSeq}:${now}`;
2113
+ this.graph.setNodeLatestRunId(nodeId, runId);
1741
2114
  const effectiveInputs = this.getEffectiveInputs(nodeId);
1742
2115
  // Take a shallow snapshot of the current policy for this run
1743
2116
  const policySnapshot = node.policy ? { ...node.policy } : undefined;
@@ -1784,9 +2157,16 @@ class NodeExecutor {
1784
2157
  */
1785
2158
  handleQueueMode(node, nodeId, plan) {
1786
2159
  const maxQ = plan.policy?.maxQueue ?? 8;
1787
- node.queue.push({ runId: plan.runId, inputs: plan.effectiveInputs });
1788
- if (node.queue.length > maxQ)
1789
- node.queue.shift();
2160
+ const currentNode = this.graph.getNode(nodeId);
2161
+ if (!currentNode)
2162
+ return;
2163
+ this.graph.addToNodeQueue(nodeId, {
2164
+ runId: plan.runId,
2165
+ inputs: plan.effectiveInputs,
2166
+ });
2167
+ if (currentNode.queue.length > maxQ) {
2168
+ this.graph.shiftNodeQueue(nodeId);
2169
+ }
1790
2170
  this.processQueue(node, nodeId);
1791
2171
  }
1792
2172
  /**
@@ -1794,17 +2174,21 @@ class NodeExecutor {
1794
2174
  */
1795
2175
  processQueue(node, nodeId) {
1796
2176
  const processNext = () => {
2177
+ const node = this.graph.getNode(nodeId);
2178
+ if (!node)
2179
+ return;
1797
2180
  if (node.activeControllers.size > 0)
1798
2181
  return;
1799
- const next = node.queue.shift();
2182
+ const next = this.graph.shiftNodeQueue(nodeId);
1800
2183
  if (!next)
1801
2184
  return;
1802
- node.latestRunId = next.runId;
2185
+ this.graph.setNodeLatestRunId(nodeId, next.runId);
1803
2186
  const policySnapshot = node.policy ? { ...node.policy } : undefined;
2187
+ const activeRunContextIds = this.graph.getNodeRunContextIds(nodeId);
1804
2188
  const plan = {
1805
2189
  runId: next.runId,
1806
2190
  effectiveInputs: next.inputs,
1807
- runContextIdsForRun: node.activeRunContextIds,
2191
+ runContextIdsForRun: activeRunContextIds.size > 0 ? activeRunContextIds : undefined,
1808
2192
  timestamp: Date.now(),
1809
2193
  policy: policySnapshot,
1810
2194
  };
@@ -1821,9 +2205,9 @@ class NodeExecutor {
1821
2205
  // Track run-contexts
1822
2206
  this.trackRunContextStart(nodeId, plan.runContextIdsForRun);
1823
2207
  // Setup execution controller
1824
- const controller = this.createExecutionController(node, plan.runId);
2208
+ const controller = this.createExecutionController(nodeId, node, plan.runId);
1825
2209
  // Handle concurrency mode
1826
- this.applyConcurrencyMode(node, controller, plan);
2210
+ this.applyConcurrencyMode(nodeId, node, controller, plan);
1827
2211
  // Setup timeout if needed
1828
2212
  const timeoutId = this.setupTimeout(node, controller, plan);
1829
2213
  // Create execution context
@@ -1844,23 +2228,26 @@ class NodeExecutor {
1844
2228
  /**
1845
2229
  * Create execution controller and update node stats
1846
2230
  */
1847
- createExecutionController(node, runId) {
2231
+ createExecutionController(nodeId, node, runId) {
1848
2232
  const controller = new AbortController();
1849
- node.stats.runs += 1;
1850
- node.stats.active += 1;
1851
- node.stats.lastStartAt = Date.now();
1852
- node.stats.progress = 0;
1853
- node.activeControllers.add(controller);
1854
- node.controllerRunIds.set(controller, runId);
2233
+ const now = Date.now();
2234
+ this.graph.updateNodeStats(nodeId, {
2235
+ runs: node.stats.runs + 1,
2236
+ active: node.stats.active + 1,
2237
+ lastStartAt: now,
2238
+ progress: 0,
2239
+ });
2240
+ this.graph.addNodeController(nodeId, controller, runId);
1855
2241
  return controller;
1856
2242
  }
1857
2243
  /**
1858
2244
  * Apply concurrency mode (switch mode aborts other controllers)
1859
2245
  */
1860
- applyConcurrencyMode(node, controller, plan) {
2246
+ applyConcurrencyMode(nodeId, node, controller, plan) {
1861
2247
  const mode = plan.policy?.asyncConcurrency ?? "switch";
1862
2248
  if (mode === "switch") {
1863
- for (const c of Array.from(node.activeControllers)) {
2249
+ const controllers = this.graph.getNodeControllers(nodeId);
2250
+ for (const c of controllers) {
1864
2251
  if (c !== controller)
1865
2252
  c.abort("switch");
1866
2253
  }
@@ -1883,6 +2270,9 @@ class NodeExecutor {
1883
2270
  const policy = plan.policy ?? {};
1884
2271
  return {
1885
2272
  emitHandler: (handle, value) => {
2273
+ const node = this.graph.getNode(nodeId);
2274
+ if (!node)
2275
+ return;
1886
2276
  const m = policy.asyncConcurrency ?? "switch";
1887
2277
  // Drop emits from runs that were explicitly cancelled due to a
1888
2278
  // snapshot/undo/redo operation, regardless of asyncConcurrency.
@@ -1893,13 +2283,17 @@ class NodeExecutor {
1893
2283
  this.edgePropagator.propagate(nodeId, handle, value, plan.runContextIdsForRun);
1894
2284
  },
1895
2285
  reportProgress: (p) => {
1896
- node.stats.progress = Math.max(0, Math.min(1, Number(p) || 0));
2286
+ const progress = Math.max(0, Math.min(1, Number(p) || 0));
2287
+ this.graph.updateNodeStats(nodeId, { progress });
2288
+ const node = this.graph.getNode(nodeId);
2289
+ if (!node)
2290
+ return;
1897
2291
  this.eventEmitter.emit("stats", {
1898
2292
  kind: "node-progress",
1899
2293
  nodeId,
1900
2294
  typeId: node.typeId,
1901
2295
  runId: plan.runId,
1902
- progress: node.stats.progress,
2296
+ progress,
1903
2297
  });
1904
2298
  },
1905
2299
  };
@@ -1938,7 +2332,7 @@ class NodeExecutor {
1938
2332
  }
1939
2333
  }
1940
2334
  hadError = true;
1941
- node.stats.lastError = err;
2335
+ this.graph.updateNodeStats(nodeId, { lastError: err });
1942
2336
  const retry = plan.policy?.retry;
1943
2337
  if (retry && attempt < (retry.attempts ?? 0)) {
1944
2338
  const delay = retry.backoffMs ? retry.backoffMs(attempt) : 0;
@@ -1974,38 +2368,53 @@ class NodeExecutor {
1974
2368
  }
1975
2369
  if (timeoutId)
1976
2370
  clearTimeout(timeoutId);
1977
- node.activeControllers.delete(controller);
1978
- node.controllerRunIds.delete(controller);
1979
- node.stats.active = Math.max(0, node.activeControllers.size);
1980
- node.stats.lastEndAt = Date.now();
1981
- node.stats.lastDurationMs =
1982
- node.stats.lastStartAt && node.stats.lastEndAt
1983
- ? node.stats.lastEndAt - node.stats.lastStartAt
1984
- : undefined;
1985
- if (!hadError)
1986
- node.stats.lastError = undefined;
2371
+ this.graph.removeNodeController(nodeId, controller);
2372
+ const currentNode = this.graph.getNode(nodeId);
2373
+ if (!currentNode)
2374
+ return;
2375
+ const controllers = this.graph.getNodeControllers(nodeId);
2376
+ const lastEndAt = Date.now();
2377
+ const lastDurationMs = currentNode.stats.lastStartAt && lastEndAt
2378
+ ? lastEndAt - currentNode.stats.lastStartAt
2379
+ : undefined;
2380
+ this.graph.updateNodeStats(nodeId, {
2381
+ active: Math.max(0, controllers.size),
2382
+ lastEndAt,
2383
+ lastDurationMs,
2384
+ lastError: hadError ? currentNode.stats.lastError : undefined,
2385
+ });
1987
2386
  // Track successful completion time (for detecting stale inputs)
1988
2387
  const isCancelled = controller.signal.aborted &&
1989
2388
  (controller.signal.reason === "snapshot" ||
1990
2389
  controller.signal.reason === "node-deleted" ||
1991
2390
  controller.signal.reason === "user-cancelled");
1992
2391
  if (!hadError && !isCancelled) {
1993
- node.lastSuccessAt = Date.now();
2392
+ this.graph.setNodeLastSuccessAt(nodeId, Date.now());
1994
2393
  }
1995
2394
  // Only emit node-done if not cancelled (cancellation events emitted separately)
1996
2395
  if (!isCancelled) {
1997
- this.eventEmitter.emit("stats", {
1998
- kind: "node-done",
1999
- nodeId,
2000
- typeId: node.typeId,
2001
- runId: plan.runId,
2002
- durationMs: node.stats.lastDurationMs,
2396
+ if (currentNode) {
2397
+ this.eventEmitter.emit("stats", {
2398
+ kind: "node-done",
2399
+ nodeId,
2400
+ typeId: currentNode.typeId,
2401
+ runId: plan.runId,
2402
+ durationMs: currentNode.stats.lastDurationMs,
2403
+ });
2404
+ }
2405
+ }
2406
+ if (currentNode) {
2407
+ ctx.log("debug", "node-done", {
2408
+ durationMs: currentNode.stats.lastDurationMs,
2409
+ hadError,
2410
+ inputs: currentNode.inputs
2411
+ ? structuredClone(currentNode.inputs)
2412
+ : undefined,
2413
+ outputs: currentNode.outputs
2414
+ ? structuredClone(currentNode.outputs)
2415
+ : undefined,
2003
2416
  });
2004
2417
  }
2005
- ctx.log("debug", "node-done", {
2006
- durationMs: node.stats.lastDurationMs,
2007
- hadError,
2008
- });
2009
2418
  if (onDone)
2010
2419
  onDone();
2011
2420
  }
@@ -2013,22 +2422,24 @@ class NodeExecutor {
2013
2422
  * Cancel all active runs for a node
2014
2423
  */
2015
2424
  cancelNodeActiveRuns(node, reason) {
2016
- for (const controller of Array.from(node.activeControllers)) {
2017
- const runId = node.controllerRunIds.get(controller);
2425
+ const nodeId = node.nodeId;
2426
+ const controllers = this.graph.getNodeControllers(nodeId);
2427
+ for (const controller of controllers) {
2428
+ const currentNode = this.graph.getNode(nodeId);
2429
+ if (!currentNode)
2430
+ continue;
2431
+ const runId = currentNode.controllerRunIds.get(controller);
2018
2432
  if (runId) {
2019
2433
  // Track cancelled runIds for snapshot and user-cancelled operations
2020
2434
  // (to drop emits from cancelled runs)
2021
2435
  if (reason === "snapshot" || reason === "user-cancelled") {
2022
- if (!node.snapshotCancelledRunIds) {
2023
- node.snapshotCancelledRunIds = new Set();
2024
- }
2025
- node.snapshotCancelledRunIds.add(runId);
2436
+ this.graph.addSnapshotCancelledRunId(nodeId, runId);
2026
2437
  }
2027
2438
  // Emit cancellation event
2028
2439
  this.eventEmitter.emit("stats", {
2029
2440
  kind: "node-done",
2030
- nodeId: node.nodeId,
2031
- typeId: node.typeId,
2441
+ nodeId: currentNode.nodeId,
2442
+ typeId: currentNode.typeId,
2032
2443
  runId,
2033
2444
  cancelled: true,
2034
2445
  });
@@ -2040,10 +2451,9 @@ class NodeExecutor {
2040
2451
  // ignore abort errors
2041
2452
  }
2042
2453
  }
2043
- node.activeControllers.clear();
2044
- node.controllerRunIds.clear();
2045
- node.stats.active = 0;
2046
- node.queue = [];
2454
+ this.graph.clearNodeControllers(nodeId);
2455
+ this.graph.updateNodeStats(nodeId, { active: 0 });
2456
+ this.graph.clearNodeQueue(nodeId);
2047
2457
  }
2048
2458
  /**
2049
2459
  * Cancel runs for multiple nodes.
@@ -2055,14 +2465,13 @@ class NodeExecutor {
2055
2465
  const toCancel = new Set(nodeIds);
2056
2466
  const visited = new Set();
2057
2467
  const queue = [...nodeIds];
2058
- const edges = this.graph.getEdges();
2059
2468
  // Collect all downstream nodes to cancel
2060
2469
  for (let i = 0; i < queue.length; i++) {
2061
2470
  const nodeId = queue[i];
2062
2471
  if (visited.has(nodeId))
2063
2472
  continue;
2064
2473
  visited.add(nodeId);
2065
- for (const edge of edges) {
2474
+ this.graph.forEachEdge((edge) => {
2066
2475
  if (edge.source.nodeId === nodeId) {
2067
2476
  const targetId = edge.target.nodeId;
2068
2477
  if (!visited.has(targetId)) {
@@ -2070,7 +2479,7 @@ class NodeExecutor {
2070
2479
  queue.push(targetId);
2071
2480
  }
2072
2481
  }
2073
- }
2482
+ });
2074
2483
  }
2075
2484
  // Cancel runs for all affected nodes
2076
2485
  for (const nodeId of toCancel) {
@@ -2078,10 +2487,10 @@ class NodeExecutor {
2078
2487
  if (!node)
2079
2488
  continue;
2080
2489
  this.cancelNodeActiveRuns(node, reason);
2081
- node.runSeq += 1;
2490
+ const runSeq = this.graph.incrementNodeRunSeq(nodeId);
2082
2491
  const now = Date.now();
2083
2492
  const suffix = reason === "snapshot" ? "snapshot" : "cancelled";
2084
- node.latestRunId = `${nodeId}:${node.runSeq}:${now}:${suffix}`;
2493
+ this.graph.setNodeLatestRunId(nodeId, `${nodeId}:${runSeq}:${now}:${suffix}`);
2085
2494
  }
2086
2495
  // Cancel nodes in run-contexts (exclude them from active run-contexts)
2087
2496
  for (const nodeId of toCancel) {
@@ -2166,7 +2575,11 @@ class GraphRuntime {
2166
2575
  gr.graph.setNode(n.nodeId, rn);
2167
2576
  }
2168
2577
  // Instantiate edges
2169
- const edges = buildEdges(def, registry, gr.graph.getResolvedHandlesMap());
2578
+ const resolvedByNode = new Map();
2579
+ gr.graph.forEachResolvedHandles((handles, nodeId) => {
2580
+ resolvedByNode.set(nodeId, handles);
2581
+ });
2582
+ const edges = buildEdges(def, registry, resolvedByNode);
2170
2583
  gr.graph.setEdges(edges);
2171
2584
  // Schedule async recompute only for nodes that indicated Promise-based resolveHandles
2172
2585
  for (const nodeId of initial.pending) {
@@ -2182,10 +2595,14 @@ class GraphRuntime {
2182
2595
  if (!node)
2183
2596
  throw new Error(`Node not found: ${nodeId}`);
2184
2597
  let anyChanged = false;
2185
- const edges = this.graph.getEdges();
2186
2598
  const registry = this.graph.getRegistry();
2187
2599
  for (const [handle, value] of Object.entries(inputs)) {
2188
- const hasInbound = edges.some((e) => e.target.nodeId === nodeId && e.target.handle === handle);
2600
+ let hasInbound = false;
2601
+ this.graph.forEachEdge((e) => {
2602
+ if (e.target.nodeId === nodeId && e.target.handle === handle) {
2603
+ hasInbound = true;
2604
+ }
2605
+ });
2189
2606
  if (hasInbound)
2190
2607
  continue;
2191
2608
  // Validate input value against declared type
@@ -2219,12 +2636,7 @@ class GraphRuntime {
2219
2636
  const prev = node.inputs[handle];
2220
2637
  const same = valuesEqual(prev, value);
2221
2638
  if (!same) {
2222
- if (value === undefined) {
2223
- delete node.inputs[handle];
2224
- }
2225
- else {
2226
- node.inputs[handle] = value;
2227
- }
2639
+ this.graph.updateNodeInput(nodeId, handle, value);
2228
2640
  // Emit value event for input updates
2229
2641
  this.eventEmitter.emit("value", { nodeId, handle, value, io: "input" });
2230
2642
  anyChanged = true;
@@ -2246,7 +2658,7 @@ class GraphRuntime {
2246
2658
  return node?.outputs[output];
2247
2659
  }
2248
2660
  launch(invalidate = false) {
2249
- for (const node of this.graph.getNodes().values()) {
2661
+ this.graph.forEachNode((node) => {
2250
2662
  const effectiveInputs = this.nodeExecutor.getEffectiveInputs(node.nodeId);
2251
2663
  const ctrl = new AbortController();
2252
2664
  const execCtx = this.nodeExecutor.createExecutionContext(node.nodeId, node, effectiveInputs, `${node.nodeId}:init`, ctrl.signal);
@@ -2256,9 +2668,9 @@ class GraphRuntime {
2256
2668
  execCtx.log("debug", "prepare-done");
2257
2669
  }
2258
2670
  node.runtime.onActivated?.();
2259
- }
2671
+ });
2260
2672
  if (this.runMode === "auto" && invalidate) {
2261
- for (const nodeId of this.graph.getNodes().keys()) {
2673
+ for (const nodeId of this.graph.getNodeIds()) {
2262
2674
  if (this.graph.allInboundHaveValue(nodeId))
2263
2675
  this.execute(nodeId);
2264
2676
  }
@@ -2293,7 +2705,7 @@ class GraphRuntime {
2293
2705
  this.nodeExecutor.cancelNodeRuns(nodeIds);
2294
2706
  }
2295
2707
  getNodeIds() {
2296
- return Array.from(this.graph.getNodes().keys());
2708
+ return this.graph.getNodeIds();
2297
2709
  }
2298
2710
  getNodeData(nodeId) {
2299
2711
  const node = this.graph.getNode(nodeId);
@@ -2314,26 +2726,30 @@ class GraphRuntime {
2314
2726
  this.environment = { ...env };
2315
2727
  this.handleResolver.setEnvironment(this.environment);
2316
2728
  this.nodeExecutor.setEnvironment(this.environment);
2317
- for (const nodeId of this.graph.getNodes().keys()) {
2729
+ for (const nodeId of this.graph.getNodeIds()) {
2318
2730
  this.handleResolver.scheduleRecomputeHandles(nodeId);
2319
2731
  }
2320
2732
  }
2321
2733
  getGraphDef() {
2322
- const nodes = Array.from(this.graph.getNodes().values()).map((n) => {
2734
+ const nodes = [];
2735
+ this.graph.forEachNode((n) => {
2323
2736
  const resolved = this.graph.getResolvedHandles(n.nodeId);
2324
- return {
2737
+ nodes.push({
2325
2738
  nodeId: n.nodeId,
2326
2739
  typeId: n.typeId,
2327
2740
  params: n.params ? { ...n.params } : undefined,
2328
2741
  resolvedHandles: resolved ? { ...resolved } : undefined,
2329
- };
2742
+ });
2743
+ });
2744
+ const edges = [];
2745
+ this.graph.forEachEdge((e) => {
2746
+ edges.push({
2747
+ id: e.id,
2748
+ source: { nodeId: e.source.nodeId, handle: e.source.handle },
2749
+ target: { nodeId: e.target.nodeId, handle: e.target.handle },
2750
+ typeId: e.typeId,
2751
+ });
2330
2752
  });
2331
- const edges = this.graph.getEdges().map((e) => ({
2332
- id: e.id,
2333
- source: { nodeId: e.source.nodeId, handle: e.source.handle },
2334
- target: { nodeId: e.target.nodeId, handle: e.target.handle },
2335
- typeId: e.typeId,
2336
- }));
2337
2753
  return { nodes, edges };
2338
2754
  }
2339
2755
  async whenIdle() {
@@ -2352,13 +2768,13 @@ class GraphRuntime {
2352
2768
  });
2353
2769
  }
2354
2770
  const isIdle = () => {
2355
- for (const n of this.graph.getNodes().values()) {
2356
- if (n.activeControllers.size > 0)
2357
- return false;
2358
- if (n.queue.length > 0)
2359
- return false;
2360
- }
2361
- return true;
2771
+ let idle = true;
2772
+ this.graph.forEachNode((n) => {
2773
+ if (n.activeControllers.size > 0 || n.queue.length > 0) {
2774
+ idle = false;
2775
+ }
2776
+ });
2777
+ return idle;
2362
2778
  };
2363
2779
  if (isIdle())
2364
2780
  return;
@@ -2378,7 +2794,7 @@ class GraphRuntime {
2378
2794
  return;
2379
2795
  return new Promise((resolve) => {
2380
2796
  const id = this.runContextManager.createRunContext(startNodeId, resolve, options);
2381
- node.activeRunContextIds.add(id);
2797
+ this.graph.addNodeRunContextId(startNodeId, id);
2382
2798
  this.execute(startNodeId, new Set([id]));
2383
2799
  });
2384
2800
  }
@@ -2412,38 +2828,38 @@ class GraphRuntime {
2412
2828
  try {
2413
2829
  const ins = payload?.inputs || {};
2414
2830
  for (const [nodeId, map] of Object.entries(ins)) {
2415
- const node = this.graph.getNode(nodeId);
2416
- if (!node)
2831
+ if (!this.graph.hasNode(nodeId))
2417
2832
  continue;
2418
2833
  for (const [h, v] of Object.entries(map || {})) {
2419
- node.inputs[h] = structuredClone(v);
2834
+ const clonedValue = structuredClone(v);
2835
+ this.graph.updateNodeInput(nodeId, h, clonedValue);
2420
2836
  this.eventEmitter.emit("value", {
2421
2837
  nodeId,
2422
2838
  handle: h,
2423
- value: node.inputs[h],
2839
+ value: clonedValue,
2424
2840
  io: "input",
2425
- runtimeTypeId: getTypedOutputTypeId(node.inputs[h]),
2841
+ runtimeTypeId: getTypedOutputTypeId(clonedValue),
2426
2842
  });
2427
2843
  }
2428
2844
  }
2429
2845
  const outs = payload?.outputs || {};
2430
2846
  for (const [nodeId, map] of Object.entries(outs)) {
2431
- const node = this.graph.getNode(nodeId);
2432
- if (!node)
2847
+ if (!this.graph.hasNode(nodeId))
2433
2848
  continue;
2434
2849
  for (const [h, v] of Object.entries(map || {})) {
2435
- node.outputs[h] = structuredClone(v);
2850
+ const clonedValue = structuredClone(v);
2851
+ this.graph.updateNodeOutput(nodeId, h, clonedValue);
2436
2852
  this.eventEmitter.emit("value", {
2437
2853
  nodeId,
2438
2854
  handle: h,
2439
- value: node.outputs[h],
2855
+ value: clonedValue,
2440
2856
  io: "output",
2441
- runtimeTypeId: getTypedOutputTypeId(node.outputs[h]),
2857
+ runtimeTypeId: getTypedOutputTypeId(clonedValue),
2442
2858
  });
2443
2859
  }
2444
2860
  }
2445
2861
  if (opts?.invalidate) {
2446
- for (const nodeId of this.graph.getNodes().keys()) {
2862
+ for (const nodeId of this.graph.getNodeIds()) {
2447
2863
  this.invalidateDownstream(nodeId);
2448
2864
  }
2449
2865
  }
@@ -2456,17 +2872,19 @@ class GraphRuntime {
2456
2872
  {
2457
2873
  // Delete nodes that are no longer in the definition
2458
2874
  const afterIds = new Set(def.nodes.map((n) => n.nodeId));
2459
- const beforeIds = new Set(this.graph.getNodes().keys());
2875
+ const beforeIds = new Set(this.graph.getNodeIds());
2460
2876
  for (const nodeId of Array.from(beforeIds)) {
2461
2877
  if (!afterIds.has(nodeId)) {
2462
2878
  const node = this.graph.getNode(nodeId);
2879
+ if (!node)
2880
+ continue;
2463
2881
  this.nodeExecutor.cancelNodeActiveRuns(node, "node-deleted");
2464
2882
  this.runContextManager.cancelNodeInRunContexts(nodeId, true);
2465
2883
  node.runtime.onDeactivated?.();
2466
2884
  node.runtime.dispose?.();
2467
2885
  node.lifecycle?.dispose?.({
2468
2886
  state: node.state,
2469
- setState: (next) => Object.assign(node.state, next),
2887
+ setState: (next) => this.graph.updateNodeState(node.nodeId, next),
2470
2888
  });
2471
2889
  this.graph.deleteNode(nodeId);
2472
2890
  this.edgePropagator.clearArrayBuckets(nodeId);
@@ -2530,43 +2948,43 @@ class GraphRuntime {
2530
2948
  newNode.runtime.onActivated?.();
2531
2949
  }
2532
2950
  else {
2533
- existing.params = n.params;
2951
+ this.graph.updateNodeParams(n.nodeId, n.params);
2534
2952
  // Re-merge policy when params change (params.policy can override descriptor/category policy)
2535
2953
  const desc = registry.nodes.get(existing.typeId);
2536
2954
  const cat = registry.categories.get(desc?.categoryId ?? "");
2537
- existing.policy = {
2955
+ this.graph.updateNodePolicy(n.nodeId, {
2538
2956
  ...cat?.policy,
2539
2957
  ...desc?.policy,
2540
2958
  ...n.params?.policy,
2541
- };
2959
+ });
2960
+ // Initialize stats if missing
2542
2961
  if (!existing.stats) {
2543
- existing.stats = {
2962
+ this.graph.updateNodeStats(n.nodeId, {
2544
2963
  runs: 0,
2545
2964
  active: 0,
2546
2965
  queued: 0,
2547
2966
  progress: 0,
2548
- };
2967
+ });
2549
2968
  }
2550
2969
  }
2551
2970
  }
2552
2971
  }
2553
2972
  {
2554
- const beforeEdges = this.graph.getEdges();
2555
2973
  const beforeInbound = new Map();
2556
- for (const e of beforeEdges) {
2974
+ const beforeOutTargets = new Map();
2975
+ this.graph.forEachEdge((e) => {
2976
+ // Build beforeInbound map
2557
2977
  const set = beforeInbound.get(e.target.nodeId) ?? new Set();
2558
2978
  set.add(e.target.handle);
2559
2979
  beforeInbound.set(e.target.nodeId, set);
2560
- }
2561
- const beforeOutTargets = new Map();
2562
- for (const e of beforeEdges) {
2980
+ // Build beforeOutTargets map
2563
2981
  const tmap = beforeOutTargets.get(e.source.nodeId) ??
2564
2982
  new Map();
2565
2983
  const tset = tmap.get(e.source.handle) ?? new Set();
2566
2984
  tset.add(`${e.target.nodeId}.${e.target.handle}`);
2567
2985
  tmap.set(e.source.handle, tset);
2568
2986
  beforeOutTargets.set(e.source.nodeId, tmap);
2569
- }
2987
+ });
2570
2988
  {
2571
2989
  // Update handles and edges
2572
2990
  const result = tryHandleResolving(def, registry, this.environment);
@@ -2581,7 +2999,11 @@ class GraphRuntime {
2581
2999
  for (const [nodeId, handles] of result.resolved) {
2582
3000
  this.graph.setResolvedHandles(nodeId, handles);
2583
3001
  }
2584
- const afterEdges = buildEdges(def, registry, this.graph.getResolvedHandlesMap());
3002
+ const resolvedByNode = new Map();
3003
+ this.graph.forEachResolvedHandles((handles, nodeId) => {
3004
+ resolvedByNode.set(nodeId, handles);
3005
+ });
3006
+ const afterEdges = buildEdges(def, registry, resolvedByNode);
2585
3007
  this.graph.setEdges(afterEdges);
2586
3008
  for (const nodeId of result.pending) {
2587
3009
  this.handleResolver.scheduleRecomputeHandles(nodeId);
@@ -2596,23 +3018,22 @@ class GraphRuntime {
2596
3018
  {
2597
3019
  // Update inputs and propagate changes
2598
3020
  const afterInbound = new Map();
2599
- const afterEdges = this.graph.getEdges();
2600
- for (const e of afterEdges) {
3021
+ this.graph.forEachEdge((e) => {
2601
3022
  const set = afterInbound.get(e.target.nodeId) ?? new Set();
2602
3023
  set.add(e.target.handle);
2603
3024
  afterInbound.set(e.target.nodeId, set);
2604
- }
3025
+ });
2605
3026
  // Propagate changes on edges removed
2606
3027
  for (const [nodeId, beforeSet] of beforeInbound) {
2607
3028
  const currSet = afterInbound.get(nodeId) ?? new Set();
2608
- const node = this.graph.getNode(nodeId);
2609
- if (!node)
3029
+ if (!this.graph.hasNode(nodeId))
2610
3030
  continue;
2611
3031
  let changed = false;
2612
3032
  for (const handle of Array.from(beforeSet)) {
2613
3033
  if (!currSet.has(handle)) {
2614
- if (handle in node.inputs) {
2615
- delete node.inputs[handle];
3034
+ const node = this.graph.getNode(nodeId);
3035
+ if (node && handle in node.inputs) {
3036
+ this.graph.deleteNodeInput(nodeId, handle);
2616
3037
  changed = true;
2617
3038
  }
2618
3039
  }
@@ -2627,14 +3048,14 @@ class GraphRuntime {
2627
3048
  }
2628
3049
  // Propagate changes on edges added
2629
3050
  const afterOutTargets = new Map();
2630
- for (const e of afterEdges) {
3051
+ this.graph.forEachEdge((e) => {
2631
3052
  const targetMap = afterOutTargets.get(e.source.nodeId) ??
2632
3053
  new Map();
2633
3054
  const targetSet = targetMap.get(e.source.handle) ?? new Set();
2634
3055
  targetSet.add(`${e.target.nodeId}.${e.target.handle}`);
2635
3056
  targetMap.set(e.source.handle, targetSet);
2636
3057
  afterOutTargets.set(e.source.nodeId, targetMap);
2637
- }
3058
+ });
2638
3059
  const setsEqual = (a, b) => {
2639
3060
  if (!a && !b)
2640
3061
  return true;
@@ -2682,14 +3103,14 @@ class GraphRuntime {
2682
3103
  }
2683
3104
  dispose() {
2684
3105
  this.runContextManager.resolveAll();
2685
- for (const node of this.graph.getNodes().values()) {
3106
+ this.graph.forEachNode((node) => {
2686
3107
  node.runtime.onDeactivated?.();
2687
3108
  node.runtime.dispose?.();
2688
3109
  node.lifecycle?.dispose?.({
2689
3110
  state: node.state,
2690
- setState: (next) => Object.assign(node.state, next),
3111
+ setState: (next) => this.graph.updateNodeState(node.nodeId, next),
2691
3112
  });
2692
- }
3113
+ });
2693
3114
  this.graph.clear();
2694
3115
  }
2695
3116
  execute(nodeId, runContextIds) {