graph-flow-types.ts3.6 KBView on GitHub
/**
 * The type-level bridge between the two universes the graph canvas adapts between: the persisted
 * `GraphNode` / `GraphEdge` model and React Flow's runtime `Node` / `Edge`.
 *
 * TWO INVARIANTS make the adapter total and keep every keyed lookup side-table-free — the same
 * two the outbound flow canvas established:
 *
 *   node.id   === the `graphNodes` key   (graph-LOCAL: the same person is a different node id
 *                                          in a different graph, which is what makes fields
 *                                          per-graph)
 *   node.type === 'node'                  (always the single literal, because a graph has
 *                                          exactly ONE node schema — hence one node component,
 *                                          never one per domain)
 *
 * `data.node` is the persisted entry and the single source of truth for what the node holds.
 * Everything else on `GraphNodeData` is a derived OVERLAY — hydration, lint, sync state — and is
 * NEVER serialized back. That separation is what stops a render-time annotation becoming a
 * persisted field the next save writes.
 */

import type { Edge, Node } from '@xyflow/react';
import type { GraphEdge, GraphEdgeKind, GraphNode, GraphSchema } from '@zero/server/graph';

export interface GraphNodeLint {
  code: string;
  message: string;
}

export type GraphNodeData = {
  /** The persisted entry. The only half that is ever written back. */
  node: GraphNode;
  /** Resolved from the pointed-at document. An overlay: never written back. */
  title: string;
  /** How to draw its fields — one row per `displayOnCard` field. */
  nodeSchema: GraphSchema['nodeSchema'];
  /**
   * Bound-field values resolved by `hydrateNodes`, when they have arrived.
   *
   * Kept SEPARATE from `node.derived` rather than merged into it, so the render can say which
   * values are live and which came from the cache — and so nothing can accidentally persist a
   * hydration result into the document.
   */
  hydrated?: Record<string, unknown>;
  /** Overlay: a missing document, an unresolved identity, a dangling edge. */
  lint?: GraphNodeLint[];
  /** Overlay: whether this node's bound fields are current. */
  syncState?: 'idle' | 'syncing' | 'stale';
  /** True when the pointed-at document no longer resolves. */
  missingDocument?: boolean;
  /**
   * The node's background tint, as a NAME from the closed shared vocabulary — resolved from
   * `view.colorField` and that field's `optionColors`. Absent = no tint.
   */
  color?: string;
} & Record<string, unknown>;

export type GraphFlowNode = Node<GraphNodeData, 'node'>;

export type GraphFlowEdgeData = {
  kind: string;
  /** The declared kind, when this graph declares one. Absent means "draw it plain". */
  edgeKind?: GraphEdgeKind;
  /** The persisted entry, for the rail to edit when the edge is selected. */
  edge: GraphEdge;
  /**
   * This edge is the layout AXIS — the relation the chart is ranked by.
   *
   * It draws differently on purpose: orthogonal, with a shared horizontal run between a parent
   * and its children, because that is what makes a tree read as a tree. Every other relation
   * stays a floating curve, which is what keeps an overlay from being mistaken for structure.
   */
  hierarchy?: boolean;
} & Record<string, unknown>;

export type GraphFlowEdge = Edge<GraphFlowEdgeData>;

/** The single node type this canvas registers. One schema per graph, so one component. */
export const GRAPH_NODE_TYPE = 'node' as const;

/** The single edge type. Style and arrowhead come from the KIND, not from a second type. */
export const GRAPH_EDGE_TYPE = 'graphEdge' as const;