Package name: @graphora/core
Responsibility: graph data model types, raw graph validation, normalization, JSON interchange, business-record mapping, read selectors, snapshots, runtime collections, mutations, and graph events.
import { createGraphStore } from '@graphora/core'
const store = createGraphStore({
nodes: [{ id: 'a' }, { id: 'b' }],
edges: [{ source: 'a', target: 'b' }]
})
createGraphStore(graph) returns a GraphStore. You can also instantiate new GraphStore(graph) directly. A store created without graph data starts at version 0; a successfully loaded graph starts at version 1.
String(id), so 1 and "1" collide.edges is optional and defaults to an empty array.directed defaults to true.__edge: prefix.GraphError with collected GraphIssue entries.Optional fields are validated too: labels must be strings, edge directed
values must be booleans, positions must contain finite coordinates, and
attributes must follow the recursive GraphAttributeValue shape without
cycles, sparse arrays, accessors, or custom prototypes.
Core exposes a small JSON boundary around RawGraph:
import {
createGraphStore,
parseRawGraphJson,
serializeRawGraph,
snapshotToRawGraph
} from '@graphora/core'
const saved = serializeRawGraph(snapshotToRawGraph(store.snapshot()), {
space: 2
})
const reloaded = parseRawGraphJson(saved)
const nextStore = createGraphStore(reloaded)
parseRawGraphJson reports syntax and graph-shape failures as GraphError.
snapshotToRawGraph preserves IDs, endpoints, labels, attributes, directed
flags, finite positions, and opaque data references. It writes generated
edge IDs as explicit raw IDs and omits node positions that are null.
serializeRawGraph rejects opaque data that JSON would silently drop or
coerce. This includes nested undefined, functions, symbols, bigints,
non-finite numbers, cycles, sparse arrays, accessors, custom toJSON behavior,
and class, Date, Map, Set, or array-subclass instances. Convert these values to
plain JSON data before saving. Optional graph fields set to undefined are
omitted. Pretty-print space accepts an integer from 0 through 10 or at most
10 JSON whitespace characters.
createRawGraphFromRecords maps arrays or other iterables with explicit,
stateless accessors:
import { createRawGraphFromRecords } from '@graphora/core'
const graph = createRawGraphFromRecords({
nodes: services,
edges: dependencies,
node: {
id: (service) => service.key,
label: (service) => service.name,
data: (service) => service
},
edge: {
source: (dependency) => dependency.from,
target: (dependency) => dependency.to,
label: (dependency) => dependency.protocol,
data: (dependency) => dependency
}
})
The mapper returns new raw node and edge containers, retains values returned by
data accessors by reference, never mutates source records, and validates the
completed RawGraph. When edges is supplied, edge mapping is required.
Omit both to create a node-only graph.
The current implementation supports:
getNode(id)getEdge(id)getNodes()getEdges()getOutgoingEdges(nodeId)getIncomingEdges(nodeId)getIncidentEdges(nodeId)getNeighbors(nodeId, direction?)getEdgesBetween(sourceId, targetId, options?)getBounds()getVersion()snapshot()Selector arrays are deterministic and read-only from the consumer perspective. Unknown IDs return undefined or empty arrays instead of throwing.
The store supports strict graph mutations:
loadGraph(graph)clear()addNode(node)addEdge(edge)removeNode(id)removeEdge(id)updateNodeData(id, data)updateEdgeData(id, data)updateNodeAttributes(id, attributes, mode?)updateEdgeAttributes(id, attributes, mode?)updateNodePosition(id, position)Successful mutations return { ok: true, version } and increment the graph version exactly once. Invalid mutations throw GraphError; selectors stay forgiving.
updateNodePositions(updates) is a narrow atomic operation for layout result
application. It validates all IDs and finite points before mutation, rejects
duplicate IDs, rebuilds state once, increments the version once, then emits one
node:positions-updated summary and one graph:changed event with reason
position:update. Empty input is a no-op. updateNodePosition keeps its
existing one-node behavior and granular event.
Removing a node also removes every connected edge by default, including incoming edges, outgoing edges, self-loops, and parallel edges. Remaining nodes and edges are reindexed in deterministic order, adjacency indices are rebuilt, and bounds are recomputed.
applyBatch(operations, options?) applies ordered node and edge mutations to a
private draft, then publishes one rebuilt snapshot. A nonempty successful batch
increments the version once and returns its operation count plus deterministic,
first-seen affected node and edge IDs. A node added earlier in a batch can be an
endpoint for an edge added later. Removing a node also records and removes its
incident draft edges.
The nine operations cover node/edge addition and removal, node/edge data and
attribute updates, and node position updates. Attribute merge/replace and null
positions use the same rules as the one-item methods. options.id is an
optional nonempty correlation label. Without one, a successful batch uses
batch:<final-version>; this label is deterministic, not globally unique.
Empty batches return a summary at the current version and emit no events. If any
operation fails, the store, version, generated-edge sequence, and event stream
remain unchanged. Use one-item mutations when observers need granular events,
applyBatch for related incremental changes, and loadGraph to replace the
whole graph.
Use store.nodes(ids?) and store.edges(ids?) to capture existing members,
or selectNodes(predicate) / selectEdges(predicate) to filter once. Unknown
IDs are ignored and numeric/string aliases are deduplicated. Omitting IDs
captures all current members; passing [] captures none.
const downstream = store.nodes(['a']).neighbors('out')
const labels = downstream.map((node) => node.label ?? String(node.id))
const edges = downstream.incidentEdges().filter((edge) => edge.directed)
const result = downstream.updateAttributes({ highlighted: true })
Collections have size, has(id), ids(), keys(), toArray(), filter,
map and forEach. Node collections also expose neighbors(direction?),
incidentEdges(), outgoingEdges() and incomingEdges(). neighbors defaults
to both; out follows source → target and in reverses it, including for
directed: false, matching the existing store selectors. Self-loops do not
make a node its own neighbor. Edge traversals retain loops and parallel edges,
once per edge ID. Traversal and filter results capture their own membership.
Membership is fixed by normalized key, while every read resolves current records
in current store order. Removed members are skipped; re-adding a captured key
restores its membership with the new stored ID representation. Other new keys
do not join an old collection. loadGraph has the same key-based behavior.
To reevaluate a predicate or adjacency after changes, construct a new result.
Collections install no subscriptions and need no disposal.
Returned arrays are frozen; opaque data values remain application-owned
references. Read callbacks see one captured record array, even when they mutate
the store independently. Read/filter methods scan current node or edge arrays;
collections are convenience views, not an indexed query engine.
updateData((record, index) => nextData) and
updateAttributes(attributes, mode?) submit one atomic batch for active members.
Attribute mode defaults to merge and also accepts replace. Empty collections
are no-ops. A throwing data updater applies none of the helper's changes. If an
updater mutates the store, the helper stops and throws GraphError with code
INVALID_GRAPH; independent callback side effects remain. Updaters run
synchronously and their returned values are stored as opaque data. Return new
data rather than mutating existing application objects when rollback matters.
Observer exceptions follow the same post-commit policy as applyBatch.
The batch example uses
engine.store.nodes(['api']).neighbors('out').updateAttributes(...) to recolor
only the direct downstream node through the ordinary engine invalidation path.
GraphStore implements a small synchronous typed event target:
const unsubscribeNodeAdded = store.on('node:added', (event) => {
console.log(event.nodeId, event.version)
})
const unsubscribeAll = store.onAny((event) => {
console.log(event.type, event.version)
})
unsubscribeNodeAdded()
unsubscribeAll()
Supported event types:
graph:loadedgraph:clearednode:addednode:removednode:updatededge:addededge:removededge:updatednode:position-updatednode:positions-updatedgraph:batch-appliedgraph:changedEvery successful mutation emits its granular event or events first, then one aggregate graph:changed event with the same final graph version. graph:changed.reason is one of load, clear, node:add, node:remove, node:update, edge:add, edge:remove, edge:update, or position:update.
removeNode(id) emits one edge:removed event for each connected edge in current edge order, then node:removed, then one graph:changed. Removal events include IDs and normalized keys captured before removal. Add and update events include the current frozen runtime record where it still exists.
Invalid mutations throw GraphError before state changes, version increments, or event emission. Unsubscribe functions are safe to call more than once, and events are dispatched synchronously without timers, promises, DOM EventTarget, or external emitter dependencies.
A successful nonempty batch emits one graph:batch-applied summary followed by
one graph:changed event with reason batch; it does not emit per-item events.
Synchronous observer exceptions retain the existing event policy: the exception
propagates after publication and may interrupt later listeners or the aggregate
change event. Observer exceptions do not roll back committed graph state.
This package currently implements raw graph normalization, JSON interchange,
record mapping, read access, runtime collections, scoped graph mutations, general atomic batches,
atomic multi-node position updates, snapshots, and graph event subscriptions.
Renderer behavior, layouts, interactions, ports, handles, grouping, filtering,
undo/redo, schema migration, remote fetch helpers,
non-JSON format parsers, async event queues, DOM EventTarget integration, and
GraphML are deferred to later tasks.