fl_nodes_v2 0.3.0
fl_nodes_v2: ^0.3.0 copied to clipboard
A node graph editor for Flutter: pan, zoom, wire and run graphs, with ordinary Flutter widgets inside every node.
0.3.0 #
The first release since 0.1.0. The package carried 0.2.0 while these landed
and stamped it into every document a host wrote — "package": "fl_nodes_v2/0.2.0"
is a graph saved by such a build — but 0.2.0 was never published.
Watching a run #
A listener on the runner that is told everything a run does as it does it,
and a way for an executor to say something into the same stream. GraphRun
already reported afterwards what a run did; a debugger needs to know while it
is happening, and what was on the wires. The package emits and keeps no log —
which lines a trace is made of, and where it goes, is the host's decision, as
the arrangement of a graph is under applyLayout.
NodeEditorRunner.onEvent(GraphRunListener?, null) — handed aGraphRunEventfor every step of every run:RunStarted,NodeStarted,NodeFinished,MemoHit,DiagnosticRaised,LogEmitted,RunFinished. A sealed hierarchy, so a host'sswitchis exhaustive. Synchronous, and fired before the notification that follows it. A listener that throws is reported throughFlutterError.reportErrorand the run goes on.NodeEditorRunner.tracePayloads(bool, false) — whether aGraphTraceValuecarries the value on the wire. Off, a value isWithheldPayloadand the event still says which ports and whichdataTypetag; a wire nothing was written on isAbsentPayload, distinct from aPresentPayloadholding null. Off by default because a value on a wire was produced by a node body the host did not write, and a trace is the thing that gets written to a file.NodeExecutionContext.log(message, {level, data})— lands onGraphRun.log(List<GraphLogEntry>) and reaches the listener asLogEmitted, with the node id and turn filled in. Not gated bytracePayloads: what an executor logs is its author's choice. Throws after the turn is over, likeemit.GraphRun.id(int) — climbs by one per run of a runner; every event of the run carries it, and every event carries itssequencewithin the run andat, read from the run's own stopwatch.GraphRunRecorder— a list and acall, forrunner.onEvent = recorder.call. A fixture, not a sink.
Nothing in an event is derived from a Dart type; dataType is the port's tag,
which survives obfuscation where runtimeType does not. The example prints a
line per event and reports how many it traced. The watching a run group in
runner_test.dart pins the order, the three payload states, the memo hit, the
cancelled turn, the reported listener and the run numbering.
The host's own format version #
NodeGraphCodec.schemaVersion (int?, null) and NodeGraphCodec.schemaMigrations
(Map<int, GraphDocumentMigration>, empty), stamped into a document as
schema and reported on GraphDocument.schemaVersion.
A document already carried version, and it governs the envelope: nodes,
connections, groups, ports — the shape this package owns. It could never govern
what a host means by a node's type or by the keys in its data, because
the package carries both and interprets neither. So a host that renamed a field
had no version to bump and no chain to hang a migration on: upgrade ran
from to target with target always NodeGraphCodec.version, which made a
host-supplied migrations chain unreachable rather than merely unused. The
predecessor had two axes and this is the second one back.
The two move on different clocks, which is the whole point of their being two.
Both gates run on the way in and the format's runs first — its migration
normalises the envelope the host's then walks — and decode refuses a
schema from the future the way it already refused a version, saying
schema rather than format so nobody goes looking on the wrong side of the
boundary. A missing schema reads as legacySchemaVersion (1): every document
written before a host declared an axis, which is all of them the first time it
does. Leaving schemaVersion null keeps the old behaviour exactly — a schema
key is then read, reported and written back untouched rather than gated, since
a build with no opinion about the host's data has no business refusing a
document over it.
GraphDocumentMigrations.upgrade gains label ('format') and key
('version') so a gap in either chain names its own axis. Pinned by the schema versions group in test/serialization_test.dart, including that a document
out of range on both axes reports the format one.
A node's corner can be dragged #
NodePrototype.resizable (bool, false), minWidth (double?, falls back
to defaultWidth, then GraphNode.defaultWidth), maxWidth and
maxHeight (double?, no limit); GraphNode.minHeight (double?, null),
written to the document as minHeight only once set. A prototype that opts
in gets the minimap's grip in its bottom-right corner — CornerGrip, one
drawing for both so a person who has found one knows the other, shown
while the node is hovered or selected. A press there resizes instead of
moving: both axes, snapped to snapToGrid like a position, clamped, and
committed as one undo step.
The width is the node's own. The height is a floor: what resolveHeight
or the content says is the least the node can be, the corner only adds room
below it, and dragged back to the natural height the floor is cleared rather
than left as a number that happens to equal it — so a card that later loses
a row is free to shrink. The box grows; the ports do not move. Every
explicit anchor is a fraction of the declared height rather than of the
box (NodeEditorLayout.anchorSizeOf, beside sizeOf), so a wire lands on
the same row of a card however tall the card has been made. A measured node
is laid out to its floor instead, so for it the two agree. A prototype that
would rather use the room than leave it blank reads the floor in
resolveHeight and answers with a height laid out to it.
Off by default and per prototype rather than per node, because a host whose cards are laid out to one width has to decide that a wider card is still a right one before the editor offers it.
The grip takes no gesture of its own. The node's one pan recogniser decides
what a press meant from where it landed — a second recogniser on the corner
would contest the arena with the one underneath, and which of two pans wins
a press is a rule nobody should have to remember. A MouseRegion on the
corner is only for the cursor. node_resize_test.dart pins the drag on
each axis, the floor, the anchors, the undo, the clamp, the snap, the
opt-in, the cursor and the document.
The minimap's buttons sit against the edge #
The gear and the fold button in the minimap's bar floated a third of the way
in from the right edge, at a distance that changed with the panel's width.
The title was a Flexible beside a Spacer, and the two split the free
width between them: the half the title did not fill sat as a gap before
the spacer. The title is an Expanded now and the spacer is gone, and the
trailing inset matches the grip's 6 on the left. On a 320-wide panel the
gap was 46.75 px; minimap_test.dart pins it under 8 and equal to the
grip's.
A submenu that slides instead of flipping #
NodeSubmenuButton, used by buildMenuChildren for every NodeMenuEntry
with children in place of Material's SubmenuButton. Material lays a
submenu out with the delegate a menu bar uses: a panel that would run off
the bottom of the window is moved to end at the top of its row whenever it
fits there. For a cascade opened at a click point that leaves the panel
entirely above the row that opened it, and the pointer's path up to it
crosses the sibling rows — each of which takes focus on hover and closes the
open child. Near the bottom of the window the three-level Create menu was
unreachable by mouse.
The row is still a MenuItemButton, the linkage is RawMenuAnchor's own —
a click elsewhere, Escape and choosing an entry still close the whole tree —
and the panel is dressed from MenuTheme. What is ours is the placement: to
the right of the row, top-aligned with it, slid up only as far as the window
demands and never off the row's band, flipped to the left only when there is
no room to the right. It opens on the focus a hover brings rather than on
the hover itself, because MenuItemButton reports the hover before it takes
focus and taking focus is what closes the previous row's children — which,
opened a moment earlier, would include this panel. Right on a row walks in,
left inside a panel walks out to the row, and a row nested in a panel hands
left up to that panel rather than walking focus sideways.
submenu_placement_test.dart pins the placement, the hover crossing, the
sibling close, the arrows and the two ways the tree closes.
And the whole cascade opens one way. CascadeSide, decided once by
NodeEditorMenuHostState when the menu opens and read by every panel under
it. Each panel deciding for itself — right when it fits, left when it does
not — had the third level flip to the left of the second when it was the
wider one, so a cascade zig-zagged across the screen and its deepest panel
landed over the root. The side is chosen from the widest chain of
panels the tree could open, estimated from the labels with the menu's own
text style (estimateCascadeWidth, erring wide) since a panel is not laid
out until it opens; a panel that cannot fit on the side it was told still
goes to the other, so nothing ever leaves the screen. Pinned by the cascade
cases in the same test.
The wire stays while the Create menu is up #
With NodeEditorMenus.createOnDrop on, the pending wire was cleared the
moment the Create menu opened at the drop point, so the person saw their wire
vanish and a menu appear — which read as the drop having failed, not as the
next step of it. _handlePortDragEnd now keeps the PendingConnection drawn,
frozen where it was let go, until the menu closes: chosen, dismissed or
clicked away. Only the drag ends at the drop — the source and target are
cleared — so a pointer over the menu cannot go on steering a wire nobody is
holding. NodeEditorMenuHostState.open returns whether a menu actually
opened, which is the cue. drop_to_create_test.dart pins both the holding and
the letting go.
How wide a caption can get #
ConnectionLabel.maxWidth (160) was private and is not any more. A host that
arranges its own graph cannot get the number any other way and needs it: a
caption is drawn at the midpoint of a curve, so the gap a layout leaves between
two ranks has to be at least this wide or every caption lands on a node. It is
a hard cap — the text is one line and ellipsised — so a layout can be derived
from it rather than tuned by eye.
The same argument applyLayout and onMeasured already make: the algorithm
stays out of the package, and the measurements it cannot take for itself come
out.
Pointing at part of a graph #
NodeEditorController.emphasis (NodeEditorEmphasis, empty) — a focus: a
set of nodes and connections that stand clear while everything else is washed
under a scrim. It is what a host needs to answer a question about a graph —
every route between these two nodes, everything this value reaches — without
editing the graph to say so.
GraphEmphasis(nodes,connections,scrim) — a value type. Membership lifts; the value tints. A null tint lifts a node without painting a halo behind it, and lifts a wire without overriding its colour.emphasis.value = …replaces the focus wholesale,clear()drops it, and an equal value is a no-op — a host that recomputes the same answer every tick costs no repaint.emphasis.revisionis the O(1) invalidation key, exactly asselection.revisionis, andemphasis.liftedis a cached snapshot so paint order and hover picking do not allocate.NodeEditorThemegainsscrimColor(null),scrimOpacity(0.62),emphasisInset(7) andemphasisRadius(Radius.circular(14)). A nullscrimColorderives frombackground, so a focus dims towards the canvas in either brightness rather than towards black in one of them.
nodes and connections are two maps rather than one, and that is the
shape the first consumer actually needed: a run of flow that passes through a
node the host does not want to show is expressed by tinting the wires either
side of it and leaving the node out, so the coloured run reads as continuous
across a card that is still dimmed.
It lives on the controller and not on the NodeEditor widget, for a reason
that only shows up once it is wrong. NodeEditorLayout.nodeAt and
nodesInPaintOrder rank through one function, and a widget-level input could
have reordered what is drawn without reordering what a press lands on —
which is the drift the layout's own doc comment already warns about. Putting it
on the controller also means the editor's existing listener does the repaint,
so a host never has to rebuild NodeEditor to show a focus, which is the one
thing this package asks a host not to do.
A focus wins outright over the selection. While anything is lifted, the selection is ignored for paint order: a selected node floating above the scrim is precisely what the scrim promises will not happen, and a union of the two would let one stray click undo the whole effect.
It is not in the document, not in the undo history, and never
reaches guard or onEdit — assigning a focus is not an edit, for the same
reason a selection is not one. It is pruned after every mutation and after
undo and redo, because a focus is not in the graph and nothing else would drop
a halo painted around a node that has gone.
Two details worth knowing before touching the rendering:
- The lifted wires are redrawn above the scrim rather than recoloured in
place.
ConnectionsPainterdraws every curve below the whole node layer, so a forced colour applied there would be washed out by the scrim. The newEmphasisPaintersits inside the node layer's own stack, immediately below the first lifted card, and re-strokes them fromConnectionLayout's cache — already warm, so nothing is computed twice.arrowheadsPathandarrowheadSizemoved out ofConnectionsPainterso both painters draw the same triangle. - A handle under the scrim is neither drawn nor grabbable.
PortsPainterpaints above the whole node layer, so a dimmed card would otherwise keep a row of bright dots floating over the wash;NodeEditorLayout.portAtskips the same nodes, because one condition has to gate drawing and hitting alike — the ruleportMinScalealready follows.
The halo is painted behind the card, which is what makes this a
package-level feature at all: a host's node widget is opaque and untouched,
NodeRenderState gains nothing, and _NodeSlot's comparison is unchanged, so
none of the rebuild isolation is at risk. There is a test that showing a focus
builds no node body at all.
Connection captions are not redrawn above the scrim; they stay under it, which is why the default scrim is 0.62 rather than opaque. A focus that erased its surroundings would answer "which routes are these" by throwing away the board they run across.
Pinned by test/emphasis_test.dart, including the two that state the rules
above: a selected-but-unlifted node does not rise, and portAt answers null
for a node under the scrim.
Somewhere to hang an automatic layout #
The package still ships no arrangement of its own and is not going to: which picture a graph should make is a question about what the nodes mean, and only the host knows that. What a host cannot get for itself is the two ends of the job, and both are here now.
NodeEditorController.applyLayout(GraphLayout,recordHistory: true) — hands an algorithm the graph and the measured size of every node, and places whatever it answers in one edit, so a re-layout is a single undo step and a single repaint rather than one per node. Nodes the layout does not name are left alone, so arranging a selection is just a smaller map. Returns whether anything moved — false when the layout named nobody, named only nodes already in place, or whenguardrefused.NodeEditorLayout.onMeasured(VoidCallback?, null) — fired the momenthasUnmeasuredNodesgoes false.
applyLayout ignores draggable where moveNodes honours it, and that is
the point of it rather than an inconsistency: the flag says whether a pointer
may push a node about, which is a different question from whether an
arrangement may place one — and a read-only canvas, where nothing is draggable,
is exactly where an automatic layout is most wanted. moveNodes is still the
gesture path and still refuses.
onMeasured exists because hasUnmeasuredNodes was only half a sentence. It
already told a caller to wait; nothing told it the wait was over. The
controller's own notifications cannot say so — they fire for every edit and
every measurement, so a host watching them re-asks on each one and has to
remember the previous answer. This fires once, on the transition, which is
where a layout that depends on real extents wants to run. A graph whose nodes
all declare a height never has anything to wait for and never fires it.
Pinned by test/graph_layout_test.dart, including the two that state the
difference: an arrangement places a draggable: false node, and moveNodes
still refuses the same one.
Telling the host what changed, and letting it say no #
Two hooks on NodeEditorController, both null by default so nothing changes
for a host that ignores them:
guard(GraphEditGuard?, null) — asked before every edit; returning false abandons it, leaving the graph untouched, no undo step recorded andonEditunfired. Consulted at the very top of_mutate, before prototypes are resolved, so a refused edit costs nothing rather than resolving against a graph that is then thrown away.onEdit(GraphEditListener?, null) — told after every edit that landed.ChangeNotifieralready says that the graph changed; this says what, which is the difference between a host diffing two graphs and reading one field.
Both take a GraphEdit: a GraphEditKind plus the node, connection and group
ids it touched. The kinds are deliberately coarse — addNodes, updateNodes,
removeNodes, moveNodes, connect, disconnect, labelConnection,
group, comment, replace — because a host wants to know whether something
went away, not to carry a case per controller method.
guard is synchronous, and that is the contract rather than a limitation:
an edit is a frame's work and the controller cannot hold a graph half-changed
while a dialog is open. A host that needs to ask a question refuses, asks, and
re-issues the edit once it has an answer. graph_edit_test.dart pins that
round trip.
Undo and redo do not pass through either hook. They restore a graph the
hooks already saw on the way in, and a host that had refused a delete would
otherwise be unable to redo one it had allowed — so history.undo() bypasses
_mutate entirely and the test says so. A host that must catch every way a
node can leave the graph listens as well as guarding.
graph_edit_test.dart covers all sixteen cases; the existing 400 tests are
unchanged.
0.1.0 #
First release. A node graph editor for Flutter, and the spiritual successor to fl_nodes — same ideas, rebuilt around one change of approach.
fl_nodes isolated rebuilds with a MultiChildRenderObject. It worked, but it
fought the framework: native channel synchronisation broke, which made text
fields inside nodes impossible and put a ceiling on what a node body could be.
The same isolation turns out to be available one layer up, at the element
level — Element.updateChild short-circuits when handed the identical widget
instance, so a per-node widget slot skips the subtree without building it.
Node bodies stay ordinary widgets. Text fields, sliders, popup menus and forms
all work inside a node.
The graph #
- Immutable
NodeGraphofGraphNodes,NodePorts andNodeConnections, edited copy-on-write through aNodeEditorController. - Ports declare a
PortKind(dataorcontrol) and an optionaldataType; the default validator refuses to wire across either. - Subsystems on the controller, each a
partof it:history,selection,camera,layout,clipboard,project,runner.
Prototypes #
A prototype is a reduction rule, not a template stamped out once. Given what a node's fields say and how it is wired right now, it returns the ports, fields and height that node should have, and resolution rewrites the node to match.
- Ports become derived state: one input per placeholder in a format string, one more exit each time the last free one is wired.
- Static and dynamic families, with foreign ports left alone so a prototype can be pointed at a document whose ports were authored by hand.
- Presentation lives here too —
label,icon,category,description,defaultWidth— which is what the editor's own menus read.
Execution #
controller.runner.run()walks control flow depth-first and pulls data inputs on demand, memoised per run against a monotonic stamp.- Control flow is a pulse: two branches converging on a node run it twice.
NodeExecutionContext.enteredVia(null) names the control input the flow arrived on, so a node with more than one can behave differently on each. Some cannot be written without it — a loop'scontinueandbreakare one node doing opposite things, and the runner previously pushed only the destination node id and dropped the port. Null means nothing flowed in: the node was a root of the run, or it was pulled because something wanted its value. Pinned byrunner_test.dart's "a node is told which control input the flow arrived on" and "a root and a pulled node arrived through nothing".- Runtime values live in a run-scoped map. Execution never touches the
document, never bumps
revisionand never marks the project dirty. - Returns a
GraphRun— trace, values, diagnostics, failure — rather than throwing out of an async subsystem.
Interaction #
- Pan, zoom, marquee select, node drag, port-to-port wiring, connection captions, keyboard shortcuts and undo.
- Context menus on node, port, wire and canvas, built from
MenuAnchorso they inherit the app'sMenuTheme. Entries are data, and a build hook hands hosts the defaults to filter rather than a blank sheet. - The secondary button never drives a drag — it opens menus and nothing else.
- A tap on the canvas takes focus, so selecting a wire and pressing Delete
removes it. A tap is not a drag, so the scale recogniser never starts, and
nothing else on that path claimed focus — which mattered more than a shortcut
going unheard: the key event went to whatever the host had focused instead,
and an editor embedded beside a file tree could answer Delete by deleting a
file. Pinned by
test/canvas_focus_test.dart. - Optional drop-to-create: a wire let go on empty canvas offers the Create menu there and wires up what it makes, in one undo step.
- Comments, as free-standing notes sharing the nodes' paint order.
- Groups, as named frames behind a set of nodes, made with
Ctrl+G.
Comments #
controller.addCommentleaves a note on the canvas: a real text field in a grey slab, with a ring of padding wide enough to grab it by.- A note is an ordinary
GraphNodeof a reserved type rather than a model of its own, so paint order, selection, dragging, the marquee, cut and paste, undo and the document format all apply to it unchanged, with no cases for it anywhere.NodeCommentreads one;graph.commentsandgraph.contentNodespartitiongraph.nodesfor the places that care. - The editor draws them itself and never hands one to the host's
nodeBuilder. They are deliberately unthemed and identical in a light app and a dark one: a note is the app user's own annotation, not part of the graph's visual language. - Typing folds into one undo step per run —
setCommentTextrecords only the first change, andendCommentEditor any other edit closes the run, so undo can never step back past something that happened while the caret was elsewhere.
Groups #
Ctrl+Gframes the selected nodes; pressing it again with the frame and further nodes selected widens it.controller.groupSelectionreturns null rather than guessing when the selection cannot be framed — nothing selected, two frames in it, or a node that already belongs to a different one. Moving a node between groups is deliberately not offered: one keystroke would otherwise rewrite a group the user was not looking at.- A
NodeGroupowns no geometry. Its frame is its members' bounding box plusNodeGroup.padding, derived every time, which is why this is its own model where a comment is a node. Membership is explicit and exclusive; a group is pruned when its last member goes. - The frame paints immediately below the lowest of its own members and no
lower, and takes no pointer events: the space inside one is still canvas.
Everything a group does goes through its handle — drag to move every member
in one step, double-click to rename, a fixed palette in the dropdown, and a
context menu carrying
DisbandbesideDelete with contents. selection.groupIdssits besidenodeIdsandconnectionIds;selection.nodeIdsWithGroupsis what acting on the selection means once a frame can be in it.- A selected group floats to the front, its members with it. Paint order
ranks a node through
nodeIdsWithGroupsrather than through the selection alone, so picking a frame up lifts everything in it over whatever it was under — and the frame follows, being bucketed beneath its lowest-ranked member. Without it a group dragged across other nodes vanishes underneath them and has to be moved somewhere else before you can reach what it is now covering.NodeEditorLayout.nodeAtranks the same way, or what is drawn on top is not what a click lands on. Pinned bygroup_test.dart's "selecting a group floats it and its members to the front" and "dragging the handle brings the group forward"; "an unselected group outranks nothing" is the guard that keeps a frame from sitting over the node you just clicked.
Minimap #
NodeEditor.minimap(null) draws a minimap panel over the canvas, as the last child of the editor's ownStackrather than something a host positions itself: it has to paint above the node layer and take presses the marquee must never see, and a host solving that twice would solve it differently the second time. Off by default, unlikecontextMenus— a right-click already means a menu, where a panel sitting over the canvas is a thing you ask for.minimap: const MinimapConfig()is the whole opt-in. Pinned byminimap_test.dart's "there is no panel unless the host asks for one".- It is a readout and nothing else. No click-to-jump and no drag-to-pan:
the drag belongs to the panel, so it can be moved off whatever you are
working on, and a panel you cannot move is a panel sitting on top of your
graph. That is also what makes it free — it never calls
NodeEditorController, so it can never bumprevision, and dragging, resizing or folding it rebuilds no node body at all. Pinned byrebuild_isolation_test.dart's "dragging the panel rebuilds no node bodies at all" and the three numbers of the isolation table re-run with it drawn. MinimapController(size240x160,maxScale0.2,idleOpacity0.28) holds everything the app user can change, so a host can persist the placement into its own document; the editor makes one when given none and disposes only the one it made. The defaults live there rather than onMinimapConfigbecause two homes for one default is how a setting somebody restored gets silently overwritten by a config seed on the next launch.- The map fits the whole document, capped by
MinimapController.maxScale— without it three nodes fitted to the panel render as three enormous slabs and the map says nothing about shape. The cap can only ever bind downward: fitting takes the smaller of the two, so capping leaves slack on both axes and can never push content off the map. There is deliberately no matching floor, since a five-thousand-node graph legitimately needs 0.001. Pinned by "the zoom cap stops a small graph filling the map" and "the cap can never push content off the map". - Everything outside the viewport is washed over with four rects rather than
a difference path. The wash is translucent, so bands that overlap
composite twice and show as a darker cross through the panel;
minimapShadeBandsslices the complement so it is covered exactly once, with noPathallocated on a repaint that happens on every scroll tick. Pinned by "the bands tile the map around the viewport and never overlap". - A node takes its group's colour, a note the note grey, a selected node the
theme's selection colour, and a host may override the lot with
MinimapConfig.nodeColor— the package has no per-node colour by design, so that callback is the same seamnodeBuilderis. Selection outranks the host: otherwise a selected node is indistinguishable from an unselected one of the same type, on the one panel whose job is telling you where you are. Pinned by "the selection outranks the host, which outranks the group". - The close button minimises rather than closing, folding the panel to its action bar; the same button restores it. With the minimap opt-in there is nothing else that would bring it back. Pinned by "the close button minimises rather than closing".
MinimapPainteris the only painter in the package taking arepaintlistenable, because it is the only one that can: every input it reads is on aChangeNotifier, where the four canvas painters depend on editor state only a rebuild can deliver. Together with the panel's cached widget instance, a scroll tick repaints one layer and rebuilds no widget. Pinned by "the panel widget survives a pan".- Scrolling, trackpad panning and hovering over the panel no longer reach
the canvas underneath. An opaque
MouseRegionstops siblings, not ancestors, and the editor's pointer plumbing is entirely ancestral — so the guard is on the editor rather than on the panel. Pinned by "scrolling over the panel does not zoom the canvas" and "hovering the panel does not pick a port beneath it".
Rendering #
- Connections, ports, grid and overlays are painted, not built. Level of detail is then a condition inside a painter rather than widgets coming and going, so crossing a threshold is a repaint instead of a rebuild storm.
- Per-node widget slots, an incremental connection layout keyed on endpoint identity, a spatial hash for hit testing and culling, and a fragment shader for the grid with a CPU fallback.
Serialisation #
NodeGraphCodecreads and writes a versioned JSON document; only what cannot be derived is stored, so ports come back from the prototypes. A group naming a node the document does not carry loses that member rather than failing the load — a frame around four of five nodes is still a frame.- Pluggable payload codecs, map-to-map migrations, and a
projectsubsystem that keeps I/O behind a host-supplied source and sink.