openquiver-graph 0.1.0

OpenQuiver graph IR: nodes, pins, types. Bottom layer, no internal dependencies.
Documentation

openquiver-graph

The graph IR: nodes, pins, wires. Pure data, no I/O, and — the invariant that actually matters — no dependency on any other workspace crate: nothing here reaches into the parser, the compiler, the runtime or the editor. That says nothing about ordinary external crates, and this one has several (thiserror, the unicode-* pair for identifier normalization). Two of them, serde and ts-rs, are there because something outside this crate needs the IR's shape in a form it did not have to hand-write. Neither lets this crate reach outward; both only let other things reach in.

Why it exists

Everything in OpenQuiver — the parser, the compiler, the runtime, the editor — works against this one representation. The file on disk is a projection of this IR, never the other way around, which is what makes evolving the storage format a load → save with no data migrations.

Design decisions worth knowing

A node's identity is its name. There are no hidden ids. That is what lets a readable DSL refer to a node as ask rather than 01J8X…. The price is that renaming is a refactor that rewrites every reference — the same deal any programming language makes.

Wires come in two kinds and the distinction is the product. EdgeKind::Exec is the white wire that orders steps; EdgeKind::Data carries values. Separating them is what lets a workflow say "first A, then B" without inventing a fake data dependency, which is the thing dataflow builders cannot express.

Collections are keyed, never positional. Nodes live in a BTreeMap keyed by name, edges in a BTreeSet. Two reasons, both load-bearing:

  • Canonical order. Nodes sort alphabetically and edges sort exec-block first, then data — so a saved file is byte-identical regardless of the order things were created in, and concurrent insertions by two people land in different parts of the file instead of colliding at the end.
  • Mergeability. No positional arrays and no insertion-order ids means the document can later be backed by a CRDT (Automerge, Loro) for live co-editing without rewriting the editor. Live sync is not being built now; keeping the door open costs nothing today and a rewrite later.

EdgeKind's variant order and Edge's field order are both load-bearing — they are what the derived Ord uses to produce the canonical edge order.

Comments live here too

Trivia holds ordinary // comments keyed by the element they describe, and it sits beside the IR rather than in the syntax crate for one reason: the keys are IR identities, so renaming a node has to update the graph and the comment table in a single operation. A table one layer above the code performing the rename cannot be kept in step with it.

/// is different — it is the node's description and its editor tooltip, so it is a real IR field (Node::doc), not trivia.

Deliberately not here

Resolved pin types. Data types are derived by the compiler through wildcard propagation, so storing them on an edge would create a second source of truth that can go stale.

Why Value and DataType derive TS

openquiver-editor's view — what the canvas draws, and what crosses into the TypeScript frontend as generated bindings — embeds Value and DataType directly: a node's configuration literals and a pin's data type are exactly this crate's Value and DataType, not a lookalike. Deriving ts-rs's TS here, beside the type it describes, is what lets the frontend see that shape at all.

The alternative was a second enum in the editor — ValueView, say, hand-kept in sync with Value's variants — purely so that something in that crate could derive TS without reaching back into openquiver-graph. That is the same fact told twice: every time Value gained or changed a variant, the mirror would need the same edit made by hand a second time, in a different file, in a different crate, and nothing would fail loudly the day someone forgot. The whole reason this project keeps resolved pin types out of the IR (see above) is to avoid exactly that shape of bug. Deriving TS in place costs one #[derive(TS)] and one external dependency; the mirror would have cost a permanent, silent way for the frontend's idea of Value to drift from the real one. ts-rs crosses the crate boundary the same way serde already does — it does not create a new kind of coupling, only a second consumer of one that was already there.