From f986ba58f84c429757aed513d7dfdd9d0216bfb0 Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Sat, 5 Sep 2026 15:12:20 +0300 Subject: [PATCH 1/2] Add graph exploration workspace and replayable investigations --- .changeset/curious-orbits-explore.md | 6 + README.md | 12 +- apps/demo/e2e/exploration-workflows.spec.ts | 54 ++ apps/demo/src/App.tsx | 56 +- apps/demo/src/ExplorationWorkspace.tsx | 83 +++ .../exploration/InvestigationWorkspace.tsx | 250 ++++++++ .../src/exploration/Workflows.stories.tsx | 171 ++++++ apps/storybook/src/exploration/supplyChain.ts | 99 ++++ .../src/exploration/supplyChainServices.ts | 150 +++++ docs/core/exploration-workflows.md | 317 ++++++++++ packages/core/src/errors.ts | 2 + packages/core/src/exploration.ts | 123 ++++ packages/core/src/index.ts | 23 + packages/core/src/instance.ts | 301 +++++++--- packages/core/src/investigation.ts | 554 ++++++++++++++++++ packages/core/src/pathService.ts | 202 ++++--- packages/core/src/services.ts | 90 +++ packages/core/src/types.ts | 100 +++- packages/core/test/exploration.test.ts | 309 ++++++++++ packages/core/test/investigation.test.ts | 324 ++++++++++ packages/react/package.json | 5 + packages/react/src/Graph.tsx | 18 +- .../react/src/components/Explorer/index.tsx | 369 ++++++++++++ .../react/src/components/Inspector/index.tsx | 26 +- .../src/components/Inspector/passive.tsx | 94 +++ .../react/src/components/Search/index.tsx | 45 +- packages/react/src/components/Table/index.tsx | 3 + packages/react/test/explorer.test.tsx | 241 ++++++++ packages/react/tsup.config.ts | 1 + 29 files changed, 3821 insertions(+), 207 deletions(-) create mode 100644 .changeset/curious-orbits-explore.md create mode 100644 apps/demo/e2e/exploration-workflows.spec.ts create mode 100644 apps/demo/src/ExplorationWorkspace.tsx create mode 100644 apps/storybook/src/exploration/InvestigationWorkspace.tsx create mode 100644 apps/storybook/src/exploration/Workflows.stories.tsx create mode 100644 apps/storybook/src/exploration/supplyChain.ts create mode 100644 apps/storybook/src/exploration/supplyChainServices.ts create mode 100644 docs/core/exploration-workflows.md create mode 100644 packages/core/src/exploration.ts create mode 100644 packages/core/src/investigation.ts create mode 100644 packages/core/test/exploration.test.ts create mode 100644 packages/core/test/investigation.test.ts create mode 100644 packages/react/src/components/Explorer/index.tsx create mode 100644 packages/react/src/components/Inspector/passive.tsx create mode 100644 packages/react/test/explorer.test.tsx diff --git a/.changeset/curious-orbits-explore.md b/.changeset/curious-orbits-explore.md new file mode 100644 index 0000000..d5e8614 --- /dev/null +++ b/.changeset/curious-orbits-explore.md @@ -0,0 +1,6 @@ +--- +"@modernrelay/orbit-core": minor +"@modernrelay/orbit-react": minor +--- + +Add bounded relationship inspection and paginated typed expansion, layout preservation, explicit path outcomes, and source-bound investigation checkpoints with replayable queries. Add GraphExplorer, passive node/edge/selection inspection, and controlled search/table intent. Include seven Storybook workflows and a demo workspace. diff --git a/README.md b/README.md index 6955eaa..817538a 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Handles 100K+ node graphs. UI components included: search, tables, histograms, a |---|---| | **Declarative end to end** | The graph is a prop — rendering, force layout, transitions, selection, and undo/redo are handled. No imperative canvas code. | | **Built for large graphs** | GPU rendering holds 100K+ nodes interactive; incremental filtering keeps the core's per-brush cost near 1 ms at that scale, measured on a disclosed reference machine. | -| **Analyst UI included** | 13 packaged components — search, minimap, tables, histograms, timelines, legends, inspectors, and more. Headless-styleable, one import each. | +| **Analyst UI included** | 14 packaged components — search, minimap, tables, histograms, timelines, legends, inspectors, and more. Headless-styleable, one import each. | | **Testable without WebGL** | A headless core and an engine seam with a `FakeEngine` double; integration tests run in plain jsdom. | | **Typed, honest boundaries** | Malformed data degrades with batched diagnostics — never throws mid-render. | @@ -34,7 +34,7 @@ npm install @modernrelay/orbit-react @modernrelay/orbit-core @modernrelay/orbit- | Package | Role | |---|---| | `@modernrelay/orbit-core` | Headless core: validation, reconciliation, projection, the instance + store. Subpaths: `/engine` (the `GraphEngine` contract), `/testing` (`FakeEngine`, worker double). No React or engine imports. | -| `@modernrelay/orbit-react` | ``, `GraphProvider`, 13 packaged UI components, hooks, ref API. React 18+ peer. | +| `@modernrelay/orbit-react` | ``, `GraphProvider`, 14 packaged UI components, hooks, ref API. React 18+ peer. | | `@modernrelay/orbit-engine-cosmos` | The default rendering engine: WebGL drawing and GPU force simulation, built on [cosmos.gl](https://github.com/cosmosgl/graph). Loaded lazily when the graph mounts. | | `@modernrelay/orbit-data` | Prepared-data adapters: rows/CSV/JSON in the root entry; Arrow and Parquet as isolated subpath entries that never reach the root bundle. | | `@modernrelay/orbit-omnigraph` | Omnigraph server adapter: streamed export loader, `.pg` schema tooling, search service. | @@ -55,11 +55,12 @@ Packaged components — each ships as its own entry point | Component | Entry | What it does | |---|---|---| +| `GraphExplorer` | `components/Explorer` | Unified search, table, passive inspection, bounded expansion, ordered paths, and saved investigations | | `GraphSearch` | `components/Search` | Search box with debounced queries, result list, keyboard activation | | `GraphNavigator` | `components/Navigator` | Bounded semantic keyboard navigator (arrow/paging traversal with a11y announcements) | | `GraphMinimap` | `components/Minimap` | Whole-graph thumbnail with a draggable viewport rectangle | | `GraphTooltip` | `components/Tooltip` | Hover card for nodes and edges | -| `GraphInspector` | `components/Inspector` | Docked detail panel for the focused/selected entity | +| `GraphInspector` | `components/Inspector` | Node, relationship, and selection comparison with passive inspection | | `GraphTable` | `components/Table` | Virtualized tabular view of nodes or edges, crossfilter-connected text filtering | | `GraphHistogram` | `components/Histogram` | Crossfilter histogram — drag-brush a numeric dimension to filter the graph | | `GraphTimeline` | `components/Timeline` | Timeline band over a temporal dimension with brush + playback | @@ -80,6 +81,11 @@ Packaged components — each ships as its own entry point - **Persistence & export**: deep-linkable view state, undo/redo, SVG / streamed JSON / PNG exports. - **Scale**: measured performance gates, telemetry snapshots, degradation ladder, off-main-thread data acceptance. +For runnable examples of search recovery, typed pagination, stable layouts, path explanations, +and durable checkpoints, see the [exploration workflow guide](docs/core/exploration-workflows.md) +and **Exploration → Investigation workflows** in Storybook. The demo’s **Explore graph** +button opens the same workspace for generated, CSV, streamed, and Omnigraph data. + ## Hooks & imperative API All hooks read the instance through `GraphProvider` (or the nearest ``): diff --git a/apps/demo/e2e/exploration-workflows.spec.ts b/apps/demo/e2e/exploration-workflows.spec.ts new file mode 100644 index 0000000..9cf0544 --- /dev/null +++ b/apps/demo/e2e/exploration-workflows.spec.ts @@ -0,0 +1,54 @@ +import { expect, test } from '@playwright/test'; + +test('CSV exploration connects search, paths, table filtering, and checkpoint restore', async ({ page }, testInfo) => { + await page.goto('/'); + await page.waitForSelector('[data-testid="status-dot"][title="ready"]'); + await page.getByTestId('csv-file-input').setInputFiles({ + name: 'suppliers.csv', mimeType: 'text/csv', + buffer: Buffer.from('source,target,type,evidence\nAcme,Beta,SUPPLIES,Contract\nBeta,Cedar,SUPPLIES,Invoice\n'), + }); + await expect(page.getByTestId('node-count')).toHaveText('3'); + await page.getByTestId('explorer-toggle').click(); + const explorer = page.getByRole('region', { name: 'Graph exploration' }); + await expect(explorer).toBeVisible(); + await explorer.getByRole('combobox', { name: 'Search graph' }).fill('Acme'); + await explorer.getByRole('option', { name: /Acme/ }).click(); + await expect(explorer.getByRole('complementary')).toContainText('Acme'); + await explorer.locator('[data-orbit-table-row="Acme"]').click(); + await explorer.getByRole('button', { name: 'Hide selected', exact: true }).click(); + await explorer.getByRole('combobox', { name: 'Search graph' }).fill('Beta'); + await explorer.getByRole('combobox', { name: 'Search graph' }).fill('Acme'); + await explorer.getByRole('option', { name: /Acme/ }).click(); + await expect(explorer.getByRole('button', { name: 'Reveal filtered entity' })).toBeVisible(); + await explorer.getByRole('button', { name: 'Reveal filtered entity' }).click(); + await expect(explorer.getByRole('region', { name: 'Active constraints' })).toContainText('3 nodes'); + await explorer.getByLabel('Path source', { exact: true }).fill('Acme'); + await explorer.getByLabel('Path target', { exact: true }).fill('Cedar'); + await explorer.getByLabel('Path direction', { exact: true }).selectOption('outgoing'); + await explorer.getByRole('button', { name: 'Find connection', exact: true }).click(); + await expect(explorer.locator('[data-orbit-saved-path] li')).toHaveText(['Acme → SUPPLIES', 'Beta → SUPPLIES', 'Cedar']); + await explorer.getByLabel('Investigation title').fill('Supplier evidence'); + await explorer.getByLabel('Investigation notes').fill('Verify the contract and invoice.'); + await explorer.locator('[data-orbit-table-filter]').fill('Beta'); + await expect(explorer.getByRole('region', { name: 'Active constraints' })).toContainText('1 nodes'); + await explorer.getByRole('button', { name: 'Save checkpoint', exact: true }).click(); + await expect(explorer.getByRole('button', { name: 'Restore Supplier evidence' })).toBeVisible(); + await explorer.locator('[data-orbit-table-filter]').fill(''); + await explorer.getByLabel('Investigation notes').fill('Changed'); + await explorer.getByRole('button', { name: 'Restore Supplier evidence' }).click(); + await expect(explorer.getByLabel('Investigation notes')).toHaveValue('Verify the contract and invoice.'); + await expect(explorer.locator('[data-orbit-table-filter]')).toHaveValue('Beta'); + await expect(explorer.getByRole('region', { name: 'Active constraints' })).toContainText('1 nodes'); + await page.screenshot({ path: testInfo.outputPath('exploration-workspace.png') }); + await page.setViewportSize({ width: 800, height: 900 }); + const panel = await explorer.boundingBox(); + expect(panel!.x).toBeGreaterThanOrEqual(0); + expect(panel!.x + panel!.width).toBeLessThanOrEqual(800); + await expect(page.getByRole('button', { name: 'Close explorer' })).toBeVisible(); + await page.reload(); + await page.waitForSelector('[data-testid="status-dot"][title="ready"]'); + await page.getByTestId('explorer-toggle').click(); + await explorer.getByRole('button', { name: 'Restore Supplier evidence' }).click(); + await expect(explorer.getByRole('alert')).toContainText('Load the checkpoint source'); + await expect(explorer.getByLabel('Investigation notes')).toHaveValue(''); +}); diff --git a/apps/demo/src/App.tsx b/apps/demo/src/App.tsx index 1c47aab..d49baac 100644 --- a/apps/demo/src/App.tsx +++ b/apps/demo/src/App.tsx @@ -82,6 +82,8 @@ import { useSyncExternalStore, } from 'react'; import type { ReactNode } from 'react'; +import { flushSync } from 'react-dom'; +import { ExplorationWorkspace, EXPLORATION_CSS } from './ExplorationWorkspace'; import type { AcceptedEdge, @@ -96,6 +98,7 @@ import type { GraphViewState, GroupSpec, InstanceStatus, + JsonValue, LabelConfig, MetaEdge, NodeId, @@ -413,7 +416,7 @@ const CREATED_DIM: DimensionSpec = { }, }; -const CROSSFILTER_DIMS: readonly DimensionSpec[] = [SCORE_DIM, CREATED_DIM]; +const CROSSFILTER_DIMS: readonly DimensionSpec[] = [SCORE_DIM, CREATED_DIM, M5_TABLE_DIMENSION]; // Omnigraph mode: intel-style graphs carry no generated metrics, but the // adapter injects `type`, most content nodes declare a `domain` enum, and the @@ -441,6 +444,7 @@ const OG_CREATED_DIM: DimensionSpec = { const OG_CROSSFILTER_DIMS: readonly DimensionSpec[] = [ OG_DOMAIN_DIM, OG_CREATED_DIM, + M5_TABLE_DIMENSION, ]; /** M5 mode adds the id-keyed dimension ``'s filter brushes @@ -574,6 +578,8 @@ declare global { } export function App() { + const [explorerOpen, setExplorerOpen] = useState(false); + const [explorerControlsOpen, setExplorerControlsOpen] = useState(false); const graphRef = useRef | null>(null); const [gen, setGen] = useState(INITIAL_GEN); const [mode, setMode] = useState(DECLARATIVE); @@ -1175,8 +1181,13 @@ export function App() { ); const onBackgroundClick = useCallback(() => setSelection([]), []); const onEdgeClick = useCallback( - ({ edge }: { edge: AcceptedEdge }) => setSelection([edge.source, edge.target]), - [], + ({ edge }: { edge: AcceptedEdge }) => { + if (explorerOpen) { + flushSync(() => setSelection([])); + graphRef.current?.instance.selectEdges([edge.id]); + } else setSelection([edge.source, edge.target]); + }, + [explorerOpen], ); const onNodeDragStart = useCallback(({ node }: { node: GraphNode }) => { setDragNote(`dragging ${labelOf(node)}…`); @@ -1376,6 +1387,21 @@ export function App() { sync(); return instance.store.subscribe(sync); }, [graphKey]); + const clearExplorerFilters = useCallback(() => { + flushSync(() => { setExcludedClusters(EMPTY_CLUSTER_SET); setExcludedTypes(EMPTY_TYPE_SET); }); + }, []); + const restoreExplorerFilters = useCallback((raw: JsonValue) => { + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Invalid saved filters'); + const { clusters, types, mode: savedMode } = raw; + if (!Array.isArray(clusters) || !clusters.every((value) => typeof value === 'number') || + !Array.isArray(types) || !types.every((value) => typeof value === 'string') || + (savedMode !== 'hide' && savedMode !== 'dim')) throw new Error('Invalid saved filters'); + flushSync(() => { + setExcludedClusters(new Set(clusters as number[])); + setExcludedTypes(new Set(types as string[])); + setFilterMode(savedMode); + }); + }, []); const semantic = mode.kind === 'semantic'; const streaming = meter !== null && meter.phase === 'streaming'; // In stream mode the cluster ids exist only once the replace committed; @@ -1388,8 +1414,9 @@ export function App() { style={{ ...S.appRoot, ...S.themeVars(themeBase) }} data-theme={themeBase} data-testid="app-root" + data-exploring={explorerOpen} > - + {mismatchRaw !== null && (
This view was saved over different data — restoring it may not show what the sender saw. @@ -1403,6 +1430,7 @@ export function App() { )} key={graphKey} + className="demo-graph" ref={graphRef} engine={engineFactory} {...(mode.kind === 'declarative' @@ -1486,7 +1514,20 @@ export function App() { onError={onError} style={S.graphStyle} > -
+ + {explorerOpen && } +
{/* FIRST in DOM (Tab reaches its toggle first); CSS `order` @@ -1495,6 +1536,7 @@ export function App() {
orbit demo + setExplorerOpen(true)}>Explore graph
+ {!explorerOpen && } {error !== null &&
engine error: {error}
} {/* The docked inspector replaces the workbench sidebar while open; in M5 mode the semantic dock (table + sim controls) owns the right edge instead — all three live below the toolbar rows. */} - {inspectorOpen ? ( + {explorerOpen ? null : inspectorOpen ? ( ) : mode.kind === 'semantic' ? ( void; + clearFilters: () => void; +}) { + const instance = useGraphInstance(); + const latest = useRef(props); + latest.current = props; + const [session, setSession] = useState(null); + const [storageError, setStorageError] = useState(null); + useEffect(() => { + const investigation = createInvestigationSession(instance, { + captureHostState: () => latest.current.filters, + restoreHostState: (filters) => latest.current.restoreFilters(filters), + }); + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored !== null) { + const saved: unknown = JSON.parse(stored); + if (!Array.isArray(saved)) throw new Error('Saved investigations must be a list'); + for (const checkpoint of saved) investigation.importCheckpoint(checkpoint); + } + } catch (error) { setStorageError(`Could not open saved investigations: ${String(error)}`); } + const unsubscribe = investigation.store.subscribe((next, previous) => { + if (next.checkpoints === previous.checkpoints) return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next.checkpoints)); + setStorageError(null); + } catch (error) { setStorageError(`Browser storage failed; export your checkpoint: ${String(error)}`); } + }); + setSession(investigation); + return () => { unsubscribe(); investigation.destroy(); }; + }, [instance]); + + if (session === null || !props.open) return null; + return ; +} + +export const EXPLORATION_CSS = ` + .demo-exploration-panel { position: fixed; right: 12px; top: 12px; bottom: 12px; width: 620px; z-index: 30; } + .demo-exploration-panel[hidden] { display: none; } + [data-exploring="true"] > .demo-graph { width: calc(100% - 644px) !important; } + .demo-exploration-nav { position: fixed; top: 14px; left: 14px; right: 658px; z-index: 40; display: flex; flex-wrap: wrap; gap: 8px; } + @media (max-width: 1000px) { + [data-exploring="true"] > .demo-graph { width: 100% !important; height: 45% !important; } + .demo-exploration-panel { left: 8px; right: 8px; top: 46%; bottom: 8px; width: auto; } + .demo-exploration-nav { right: 14px; } + } +`; diff --git a/apps/storybook/src/exploration/InvestigationWorkspace.tsx b/apps/storybook/src/exploration/InvestigationWorkspace.tsx new file mode 100644 index 0000000..52519cf --- /dev/null +++ b/apps/storybook/src/exploration/InvestigationWorkspace.tsx @@ -0,0 +1,250 @@ +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import type { CSSProperties, ReactElement, RefObject } from 'react'; +import { canonicalJson, createInvestigationSession, GRAPH_THEME_DARK } from '@modernrelay/orbit-core'; +import type { ExpansionOptions, GraphInstance, InvestigationSession, SearchResult, SearchUnavailableReason } from '@modernrelay/orbit-core'; +import { Graph } from '@modernrelay/orbit-react'; +import type { GraphHandle } from '@modernrelay/orbit-react'; +import { GraphExplorer } from '@modernrelay/orbit-react/components/Explorer'; +import { cosmosEngine } from '../fixtures/engines'; +import { colorOf, delay, labels, seedSnapshot, sourceReference } from './supplyChain'; +import type { Entity, Relationship } from './supplyChain'; +import { createSupplyChainServices, loadCatalogEntity } from './supplyChainServices'; +import type { ServiceEvent } from './supplyChainServices'; + +export type Workflow = 'find' | 'expand' | 'stable' | 'inspect' | 'paths' | 'save' | 'async'; +type Instance = GraphInstance; +const expansion: ExpansionOptions = { + direction: 'incoming', relationshipTypes: ['SUPPLIES'], relationshipTypeField: 'type', + hops: 1, limit: 2, preserveLayout: true, +}; +const tableDimension = [{ key: 'table', kind: 'categorical' as const, get: (node: { id: string }) => node.id }]; +const entityColumns = [{ key: 'label', label: 'Entity' }, { key: 'type', label: 'Type' }, { key: 'status', label: 'Status' }]; +const storageKey = 'orbit:storybook:supplier-investigations:v1'; +const button: CSSProperties = { + border: '1px solid #415572', borderRadius: 5, background: '#18283e', color: '#edf4ff', + padding: '7px 11px', font: 'inherit', cursor: 'pointer', +}; + +const instructions: Record = { + find: { + title: 'Find an entity without losing the investigation', + steps: 'Search “Harbor” (hidden), “Cedar” (outside an isolation), or “Ember” (not loaded). Activate the hit, then use the explicit recovery action. Selection and inspection stay beside the graph.', + }, + expand: { + title: 'Explore one relationship type, two neighbors at a time', + steps: 'Load incoming SUPPLIES for Atlas. The first page contains two already-loaded suppliers; Load more fetches Ember and Lumen. A final page reaches Northstar. Retract removes only this expansion’s contribution.', + }, + stable: { + title: 'Keep the current map while the investigation grows', + steps: 'Pan or zoom, then load supplier pages. preserveLayout holds established nodes and cancels settle-camera follow. Use Fit all when you want a wider view; resume layout to let unpinned established nodes move again.', + }, + inspect: { + title: 'Inspect evidence and compare entities', + steps: 'Inspect Harbor → Atlas to read its relationship type, confidence, date, and evidence. Then compare Harbor and Cedar. Follow endpoint and related-entity actions without closing the inspector.', + }, + paths: { + title: 'Explain a connection in traversal order', + steps: 'Harbor starts hidden. The visible graph cannot connect the report to the bicycle; the loaded graph can. Find the loaded path, inspect its ordered steps, then reveal Harbor. This is a path through loaded data, not proof about the entire remote catalog.', + }, + save: { + title: 'Save and reopen a named investigation', + steps: 'Load supplier pages, add a title and note in the explorer, then save a checkpoint. Retract or start a fresh canvas and reopen it. This story saves up to eight exported checkpoints in this browser; refresh the page to verify reopening.', + }, + async: { + title: 'See bounded asynchronous requests and cancellation', + steps: 'Start a supplier page and immediately cancel it. The service waits, then emits two bounded batches; Orbit publishes the expansion atomically. Watch the request log, then retry. Type a different search while the first is pending to supersede it.', + }, +}; + +export function InvestigationWorkspace({ workflow }: { workflow: Workflow }): ReactElement { + const graph = useRef>(null); + const [investigation, setInvestigation] = useState | null>(null); + useEffect(() => { + const owned = createInvestigationSession(() => graph.current?.instance ?? null, { + async loadSource(source, { signal }) { + // This self-contained host serves exactly one version of one catalog. + if (source.datasetKey !== seedSnapshot.datasetKey || source.sourceRevision !== seedSnapshot.sourceRevision || + canonicalJson(source.dataRef) !== canonicalJson(sourceReference)) { + throw new Error('This example cannot load that source revision. The current graph is unchanged.'); + } + await delay(200, signal); + const current = graph.current?.instance; + if (current === undefined) throw new Error('Wait for the graph to mount.'); + current.applyHostUpdate({ data: seedSnapshot, dataRef: sourceReference }); + }, + }); + setInvestigation(owned); + return () => owned.destroy(); + }, []); + return investigation === null ?

Preparing investigation…

+ : ; +} + +function InvestigationWorkspaceContent({ workflow, graph, investigation }: { + workflow: Workflow; + graph: RefObject>; + investigation: InvestigationSession; +}): ReactElement { + const [instance, setInstance] = useState(null); + const [canvasKey, setCanvasKey] = useState(0); + const [events, setEvents] = useState([]); + const [notice, setNotice] = useState(''); + const [cursor, setCursor] = useState(); + const [pagingStarted, setPagingStarted] = useState(false); + const recoveries = useRef(new Set()); + const log = useCallback((event: ServiceEvent) => setEvents((old) => [...old.slice(-11), event]), []); + const services = useMemo(() => createSupplyChainServices(log), [log]); + const session = useSyncExternalStore(investigation.store.subscribe, investigation.store.getState, investigation.store.getState); + const subscribeGraph = useCallback((listener: () => void) => instance?.store.subscribe(listener) ?? (() => undefined), [instance]); + const readGraph = useCallback(() => instance?.store.getState() ?? null, [instance]); + const state = useSyncExternalStore(subscribeGraph, readGraph, readGraph); + const pending = state?.pendingExpansions.has('atlas') ?? false; + const usable = instance !== null && session.status === 'idle'; + + const priorRestoreStatus = useRef(session.status); + useEffect(() => { + if (priorRestoreStatus.current === 'restoring' && session.status === 'idle') { + setCursor(undefined); + setPagingStarted(false); + } + priorRestoreStatus.current = session.status; + }, [session.status]); + + useEffect(() => { + investigation.setTitle('Atlas supplier disruption'); + investigation.setNotes('Hypothesis: the port delay affects battery deliveries. Verify the bulletin before changing suppliers.'); + if (workflow !== 'save') return; + try { + const saved: unknown = JSON.parse(localStorage.getItem(storageKey) ?? '[]'); + if (!Array.isArray(saved)) throw new Error('Saved checkpoint list is invalid.'); + for (const raw of saved.slice(-8)) investigation.importCheckpoint(raw); + } catch (error) { + setNotice(`Could not read saved checkpoints: ${String(error)}`); + } + let previous = investigation.store.getState().checkpoints; + return investigation.store.subscribe((next) => { + if (next.checkpoints === previous) return; + previous = next.checkpoints; + try { + localStorage.setItem(storageKey, JSON.stringify(next.checkpoints.slice(-8).map((c) => JSON.parse(investigation.exportCheckpoint(c))))); + } catch (error) { + setNotice(`Could not save in this browser: ${String(error)}`); + } + }); + }, [investigation, workflow]); + + useEffect(() => () => { + for (const controller of recoveries.current) controller.abort(); + }, []); + + function ready(): void { + const current = graph.current?.instance; + if (current === undefined) return; + setInstance(current); + if (workflow === 'find' || workflow === 'paths') current.hideNodes(['harbor']); + if (workflow === 'inspect') current.selectEdges(['harbor-atlas']); + else current.selectNodes(['atlas']); + } + + const recover = async (result: SearchResult, reason: SearchUnavailableReason): Promise => { + const current = graph.current?.instance; + if (current === undefined) return; + if (reason === 'not-loaded') { + const controller = new AbortController(); + recoveries.current.add(controller); + try { await loadCatalogEntity(current, result.id, controller.signal); } + finally { recoveries.current.delete(controller); } + } else if (reason === 'out-of-scope') current.resetIsolation(); + else current.showNodes([result.id]); + }; + + function run(action: () => void | Promise): void { + setNotice(''); + void Promise.resolve().then(action).catch((error: unknown) => setNotice(error instanceof Error ? error.message : String(error))); + } + + async function loadPage(): Promise { + const result = await investigation.expandNode('atlas', { ...expansion, ...(cursor === undefined ? {} : { cursor }) }); + if ('page' in result) { + setCursor(result.page?.nextCursor); + setPagingStarted(true); + setNotice(result.page?.nextCursor === undefined ? 'All supplier pages loaded.' : 'Page complete. More suppliers are available.'); + } + } + + async function findPath(universe: 'visible' | 'loaded'): Promise { + if (instance === null) return; + const options = { direction: 'outgoing' as const, universe, maxHops: 6 }; + const result = await instance.findPathDetailed('report', 'bike', options); + if (result.status === 'found') { + investigation.savePath({ title: `${universe} report → bicycle`, sourceId: 'report', targetId: 'bike', options, path: result.path }); + setNotice(`${universe}: ${result.path.nodeIds.join(' → ')}. Saved to the path list.`); + } else setNotice(`${universe}: ${result.status}. No path was added.`); + } + + const showExpansion = ['expand', 'stable', 'save', 'async'].includes(workflow); + return ( +
+

{instructions[workflow].title}

+

{instructions[workflow].steps}

+
+ {workflow === 'find' && <> + + + + } + {showExpansion && <> + + + + } + {workflow === 'stable' && <> + + + } + {workflow === 'inspect' && <> + + + } + {workflow === 'paths' && <> + + + + } + {workflow === 'save' && <> + + + + } + +
+

{notice}

+
+
+ + key={canvasKey} ref={graph} engine={cosmosEngine} data={seedSnapshot} dataRef={sourceReference} + services={services} crossfilter={tableDimension} layout={workflow === 'stable' ? 'force' : 'fixed'} fitViewOnSettle={false} + nodeColor={colorOf} nodeSize={8} linkColor="#647c9e" linkWidth={2} edgeArrows labels={labels} + theme={GRAPH_THEME_DARK} onReady={ready} onError={({ error }) => setNotice(error.message)} + accessibility={{ label: 'Fictional Atlas Mobility supply chain' }} + /> +
+ {state?.nodeCount ?? 0} loaded nodes · {instance?.getVisibleNodeIds().length ?? 0} visible · {state?.pinnedNodeIds.size ?? 0} user pins +
+
+
+ {instance !== null && } +
+
+

Fictional source: supplier catalog · revision {sourceReference.revision}. Six entities are initially loaded; the backend catalog contains twelve. No network or credentials required.

+ {workflow === 'async' &&
+ Latest service lifecycle events (maximum 12) +
    {events.map((event, i) =>
  1. {event.status}: {event.operation} [{event.request}]
  2. )}
+
} +
+ ); +} diff --git a/apps/storybook/src/exploration/Workflows.stories.tsx b/apps/storybook/src/exploration/Workflows.stories.tsx new file mode 100644 index 0000000..93e3856 --- /dev/null +++ b/apps/storybook/src/exploration/Workflows.stories.tsx @@ -0,0 +1,171 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; +import { InvestigationWorkspace } from './InvestigationWorkspace'; + +const meta = { + title: 'Exploration/Investigation workflows', + component: InvestigationWorkspace, + parameters: { + layout: 'fullscreen', + controls: { disable: true }, + docs: { + description: { + component: 'Seven runnable workflows share a fictional supplier catalog. The graph loads six of twelve entities; a delayed, cancellable in-memory service supplies search and paginated neighborhoods. No credentials or network are needed. Use Canvas for the full workspace. The source files include the backend implementation and browser checkpoint persistence.', + }, + }, + }, + argTypes: { workflow: { table: { disable: true } } }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const FindAndInspect: Story = { + name: '1 · Find and inspect', + args: { workflow: 'find' }, + parameters: { docs: { + description: { story: 'Search is query-coherent and does not mutate visibility. Harbor is loaded but hidden, Cedar can be outside an isolation, and Ember is remote-only. The host recovery callback explicitly reveals, resets scope, or fetches an entity before activation.' }, + source: { code: `import { GraphExplorer } from '@modernrelay/orbit-react/components/Explorer'; + + { + if (reason === 'not-loaded') await loadEntity(result.id); + else if (reason === 'out-of-scope') instance.resetIsolation(); + else instance.showNodes([result.id]); // this host uses explicit hidden IDs + // Explorer activates only if the query/source is still current. + }} +/>` }, + } }, +}; + +export const TypedExpansion: Story = { + name: '2 · Bounded typed expansion', + args: { workflow: 'expand' }, + parameters: { docs: { + description: { story: 'Request incoming SUPPLIES only, capped at two neighbors. The first page contains already-loaded nodes and still has a continuation; keep following the server cursor. The session records expansion intent for later checkpoint replay. Retract preserves base data and contributions owned by other expansions.' }, + source: { code: `const result = await investigation.expandNode('atlas', { + direction: 'incoming', + relationshipTypes: ['SUPPLIES'], + relationshipTypeField: 'type', + hops: 1, + limit: 2, + preserveLayout: true, + ...(cursor === undefined ? {} : { cursor }), +}); +if ('page' in result) cursor = result.page?.nextCursor; +// A zero-new-node page can still have another cursor. +instance.cancelExpansion('atlas'); // pending work only +investigation.retractExpansion('atlas'); // most recent committed page` }, + } }, +}; + +export const StableMap: Story = { + name: '3 · Stable layout and camera', + args: { workflow: 'stable' }, + parameters: { docs: { + description: { story: 'This example runs a real force layout. Pan or zoom before loading another supplier page. preserveLayout retains established positions through internal holds and stops settle-camera follow. The new nodes can settle; Fit all is a deliberate camera action, and Resume full layout releases those holds while keeping user pins.' }, + source: { code: `await investigation.expandNode('atlas', { + direction: 'incoming', relationshipTypes: ['SUPPLIES'], + limit: 2, preserveLayout: true, +}); +// Camera changes are explicit user actions. +instance.fitView(); +// Release exploration holds; user pins remain intact. +instance.resumeSimulation();` }, + } }, +}; + +export const RelationshipsAndComparison: Story = { + name: '4 · Relationships and comparison', + args: { workflow: 'inspect' }, + parameters: { docs: { + description: { story: 'The relationship inspector shows the directed endpoints, type, evidence, confidence, and observation date. Compare two suppliers without converting inspection into navigation. The node, edge, and multiselection cases use the same persistent inspector.' }, + source: { code: `import { GraphInspector } from '@modernrelay/orbit-react/components/Inspector'; + + +` }, + } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const compare = canvas.getByRole('button', { name: 'Compare Harbor and Cedar' }); + await waitFor(() => expect(compare).toBeEnabled()); + await userEvent.click(compare); + await canvas.findByRole('heading', { name: '2 nodes selected' }); + await userEvent.click(canvas.getByRole('button', { name: 'Inspect Harbor → Atlas' })); + await canvas.findByText('Supplier register: battery contract'); + }, +}; + +export const OrderedPaths: Story = { + name: '5 · Ordered paths and graph coverage', + args: { workflow: 'paths' }, + parameters: { docs: { + description: { story: 'The report-to-bicycle chain crosses hidden Harbor. A visible-only request reports filtered; a loaded-graph request returns report → delay → harbor → atlas → bike. The saved result preserves step order and relationship IDs, while the explorer can inspect each step. Neither universe searches unloaded remote topology.' }, + source: { code: `const options = { direction: 'outgoing', universe: 'loaded', maxHops: 6 } as const; +const result = await instance.findPathDetailed('report', 'bike', options); +if (result.status === 'found') { + investigation.savePath({ + title: 'Evidence to product', sourceId: 'report', targetId: 'bike', + options, path: result.path, + }); +} +// Handle not-loaded, filtered, unreachable, and hop-limit explicitly.` }, + } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const visible = canvas.getByRole('button', { name: 'Find visible path' }); + await waitFor(() => expect(visible).toBeEnabled()); + await userEvent.click(visible); + await canvas.findByText('visible: filtered. No path was added.'); + await userEvent.click(canvas.getByRole('button', { name: 'Find loaded path' })); + await canvas.findByText('loaded: report → delay → harbor → atlas → bike. Saved to the path list.'); + }, +}; + +export const NamedInvestigations: Story = { + name: '6 · Save and reopen investigations', + args: { workflow: 'save' }, + parameters: { docs: { + description: { story: 'Use named checkpoints to retain notes, source identity, search text, paths, expansion recipes, and view state. This host persists exported checkpoint JSON to localStorage (at most eight); the library does not choose your storage. A fresh canvas starts from the same seed snapshot. Reopening replays expansion requests, then restores the saved view. Clear saved examples removes only this story’s storage key.' }, + source: { code: `import { createInvestigationSession } from '@modernrelay/orbit-core'; + +const investigation = createInvestigationSession(() => graphRef.current?.instance ?? null); +investigation.setTitle('Atlas supplier disruption'); +investigation.setNotes('Verify the port bulletin before changing suppliers.'); +await investigation.expandNode('atlas', { direction: 'incoming', limit: 2 }); +const checkpoint = await investigation.checkpoint('Initial evidence', { includePositions: true }); +localStorage.setItem('my-investigation', investigation.exportCheckpoint(checkpoint)); + +// On reopening: load the intended source, validate the imported document, restore. +const imported = investigation.importCheckpoint(JSON.parse(localStorage.getItem('my-investigation')!)); +await investigation.restoreCheckpoint(imported); +// The source includes the Graph dataRef; configure loadSource for other sources.` }, + } }, +}; + +export const CancellableService: Story = { + name: '7 · Async service lifecycle', + args: { workflow: 'async' }, + parameters: { docs: { + description: { story: 'The fixture implements ExpansionService.queryNeighbors and SearchService with real asynchronous delays, source-bound revision dependencies, bounded pages, request provenance, and AbortSignal checks. Expansion responses stream nodes and edges in separate batches; the instance stages them atomically. Cancel a request and inspect the lifecycle log, then retry.' }, + source: { code: `const expansion = { + revisionDependencies: ['source'], + async queryNeighbors(seedIds, options, ctx) { + const response = await fetch('/api/neighbors', { + method: 'POST', signal: ctx.signal, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ seedIds, ...options, sourceRevision: ctx.sourceRevision }), + }); + if (!response.ok) throw new Error('Neighborhood request failed'); + // Validate the server's nodes, edges, and page metadata here. + return parseNeighborhood(await response.json()); + }, + neighbors: legacyOneHopRequest, +}; +// These service options are construction-only: remount for a new service. +` }, + } }, +}; diff --git a/apps/storybook/src/exploration/supplyChain.ts b/apps/storybook/src/exploration/supplyChain.ts new file mode 100644 index 0000000..a475665 --- /dev/null +++ b/apps/storybook/src/exploration/supplyChain.ts @@ -0,0 +1,99 @@ +/** A fictional, deterministic supplier investigation. All data stays in this tab. */ +import type { + GraphEdge, + GraphNode, + GraphSnapshot, + LabelConfig, +} from '@modernrelay/orbit-core'; + +export interface Entity extends Record { + label: string; + type: 'Company' | 'Product' | 'Facility' | 'Incident' | 'Report'; + country: string; + status: string; + summary: string; +} + +export interface Relationship extends Record { + type: 'SUPPLIES' | 'BUILDS' | 'OPERATES' | 'AFFECTS' | 'DOCUMENTS'; + evidence: string; + confidence: number; + observedAt: string; +} + +function node(id: string, label: string, type: Entity['type'], country: string, + status: string, summary: string, x: number, y: number): GraphNode { + return { id, x, y, attrs: { label, type, country, status, summary } }; +} + +export const catalogNodes: readonly GraphNode[] = [ + node('atlas', 'Atlas Mobility', 'Company', 'DE', 'Investigating', 'Assembles the Atlas commuter bicycle.', 0, 0), + node('harbor', 'Harbor Cells', 'Company', 'NL', 'At risk', 'Primary battery supplier; a port delay is under review.', -170, -70), + node('cedar', 'Cedar Frames', 'Company', 'PL', 'Verified', 'Supplies recycled aluminum frames.', -160, 100), + node('bike', 'Atlas commuter bicycle', 'Product', 'DE', 'In production', 'Production depends on five specialist suppliers.', 170, 0), + node('delay', 'Harbor port delay', 'Incident', 'NL', 'Open', 'Fictional transport disruption reported on September 4.', -280, -170), + node('report', 'Port operations bulletin', 'Report', 'NL', 'Reviewed', 'Primary evidence for the disruption hypothesis.', -410, -170), + node('ember', 'Ember Electronics', 'Company', 'TW', 'Verified', 'Controller supplier; starts outside the loaded graph.', 80, -210), + node('lumen', 'Lumen Lighting', 'Company', 'CZ', 'Verified', 'Lighting supplier.', 240, -180), + node('northstar', 'Northstar Rubber', 'Company', 'FR', 'Monitoring', 'Tire supplier with a pending capacity assessment.', 300, 100), + node('foundry', 'Atlas assembly plant', 'Facility', 'DE', 'Operational', 'Final assembly facility.', 90, 190), + node('battery', 'Battery module', 'Product', 'NL', 'At risk', 'Module supplied by Harbor Cells.', -180, -260), + node('frame', 'Recycled frame', 'Product', 'PL', 'Available', 'Frame manufactured by Cedar Frames.', -180, 250), +]; + +function edge(id: string, source: string, target: string, type: Relationship['type'], + evidence: string, confidence = 0.95): GraphEdge { + return { id, source, target, attrs: { type, evidence, confidence, observedAt: '2026-09-04' } }; +} + +export const catalogEdges: readonly GraphEdge[] = [ + edge('harbor-atlas', 'harbor', 'atlas', 'SUPPLIES', 'Supplier register: battery contract'), + edge('cedar-atlas', 'cedar', 'atlas', 'SUPPLIES', 'Supplier register: frame contract'), + edge('ember-atlas', 'ember', 'atlas', 'SUPPLIES', 'Supplier register: controller contract'), + edge('lumen-atlas', 'lumen', 'atlas', 'SUPPLIES', 'Supplier register: lighting contract'), + edge('northstar-atlas', 'northstar', 'atlas', 'SUPPLIES', 'Supplier register: tire contract', 0.8), + edge('atlas-bike', 'atlas', 'bike', 'BUILDS', 'Product catalog'), + edge('delay-harbor', 'delay', 'harbor', 'AFFECTS', 'Port operations bulletin', 0.82), + edge('report-delay', 'report', 'delay', 'DOCUMENTS', 'Bulletin paragraph 3'), + edge('atlas-foundry', 'atlas', 'foundry', 'OPERATES', 'Facility register'), + edge('harbor-battery', 'harbor', 'battery', 'BUILDS', 'Product catalog'), + edge('cedar-frame', 'cedar', 'frame', 'BUILDS', 'Product catalog'), +]; + +const seedIds = new Set(['atlas', 'harbor', 'cedar', 'bike', 'delay', 'report']); +export const seedSnapshot: GraphSnapshot = { + datasetKey: 'storybook:supply-chain', + sourceRevision: '2026-09-04', + nodes: catalogNodes.filter((n) => seedIds.has(n.id)), + edges: catalogEdges.filter((e) => seedIds.has(e.source) && seedIds.has(e.target)), +}; + +export const sourceReference = { + source: 'fictional-supplier-catalog', + revision: '2026-09-04', + query: 'Atlas Mobility supply chain', +}; + +export const labelOf = (n: GraphNode): string => n.attrs?.label ?? n.id; +export const labels: LabelConfig = { minZoom: 0, maxVisible: 20, getText: labelOf }; +const colors: Record = { + Company: '#68a7ff', Product: '#6cdbb0', Facility: '#e5bd65', Incident: '#ed8392', Report: '#b59cf3', +}; +export const colorOf = (n: GraphNode): string => colors[n.attrs?.type ?? 'Company']; + +/** Real asynchronous work with cooperative cancellation; no fetch or credentials. */ +export function delay(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const abort = (): void => { + clearTimeout(timer); + signal.removeEventListener('abort', abort); + reject(new DOMException('The fixture request was cancelled.', 'AbortError')); + }; + const timer = setTimeout(() => { + signal.removeEventListener('abort', abort); + resolve(); + }, ms); + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) abort(); + }); +} diff --git a/apps/storybook/src/exploration/supplyChainServices.ts b/apps/storybook/src/exploration/supplyChainServices.ts new file mode 100644 index 0000000..4e37d6d --- /dev/null +++ b/apps/storybook/src/exploration/supplyChainServices.ts @@ -0,0 +1,150 @@ +import type { + ExpansionQuery, + ExpansionResponse, + GraphInstance, + GraphServices, + RequestContext, +} from '@modernrelay/orbit-core'; +import { catalogEdges, catalogNodes, delay } from './supplyChain'; +import type { Entity, Relationship } from './supplyChain'; + +export interface ServiceEvent { + request: string; + operation: string; + status: 'started' | 'completed' | 'cancelled' | 'failed'; +} + +/** + * A small backend simulator. It owns the FULL catalog, while Orbit starts with + * six nodes. Requests really await a timer, observe AbortSignal, paginate, and + * return async batches. In production these bodies can use fetch({ signal }). + */ +export function createSupplyChainServices( + report: (event: ServiceEvent) => void = () => undefined, +): GraphServices { + async function queryNeighbors( + seeds: readonly string[], + options: ExpansionQuery, + ctx: RequestContext, + ): Promise> { + const operation = `Neighbors of ${seeds.join(', ')}`; + const record = (status: ServiceEvent['status']): void => report({ request: ctx.requestId, operation, status }); + record('started'); + try { + await delay(450, ctx.signal); + if ((options.hops ?? 1) !== 1) throw new Error('This example backend supports one hop per request. Expand the next entity to continue.'); + const seedSet = new Set(seeds); + const direction = options.direction ?? 'either'; + const typeField = options.relationshipTypeField ?? 'type'; + const relationshipTypes = options.relationshipTypes; + const matching = catalogEdges.filter((e) => { + if (relationshipTypes !== undefined && !relationshipTypes.includes(String(e.attrs?.[typeField]))) return false; + return (direction !== 'incoming' && seedSet.has(e.source)) || + (direction !== 'outgoing' && seedSet.has(e.target)); + }); + const neighborIds = new Set(matching.flatMap((e) => [e.source, e.target])); + const neighbors = catalogNodes.filter((n) => neighborIds.has(n.id) && !seedSet.has(n.id)).map((n) => n.id); + // Bind the opaque cursor to its query so another seed/type cannot reuse it. + const fingerprint = JSON.stringify([ctx.datasetKey, ctx.sourceRevision, seeds, direction, relationshipTypes ?? null, typeField, options.limit ?? 2, options.edgeLimit ?? 10000]); + let offset = 0; + if (options.cursor !== undefined) { + const parsed: unknown = JSON.parse(options.cursor); + if (!Array.isArray(parsed) || parsed[0] !== fingerprint || !Number.isSafeInteger(parsed[1]) || parsed[1] < 0) { + throw new Error('Cursor does not belong to this neighborhood request.'); + } + offset = parsed[1] as number; + } + const limit = Math.min(50, Math.max(1, options.limit ?? 2)); + const selected = neighbors.slice(offset, offset + limit); + const endpoints = new Set([...seeds, ...selected]); + const nodes = catalogNodes.filter((n) => endpoints.has(n.id)); + const edges = matching.filter((e) => endpoints.has(e.source) && endpoints.has(e.target)); + if (edges.length > (options.edgeLimit ?? 10000)) throw new Error('Relationship budget is too small for this page. Request fewer neighbors or raise edgeLimit.'); + const nextOffset = offset + selected.length; + const nextCursor = nextOffset < neighbors.length ? JSON.stringify([fingerprint, nextOffset]) : undefined; + return { + provenance: { source: 'fictional-supplier-catalog', revision: ctx.sourceRevision, request: ctx.requestId }, + page: { + returnedNodes: selected.length, + returnedEdges: edges.length, + totalNeighbors: neighbors.length, + truncated: nextCursor !== undefined, + ...(nextCursor === undefined ? {} : { nextCursor }), + }, + batches: (async function* () { + try { + // Two bounded batches demonstrate atomic staging before publication. + await delay(150, ctx.signal); + yield { nodes }; + await delay(150, ctx.signal); + yield { edges }; + record('completed'); + } catch (error) { + record(ctx.signal.aborted ? 'cancelled' : 'failed'); + throw error; + } + })(), + }; + } catch (error) { + record(ctx.signal.aborted ? 'cancelled' : 'failed'); + throw error; + } + } + + return { + expansion: { + revisionDependencies: ['source'], + neighbors: (seeds, hops, ctx) => queryNeighbors(seeds, { hops, direction: 'either', limit: 50 }, ctx), + queryNeighbors, + }, + search: { + // The catalog is source-bound; hiding/loading nodes cannot change its hits. + revisionDependencies: ['source'], + async search(query, { limit }, ctx) { + const operation = `Search “${query}”`; + report({ request: ctx.requestId, operation, status: 'started' }); + try { + await delay(240, ctx.signal); + const needle = query.trim().toLowerCase(); + const hits = catalogNodes + .filter((n) => `${n.id} ${n.attrs?.label ?? ''}`.toLowerCase().includes(needle)) + .slice(0, Math.min(limit, 20)) + .map((n) => ({ id: n.id, label: n.attrs?.label ?? n.id })); + report({ request: ctx.requestId, operation, status: 'completed' }); + return hits; + } catch (error) { + report({ request: ctx.requestId, operation, status: ctx.signal.aborted ? 'cancelled' : 'failed' }); + throw error; + } + }, + }, + }; +} + +/** Explicit host action for a not-loaded search hit; never a search side effect. */ +export async function loadCatalogEntity( + instance: GraphInstance, + id: string, + signal: AbortSignal, +): Promise { + await delay(350, signal); + const node = catalogNodes.find((n) => n.id === id); + if (node === undefined) throw new Error(`Unknown catalog entity: ${id}`); + const edges = catalogEdges.filter((e) => + (e.source === id && instance.getNode(e.target) !== undefined) || + (e.target === id && instance.getNode(e.source) !== undefined)); + const session = instance.beginIngest({ + purpose: 'overlay', + datasetKey: 'storybook:supply-chain', + baseModelRevision: instance.store.getState().revisions.model, + atomic: true, + }); + try { + await session.append({ sequence: 0, batchId: `search:${id}`, nodes: [node], edges }); + if (signal.aborted) throw new DOMException('Search recovery cancelled.', 'AbortError'); + await session.commit(); + } catch (error) { + await session.abort(error); + throw error; + } +} diff --git a/docs/core/exploration-workflows.md b/docs/core/exploration-workflows.md new file mode 100644 index 0000000..2699a71 --- /dev/null +++ b/docs/core/exploration-workflows.md @@ -0,0 +1,317 @@ +# Graph exploration workflows + +Orbit can combine search, inspection, bounded expansion, paths, and saved +investigations in one workspace. The headless APIs live in +`@modernrelay/orbit-core`; the optional React workspace is a component subpath: + +```tsx +import { Graph } from '@modernrelay/orbit-react'; +import { GraphExplorer } from '@modernrelay/orbit-react/components/Explorer'; + + node.id }]} +> + + +``` + +Inside ``, the explorer uses its instance from context. To place it beside +the canvas, pass `instance={instance}` and `layout="panel"` in your own layout. +The default `layout="dock"` positions the workspace over its parent. Use `height`, +`style`, and `className` to size the panel. Importing the root React package does +not eagerly import the explorer. + +Run `pnpm --filter orbit-storybook dev` and open **Exploration → Investigation +workflows** for seven runnable examples. They share a fictional supplier catalog +with six initially loaded entities and six additional entities available through +a cancellable asynchronous service. No credentials or network are needed. The +complete example host and service are in +`apps/storybook/src/exploration/InvestigationWorkspace.tsx` and +`apps/storybook/src/exploration/supplyChainServices.ts`. + +## Find, inspect, and recover explicitly + +Search does not alter scope, filters, or the loaded graph. The built-in service +indexes IDs plus the declared `searchIndex` attributes. A remote search service +can return IDs that are not loaded. Activating a result produces one of: + +| Result | Meaning | Host action | +| --- | --- | --- | +| Focused | Loaded, in scope, and visible | Inspect or navigate | +| `not-loaded` | Absent from the accepted graph | Fetch and ingest it | +| `out-of-scope` | Loaded but outside the current hard scope | Change or clear that scope | +| `filtered` | Loaded but hidden by a mask | Change the relevant mask or host filter | + +`GraphExplorer` exposes a recovery action for unavailable hits through +`onRecoverSearchResult`. Make the change deliberate and specific to your host: + +```tsx + { + if (reason === 'not-loaded') { + await loadEntity(result.id); // resolve after ingestion commits + } else if (reason === 'out-of-scope') { + instance.resetIsolation(); + } else { + instance.showNodes([result.id]); // appropriate for explicit hidden IDs + } + // Explorer activates after recovery, unless the query/source changed. + }} +/> +``` + +`showNodes` clears explicit hiding. It cannot undo a predicate filter or a +crossfilter brush. When those are the cause, update the corresponding host state +and wait for it to apply. Pass host-owned active filters as explorer `constraints` +with `{ id, label, onClear }` so the user can see and remove them. Query-coherent +search prevents results for an old input from becoming current after a slow +request completes. + +## Expand a bounded neighborhood + +Use the investigation session wrapper when an expansion should be replayable in +a saved investigation: + +```ts +import { createInvestigationSession } from '@modernrelay/orbit-core'; + +const investigation = createInvestigationSession(instance); +const query = { + direction: 'incoming', + relationshipTypes: ['SUPPLIES'], + relationshipTypeField: 'type', + hops: 1, + limit: 2, + edgeLimit: 20, + preserveLayout: true, +} as const; + +const first = await investigation.expandNode('atlas', query); +const cursor = 'page' in first ? first.page?.nextCursor : undefined; +if (cursor !== undefined) { + await investigation.expandNode('atlas', { ...query, cursor }); +} + +instance.cancelExpansion('atlas'); // pending request only +investigation.retractExpansion('atlas'); // retract the most recent recorded page +``` + +Relationship matching uses exact strings in `edge.attrs[relationshipTypeField]`. +The field defaults to `type`; an Omnigraph adapter uses `orbit:type`. Keep the +same seed, relationship options, and limits when following a cursor. A page can +contain only already-loaded neighbors and still have a continuation. Do not +infer completion from the number of newly visible nodes. Read `page.nextCursor` +and `page.truncated`; `totalNeighbors` can be absent when the service cannot know +its full universe. Retraction removes the most recent recorded page for that seed. It leaves base +data and contributions still owned by other expansions intact. + +For passive inspection, `instance.getNeighborhood(id, options)` reads a bounded +neighborhood from the loaded graph without revealing anything. Its options +include `direction`, `relationshipTypes`, `relationshipTypeField`, `limit`, +`edgeLimit`, `cursor`, and `visibility: 'loaded' | 'visible'`. Results distinguish +node pagination from `edgesTruncated` and report visibility for returned nodes. + +### Asynchronous service contract + +A custom `ExpansionService` keeps the existing `neighbors(seedIds, hops, ctx)` +method and can implement `queryNeighbors(seedIds, options, ctx)` for typed, +bounded queries. Return nodes and edges, or an asynchronous batch iterable, with +optional page metadata and provenance: + +```ts +const response = { + nodes: pageNodes, + edges: pageEdges, + page: { + returnedNodes: neighborCount, // exclude seed nodes + returnedEdges: pageEdges.length, + totalNeighbors, + truncated: nextCursor !== undefined, + ...(nextCursor === undefined ? {} : { nextCursor }), + }, + provenance: { source: 'supplier-catalog', revision: sourceRevision }, +}; +``` + +Enforce the requested node and edge budgets before returning a response. Bind +cursors to the query and source revision; reject a cursor from another query. +The fixture service deliberately supports one hop per request and reports an +error for larger values. It uses a timer and `AbortSignal`, then streams two +small batches. A production implementation can use `fetch(url, { signal: +ctx.signal })` and validate the response before returning it. + +Declare the revision dimensions the service actually reads. The fixture's remote +catalog depends on `source`, while a computation over the current loaded model +may also depend on `model` or `scope`. Core checks dataset lineage and declared +revision coordinates before admitting results. Cancellation reduces wasted +work; revision admission protects correctness even when a transport ignores +abort. Expansion ingestion is atomic: cancelling a staged request must not +publish half of its neighborhood. `onProgress` reports receipt/staging progress, +not partial graph visibility. + +Services, engine factories, and `searchIndex` are construction options. Use a +new instance or keyed `` remount when replacing them. + +## Keep layout and camera context + +`preserveLayout: true` holds established nodes internally and cancels +automatic settle-camera follow while new neighbors arrive. Users can continue +reading their current region, then explicitly choose `instance.fitView()` when +they want to include the new area. This option does not promise that newly added +nodes already have a settled layout. + +Use an explicit resume action to release exploration holds and let the existing +layout move again: + +```ts +instance.resumeSimulation(); +``` + +Changing layout kind also clears these holds. They are separate from user pins, +which remain intact; `pinnedNodeIds` reports user pins, not exploration holds. Prefer explicit camera actions to fitting after every filter, +inspection, or search input change. The **Stable layout and camera** story uses +the real force engine so these actions can be observed. + +## Inspect relationships and compare selections + +The reusable inspector accepts an explicit subject. Selecting a subject for +inspection can remain separate from graph selection or camera movement: + +```tsx +import { GraphInspector } from '@modernrelay/orbit-react/components/Inspector'; + + + + +``` + +Relationship inspection includes directed endpoints and attributes. The supplier +example carries `evidence`, `confidence`, and `observedAt`, so a user can assess +why the relationship is present. Multiselection inspection supports comparison +without requiring an arbitrary single selected node. `GraphExplorer` composes +these surfaces with a persistent node table and neighborhood actions. + +## Preserve path order and state the searched universe + +Use `findPathDetailed` when the UI needs an explanatory outcome. Its default +universe is `visible`. The `loaded` universe deliberately includes loaded nodes +outside the current scope or hidden by masks. Neither built-in universe fetches +unloaded remote topology. + +```ts +const options = { + direction: 'outgoing', + universe: 'loaded', + maxHops: 6, +} as const; +const result = await instance.findPathDetailed('report', 'bike', options); + +if (result.status === 'found') { + investigation.savePath({ + title: 'Evidence to product', + sourceId: 'report', + targetId: 'bike', + options, + path: result.path, + }); +} +``` + +A found result contains ordered `nodeIds` and the corresponding `edgeIds` between +them. Retain that order when rendering steps. Other outcomes are `not-loaded`, +`filtered`, `unreachable`, and `hop-limit`; an unreachable result is scoped to the +chosen loaded/visible universe and relationship constraints. A hidden +intermediate node can disconnect the visible graph even when both endpoints are +visible; the built-in resolver reports `filtered` with blocker node IDs when a +corresponding loaded path exists. The detailed query is passive; saving or inspecting its result need not +move the camera or overwrite selection. + +## Save and reopen investigations + +An investigation checkpoint contains a title, notes, source coordinates, view +state, search text, saved paths, and replayable expansion requests. Use +`dataRef` for the host's durable source reference. Runtime overlay IDs and +revision-bound cursor strings are not the durable representation: restoring +replays requests and obtains fresh continuation cursors. + +```ts +investigation.setTitle('Atlas supplier disruption'); +investigation.setNotes('Verify the port bulletin before changing suppliers.'); +const saved = await investigation.checkpoint('Initial evidence', { + includePositions: true, +}); + +// Storage is a host choice: local storage, a file, or your authenticated backend. +const json = investigation.exportCheckpoint(saved); +localStorage.setItem('supplier-investigation', json); + +const stored = localStorage.getItem('supplier-investigation'); +if (stored !== null) { + const imported = investigation.importCheckpoint(stored); // validates; no restore yet + await investigation.restoreCheckpoint(imported); +} +``` + +To survive host remounts, construct the session with a getter: + +```ts +const investigation = createInvestigationSession( + () => graphRef.current?.instance ?? null, + { + async loadSource(source, { signal }) { + await loadAndAcceptSource(source, signal); + // Resolve only once the getter returns the current instance AND its + // accepted datasetKey, sourceRevision, and dataRef match source exactly. + }, + captureHostState: () => serializableFilters, + async restoreHostState(savedFilters, { signal }) { + await reflectFiltersAndWaitForGraph(savedFilters, signal); + }, + }, +); +``` + +`loadAndAcceptSource` and `reflectFiltersAndWaitForGraph` are host functions, not +Orbit exports. A host loader can use an awaited replacing ingest session; a +React state setter alone does not mean the source has been accepted. A source +mismatch without a loader fails explicitly. A loader that cannot serve the +requested revision must reject rather than substitute its current revision. +Use `captureHostState`/`restoreHostState` for predicate-filter intent or other +host state that view-state serialization cannot reconstruct. + +Call `investigation.refreshSource()` after replacing the instance returned by the +getter; `GraphExplorer` does this when its instance changes. Source changes discard +current source-specific evidence while retaining saved checkpoints. Undo and redo +reconcile recorded expansion recipes with the live expansion stack. + +Pass this session as ``. Route +durable expansion and path actions through it; unrelated direct instance actions +are not automatically recorded as replay recipes. Dispose the session when its +owning workspace closes. Handle storage failures and restore errors in the host. + +The **Save and reopen investigations** story stores up to eight exported +checkpoints under its own localStorage key. **Fresh canvas** recreates the graph +from the seed data, and reopening replays the saved supplier pages. **Try +unavailable source** demonstrates an explicit failure without substituting data. +Checkpoint replay requires the referenced source and services to remain +available; the checkpoint is an investigation recipe, not an offline graph dump. + +Checkpoint restore replays multiple operations. If replay fails or is cancelled, +it removes contributions admitted by that restore and reports the error; it does +not reconstruct the previous investigation or reverse host loader/filter side +effects. Save the current work as a checkpoint before switching investigations. diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 43c4a8d..503c5a8 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -33,6 +33,8 @@ export type GraphError = * the union is the public contract, the wiring arrives with each subsystem. */ export type GraphOperationError = + | { code: 'invalid-operation'; detail: string } + | { code: 'unsupported-operation'; detail: string } | { code: 'export-too-large'; elementCount: number; limit: number } | { code: 'export-materialization-too-large'; rowCount: number; limit: number } | { code: 'queue-overflow'; queuedBytes: number; limit: number } diff --git a/packages/core/src/exploration.ts b/packages/core/src/exploration.ts new file mode 100644 index 0000000..ae5633e --- /dev/null +++ b/packages/core/src/exploration.ts @@ -0,0 +1,123 @@ +/** Passive exploration reads and shared public-query validation. */ +import { OrbitOperationError } from './errors'; +import type { + AcceptedGraph, AcceptedEdge, ExpansionOptions, NeighborhoodOptions, + NeighborhoodResult, NodeId, NodeVisibility, PathOptions, RelationshipOptions, +} from './types'; + +export function invalidQuery(detail: string): never { + throw new OrbitOperationError({ code: 'invalid-operation', detail }, detail); +} + +export function boundedInteger(value: number | undefined, fallback: number, min: number, max: number, name: string): number { + const result = value ?? fallback; + if (!Number.isInteger(result) || result < min || result > max) { + invalidQuery(`${name} must be an integer from ${min} to ${max}`); + } + return result; +} + +export function validateRelationships(options: T): T { + if (options.direction !== undefined && !['outgoing', 'incoming', 'either'].includes(options.direction)) { + invalidQuery('direction must be outgoing, incoming, or either'); + } + if (options.relationshipTypeField !== undefined && (typeof options.relationshipTypeField !== 'string' || options.relationshipTypeField.length === 0)) { + invalidQuery('relationshipTypeField must be a non-empty string'); + } + if (options.relationshipTypes !== undefined && (!Array.isArray(options.relationshipTypes) || options.relationshipTypes.some((v) => typeof v !== 'string'))) { + invalidQuery('relationshipTypes must be an array of strings'); + } + return { + ...options, + ...(options.relationshipTypes === undefined ? {} : { relationshipTypes: [...new Set(options.relationshipTypes)].sort() }), + }; +} + +export function validateCursor(cursor: string | undefined): void { + if (cursor !== undefined && (typeof cursor !== 'string' || cursor.length === 0 || cursor.length > 100000)) { + invalidQuery('cursor must be a non-empty continuation token'); + } +} + +export function validateExpansion(options: ExpansionOptions): ExpansionOptions { + const result = validateRelationships(options); + boundedInteger(options.hops, 1, 1, 1000, 'hops'); + boundedInteger(options.limit, 50, 1, 1000, 'limit'); + boundedInteger(options.edgeLimit, 10000, 1, 10000, 'edgeLimit'); + validateCursor(options.cursor); + if (options.preserveLayout !== undefined && typeof options.preserveLayout !== 'boolean') invalidQuery('preserveLayout must be boolean'); + if (options.onProgress !== undefined && typeof options.onProgress !== 'function') invalidQuery('onProgress must be a function'); + return result; +} + +export function validatePath(options: PathOptions): PathOptions { + const result = validateRelationships(options); + if (options.universe !== undefined && options.universe !== 'visible' && options.universe !== 'loaded') invalidQuery('universe must be visible or loaded'); + if (options.maxHops !== undefined) boundedInteger(options.maxHops, 0, 0, 1000, 'maxHops'); + return result; +} + +export function relationshipType(edge: AcceptedEdge, field = 'type'): string { + const attrs = edge.attrs as Record | undefined; + const value = attrs?.[field]; + return typeof value === 'string' ? value : ''; +} + +export function matchesRelationship(edge: AcceptedEdge, options: RelationshipOptions): boolean { + return options.relationshipTypes === undefined || options.relationshipTypes.includes(relationshipType(edge, options.relationshipTypeField)); +} + +export function pageOffset(cursor: string | undefined, key: string): number { + if (cursor === undefined) return 0; + try { + const parsed: unknown = JSON.parse(cursor); + if (Array.isArray(parsed) && parsed.length === 2 && parsed[0] === key && Number.isSafeInteger(parsed[1]) && parsed[1] >= 0) return parsed[1] as number; + } catch { /* typed failure below */ } + return invalidQuery('cursor is stale or belongs to a different query'); +} + +export function neighborhoodOf( + accepted: AcceptedGraph | null, + seedId: NodeId, + rawOptions: NeighborhoodOptions, + revisionKey: string, + visibilityOf: (id: NodeId) => NodeVisibility, + edgeVisible: (id: string) => boolean, +): NeighborhoodResult { + const options = validateRelationships(rawOptions); + const limit = boundedInteger(options.limit, 50, 1, 1000, 'limit'); + const edgeLimit = boundedInteger(options.edgeLimit, 200, 1, 10000, 'edgeLimit'); + validateCursor(options.cursor); + if (options.visibility !== undefined && options.visibility !== 'loaded' && options.visibility !== 'visible') invalidQuery('visibility must be loaded or visible'); + const key = JSON.stringify([revisionKey, seedId, options.direction ?? 'either', options.relationshipTypes ?? null, options.relationshipTypeField ?? 'type', options.visibility ?? 'loaded', limit, edgeLimit]); + const offset = pageOffset(options.cursor, key); + const empty: NeighborhoodResult = { seedId, status: 'not-loaded', nodes: [], edges: [], visibility: new Map(), relationshipTypes: [], totalNeighbors: 0, totalEdges: 0, edgesTruncated: false }; + if (accepted === null || !accepted.nodeIndex.has(seedId)) return empty; + const neighbors = new Set(); + const matched: AcceptedEdge[] = []; + const counts = new Map(); + const direction = options.direction ?? 'either'; + for (const edge of accepted.edges) { + let neighbor: NodeId | undefined; + if (edge.source === seedId && direction !== 'incoming') neighbor = edge.target; + else if (edge.target === seedId && direction !== 'outgoing') neighbor = edge.source; + if (neighbor === undefined || !accepted.nodeIndex.has(neighbor)) continue; + if (options.visibility === 'visible' && (!edgeVisible(edge.id) || visibilityOf(neighbor) !== 'visible' || visibilityOf(seedId) !== 'visible')) continue; + const type = relationshipType(edge, options.relationshipTypeField); + counts.set(type, (counts.get(type) ?? 0) + 1); + if (!matchesRelationship(edge, options)) continue; + if (neighbor !== seedId) neighbors.add(neighbor); + matched.push(edge); + } + const ids = [...neighbors].slice(offset, offset + limit); + const pageIds = new Set([seedId, ...ids]); + const pageEdges = matched.filter((e) => pageIds.has(e.source) && pageIds.has(e.target)); + const result: NeighborhoodResult = { + seedId, status: 'loaded', nodes: ids.map((id) => accepted.nodes[accepted.nodeIndex.get(id)!]!), + edges: pageEdges.slice(0, edgeLimit), visibility: new Map(ids.map((id) => [id, visibilityOf(id)])), + relationshipTypes: [...counts].map(([type, count]) => ({ type, count })), + totalNeighbors: neighbors.size, totalEdges: matched.length, edgesTruncated: pageEdges.length > edgeLimit, + }; + if (offset + limit < neighbors.size) result.nextCursor = JSON.stringify([key, offset + limit]); + return result; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5200583..1161a0d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -77,6 +77,15 @@ export type { ExpansionBatch, ExpansionResponse, ExpansionService, + RelationshipOptions, + NodeVisibility, + NeighborhoodOptions, + NeighborhoodResult, + ExpansionPage, + ExpansionProgress, + ExpansionOptions, + ExpansionQuery, + PathOutcome, BeginIngestOptions, IngestBatch, AppendReceipt, @@ -171,9 +180,23 @@ export type { LabelSubscriptions, GraphServices, ExpandNodeResult, + ExpansionRecord, ExpansionOverlayRecord, } from './instance'; +// Durable, host-loadable exploration checkpoints and replayable intent. +export { createInvestigationSession, parseInvestigation, serializeInvestigation, InvestigationError } from './investigation'; +export type { + InvestigationSource, + InvestigationExpansionOptions, + InvestigationExpansion, + SavedInvestigationPath, + GraphInvestigation, + InvestigationSessionState, + InvestigationSessionOptions, + InvestigationSession, +} from './investigation'; + // Label lane: pure candidate selector + overlay types export { selectLabelCandidates, diff --git a/packages/core/src/instance.ts b/packages/core/src/instance.ts index 8c8b702..709d47b 100644 --- a/packages/core/src/instance.ts +++ b/packages/core/src/instance.ts @@ -28,6 +28,13 @@ import type { ExpansionBatch, ExpansionResponse, ExpansionService, + ExpansionOptions, + ExpansionPage, + ExpansionProgress, + NeighborhoodOptions, + NeighborhoodResult, + NodeVisibility, + PathOutcome, FilterMode, FilterSpec, GraphDiagnostic, @@ -137,6 +144,7 @@ import { } from './services'; import type { RequestContextHandle, RevisionSnapshot } from './services'; import { computePathEmphasis, createLocalPathService } from './pathService'; +import { neighborhoodOf, validateExpansion, validatePath, invalidQuery, validateCursor } from './exploration'; import { createLocalSearchService } from './search'; import type { SearchService } from './search'; import { nextSynthesizedEdgeId } from './edgeIdentity'; @@ -420,7 +428,7 @@ export interface CreateGraphInstanceOptions< * SECOND caller the IDENTICAL in-flight promise, so both callers observe * the primary call's `{added}`/`{noop}` result instead of this marker. */ -export type ExpandNodeResult = { added: number } | { coalesced: true } | { noop: true }; +export type ExpandNodeResult = ({ added: number } | { coalesced: true } | { noop: true }) & { page?: ExpansionPage }; /** * expansion bookkeeping: one committed expansion overlay. Data-merging @@ -449,7 +457,7 @@ export interface ExpansionOverlayRecord { * out of it). They DO interleave with the undo stack as 'expansion' * steps (serializable value diffs of the effective-set state). */ -interface ExpansionRecord { +export interface ExpansionRecord { expandedId: NodeId; addedNodeIds: readonly NodeId[]; /** Owning overlay — removed wholesale when a collapse leaves no survivor @@ -752,19 +760,22 @@ export interface GraphInstance, E = Record; + expandNode(id: NodeId, opts?: ExpansionOptions): Promise; + /** Abort pending work without retracting a previously committed page. */ + cancelExpansion(id: NodeId): void; + /** Bounded passive loaded-neighborhood read. Does not focus or select. */ + getNeighborhood(id: NodeId, options?: NeighborhoodOptions): NeighborhoodResult; + getSource(): { datasetKey: string; sourceRevision: number | string } | null; /** - * Undoes `id`'s own expansions — the navigation Back button, NOT a - * containment operation. Aborts `id`'s pending expansion AND explicitly - * removes the overlays its past expansions committed (plus their scope - * accretion). Committed overlay DATA otherwise persists until - * `removeOverlay()` or a replacing snapshot; this IS that explicit removal for - * expansion overlays. + * Retracts the newest committed expansion page for `id` and cancels its + * pending request. Earlier pages and other seeds retain their ownership. + * Removes its overlay wholesale when no introduced node survives; shared + * data can remain loaded while its effective visibility is retracted. * * On a node that was never expanded from, this does nothing — there is no * record to pop. To hide a node's neighbourhood behind it on a freshly @@ -775,6 +786,8 @@ export interface GraphInstance, E = Record, E = Record; + /** Passive detailed query; unlike findPath, never changes emphasis. */ + findPathDetailed(sourceId: NodeId, targetId: NodeId, options?: PathOptions): Promise; clearPath(): void; getActivePath(): PathResult | null; /** @@ -1223,6 +1238,8 @@ export function createGraphInstance, E = Record>(); + const expansionKeys = new Map(); + const expansionObservers = new Map void>>(); /** requestId → abort handle for the in-flight service call. */ const expansionHandles = new Map(); /** requestId → external rejector (collapse/destroy settle the caller @@ -1243,6 +1260,13 @@ export function createGraphInstance, E = Record | null = null; + /** Explicit preserve-layout expansions retain landmarks until resume/layout reset. */ + let explorationPinIds: ReadonlySet = new Set(); + let explorationPinsNeedPush = false; + function clearExplorationPins(): void { + if (explorationPinIds.size > 0) explorationPinsNeedPush = true; + explorationPinIds = new Set(); + } /** expansion resolver (caller-supplied or the built-in local walk). */ const expansionService: ExpansionService = opts.services?.expansion ?? @@ -1319,28 +1343,15 @@ export function createGraphInstance, E = Record(() => ({ - nodes: accepted === null ? [] : accepted.nodes, - edges: accepted === null ? [] : accepted.edges, - isEdgeVisible: (id) => { - if (scene === null) return true; // pre-mount: the loaded set is the base - const k = sceneLinkIndexOf(id); - if (k === undefined) return false; // out of scope → not traversable - return softMask === null || softMask.isEdgeVisible(k); - }, - })); - /** Lazy edge-id → SCENE link index (invalidated with the scene; the path - * base and emphasis both need link-lane indices, not accepted order). */ - let sceneLinkIndexCache: { scene: RenderScene; map: ReadonlyMap } | null = null; - function sceneLinkIndexOf(id: EdgeId): number | undefined { - if (scene === null) return undefined; - if (sceneLinkIndexCache === null || sceneLinkIndexCache.scene !== scene) { - const map = new Map(); - for (let k = 0; k < scene.edgeIdByIndex.length; k++) map.set(scene.edgeIdByIndex[k]!, k); - sceneLinkIndexCache = { scene, map }; - } - return sceneLinkIndexCache.map.get(id); - } + createLocalPathService(() => { + const pin = captureExportPin('visible'); + return { + nodes: accepted === null ? [] : accepted.nodes, + edges: accepted === null ? [] : accepted.edges, + isNodeVisible: (id) => nodeVisibilityOf(id) === 'visible', + isEdgeVisible: (id) => pin?.visibleEdgeIds?.has(id) ?? true, + }; + }); /** search resolver (caller-supplied or the built-in local index). */ const searchService: SearchService = opts.services?.search ?? @@ -4054,11 +4065,9 @@ export function createGraphInstance, E = Record 0 || persistentSize > 0)) + pinsNext !== null || pinnedNodesNext !== null || explorationPinsNeedPush || + (structuralChange && (pinsForPush.size > 0 || store.getState().pinnedNodeIds.size > 0 || explorationPinIds.size > 0)) ) { pushPinsToEngine(eng, pinsForPush); } @@ -5175,9 +5184,10 @@ export function createGraphInstance, E = Record, ): void { + explorationPinsNeedPush = false; if (eng.setPinnedIndices === undefined) return; const persistent = store.getState().pinnedNodeIds; - if (scene === null || (pins.size === 0 && persistent.size === 0 && accretionPinIds === null)) { + if (scene === null || (pins.size === 0 && persistent.size === 0 && accretionPinIds === null && explorationPinIds.size === 0)) { eng.setPinnedIndices(null); return; } @@ -5190,6 +5200,10 @@ export function createGraphInstance, E = Record, E = Record 0 || pinnedNodeIds.size > 0) pushPinsToEngine(eng, pins); + if (pins.size > 0 || pinnedNodeIds.size > 0 || explorationPinIds.size > 0) pushPinsToEngine(eng, pins); if (emphasizedNodeId !== null && scene !== null && !scene.indexById.has(emphasizedNodeId)) { emphasizedNodeId = null; // departed while detached/lost — never resurrect } @@ -6244,6 +6258,8 @@ export function createGraphInstance, E = Record, E = Record, E = Record 0 || nextPinned.size > 0)) + pinnedChanged || explorationPinsNeedPush || + (structuralChange && (nextPins.size > 0 || nextPinned.size > 0 || explorationPinIds.size > 0)) ) { pushPinsToEngine(eng, nextPins); // union sink reads the published slice } @@ -7089,12 +7106,36 @@ export function createGraphInstance, E = Record> | AsyncIterable>, provenance: unknown, + options: ExpansionOptions, + bounded: boolean, + page: ExpansionPage | undefined, ): Promise { const requestId = ctx.requestId; const overlayId = `expand:${id}:${requestId}`; @@ -7105,13 +7146,24 @@ export function createGraphInstance, E = Record { + if (ingest.state === 'open') void ingest.abort(ctx.signal.reason).catch(() => {}); + }; + ctx.signal.addEventListener('abort', abortIngest, { once: true }); const allNodes: GraphNode[] = []; + const receivedIds = new Set(); + let receivedEdges = 0; let sequence = 0; try { for await (const b of batches) { if (!ownsExpansion(id, requestId)) { throw new OrbitOperationError({ code: 'aborted', cause: 'collapsed' }); } + for (const node of b.nodes ?? []) if (node.id !== id) receivedIds.add(node.id); + receivedEdges += b.edges?.length ?? 0; + if (bounded && (receivedIds.size > (options.limit ?? 50) || receivedEdges > (options.edgeLimit ?? 10000))) { + invalidQuery('expansion response exceeded the requested node or edge budget'); + } const batch: IngestBatch = { sequence, batchId: `${requestId}#${sequence}` }; if (b.nodes !== undefined) { batch.nodes = b.nodes; @@ -7120,6 +7172,7 @@ export function createGraphInstance, E = Record, E = Record {}); + invalidQuery('expansion page counts do not match its response'); } // Final admission decision, serialized at the acceptance queue. @@ -7196,6 +7256,11 @@ export function createGraphInstance, E = Record 0) { @@ -7207,6 +7272,7 @@ export function createGraphInstance, E = Record, E = Record { @@ -7250,7 +7318,11 @@ export function createGraphInstance, E = Record; try { - response = await expansionService.neighbors([id], hops, ctx); + if (bounded) { + const { onProgress: _progress, preserveLayout: _preserve, ...query } = options; + void _progress; void _preserve; + response = await expansionService.queryNeighbors!([id], query, ctx); + } else response = await expansionService.neighbors([id], options.hops ?? 1, ctx); } catch (err) { if (!ownsExpansion(id, requestId) || ctx.signal.aborted) { // Collapsed/destroyed while in flight — the caller promise was @@ -7272,12 +7344,14 @@ export function createGraphInstance, E = Record expansionAdmissible(id, ctx, at)); if (denial !== null) discardExpansion(id, denial); - return mergeExpansionSession(id, ctx, at, response.batches, response.provenance); + return mergeExpansionSession(id, ctx, at, response.batches, response.provenance, options, bounded, page); } const denial = acceptanceQueue.admit(() => expansionAdmissible(id, ctx, at)); @@ -7288,14 +7362,24 @@ export function createGraphInstance, E = Record node.id !== id).map((node) => node.id)).size; + if (bounded && (nodeCount > (options.limit ?? 50) || edges.length > (options.edgeLimit ?? 10000))) invalidQuery('expansion response exceeded the requested node or edge budget'); + if (page !== undefined && (page.returnedNodes !== nodeCount || page.returnedEdges !== edges.length || (page.totalNeighbors !== undefined && page.totalNeighbors < nodeCount))) invalidQuery('expansion page counts do not match its response'); + if (revealed.length === 0 && !anyUnknownEdge(edges)) { + notifyExpansion({ requestId, seedId: id, batches: 1, receivedNodes: nodeCount, receivedEdges: edges.length, status: 'committed' }); + return page === undefined ? { noop: true } : { noop: true, page: { ...page } }; + } const rows: ExpansionBatch = {}; if (response.nodes !== undefined) rows.nodes = response.nodes; if (response.edges !== undefined) rows.edges = response.edges; - return mergeExpansionSession(id, ctx, at, [rows], response.provenance); + return mergeExpansionSession(id, ctx, at, [rows], response.provenance, options, bounded, page); } - function expandNode(id: NodeId, expandOpts?: { hops?: number }): Promise { + function expandNode(id: NodeId, expandOpts?: ExpansionOptions): Promise { + let options: ExpansionOptions; + try { options = validateExpansion(expandOpts ?? {}); } catch (error) { return Promise.reject(error); } + const bounded = options.direction !== undefined || options.relationshipTypes !== undefined || options.relationshipTypeField !== undefined || options.limit !== undefined || options.edgeLimit !== undefined || options.cursor !== undefined; + if (bounded && expansionService.queryNeighbors === undefined) return Promise.reject(new OrbitOperationError({ code: 'unsupported-operation', detail: 'the expansion service does not implement queryNeighbors' })); if (destroyed) { return Promise.reject( new OrbitOperationError( @@ -7304,15 +7388,18 @@ export function createGraphInstance, E = Record, E = Record, E = Record { - if (expansionPromises.get(id) === promise) expansionPromises.delete(id); + if (expansionPromises.get(id) === promise) { + expansionPromises.delete(id); + expansionKeys.delete(id); + } + expansionObservers.delete(requestId); expansionHandles.delete(requestId); expansionRejectors.delete(requestId); if (expansionLedger.resolve(id, requestId) && !destroyed) { @@ -7357,7 +7451,7 @@ export function createGraphInstance, E = Record { finishExpansion(); if (!settled) { @@ -7376,7 +7470,7 @@ export function createGraphInstance, E = Record, E = Record, E = Record 0 || store.getState().pinnedNodeIds.size > 0) { - pushPinsToEngine(eng, pins); - } + pushPinsToEngine(eng, pins); } if (labelRerank.setChanged) notifyLabelSubs(candidateSubs); } @@ -7927,6 +8028,29 @@ export function createGraphInstance, E = Record { + const options = validatePath(rawOptions); + if (destroyed) throw new OrbitOperationError({ code: 'aborted', cause: 'destroyed' }); + const rich = options.universe !== undefined || options.maxHops !== undefined || options.relationshipTypes !== undefined || options.relationshipTypeField !== undefined; + if (rich && pathServiceImpl.findDetailed === undefined) throw new OrbitOperationError({ code: 'unsupported-operation', detail: 'the path service does not implement findDetailed' }); + if (accepted === null) return { status: 'not-loaded', nodeIds: [...new Set([sourceId, targetId])] }; + const at = revisionSnapshot(); + const handle = createRequestContext({ datasetKey: accepted.datasetKey, revisions: at }); + let outcome: PathOutcome; + if (pathServiceImpl.findDetailed !== undefined) outcome = await pathServiceImpl.findDetailed(sourceId, targetId, options, handle.context); + else { + const missing = [...new Set([sourceId, targetId])].filter((id) => !accepted!.nodeIndex.has(id)); + if (missing.length > 0) return { status: 'not-loaded', nodeIds: missing }; + const filtered = [...new Set([sourceId, targetId])].filter((id) => nodeVisibilityOf(id) !== 'visible'); + if (filtered.length > 0) return { status: 'filtered', nodeIds: filtered }; + const path = await pathServiceImpl.find(sourceId, targetId, options, handle.context); + outcome = path === null ? { status: 'unreachable' } : { status: 'found', path }; + } + const denial = acceptanceQueue.admit(() => pathAdmissible(handle.context, at)); + if (denial !== null) throw new OrbitOperationError({ code: 'aborted', cause: 'stale' }, `path query discarded: ${denial}`); + return outcome; + } + async function findPath( sourceId: NodeId, targetId: NodeId, @@ -7938,21 +8062,11 @@ export function createGraphInstance, E = Record pathAdmissible(handle.context, at)); - if (denial !== null) { - throw new OrbitOperationError( - { code: 'aborted', cause: 'stale' }, - `findPath('${sourceId}' → '${targetId}') discarded: ${denial}`, - ); - } + const outcome = await findPathDetailed(sourceId, targetId, validated); + const result = outcome.status === 'found' ? outcome.path : null; // Superseded by a newer findPath/clearPath: the RESULT still returns to // the caller, but emphasis belongs to the latest action only. if (token !== pathSeq || result === null) return result; @@ -7985,6 +8099,21 @@ export function createGraphInstance, E = Record { + // Snapshot hide predicates because SoftMask mutates in place. + const pin = options.visibility === 'visible' ? captureExportPin('visible') : null; + return neighborhoodOf(accepted, id, options, + JSON.stringify([accepted?.datasetKey, store.getState().revisions.model, store.getState().revisions.scope]), + nodeVisibilityOf, (edgeId) => pin?.visibleEdgeIds?.has(edgeId) ?? true); + } + /** Result contract. Classification only — NEVER mutates scope * or filters (the host reacts explicitly). */ function activateSearchResult(result: SearchResult): SearchActivation { @@ -8198,6 +8327,7 @@ export function createGraphInstance, E = Record, E = Record, E = Record 0 || nextPinned.size > 0)) + pinnedChanged || explorationPinsNeedPush || + (structuralChange && (nextPins.size > 0 || nextPinned.size > 0 || explorationPinIds.size > 0)) ) { // Persistent pins re-push after every structural commit — indices // shift; the union sink reads the just-published slice. @@ -9938,8 +10069,11 @@ export function createGraphInstance, E = Record, E = Record accepted === null ? null : ({ datasetKey: accepted.datasetKey, sourceRevision: accepted.sourceRevision }), retractExpansion, + getExpansionRecords: () => expansionRecords.map((record) => ({ ...record, addedNodeIds: [...record.addedNodeIds] })), getExpansionOverlays: (id: NodeId) => { const records = expansionOverlays.get(id); return records === undefined ? EMPTY_EXPANSION_RECORDS : [...records]; @@ -11206,6 +11344,7 @@ export function createGraphInstance, E = Record { @@ -11217,9 +11356,11 @@ export function createGraphInstance, E = Record { if (destroyed) return; + clearExplorationPins(); pauseOnReadyAfterRestore = false; const eng = engineIfReady(); if (eng === null) return; + if (explorationPinsNeedPush) pushPinsToEngine(eng, store.getState().pins); eng.start(); if (!store.getState().simulationRunning) publish({ simulationRunning: true }); }, diff --git a/packages/core/src/investigation.ts b/packages/core/src/investigation.ts new file mode 100644 index 0000000..29fcc53 --- /dev/null +++ b/packages/core/src/investigation.ts @@ -0,0 +1,554 @@ +/** + * Durable investigation state above a graph instance. Checkpoints contain + * source coordinates and replayable requests, never internal overlay IDs or + * revision-bound pagination cursors. Storage and source loading belong to the + * host; this module has no browser, filesystem, or network side effects. + */ +import { createStore } from 'zustand/vanilla'; +import type { StoreApi } from 'zustand/vanilla'; +import type { ExpandNodeResult, GraphInstance } from './instance'; +import type { ExpansionOptions, JsonValue, PathOptions, PathResult } from './types'; +import { canonicalJson, validateViewState } from './viewState'; +import type { GraphViewState } from './viewState'; + +export interface InvestigationSource { + datasetKey: string; + sourceRevision: number | string | null; + dataRef?: JsonValue; +} + +export type InvestigationExpansionOptions = Omit; + +export interface InvestigationExpansion { + seedId: string; + options: InvestigationExpansionOptions; + /** Resume the preceding page for the same seed/query using its fresh cursor. */ + continuation: boolean; +} + +export interface SavedInvestigationPath { + id: string; + title: string; + sourceId: string; + targetId: string; + options: PathOptions; + path: PathResult; +} + +export interface GraphInvestigation { + v: 1; + id: string; + title: string; + notes: string; + createdAt: string; + source: InvestigationSource; + view: GraphViewState; + searchQuery: string; + tableQuery?: string; + expansions: readonly InvestigationExpansion[]; + paths: readonly SavedInvestigationPath[]; + /** JSON-owned filters, query parameters, and other host-specific intent. */ + hostState?: JsonValue; +} + +export interface InvestigationSessionState { + title: string; + notes: string; + searchQuery: string; + tableQuery: string; + paths: readonly SavedInvestigationPath[]; + expansions: readonly InvestigationExpansion[]; + checkpoints: readonly GraphInvestigation[]; + activeCheckpointId: string | null; + status: 'idle' | 'restoring'; + error: string | null; +} + +export interface InvestigationSessionOptions { + /** Load the exact requested source and resolve after the instance accepts it. */ + loadSource?: (source: InvestigationSource, context: { signal: AbortSignal }) => Promise; + captureHostState?: () => JsonValue; + /** Resolve after the host has reflected its state into graph props. */ + restoreHostState?: (state: JsonValue, context: { signal: AbortSignal }) => void | Promise; + now?: () => Date; +} + +export interface InvestigationSession, E = Record> { + readonly store: StoreApi; + setTitle(title: string): void; + setNotes(notes: string): void; + setSearchQuery(query: string): void; + setTableQuery(query: string): void; + expandNode(id: string, options?: ExpansionOptions): Promise; + retractExpansion(id: string): void; + savePath(path: Omit & { + title?: string; + options?: PathOptions; + }): SavedInvestigationPath; + removePath(id: string): void; + checkpoint(title?: string, options?: { includePositions?: boolean }): Promise; + restoreCheckpoint(checkpoint: GraphInvestigation | string, options?: { signal?: AbortSignal }): Promise; + importCheckpoint(raw: unknown): GraphInvestigation; + exportCheckpoint(checkpoint: GraphInvestigation | string): string; + removeCheckpoint(id: string): void; + /** Rebind a getter-backed session after the host replaces its instance. */ + refreshSource(): void; + destroy(): void; + /** Type-only association for consumers using an explicitly typed instance. */ + readonly graphTypes?: { node: N; edge: E }; +} + +export class InvestigationError extends Error { + override readonly name = 'InvestigationError'; + constructor( + readonly code: 'invalid-investigation' | 'source-mismatch' | 'graph-unavailable' | + 'restore-failed' | 'restore-pending' | 'aborted' | 'untracked-cursor' | 'untracked-expansion', + message: string, + ) { super(message); } +} + +function invalid(message: string): never { + throw new InvestigationError('invalid-investigation', message); +} + +function object(value: unknown, name: string): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) invalid(`${name} must be an object`); + return value as Record; +} + +function string(value: unknown, name: string, nonempty = false): string { + if (typeof value !== 'string' || (nonempty && value.length === 0)) invalid(`${name} must be ${nonempty ? 'a nonempty string' : 'a string'}`); + return value; +} + +/** Reject silent JSON coercion, accessors, parser objects, and cyclic state. */ +function jsonCopy(value: T): T { + const seen = new Set(); + const walk = (item: unknown, depth: number): void => { + if (depth > 64) invalid('Investigation nesting exceeds 64 levels'); + if (item === null || typeof item === 'string' || typeof item === 'boolean') return; + if (typeof item === 'number' && Number.isFinite(item)) return; + if (typeof item !== 'object') invalid('Investigation values must be finite JSON data'); + if (seen.has(item)) invalid('Investigation values must not contain cycles'); + const proto = Object.getPrototypeOf(item); + if (!Array.isArray(item) && proto !== Object.prototype && proto !== null) invalid('Investigation values must be plain JSON objects'); + seen.add(item); + for (const key of Object.keys(item)) { + const descriptor = Object.getOwnPropertyDescriptor(item, key)!; + if (!('value' in descriptor)) invalid('Investigation values must not contain accessors'); + // Optional object properties use standard JSON omission semantics. + if (descriptor.value === undefined && !Array.isArray(item)) continue; + walk(descriptor.value, depth + 1); + } + seen.delete(item); + }; + walk(value, 0); + const text = JSON.stringify(value); + if (text.length > 16 * 1024 * 1024) invalid('Investigation exceeds the 16 MiB JSON limit'); + return JSON.parse(text) as T; +} + +function freeze(value: T): T { + if (value !== null && typeof value === 'object') { + for (const child of Object.values(value)) freeze(child); + Object.freeze(value); + } + return value; +} + +function relationshipOptions(value: unknown, name: string, path = false): Record { + const opts = object(value, name); + const allowed = new Set(path + ? ['direction', 'relationshipTypes', 'relationshipTypeField', 'universe', 'maxHops'] + : ['direction', 'relationshipTypes', 'relationshipTypeField', 'hops', 'limit', 'edgeLimit', 'preserveLayout']); + for (const key of Object.keys(opts)) if (!allowed.has(key)) invalid(`${name}.${key} is not replayable`); + if (opts.direction !== undefined && !['incoming', 'outgoing', 'either'].includes(String(opts.direction))) invalid(`${name}.direction is invalid`); + if (opts.relationshipTypeField !== undefined) string(opts.relationshipTypeField, `${name}.relationshipTypeField`, true); + if (opts.relationshipTypes !== undefined) { + if (!Array.isArray(opts.relationshipTypes) || !opts.relationshipTypes.every((v) => typeof v === 'string')) invalid(`${name}.relationshipTypes must be strings`); + } + for (const [key, max] of [['hops', 1000], ['maxHops', 1000], ['limit', 1000], ['edgeLimit', 10000]] as const) { + const n = opts[key]; + if (n !== undefined && (typeof n !== 'number' || !Number.isInteger(n) || n < (key === 'maxHops' ? 0 : 1) || n > max)) invalid(`${name}.${key} is outside its supported bounds`); + } + if (opts.preserveLayout !== undefined && typeof opts.preserveLayout !== 'boolean') invalid(`${name}.preserveLayout must be boolean`); + if (opts.universe !== undefined && opts.universe !== 'loaded' && opts.universe !== 'visible') invalid(`${name}.universe is invalid`); + return opts; +} + +function validatePath(value: unknown): SavedInvestigationPath { + const p = object(value, 'path'); + for (const key of ['id', 'title', 'sourceId', 'targetId']) string(p[key], `path.${key}`, key !== 'title'); + relationshipOptions(p.options, 'path.options', true); + const result = object(p.path, 'path.path'); + for (const key of ['nodeIds', 'edgeIds']) { + if (!Array.isArray(result[key]) || !(result[key] as unknown[]).every((id) => typeof id === 'string')) invalid(`path.path.${key} must be strings`); + } + const nodes = result.nodeIds as string[]; + const edges = result.edgeIds as string[]; + if (nodes.length === 0 || nodes[0] !== p.sourceId || nodes.at(-1) !== p.targetId || edges.length !== nodes.length - 1) invalid('Saved path endpoints and ordered steps do not agree'); + return p as unknown as SavedInvestigationPath; +} + +/** Validate the complete envelope before source loading or graph mutation. */ +export function parseInvestigation(raw: unknown): GraphInvestigation { + let input = raw; + if (typeof raw === 'string') { + if (raw.length > 16 * 1024 * 1024) invalid('Investigation exceeds the 16 MiB JSON limit'); + try { input = JSON.parse(raw); } catch { invalid('Investigation is not valid JSON'); } + } + const value = object(jsonCopy(input), 'investigation'); + if (value.v !== 1) invalid('Unsupported investigation version'); + for (const key of ['id', 'title', 'notes', 'createdAt', 'searchQuery']) string(value[key], key, key === 'id'); + value.tableQuery = value.tableQuery === undefined ? '' : string(value.tableQuery, 'tableQuery'); + if (!Number.isFinite(Date.parse(value.createdAt as string))) invalid('createdAt must be an ISO date'); + const source = object(value.source, 'source'); + string(source.datasetKey, 'source.datasetKey', true); + const revision = source.sourceRevision; + if (revision !== null && typeof revision !== 'string' && !(typeof revision === 'number' && Number.isFinite(revision))) invalid('source.sourceRevision must be a string, finite number, or null'); + const view = validateViewState(value.view); + if (!view.ok) invalid(view.problems.join('; ')); + if (canonicalJson(source.dataRef as JsonValue | undefined) !== canonicalJson(view.state.dataRef)) invalid('Source reference and view reference must agree'); + if (!Array.isArray(value.expansions) || value.expansions.length > 10000) invalid('expansions must contain at most 10000 requests'); + const priorQueries = new Set(); + for (const entry of value.expansions) { + const action = object(entry, 'expansion'); + string(action.seedId, 'expansion.seedId', true); + relationshipOptions(action.options, 'expansion.options'); + if (typeof action.continuation !== 'boolean') invalid('expansion.continuation must be boolean'); + const key = queryKey(action.seedId as string, action.options as InvestigationExpansionOptions); + if (action.continuation && !priorQueries.has(key)) invalid('An expansion continuation needs a preceding page'); + priorQueries.add(key); + } + if (!Array.isArray(value.paths) || value.paths.length > 10000) invalid('paths must contain at most 10000 results'); + const ids = new Set(); + for (const path of value.paths) { + const parsed = validatePath(path); + if (ids.has(parsed.id)) invalid('Saved path IDs must be unique'); + ids.add(parsed.id); + } + return freeze(value as unknown as GraphInvestigation); +} + +export function serializeInvestigation(value: GraphInvestigation): string { + return JSON.stringify(parseInvestigation(value), null, 2); +} + +function queryKey(seedId: string, options: InvestigationExpansionOptions): string { + return canonicalJson([seedId, options] as unknown as JsonValue)!; +} + +function replayOptions(options: ExpansionOptions): InvestigationExpansionOptions { + const { cursor: _cursor, onProgress: _onProgress, ...serializable } = options; + relationshipOptions(serializable, 'expansion.options'); + return jsonCopy(serializable); +} + +/** + * Route durable expansion/path actions through this controller. Operations + * performed directly on the instance remain ordinary session-local actions. + * A getter supports hosts that replace their graph instance when loading a + * different source. `loadSource` must honor its signal and resolve only once + * the requested source is accepted by the getter's current instance. + */ +export function createInvestigationSession, E = Record>( + target: GraphInstance | (() => GraphInstance | null), + options: InvestigationSessionOptions = {}, +): InvestigationSession { + const store = createStore(() => ({ + title: 'Untitled investigation', notes: '', searchQuery: '', tableQuery: '', paths: [], expansions: [], + checkpoints: [], activeCheckpointId: null, status: 'idle', error: null, + })); + const get = typeof target === 'function' ? target : () => target; + let disposed = false; + let sequence = 0; + let restoreFlight: AbortController | null = null; + let trackedSource: string | null = null; + let bound: GraphInstance | null = null; + let unsubscribe: (() => void) | null = null; + const cursors = new Map(); + // Query recipes include no-op pages needed to regenerate continuations. + // Only actual admitted contributions may be retracted from the instance. + const ownedActions = new WeakSet(); + const recordIds = new WeakMap(); + let actionCatalog: InvestigationExpansion[] = []; + const pendingSeeds = new Set(); + const now = options.now ?? (() => new Date()); + const nextId = (kind: string) => `${kind}-${now().getTime()}-${++sequence}`; + const graph = (): GraphInstance => { + const instance = get(); + if (disposed || instance === null || instance.store.getState().status === 'destroyed') throw new InvestigationError('graph-unavailable', 'The investigation graph is unavailable'); + return instance; + }; + const sourceOf = (instance: GraphInstance): InvestigationSource => { + const source = instance.getSource(); + if (source === null) throw new InvestigationError('graph-unavailable', 'Load a graph before saving or restoring an investigation'); + const dataRef = instance.getViewState().dataRef; + return { ...source, ...(dataRef === undefined ? {} : { dataRef }) }; + }; + const sourceKey = (source: InvestigationSource) => canonicalJson(source as unknown as JsonValue)!; + const syncSource = (instance: GraphInstance) => { + const key = sourceKey(sourceOf(instance)); + if (trackedSource !== null && trackedSource !== key) { + cursors.clear(); + actionCatalog = []; + store.setState({ expansions: [], paths: [], activeCheckpointId: null }); + } + trackedSource = key; + return key; + }; + const reconcileHistory = (instance: GraphInstance) => { + const live = new Set(instance.getExpansionRecords().map((record) => record.overlayId)); + const queries = new Set(); + const active = actionCatalog.filter((action) => { + const id = recordIds.get(action); + const key = queryKey(action.seedId, action.options); + if ((id !== undefined && !live.has(id)) || (action.continuation && !queries.has(key))) return false; + queries.add(key); + return true; + }); + const previous = store.getState().expansions; + if (active.length !== previous.length || active.some((action, index) => action !== previous[index])) { + if (previous.some((action) => !active.includes(action))) cursors.clear(); + store.setState({ expansions: freeze(active), activeCheckpointId: null }); + } + }; + const trackAction = (instance: GraphInstance, action: InvestigationExpansion, result: ExpandNodeResult) => { + if ('added' in result) { + ownedActions.add(action); + const record = instance.getExpansionRecords().filter((item) => item.expandedId === action.seedId).at(-1); + if (record?.overlayId != null) recordIds.set(action, record.overlayId); + } + actionCatalog.push(action); + }; + const assertRetractable = (instance: GraphInstance, actions: readonly InvestigationExpansion[]) => { + const records = [...instance.getExpansionRecords()]; + for (const action of actions) { + if (!ownedActions.has(action)) continue; + let index = records.length - 1; + while (index >= 0 && records[index]!.expandedId !== action.seedId) index--; + if (index < 0 || records[index]!.overlayId !== recordIds.get(action)) { + throw new InvestigationError('untracked-expansion', `An expansion of '${action.seedId}' changed outside this investigation; reconcile it before retracting or restoring`); + } + records.splice(index, 1); + } + }; + const refreshSource = () => { + if (disposed) return; + const instance = get(); + if (instance !== bound) { + unsubscribe?.(); + bound = instance; + unsubscribe = instance?.store.subscribe((next, previous) => { + if (!disposed && instance.getSource() !== null && (next.revisions.model !== previous.revisions.model || next.revisions.scope !== previous.revisions.scope || next.history !== previous.history)) { + syncSource(instance); + if (restoreFlight === null) reconcileHistory(instance); + } + }) ?? null; + } + if (instance !== null && instance.getSource() !== null) { syncSource(instance); reconcileHistory(instance); } + }; + const editable = () => { + if (disposed) throw new InvestigationError('graph-unavailable', 'The investigation session was destroyed'); + if (restoreFlight !== null) throw new InvestigationError('restore-pending', 'Wait for the investigation restore to finish'); + }; + const checkAbort = (signal: AbortSignal) => { + if (disposed || signal.aborted) throw new InvestigationError('aborted', 'Investigation restore was cancelled'); + }; + const resolveCheckpoint = (value: GraphInvestigation | string): GraphInvestigation => { + if (typeof value !== 'string') return parseInvestigation(value); + const result = store.getState().checkpoints.find((item) => item.id === value); + if (result === undefined) invalid(`Unknown checkpoint: ${value}`); + return result; + }; + const remember = (checkpoint: GraphInvestigation) => { + const prior = store.getState().checkpoints; + store.setState({ checkpoints: freeze([...prior.filter((v) => v.id !== checkpoint.id), checkpoint]) }); + }; + const recordPage = (seedId: string, opts: InvestigationExpansionOptions, result: ExpandNodeResult) => { + const key = queryKey(seedId, opts); + const cursor = result.page?.nextCursor; + if (cursor === undefined) cursors.delete(key); + else cursors.set(key, cursor); + }; + + const api: InvestigationSession = { + store, + refreshSource, + setTitle(title) { editable(); store.setState({ title: string(title, 'title') }); }, + setNotes(notes) { editable(); store.setState({ notes: string(notes, 'notes') }); }, + setSearchQuery(searchQuery) { editable(); store.setState({ searchQuery: string(searchQuery, 'searchQuery') }); }, + setTableQuery(tableQuery) { editable(); store.setState({ tableQuery: string(tableQuery, 'tableQuery') }); }, + async expandNode(id, expandOptions = {}) { + editable(); + const instance = graph(); + const source = syncSource(instance); + const serializable = replayOptions(expandOptions); + const key = queryKey(id, serializable); + if (expandOptions.cursor !== undefined && cursors.get(key) !== expandOptions.cursor) throw new InvestigationError('untracked-cursor', 'Load the preceding page through this investigation before continuing'); + if (pendingSeeds.has(id)) throw new InvestigationError('restore-pending', 'This entity already has an expansion in progress'); + pendingSeeds.add(id); + try { + const result = await instance.expandNode(id, expandOptions); + if (disposed || get() !== instance || sourceKey(sourceOf(instance)) !== source) throw new InvestigationError('source-mismatch', 'The graph source changed during expansion'); + recordPage(id, serializable, result); + const action = freeze({ seedId: id, options: serializable, continuation: expandOptions.cursor !== undefined }); + trackAction(instance, action, result); + store.setState({ expansions: freeze([...store.getState().expansions, action]), activeCheckpointId: null, error: null }); + return result; + } finally { pendingSeeds.delete(id); } + }, + retractExpansion(id) { + editable(); + if (pendingSeeds.has(id)) { graph().cancelExpansion(id); return; } + const instance = graph(); + syncSource(instance); + const actions = [...store.getState().expansions]; + let index = actions.length - 1; + while (index >= 0 && actions[index]!.seedId !== id) index--; + if (index < 0) return; + const [removed] = actions.splice(index, 1); + assertRetractable(instance, [removed!]); + if (ownedActions.has(removed!)) instance.retractExpansion(id); + else actionCatalog = actionCatalog.filter((action) => action !== removed); + cursors.delete(queryKey(id, removed!.options)); + store.setState({ expansions: freeze(actions), activeCheckpointId: null }); + }, + savePath(input) { + editable(); + syncSource(graph()); + const path = freeze(jsonCopy(validatePath({ ...input, id: nextId('path'), title: input.title ?? `${input.sourceId} → ${input.targetId}`, options: input.options ?? {} }))); + store.setState({ paths: freeze([...store.getState().paths, path]), activeCheckpointId: null }); + return path; + }, + removePath(id) { editable(); store.setState({ paths: store.getState().paths.filter((p) => p.id !== id), activeCheckpointId: null }); }, + async checkpoint(title, captureOptions = {}) { + editable(); + if (pendingSeeds.size > 0) throw new InvestigationError('restore-pending', 'Wait for pending expansions before saving a checkpoint'); + const instance = graph(); + const key = syncSource(instance); + const state = store.getState(); + const revision = instance.getRevisions(); + const source = sourceOf(instance); + const view = captureOptions.includePositions === false + ? instance.getViewState() + : await instance.getViewState({ includePositions: true }); + const after = instance.getRevisions(); + if (disposed || get() !== instance || sourceKey(sourceOf(instance)) !== key || after.model !== revision.model || after.scope !== revision.scope || pendingSeeds.size > 0) throw new InvestigationError('source-mismatch', 'The graph changed while saving; save a new checkpoint'); + const checkpoint = parseInvestigation({ + v: 1, id: nextId('checkpoint'), title: title ?? state.title, notes: state.notes, + createdAt: now().toISOString(), source, view, searchQuery: state.searchQuery, tableQuery: state.tableQuery, + expansions: state.expansions, paths: state.paths, + ...(options.captureHostState === undefined ? {} : { hostState: options.captureHostState() }), + }); + remember(checkpoint); + store.setState({ activeCheckpointId: checkpoint.id, title: checkpoint.title, error: null }); + return checkpoint; + }, + async restoreCheckpoint(value, restoreOptions = {}) { + editable(); + if (pendingSeeds.size > 0) throw new InvestigationError('restore-pending', 'Cancel pending expansions before restoring'); + const checkpoint = resolveCheckpoint(value); + if (checkpoint.hostState !== undefined && options.restoreHostState === undefined) throw new InvestigationError('restore-failed', 'This checkpoint requires a host-state restore callback'); + const current = graph(); + const currentSource = sourceOf(current); + const mismatch = sourceKey(currentSource) !== sourceKey(checkpoint.source); + if (mismatch && options.loadSource === undefined) throw new InvestigationError('source-mismatch', 'Load the checkpoint source before restoring this investigation'); + const flight = new AbortController(); + const upstream = restoreOptions.signal; + const abort = () => flight.abort(); + if (upstream?.aborted) abort(); + else upstream?.addEventListener('abort', abort, { once: true }); + checkAbort(flight.signal); + restoreFlight = flight; + store.setState({ status: 'restoring', error: null }); + const applied: InvestigationExpansion[] = []; + let replayStarted = false; + let instance = current; + try { + if (mismatch) { + await options.loadSource!(checkpoint.source, { signal: flight.signal }); + checkAbort(flight.signal); + instance = graph(); + } + if (sourceKey(sourceOf(instance)) !== sourceKey(checkpoint.source)) throw new InvestigationError('source-mismatch', 'The loaded source does not match this checkpoint'); + if (!mismatch && trackedSource === sourceKey(currentSource)) { + const retract = [...store.getState().expansions].reverse(); + assertRetractable(instance, retract); + replayStarted = true; + for (const action of retract) { + if (ownedActions.has(action)) instance.retractExpansion(action.seedId); + } + } + replayStarted = true; + cursors.clear(); + trackedSource = sourceKey(checkpoint.source); + const replayed: InvestigationExpansion[] = []; + actionCatalog = []; + store.setState({ expansions: [], paths: [], activeCheckpointId: null }); + for (const action of checkpoint.expansions) { + checkAbort(flight.signal); + if (get() !== instance || sourceKey(sourceOf(instance)) !== trackedSource) throw new InvestigationError('source-mismatch', 'The source changed during investigation restore'); + const cursor = action.continuation ? cursors.get(queryKey(action.seedId, action.options)) : undefined; + if (action.continuation && cursor === undefined) throw new InvestigationError('restore-failed', 'An expansion no longer has the saved continuation page'); + const cancel = () => instance.cancelExpansion(action.seedId); + flight.signal.addEventListener('abort', cancel, { once: true }); + let result: ExpandNodeResult; + try { result = await instance.expandNode(action.seedId, { ...action.options, ...(cursor === undefined ? {} : { cursor }) }); } + finally { flight.signal.removeEventListener('abort', cancel); } + if ('added' in result) applied.push(action); + trackAction(instance, action, result); + checkAbort(flight.signal); + recordPage(action.seedId, action.options, result); + replayed.push(action); + } + if (checkpoint.hostState !== undefined) await options.restoreHostState!(checkpoint.hostState, { signal: flight.signal }); + checkAbort(flight.signal); + if (get() !== instance || sourceKey(sourceOf(instance)) !== trackedSource) throw new InvestigationError('source-mismatch', 'The source changed before restoring the saved view'); + const result = await instance.setViewState(checkpoint.view); + checkAbort(flight.signal); + if (result.status !== 'applied') throw new InvestigationError('restore-failed', result.status === 'mismatch' ? 'The view source reference changed' : result.problems.join('; ')); + remember(checkpoint); + store.setState({ title: checkpoint.title, notes: checkpoint.notes, searchQuery: checkpoint.searchQuery, tableQuery: checkpoint.tableQuery ?? '', + expansions: freeze(replayed), paths: checkpoint.paths, activeCheckpointId: checkpoint.id }); + } catch (error) { + // Roll back only expansion contributions admitted by this restore. + if (!disposed && get() === instance && instance.getSource() !== null && instance.store.getState().status !== 'destroyed' && sourceKey(sourceOf(instance)) === trackedSource) { + for (const action of applied.reverse()) { + // A concurrent host action must never be removed as our rollback. + try { assertRetractable(instance, [action]); } + catch { continue; } + instance.retractExpansion(action.seedId); + } + } + if (replayStarted) { cursors.clear(); actionCatalog = []; } + if (!disposed) store.setState({ ...(replayStarted ? { expansions: [] } : {}), error: error instanceof Error ? error.message : String(error) }); + throw error; + } finally { + upstream?.removeEventListener('abort', abort); + restoreFlight = null; + if (!disposed) store.setState({ status: 'idle' }); + } + }, + importCheckpoint(raw) { editable(); const checkpoint = parseInvestigation(raw); remember(checkpoint); return checkpoint; }, + exportCheckpoint(value) { return serializeInvestigation(resolveCheckpoint(value)); }, + removeCheckpoint(id) { + editable(); + store.setState({ checkpoints: store.getState().checkpoints.filter((c) => c.id !== id), + ...(store.getState().activeCheckpointId === id ? { activeCheckpointId: null } : {}) }); + }, + destroy() { + if (disposed) return; + disposed = true; + unsubscribe?.(); + unsubscribe = null; + restoreFlight?.abort(); + const instance = get(); + for (const seed of pendingSeeds) instance?.cancelExpansion(seed); + pendingSeeds.clear(); + cursors.clear(); + }, + }; + refreshSource(); + return api; +} diff --git a/packages/core/src/pathService.ts b/packages/core/src/pathService.ts index 5c26d5e..ca893ab 100644 --- a/packages/core/src/pathService.ts +++ b/packages/core/src/pathService.ts @@ -2,23 +2,17 @@ * path resolution — the built-in LOCAL PathService plus the * pure emphasis-plan helper the instance wires into `findPath`. * - * The default service runs an unweighted BFS over the LOADED VISIBLE edge - * list — it never fetches, never touches scope/filters, and never reads the - * engine. `null` is a RESULT (unreachable), not an error; an endpoint id not - * in the loaded base is unreachable by definition and also resolves `null`. - * Direction ('outgoing' default | 'incoming' | 'either') decides which way - * an edge may be walked. Ties break deterministically: neighbors are visited - * in base edge order, so among equal-length paths the first in edge order - * wins. `ctx.signal` is honored between scan chunks (an awaited microtask - * every {@link PATH_SCAN_CHUNK} scanned edges, as in search.ts) — abort is - * an optimization; the instance's revision admission gate is authoritative. - * Declares `revisionDependencies: ['source', 'model', 'scope']`: a - * path is a point-in-scene answer, so ANY revision drift discards it. + * The default service runs deterministic unweighted BFS over visible or + * loaded relationships. It never fetches or changes scope/filters. Direction, + * exact relationship types, and an optional hop budget constrain traversal. + * Detailed results distinguish missing endpoints, visibility obstacles, + * unreachable targets, and exhausted hop budgets; the legacy find method + * maps every non-found outcome to null. * - * Nothing is cached across calls: visibility is mask-derived and moves - * WITHOUT advancing any revision dimension, so a revision-keyed adjacency - * cache would serve stale reachability. One O(V + E) build per call is the - * "O(n) per action" budget. + * Adjacency is rebuilt per query. Every PATH_SCAN_CHUNK scanned edges yields + * to a browser task, permitting input and cancellation. The service declares + * source/model/scope dependencies; the instance discards revision drift at + * admission, regardless of whether the producer honored cancellation. * * EMPHASIS CONTRACT ({@link computePathEmphasis}, wired by the integrator): * one findPath application is ONE atomic action — path-node point indices to @@ -33,6 +27,7 @@ import { nextSynthesizedEdgeId } from './edgeIdentity'; import type { EdgePairCounters } from './edgeIdentity'; import { OrbitOperationError } from './errors'; +import { validatePath, matchesRelationship } from './exploration'; import type { EdgeId, GraphEdge, @@ -40,6 +35,8 @@ import type { NodeId, PathOptions, PathResult, + PathOutcome, + AcceptedEdge, PathService, RequestContext, } from './types'; @@ -59,10 +56,10 @@ export interface LocalPathBase, E = Record boolean; + isNodeVisible?: (id: NodeId) => boolean; } -/** Edges scanned between cooperative yields (awaited microtask + signal - * check) — same cadence as SEARCH_SCAN_CHUNK. */ +/** Edges scanned between cooperative task yields and abort checks. */ export const PATH_SCAN_CHUNK = 4096; function throwAborted(signal: AbortSignal, sourceId: NodeId, targetId: NodeId): never { @@ -87,96 +84,97 @@ function throwAborted(signal: AbortSignal, sourceId: NodeId, targetId: NodeId): export function createLocalPathService, E = Record>( getBase: () => LocalPathBase, ): PathService { - return { - revisionDependencies: ['source', 'model', 'scope'], - async find( - sourceId: NodeId, - targetId: NodeId, - options: PathOptions, - ctx: RequestContext, - ): Promise { - if (ctx.signal.aborted) throwAborted(ctx.signal, sourceId, targetId); - const direction = options.direction ?? 'outgoing'; - const { nodes, edges, isEdgeVisible } = getBase(); - - // Ordinal index over the loaded nodes (first-wins, matching dedup). - const ordinalOf = new Map(); - for (let i = 0; i < nodes.length; i++) { - const id = nodes[i]!.id; - if (!ordinalOf.has(id)) ordinalOf.set(id, i); + async function findDetailed( + sourceId: NodeId, + targetId: NodeId, + rawOptions: PathOptions, + ctx: RequestContext, + ): Promise { + const options = validatePath(rawOptions); + if (ctx.signal.aborted) throwAborted(ctx.signal, sourceId, targetId); + const direction = options.direction ?? 'outgoing'; + const { nodes, edges, isEdgeVisible, isNodeVisible } = getBase(); + const ordinalOf = new Map(); + for (let i = 0; i < nodes.length; i++) if (!ordinalOf.has(nodes[i]!.id)) ordinalOf.set(nodes[i]!.id, i); + const missing = [...new Set([sourceId, targetId])].filter((id) => !ordinalOf.has(id)); + if (missing.length > 0) return { status: 'not-loaded', nodeIds: missing }; + const filtered = options.universe === 'loaded' ? [] : [...new Set([sourceId, targetId])].filter((id) => isNodeVisible !== undefined && !isNodeVisible(id)); + if (filtered.length > 0) return { status: 'filtered', nodeIds: filtered }; + const src = ordinalOf.get(sourceId)!; + const dst = ordinalOf.get(targetId)!; + if (src === dst) return { status: 'found', path: { nodeIds: [sourceId], edgeIds: [] } }; + const adjacency: number[][] = new Array(nodes.length); + const edgeIds: EdgeId[] = new Array(edges.length); + const counters: EdgePairCounters = new Map(); + let scanned = 0; + for (let e = 0; e < edges.length; e++) { + if (++scanned % PATH_SCAN_CHUNK === 0) { + // A task yield lets input/paint and cancellation run, unlike a microtask. + await new Promise((resolve) => setTimeout(resolve, 0)); + if (ctx.signal.aborted) throwAborted(ctx.signal, sourceId, targetId); } - const src = ordinalOf.get(sourceId); - const dst = ordinalOf.get(targetId); - if (src === undefined || dst === undefined) return null; // unloaded = unreachable - if (src === dst) return { nodeIds: [nodes[src]!.id], edgeIds: [] }; - - // Directed adjacency over VISIBLE edges: adj[u] holds [v, edgeOrdinal] - // pairs flat, filled in base edge order (the determinism tie-break). - // Synthesized-id counters advance for EVERY id-less edge — visible or - // not — so ordinals match the accepted model's synthesis. - const adjacency: number[][] = new Array(nodes.length); - const edgeIds: EdgeId[] = new Array(edges.length); - const counters: EdgePairCounters = new Map(); - let scanned = 0; - for (let e = 0; e < edges.length; e++) { + const edge = edges[e]!; + const id = edge.id ?? nextSynthesizedEdgeId(counters, edge.source, edge.target); + edgeIds[e] = id; + if (!matchesRelationship({ ...edge, id } as AcceptedEdge, options)) continue; + if (options.universe !== 'loaded' && ((isEdgeVisible !== undefined && !isEdgeVisible(id)) || (isNodeVisible !== undefined && (!isNodeVisible(edge.source) || !isNodeVisible(edge.target))))) continue; + const s = ordinalOf.get(edge.source); + const t = ordinalOf.get(edge.target); + if (s === undefined || t === undefined) continue; + if (direction !== 'incoming') (adjacency[s] ??= []).push(t, e); + if (direction !== 'outgoing') (adjacency[t] ??= []).push(s, e); + } + const prevNode = new Int32Array(nodes.length).fill(-1); + const prevEdge = new Int32Array(nodes.length).fill(-1); + const depth = new Int32Array(nodes.length).fill(-1); + depth[src] = 0; + const queue: number[] = [src]; + let found = false; + let limited = false; + outer: for (let head = 0; head < queue.length; head++) { + const u = queue[head]!; + const list = adjacency[u]; + if (list === undefined) continue; + for (let j = 0; j < list.length; j += 2) { if (++scanned % PATH_SCAN_CHUNK === 0) { - // Cooperative yield between scan chunks; the signal is the - // cancellation seam. - await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); if (ctx.signal.aborted) throwAborted(ctx.signal, sourceId, targetId); } - const edge = edges[e]!; - const id = edge.id ?? nextSynthesizedEdgeId(counters, edge.source, edge.target); - edgeIds[e] = id; - if (isEdgeVisible !== undefined && !isEdgeVisible(id)) continue; - const s = ordinalOf.get(edge.source); - const t = ordinalOf.get(edge.target); - if (s === undefined || t === undefined) continue; // dangling endpoint - if (direction !== 'incoming') (adjacency[s] ??= []).push(t, e); - if (direction !== 'outgoing') (adjacency[t] ??= []).push(s, e); - } - - // BFS with predecessor arrays; queue is an array with a head cursor. - const prevNode = new Int32Array(nodes.length).fill(-1); - const prevEdge = new Int32Array(nodes.length).fill(-1); - const visited = new Uint8Array(nodes.length); - visited[src] = 1; - const queue: number[] = [src]; - let found = false; - outer: for (let head = 0; head < queue.length; head++) { - const u = queue[head]!; - const list = adjacency[u]; - if (list === undefined) continue; - for (let j = 0; j < list.length; j += 2) { - if (++scanned % PATH_SCAN_CHUNK === 0) { - await Promise.resolve(); - if (ctx.signal.aborted) throwAborted(ctx.signal, sourceId, targetId); - } - const v = list[j]!; - if (visited[v] !== 0) continue; - visited[v] = 1; - prevNode[v] = u; - prevEdge[v] = list[j + 1]!; - if (v === dst) { - found = true; - break outer; - } - queue.push(v); - } + const v = list[j]!; + if (depth[v]! >= 0) continue; + if (options.maxHops !== undefined && depth[u]! >= options.maxHops) { limited = true; continue; } + depth[v] = depth[u]! + 1; + prevNode[v] = u; + prevEdge[v] = list[j + 1]!; + if (v === dst) { found = true; break outer; } + queue.push(v); } - if (!found) return null; - - // Reconstruct dst → src, then reverse into path order. - const nodeIds: NodeId[] = []; - const pathEdgeIds: EdgeId[] = []; - for (let at = dst; at !== src; at = prevNode[at]!) { - nodeIds.push(nodes[at]!.id); - pathEdgeIds.push(edgeIds[prevEdge[at]!]!); + } + if (ctx.signal.aborted) throwAborted(ctx.signal, sourceId, targetId); + if (!found) { + if (!limited && options.universe !== 'loaded' && (isNodeVisible !== undefined || isEdgeVisible !== undefined)) { + const loaded = await findDetailed(sourceId, targetId, { ...options, universe: 'loaded' }, ctx); + if (loaded.status === 'found') return { status: 'filtered', nodeIds: loaded.path.nodeIds.filter((id) => isNodeVisible !== undefined && !isNodeVisible(id)) }; } - nodeIds.push(nodes[src]!.id); - nodeIds.reverse(); - pathEdgeIds.reverse(); - return { nodeIds, edgeIds: pathEdgeIds }; + return { status: limited ? 'hop-limit' : 'unreachable' }; + } + const nodeIds: NodeId[] = []; + const pathEdgeIds: EdgeId[] = []; + for (let at = dst; at !== src; at = prevNode[at]!) { + nodeIds.push(nodes[at]!.id); + pathEdgeIds.push(edgeIds[prevEdge[at]!]!); + } + nodeIds.push(nodes[src]!.id); + nodeIds.reverse(); + pathEdgeIds.reverse(); + return { status: 'found', path: { nodeIds, edgeIds: pathEdgeIds } }; + } + return { + revisionDependencies: ['source', 'model', 'scope'], + findDetailed, + async find(sourceId, targetId, options, ctx) { + const outcome = await findDetailed(sourceId, targetId, options, ctx); + return outcome.status === 'found' ? outcome.path : null; }, }; } diff --git a/packages/core/src/services.ts b/packages/core/src/services.ts index 32fc052..f934959 100644 --- a/packages/core/src/services.ts +++ b/packages/core/src/services.ts @@ -18,9 +18,13 @@ import { OrbitOperationError } from './errors'; import { resolveScope } from './scope'; +import { validateExpansion, matchesRelationship, invalidQuery } from './exploration'; import type { Adjacency } from './adjacency'; import type { AcceptedGraph, + AcceptedEdge, + GraphNode, + ExpansionQuery, ExpansionResponse, ExpansionService, NodeId, @@ -231,8 +235,94 @@ function throwAborted(signal: AbortSignal): never { export function createLocalExpansionService, E = Record>( getBase: () => LocalExpansionBase, ): ExpansionService { + // Continuations retain the original loaded result across their own overlay + // commits. Source/query changes reject; unrelated overlays do not reshuffle + // page membership. Bounded LRU ownership ends with this service instance. + interface PagePlan { + key: string; + nodes: readonly GraphNode[]; + edges: readonly AcceptedEdge[]; + seeds: ReadonlySet; + } + const pages = new Map(); + + async function queryNeighbors(seedIds: readonly NodeId[], rawOptions: ExpansionQuery, ctx: RequestContext): Promise> { + const options = validateExpansion(rawOptions); + if (ctx.signal.aborted) throwAborted(ctx.signal); + const { cursor: _cursor, onProgress: _progress, preserveLayout: _preserve, ...params } = options; + void _cursor; void _progress; void _preserve; + const key = canonicalJson([ctx.datasetKey, ctx.sourceRevision, seedIds, { ...params, hops: options.hops ?? 1, direction: options.direction ?? 'either', relationshipTypeField: options.relationshipTypeField ?? 'type', limit: options.limit ?? 50, edgeLimit: options.edgeLimit ?? 10000 }]); + let plan: PagePlan; + let token: string; + let offset = 0; + if (options.cursor !== undefined) { + let decoded: unknown; + try { decoded = JSON.parse(options.cursor); } catch { invalidQuery('invalid expansion cursor'); } + if (!Array.isArray(decoded) || decoded.length !== 2 || typeof decoded[0] !== 'string' || !Number.isSafeInteger(decoded[1]) || decoded[1] < 0) invalidQuery('invalid expansion cursor'); + token = decoded[0] as string; + offset = decoded[1] as number; + const cached = pages.get(token); + if (cached === undefined || cached.key !== key) invalidQuery('expansion cursor is stale or belongs to a different query'); + plan = cached; + pages.delete(token); + pages.set(token, plan); + } else { + const { accepted } = getBase(); + const seeds = new Set(seedIds.filter((id) => accepted.nodeIndex.has(id))); + const adjacency = new Map(); + const edges: AcceptedEdge[] = []; + const direction = options.direction ?? 'either'; + let scanned = 0; + for (const edge of accepted.edges) { + if (++scanned % 4096 === 0) { + await new Promise((resolve) => setTimeout(resolve, 0)); + if (ctx.signal.aborted) throwAborted(ctx.signal); + } + if (!matchesRelationship(edge, options)) continue; + edges.push(edge); + if (direction !== 'incoming') { + const list = adjacency.get(edge.source) ?? []; + list.push(edge.target); adjacency.set(edge.source, list); + } + if (direction !== 'outgoing') { + const list = adjacency.get(edge.target) ?? []; + list.push(edge.source); adjacency.set(edge.target, list); + } + } + const seen = new Set(seeds); + const queue = [...seeds]; + const depths = queue.map(() => 0); + for (let head = 0; head < queue.length; head++) { + if (depths[head]! >= (options.hops ?? 1)) continue; + for (const neighbor of adjacency.get(queue[head]!) ?? []) { + if (++scanned % 4096 === 0) { + await new Promise((resolve) => setTimeout(resolve, 0)); + if (ctx.signal.aborted) throwAborted(ctx.signal); + } + if (seen.has(neighbor) || !accepted.nodeIndex.has(neighbor)) continue; + seen.add(neighbor); queue.push(neighbor); depths.push(depths[head]! + 1); + } + } + plan = { key, seeds, nodes: queue.filter((id) => !seeds.has(id)).map((id) => accepted.nodes[accepted.nodeIndex.get(id)!]!), edges }; + token = ctx.requestId; + pages.set(token, plan); + while (pages.size > 8) pages.delete(pages.keys().next().value!); + } + if (ctx.signal.aborted) throwAborted(ctx.signal); + const limit = options.limit ?? 50; + const nodes = plan.nodes.slice(offset, offset + limit); + const ids = new Set([...plan.seeds, ...nodes.map((node) => node.id)]); + const pageEdges = plan.edges.filter((edge) => ids.has(edge.source) && ids.has(edge.target)); + const edges = pageEdges.slice(0, options.edgeLimit ?? 10000); + const nextCursor = offset + limit < plan.nodes.length ? JSON.stringify([token, offset + limit]) : undefined; + return { + nodes, edges, + page: { returnedNodes: nodes.length, returnedEdges: edges.length, totalNeighbors: plan.nodes.length, truncated: nextCursor !== undefined || edges.length < pageEdges.length, ...(nextCursor === undefined ? {} : { nextCursor }) }, + }; + } return { revisionDependencies: ['source'], + queryNeighbors, async neighbors( seedIds: readonly NodeId[], hops: number, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7b993cb..f11bdb2 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -717,9 +717,79 @@ export interface ExpansionBatch, E = Record[]; } +export interface RelationshipOptions { + direction?: 'outgoing' | 'incoming' | 'either'; + /** Exact string values in edge.attrs[relationshipTypeField]. */ + relationshipTypes?: readonly string[]; + /** JSON attribute field name; default 'type'. */ + relationshipTypeField?: string; +} + +export type NodeVisibility = 'visible' | 'filtered' | 'out-of-scope'; + +export interface NeighborhoodOptions extends RelationshipOptions { + /** Default 'loaded'; a read never reveals nodes or changes filters. */ + visibility?: 'loaded' | 'visible'; + /** Neighbor node page size, default 50, maximum 1000. */ + limit?: number; + /** Maximum returned relationship rows, default 200, maximum 10000. */ + edgeLimit?: number; + cursor?: string; +} + +export interface NeighborhoodResult, E = Record> { + seedId: NodeId; + status: 'loaded' | 'not-loaded'; + nodes: readonly GraphNode[]; + edges: readonly AcceptedEdge[]; + visibility: ReadonlyMap; + /** Counts before relationshipTypes filtering, within direction/visibility. */ + relationshipTypes: readonly { type: string; count: number }[]; + totalNeighbors: number; + totalEdges: number; + edgesTruncated: boolean; + nextCursor?: string; +} + +export interface ExpansionPage { + /** Unique returned nodes excluding the seed, and returned edge rows. */ + returnedNodes: number; + returnedEdges: number; + /** Omitted when the service cannot know the full matching universe. */ + totalNeighbors?: number; + nextCursor?: string; + truncated: boolean; +} + +export interface ExpansionProgress { + requestId: string; + seedId: NodeId; + batches: number; + receivedNodes: number; + receivedEdges: number; + status: 'loading' | 'committed'; +} + +export interface ExpansionOptions extends RelationshipOptions { + hops?: number; + /** New query API defaults to 50 neighbors; maximum 1000. */ + limit?: number; + /** Maximum relationship rows, default 10000, maximum 10000. */ + edgeLimit?: number; + cursor?: string; + /** Hold established nodes and cancel automatic camera following. Holds + * survive settle and release on resumeSimulation, a layout-kind change, + * or a dataset-key change; user-owned pinnedNodeIds are unchanged. */ + preserveLayout?: boolean; + onProgress?: (progress: ExpansionProgress) => void; +} + +/** Serializable query passed only to services implementing queryNeighbors. */ +export type ExpansionQuery = Omit; + export type ExpansionResponse, E = Record> = - | (ExpansionBatch & { provenance?: unknown }) - | { batches: AsyncIterable>; provenance?: unknown }; + | (ExpansionBatch & { provenance?: unknown; page?: ExpansionPage }) + | { batches: AsyncIterable>; provenance?: unknown; page?: ExpansionPage }; /** * path resolver seam. `find` resolves the node/edge id path @@ -734,6 +804,13 @@ export interface PathService extends RevisionAwareService { options: PathOptions, ctx: RequestContext, ): Promise; + /** Optional richer query contract; legacy find services remain valid. */ + findDetailed?( + sourceId: NodeId, + targetId: NodeId, + options: PathOptions, + ctx: RequestContext, + ): Promise; } export interface ExpansionService, E = Record> @@ -743,6 +820,14 @@ export interface ExpansionService, E = Record>; + /** Typed bounded query. Required when richer options are requested. + * Responses must include page metadata matching their aggregate rows. + * Exceeding the requested node/edge budget rejects the entire response. */ + queryNeighbors?( + seedIds: readonly NodeId[], + options: ExpansionQuery, + ctx: RequestContext, + ): Promise>; } // --------------------------------------------------------------------------- @@ -1006,11 +1091,20 @@ export interface MetaEdge { } /** path query options (PathService). */ -export interface PathOptions { +export interface PathOptions extends RelationshipOptions { /** Edge-direction rule for traversal. Default 'outgoing'. */ direction?: 'outgoing' | 'incoming' | 'either'; + /** Default visible; loaded deliberately traverses hidden/out-of-scope rows. */ + universe?: 'visible' | 'loaded'; + /** Maximum traversed hops, 0..1000; omission means unlimited. */ + maxHops?: number; } +export type PathOutcome = + | { status: 'found'; path: PathResult } + | { status: 'not-loaded' | 'filtered'; nodeIds: readonly NodeId[] } + | { status: 'unreachable' | 'hop-limit' }; + /** A resolved path: node ids in order plus the edge ids walked. */ export interface PathResult { nodeIds: readonly NodeId[]; diff --git a/packages/core/test/exploration.test.ts b/packages/core/test/exploration.test.ts new file mode 100644 index 0000000..cadf51b --- /dev/null +++ b/packages/core/test/exploration.test.ts @@ -0,0 +1,309 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createGraphInstance } from '../src/instance'; +import { FakeEngine } from '../src/testing'; +import type { ExpansionResponse, ExpansionService, GraphSnapshot, RequestContext } from '../src/types'; +import { container } from './helpers'; + +const snapshot = (revision = 1): GraphSnapshot => ({ + datasetKey: 'explore', sourceRevision: revision, + nodes: ['a', 'b', 'c', 'd', 'e', 'isolated'].map((id) => ({ id })), + edges: [ + { id: 'ab', source: 'a', target: 'b', attrs: { 'orbit:type': 'works' } }, + { id: 'ab2', source: 'a', target: 'b', attrs: { 'orbit:type': 'owns' } }, + { id: 'ac', source: 'a', target: 'c', attrs: { 'orbit:type': 'works' } }, + { id: 'da', source: 'd', target: 'a', attrs: { 'orbit:type': 'works' } }, + { id: 'ce', source: 'c', target: 'e', attrs: { 'orbit:type': 'works' } }, + ], +}); + +async function rig(service?: ExpansionService) { + const engine = new FakeEngine(); + const instance = createGraphInstance({ engine: () => engine, fitViewOnFirstData: false, + ...(service === undefined ? {} : { services: { expansion: service } }), + }); + await instance.attach(container); + instance.applyHostUpdate({ data: snapshot() }); + return { instance, engine }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; +} + +const options = { direction: 'outgoing', relationshipTypeField: 'orbit:type', relationshipTypes: ['works'], limit: 1 } as const; +const response = (id: string): ExpansionResponse => ({ nodes: [{ id }], edges: [{ id: `a-${id}`, source: 'a', target: id }], page: { returnedNodes: 1, returnedEdges: 1, truncated: false } }); + +describe('passive neighborhood inspection', () => { + it('returns directional/type counts, bounded rows, and visibility without a write or engine call', async () => { + const { instance, engine } = await rig(); + instance.applyHostUpdate({ subgraph: { seedIds: ['a', 'b', 'c'] } }); + instance.hideNodes(['b']); + const before = instance.store.getState(); + const calls = engine.calls.length; + const result = instance.getNeighborhood('a', { relationshipTypeField: 'orbit:type', limit: 2, edgeLimit: 1 }); + expect(result.nodes.map((n) => n.id)).toEqual(['b', 'c']); + expect(result.visibility.get('b')).toBe('filtered'); + expect(result.visibility.get('c')).toBe('visible'); + expect(result.relationshipTypes).toEqual([{ type: 'works', count: 3 }, { type: 'owns', count: 1 }]); + expect(result.totalNeighbors).toBe(3); + expect(result.totalEdges).toBe(4); + expect(result.edges).toHaveLength(1); + expect(result.edgesTruncated).toBe(true); + const last = instance.getNeighborhood('a', { relationshipTypeField: 'orbit:type', limit: 2, edgeLimit: 1, cursor: result.nextCursor! }); + expect(last.nodes.map((n) => n.id)).toEqual(['d']); + expect(last.visibility.get('d')).toBe('out-of-scope'); + expect(instance.store.getState()).toBe(before); + expect(engine.calls.length).toBe(calls); + instance.destroy(); + }); + + it('honors loaded vs visible, relationship types, incoming direction, and unknown nodes', async () => { + const { instance } = await rig(); + instance.hideNodes(['b']); + expect(instance.getNeighborhood('a', { ...options, limit: 50 }).nodes.map((n) => n.id)).toEqual(['b', 'c']); + expect(instance.getNeighborhood('a', { ...options, visibility: 'visible' }).nodes.map((n) => n.id)).toEqual(['c']); + expect(instance.getNeighborhood('a', { ...options, direction: 'incoming' }).nodes.map((n) => n.id)).toEqual(['d']); + expect(instance.getNeighborhood('missing').status).toBe('not-loaded'); + instance.destroy(); + }); + + it('rejects invalid bounds and changed-query/revision cursors without mutation', async () => { + const { instance } = await rig(); + const result = instance.getNeighborhood('a', { limit: 1 }); + const before = instance.store.getState(); + expect(() => instance.getNeighborhood('a', { limit: 0 })).toThrow(/limit/); + expect(() => instance.getNeighborhood('a', { limit: 2, cursor: result.nextCursor! })).toThrow(/cursor/); + expect(instance.store.getState()).toBe(before); + instance.hideNodes(['b']); + expect(() => instance.getNeighborhood('a', { limit: 1, cursor: result.nextCursor! })).toThrow(/cursor/); + instance.destroy(); + }); + + it('visible relationship inspection applies edge hide rules, including parallel rows', async () => { + const { instance } = await rig(); + instance.applyHostUpdate({ filter: { edges: (edge) => edge.id !== 'ab2', mode: 'hide' } }); + const result = instance.getNeighborhood('a', { visibility: 'visible' }); + expect(result.edges.map((e) => e.id)).toEqual(['ab', 'ac', 'da']); + expect(instance.getNeighborhood('a').totalEdges).toBe(4); + instance.destroy(); + }); +}); + +describe('bounded expansion queries', () => { + it('loads stable pages with exact counts, preserves scope intent, and retracts only the newest page', async () => { + const { instance } = await rig(); + instance.applyHostUpdate({ subgraph: { seedIds: ['a'] } }); + const first = await instance.expandNode('a', options); + expect(first).toMatchObject({ added: 1, page: { returnedNodes: 1, returnedEdges: 1, totalNeighbors: 2, truncated: true } }); + expect(instance.getVisibleNodeIds()).toEqual(['a', 'b']); + const second = await instance.expandNode('a', { ...options, cursor: first.page!.nextCursor! }); + expect(second).toMatchObject({ added: 1, page: { totalNeighbors: 2, truncated: false } }); + expect(instance.getVisibleNodeIds()).toEqual(['a', 'b', 'c']); + expect(instance.store.getState().scope).toEqual({ seedIds: ['a'] }); + instance.retractExpansion('a'); + expect(instance.getVisibleNodeIds()).toEqual(['a', 'b']); + expect(instance.getExpansionOverlays('a')).toHaveLength(1); + instance.destroy(); + }); + + it('reports pages for noops and rejects changed source/query continuations', async () => { + const { instance } = await rig(); + const first = await instance.expandNode('a', options); + expect(first).toMatchObject({ noop: true, page: { totalNeighbors: 2 } }); + expect(instance.getExpansionOverlays('a')).toHaveLength(0); + const model = instance.getRevisions().model; + await expect(instance.expandNode('a', { ...options, direction: 'incoming', cursor: first.page!.nextCursor! })).rejects.toMatchObject({ detail: { code: 'invalid-operation' } }); + expect(instance.getRevisions().model).toBe(model); + instance.applyHostUpdate({ data: snapshot(2) }); + await expect(instance.expandNode('a', { ...options, cursor: first.page!.nextCursor! })).rejects.toMatchObject({ detail: { code: 'invalid-operation' } }); + instance.destroy(); + }); + + it('exposes copied live record identities through undo and redo', async () => { + const { instance } = await rig(); + instance.applyHostUpdate({ subgraph: { seedIds: ['a'] } }); + await instance.expandNode('a', options); + const records = instance.getExpansionRecords(); + const id = records[0]!.overlayId; + expect(id).toBeTypeOf('string'); + (records[0]!.addedNodeIds as string[]).push('external'); + expect(instance.getExpansionRecords()[0]!.addedNodeIds).toEqual(['b']); + instance.undo(); + expect(instance.getExpansionRecords()).toEqual([]); + expect(instance.getExpansionOverlays('a')).toHaveLength(1); + instance.redo(); + expect(instance.getExpansionRecords()[0]!.overlayId).toBe(id); + instance.destroy(); + }); + + it('sends completion progress to every coalesced caller', async () => { + const gate = deferred(); + const service: ExpansionService = { revisionDependencies: ['source'], neighbors: async () => ({}), queryNeighbors: () => gate.promise }; + const { instance } = await rig(service); + const one = vi.fn(); const two = vi.fn(); + const pending = instance.expandNode('a', { limit: 1, onProgress: one }); + expect(instance.expandNode('a', { onProgress: two, limit: 1 })).toBe(pending); + gate.resolve(response('new')); await pending; + for (const callback of [one, two]) expect(callback).toHaveBeenCalledWith(expect.objectContaining({ status: 'committed' })); + instance.destroy(); + }); + + it('coalesces equal semantic queries and notifies both observers; different queries supersede', async () => { + const gates: ReturnType>[] = []; + const contexts: RequestContext[] = []; + const service: ExpansionService = { revisionDependencies: ['source'], neighbors: async () => ({}), queryNeighbors(_ids, _opts, ctx) { + contexts.push(ctx); const gate = deferred(); gates.push(gate); return gate.promise; + } }; + const { instance } = await rig(service); + const observer1 = vi.fn(); const observer2 = vi.fn(); + const first = instance.expandNode('a', { limit: 1, relationshipTypes: ['x', 'y'], onProgress: observer1 }); + const again = instance.expandNode('a', { relationshipTypes: ['y', 'x'], limit: 1, hops: 1, onProgress: observer2 }); + expect(first).toBe(again); + expect(gates).toHaveLength(1); + const rejection = expect(first).rejects.toMatchObject({ detail: { code: 'aborted' } }); + const next = instance.expandNode('a', { limit: 2, onProgress: observer2 }); + await rejection; + expect(contexts[0]!.signal.aborted).toBe(true); + gates[0]!.resolve(response('stale')); + gates[1]!.resolve(response('fresh')); + await next; + expect(instance.getNode('stale')).toBeUndefined(); + expect(instance.getNode('fresh')).toBeDefined(); + expect(observer2).toHaveBeenCalledWith(expect.objectContaining({ status: 'committed', receivedNodes: 1 })); + instance.destroy(); + }); + + it('validates before canceling a pending query and rejects rich options on legacy services', async () => { + const gate = deferred(); + const service: ExpansionService = { revisionDependencies: ['source'], neighbors: () => gate.promise }; + const { instance } = await rig(service); + const pending = instance.expandNode('a'); + const before = instance.store.getState(); + await expect(instance.expandNode('a', { limit: 0 })).rejects.toMatchObject({ detail: { code: 'invalid-operation' } }); + await expect(instance.expandNode('a', { limit: 1 })).rejects.toMatchObject({ detail: { code: 'unsupported-operation' } }); + expect(instance.store.getState()).toBe(before); + gate.resolve({ nodes: [{ id: 'new' }] }); + await pending; + expect(instance.getNode('new')).toBeDefined(); + instance.destroy(); + }); + + it('cancelExpansion preserves committed pages and discards a late ignored-abort response', async () => { + const gates: ReturnType>[] = []; + const service: ExpansionService = { revisionDependencies: ['source'], neighbors: async () => ({}), queryNeighbors() { const gate = deferred(); gates.push(gate); return gate.promise; } }; + const { instance } = await rig(service); + const first = instance.expandNode('a', { limit: 1 }); gates[0]!.resolve(response('first')); await first; + const pending = instance.expandNode('a', { limit: 1 }); + const rejected = expect(pending).rejects.toMatchObject({ detail: { code: 'aborted' } }); + instance.cancelExpansion('a'); + await rejected; + gates[1]!.resolve(response('late')); + await new Promise((r) => setTimeout(r, 0)); + expect(instance.getExpansionOverlays('a')).toHaveLength(1); + expect(instance.getNode('first')).toBeDefined(); + expect(instance.getNode('late')).toBeUndefined(); + expect(instance.store.getState().pendingExpansions.size).toBe(0); + instance.destroy(); + }); + + it('rejects streaming budget excess atomically and reports progress before publication', async () => { + const seenModels: number[] = []; + const service: ExpansionService = { revisionDependencies: ['source'], neighbors: async () => ({}), queryNeighbors: async () => ({ + page: { returnedNodes: 2, returnedEdges: 0, truncated: false }, + batches: (async function* () { yield { nodes: [{ id: 'one' }] }; yield { nodes: [{ id: 'two' }] }; })(), + }) }; + const { instance } = await rig(service); + const model = instance.getRevisions().model; + await expect(instance.expandNode('a', { limit: 1, onProgress: () => { seenModels.push(instance.getRevisions().model); } })).rejects.toMatchObject({ detail: { code: 'invalid-operation' } }); + expect(seenModels).toEqual([model, model]); + expect(instance.getRevisions().model).toBe(model); + expect(instance.getNode('one')).toBeUndefined(); + expect(instance.getOverlayIds()).toEqual([]); + instance.destroy(); + }); + + it('requires honest metadata and rejects edge budget excess before graph mutation', async () => { + const service: ExpansionService = { revisionDependencies: ['source'], neighbors: async () => ({}), queryNeighbors: async () => ({ ...response('new'), edges: [{ source: 'a', target: 'new' }, { source: 'a', target: 'new' }] }) }; + const { instance } = await rig(service); + const model = instance.getRevisions().model; + await expect(instance.expandNode('a', { limit: 1, edgeLimit: 1 })).rejects.toMatchObject({ detail: { code: 'invalid-operation' } }); + expect(instance.getRevisions().model).toBe(model); + expect(instance.getNode('new')).toBeUndefined(); + instance.destroy(); + }); + + it('holds established positions and camera through settle, preserves user pins, and releases on resume', async () => { + const { instance, engine } = await rig(); + instance.applyHostUpdate({ subgraph: { seedIds: ['a'] } }); + engine.nudgePositions(30, 40); + const before = Array.from(engine.getPositions()!); + const cameraCalls = engine.cameraCalls.length; + await instance.expandNode('a', { ...options, preserveLayout: true }); + expect(Array.from(engine.getPositions()!).slice(0, 2)).toEqual(before); + expect(engine.pinnedIndices).toContain(0); + instance.pinNodes(['b']); + engine.injectSimulationEnd(); + expect(engine.pinnedIndices).toEqual([1, 0]); + expect(engine.cameraCalls).toHaveLength(cameraCalls); + instance.resumeSimulation(); + expect(engine.pinnedIndices).toEqual([1]); + instance.destroy(); + }); + + it('remaps landmark pins through scope changes and history, and clears them on a new dataset', async () => { + const { instance, engine } = await rig(); + instance.applyHostUpdate({ subgraph: { seedIds: ['c'] } }); + await instance.expandNode('c', { limit: 1, preserveLayout: true }); + engine.injectSimulationEnd(); + // The expansion returns a before c in accepted order: c's pinned index changes. + expect(engine.pinnedIndices).toEqual([1]); + instance.undo(); + expect(engine.pinnedIndices).toEqual([0]); + instance.redo(); + expect(engine.pinnedIndices).toEqual([1]); + instance.applyHostUpdate({ data: { ...snapshot(), datasetKey: 'other' } }); + expect(engine.pinnedIndices).toBeNull(); + instance.destroy(); + }); +}); + +describe('detailed path queries', () => { + it('distinguishes missing/filtered endpoints, loaded paths, unreachable, and hop limits passively', async () => { + const { instance, engine } = await rig(); + instance.hideNodes(['c']); + const before = instance.store.getState(); const calls = engine.calls.length; + expect(await instance.findPathDetailed('a', 'missing')).toEqual({ status: 'not-loaded', nodeIds: ['missing'] }); + expect(await instance.findPathDetailed('a', 'c')).toEqual({ status: 'filtered', nodeIds: ['c'] }); + expect(await instance.findPathDetailed('a', 'e')).toEqual({ status: 'filtered', nodeIds: ['c'] }); + expect(await instance.findPathDetailed('a', 'e', { universe: 'loaded' })).toEqual({ status: 'found', path: { nodeIds: ['a', 'c', 'e'], edgeIds: ['ac', 'ce'] } }); + expect(await instance.findPathDetailed('a', 'e', { universe: 'loaded', maxHops: 1 })).toEqual({ status: 'hop-limit' }); + expect(await instance.findPathDetailed('a', 'isolated', { universe: 'loaded' })).toEqual({ status: 'unreachable' }); + expect(instance.store.getState()).toBe(before); expect(engine.calls.length).toBe(calls); + instance.destroy(); + }); + + it('traverses exact relationship fields and direction, handles zero hops, and keeps legacy emphasis', async () => { + const { instance } = await rig(); + expect(await instance.findPathDetailed('b', 'a', { direction: 'incoming', relationshipTypeField: 'orbit:type', relationshipTypes: ['owns'] })).toEqual({ status: 'found', path: { nodeIds: ['b', 'a'], edgeIds: ['ab2'] } }); + expect(await instance.findPathDetailed('a', 'a', { maxHops: 0 })).toMatchObject({ status: 'found' }); + expect(await instance.findPathDetailed('a', 'b', { maxHops: 0 })).toEqual({ status: 'hop-limit' }); + expect(await instance.findPath('a', 'e', { relationshipTypeField: 'orbit:type', relationshipTypes: ['works'] })).toEqual({ nodeIds: ['a', 'c', 'e'], edgeIds: ['ac', 'ce'] }); + expect(instance.getActivePath()?.nodeIds).toEqual(['a', 'c', 'e']); + instance.destroy(); + }); + + it('rejects rich options for legacy services without superseding an admitted in-flight path', async () => { + const gate = deferred<{ nodeIds: string[]; edgeIds: string[] }>(); + const engine = new FakeEngine(); + const instance = createGraphInstance({ engine: () => engine, services: { path: { revisionDependencies: ['source'], find: () => gate.promise } } }); + await instance.attach(container); instance.applyHostUpdate({ data: snapshot() }); + const pending = instance.findPath('a', 'b'); + await expect(instance.findPath('a', 'c', { universe: 'loaded' })).rejects.toMatchObject({ detail: { code: 'unsupported-operation' } }); + gate.resolve({ nodeIds: ['a', 'b'], edgeIds: ['ab'] }); + await pending; + expect(instance.getActivePath()?.nodeIds).toEqual(['a', 'b']); + instance.destroy(); + }); +}); diff --git a/packages/core/test/investigation.test.ts b/packages/core/test/investigation.test.ts new file mode 100644 index 0000000..94ebf8a --- /dev/null +++ b/packages/core/test/investigation.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createGraphInstance } from '../src/instance'; +import { createInvestigationSession, parseInvestigation, serializeInvestigation } from '../src/investigation'; +import type { GraphInvestigation } from '../src/investigation'; +import type { ExpansionQuery, ExpansionService, GraphSnapshot } from '../src/types'; +import { FakeEngine } from '../src/testing/FakeEngine'; +import { container } from './helpers'; + +type N = { label: string }; +type E = { type: string }; +const data: GraphSnapshot = { + datasetKey: 'investigation', sourceRevision: 'v1', + nodes: [{ id: 'a', x: 0, y: 0, attrs: { label: 'Acme' } }, { id: 'b', x: 30, y: 20, attrs: { label: 'Beta' } }], + edges: [{ id: 'ab', source: 'a', target: 'b', attrs: { type: 'SUPPLIES' } }], +}; + +async function rig(service?: ExpansionService, initial = data) { + const engine = new FakeEngine(); + const instance = createGraphInstance({ + engine: () => engine, fitViewOnFirstData: false, + ...(service === undefined ? {} : { services: { expansion: service } }), + }); + instance.applyHostUpdate({ data: initial, layout: 'fixed', dataRef: { dataset: initial.datasetKey, revision: initial.sourceRevision } }); + await instance.attach(container); + return { instance, engine }; +} + +describe('investigation checkpoints', () => { + it('round-trips notes, search/table intent, ordered evidence, selection, scope and positions', async () => { + const { instance } = await rig(); + const session = createInvestigationSession(instance, { now: () => new Date('2026-09-05T00:00:00Z') }); + try { + session.setTitle('Supplier overlap'); + session.setNotes('Acme supplies Beta. Verify the contract date.'); + session.setSearchQuery('Acme'); + session.setTableQuery('Beta'); + instance.selectNodes(['a']); + session.savePath({ sourceId: 'a', targetId: 'b', options: { direction: 'outgoing', universe: 'loaded' }, path: { nodeIds: ['a', 'b'], edgeIds: ['ab'] } }); + const saved = await session.checkpoint(); + expect(saved.view.positions).toEqual([['a', 0, 0], ['b', 30, 20]]); + expect(saved.source).toEqual({ datasetKey: 'investigation', sourceRevision: 'v1', dataRef: { dataset: 'investigation', revision: 'v1' } }); + expect(parseInvestigation(session.exportCheckpoint(saved.id))).toEqual(saved); + expect(Object.isFrozen(saved.paths[0]!.path.nodeIds)).toBe(true); + session.setNotes('Changed'); + session.setTableQuery('Changed'); + session.removePath(saved.paths[0]!.id); + instance.hideNodes(['a']); + instance.applyHostUpdate({ subgraph: { seedIds: ['b'], hops: 0 } }); + await session.restoreCheckpoint(saved.id); + expect(instance.getVisibleNodeIds()).toEqual(['a', 'b']); + expect(instance.store.getState().selection.nodeIds).toEqual(['a']); + expect(session.store.getState()).toMatchObject({ title: 'Supplier overlap', notes: saved.notes, searchQuery: 'Acme', tableQuery: 'Beta', paths: saved.paths, activeCheckpointId: saved.id, status: 'idle' }); + } finally { session.destroy(); instance.destroy(); } + }); + + it('imports without applying and rejects invalid envelopes before a loader or graph mutation', async () => { + const { instance } = await rig(); + const loadSource = vi.fn(async () => {}); + const session = createInvestigationSession(instance, { loadSource }); + try { + const saved = await session.checkpoint('Original', { includePositions: false }); + instance.hideNodes(['b']); + const before = instance.store.getState(); + const imported = session.importCheckpoint(serializeInvestigation(saved)); + expect(imported).toEqual(saved); + expect(instance.store.getState()).toBe(before); + const invalid = [ + { ...saved, v: 2 }, + { ...saved, source: { ...saved.source, datasetKey: 2 } }, + { ...saved, view: { ...saved.view, hiddenNodeIds: [42] } }, + { ...saved, expansions: [{ seedId: 'a', options: { cursor: 'ephemeral' }, continuation: false }] }, + { ...saved, expansions: [{ seedId: 'a', options: {}, continuation: true }] }, + { ...saved, paths: [{ id: 'p', title: 'Wrong endpoints', sourceId: 'a', targetId: 'b', options: {}, path: { nodeIds: ['a'], edgeIds: [] } }] }, + { ...saved, hostState: { predicate: () => true } }, + ]; + for (const value of invalid) { + await expect(session.restoreCheckpoint(value as GraphInvestigation)).rejects.toMatchObject({ code: 'invalid-investigation' }); + expect(instance.store.getState()).toBe(before); + } + expect(loadSource).not.toHaveBeenCalled(); + } finally { session.destroy(); instance.destroy(); } + }); + + it('requires the exact source and lets a host load it before applying the saved view', async () => { + const { instance } = await rig(); + const plain = createInvestigationSession(instance); + let restoredHost: unknown; + const session = createInvestigationSession(() => instance, { + loadSource: async (source) => { + expect(source.sourceRevision).toBe('v1'); + instance.applyHostUpdate({ data, dataRef: source.dataRef! }); + }, + captureHostState: () => ({ relationship: 'SUPPLIES' }), + restoreHostState: (state) => { restoredHost = state; }, + }); + try { + const basic = await plain.checkpoint(); + const saved = await session.checkpoint(); + instance.applyHostUpdate({ data: { ...data, sourceRevision: 'v2' }, dataRef: { dataset: 'investigation', revision: 'v2' } }); + const before = instance.store.getState(); + await expect(plain.restoreCheckpoint(basic)).rejects.toMatchObject({ code: 'source-mismatch' }); + expect(instance.store.getState()).toBe(before); + await session.restoreCheckpoint(saved); + expect(instance.getSource()).toEqual({ datasetKey: 'investigation', sourceRevision: 'v1' }); + expect(restoredHost).toEqual({ relationship: 'SUPPLIES' }); + expect(session.store.getState().activeCheckpointId).toBe(saved.id); + } finally { plain.destroy(); session.destroy(); instance.destroy(); } + }); + + it('rejects an incorrect loader result without applying the checkpoint', async () => { + const { instance } = await rig(); + const session = createInvestigationSession(instance, { loadSource: async () => {} }); + try { + const saved = await session.checkpoint(); + instance.applyHostUpdate({ data: { ...data, datasetKey: 'different' }, dataRef: null }); + const before = instance.store.getState(); + await expect(session.restoreCheckpoint(saved)).rejects.toMatchObject({ code: 'source-mismatch' }); + expect(instance.store.getState()).toBe(before); + expect(session.store.getState()).toMatchObject({ status: 'idle', activeCheckpointId: null }); + } finally { session.destroy(); instance.destroy(); } + }); + + it('honors pre-cancellation without starting a source load or publishing session changes', async () => { + const { instance } = await rig(); + const loadSource = vi.fn(async () => {}); + const session = createInvestigationSession(instance, { loadSource }); + try { + const saved = await session.checkpoint(); + instance.applyHostUpdate({ data: { ...data, datasetKey: 'other' } }); + const controller = new AbortController(); + controller.abort(); + const before = session.store.getState(); + await expect(session.restoreCheckpoint(saved, { signal: controller.signal })).rejects.toMatchObject({ code: 'aborted' }); + expect(session.store.getState()).toBe(before); + expect(loadSource).not.toHaveBeenCalled(); + } finally { session.destroy(); instance.destroy(); } + }); + + it('keeps checkpoints while discarding source-specific evidence on a new source', async () => { + const { instance } = await rig(); + const session = createInvestigationSession(instance); + try { + session.savePath({ sourceId: 'a', targetId: 'b', path: { nodeIds: ['a', 'b'], edgeIds: ['ab'] } }); + const saved = await session.checkpoint(); + instance.applyHostUpdate({ data: { ...data, sourceRevision: 'v2' } }); + expect(session.store.getState()).toMatchObject({ paths: [], activeCheckpointId: null }); + const next = await session.checkpoint('Next'); + expect(next.paths).toEqual([]); + expect(session.store.getState().checkpoints.map((c) => c.id)).toEqual([saved.id, next.id]); + } finally { session.destroy(); instance.destroy(); } + }); + + it('rebinds a getter after remount and detaches the previous source subscription', async () => { + const first = await rig(); + const second = await rig(undefined, { ...data, datasetKey: 'replacement' }); + let current = first.instance; + const session = createInvestigationSession(() => current); + try { + session.savePath({ sourceId: 'a', targetId: 'b', path: { nodeIds: ['a', 'b'], edgeIds: ['ab'] } }); + const saved = await session.checkpoint(); + current = second.instance; + session.refreshSource(); + expect(session.store.getState()).toMatchObject({ paths: [], checkpoints: [saved], activeCheckpointId: null }); + const before = session.store.getState(); + first.instance.applyHostUpdate({ data: { ...data, sourceRevision: 'old-instance-change' } }); + expect(session.store.getState()).toBe(before); + session.destroy(); + second.instance.applyHostUpdate({ data: { ...data, sourceRevision: 'after-destroy' } }); + expect(session.store.getState()).toBe(before); + } finally { session.destroy(); first.instance.destroy(); second.instance.destroy(); } + }); +}); + +describe('replayable expansion requests', () => { + function pagedService() { + const requests: ExpansionQuery[] = []; + const cursors = new Map(); + let generation = 0; + const service: ExpansionService = { + revisionDependencies: ['source'], + neighbors: async () => ({ nodes: [], edges: [] }), + queryNeighbors: async (_seeds, query, context) => { + if (context.signal.aborted) throw new Error('aborted'); + requests.push({ ...query }); + const offset = query.cursor === undefined ? 0 : cursors.get(query.cursor); + if (offset === undefined) throw new Error('Unknown continuation'); + const id = ['b', 'c'][offset]!; + const nextCursor = offset === 0 ? `cursor-${++generation}` : undefined; + if (nextCursor !== undefined) cursors.set(nextCursor, 1); + return { + nodes: [{ id, attrs: { label: id } }], + edges: [{ id: `a${id}`, source: 'a', target: id, attrs: { type: 'SUPPLIES' } }], + page: { returnedNodes: 1, returnedEdges: 1, totalNeighbors: 2, truncated: nextCursor !== undefined, ...(nextCursor === undefined ? {} : { nextCursor }) }, + }; + }, + }; + return { service, requests }; + } + + it('regenerates opaque continuation cursors when replaying saved expansions', async () => { + const { service, requests } = pagedService(); + const { instance } = await rig(service, { ...data, nodes: [data.nodes[0]!], edges: [] }); + const session = createInvestigationSession(instance); + try { + const options = { direction: 'outgoing' as const, relationshipTypes: ['SUPPLIES'], limit: 1, preserveLayout: true }; + const first = await session.expandNode('a', options); + expect(first.page?.nextCursor).toBeDefined(); + await session.expandNode('a', { ...options, cursor: first.page!.nextCursor! }); + const saved = await session.checkpoint('Expanded suppliers'); + expect(saved.expansions.map((e) => e.continuation)).toEqual([false, true]); + expect(serializeInvestigation(saved)).not.toContain('cursor-'); + await session.restoreCheckpoint(saved); + expect(instance.getVisibleNodeIds()).toEqual(['a', 'b', 'c']); + expect(requests).toHaveLength(4); + expect(requests[3]!.cursor).not.toBe(requests[1]!.cursor); + session.retractExpansion('a'); + expect(instance.getVisibleNodeIds()).toEqual(['a', 'b']); + session.retractExpansion('a'); + expect(instance.getVisibleNodeIds()).toEqual(['a']); + } finally { session.destroy(); instance.destroy(); } + }); + + it('does not retract an earlier contribution when the latest query was a no-op', async () => { + const { service } = pagedService(); + const { instance } = await rig(service, { ...data, nodes: [data.nodes[0]!], edges: [] }); + const session = createInvestigationSession(instance); + try { + const options = { limit: 1 }; + await session.expandNode('a', options); + const again = await session.expandNode('a', options); + expect(again).toMatchObject({ noop: true }); + session.retractExpansion('a'); + expect(instance.getVisibleNodeIds()).toEqual(['a', 'b']); + session.retractExpansion('a'); + expect(instance.getVisibleNodeIds()).toEqual(['a']); + } finally { session.destroy(); instance.destroy(); } + }); + + it('saves the live expansion recipe after undo and redo', async () => { + const { service } = pagedService(); + const { instance } = await rig(service, { ...data, nodes: [data.nodes[0]!], edges: [] }); + const session = createInvestigationSession(instance); + try { + await session.expandNode('a', { limit: 1 }); + instance.undo(); + expect(session.store.getState().expansions).toEqual([]); + expect((await session.checkpoint()).expansions).toEqual([]); + instance.redo(); + expect(session.store.getState().expansions).toHaveLength(1); + const saved = await session.checkpoint(); + await session.restoreCheckpoint(saved); + expect(instance.getVisibleNodeIds()).toEqual(['a', 'b']); + } finally { session.destroy(); instance.destroy(); } + }); + + it('retains the current investigation when a different source cannot be loaded', async () => { + const { service } = pagedService(); + const { instance } = await rig(service, { ...data, nodes: [data.nodes[0]!], edges: [] }); + const session = createInvestigationSession(instance, { loadSource: async () => { throw new Error('Revision unavailable'); } }); + try { + await session.expandNode('a', { limit: 1 }); + const saved = await session.checkpoint(); + const other = { ...saved, source: { ...saved.source, datasetKey: 'unavailable' } }; + const before = session.store.getState(); + await expect(session.restoreCheckpoint(other)).rejects.toThrow('Revision unavailable'); + expect(session.store.getState()).toMatchObject({ expansions: before.expansions, paths: before.paths, activeCheckpointId: saved.id }); + expect(instance.getVisibleNodeIds()).toEqual(['a', 'b']); + } finally { session.destroy(); instance.destroy(); } + }); + + it('never retracts a newer same-seed contribution created outside the session', async () => { + const { service } = pagedService(); + const { instance } = await rig(service, { ...data, nodes: [data.nodes[0]!], edges: [] }); + const session = createInvestigationSession(instance); + try { + const first = await session.expandNode('a', { limit: 1 }); + const saved = await session.checkpoint(); + await instance.expandNode('a', { limit: 1, cursor: first.page!.nextCursor! }); + const before = instance.store.getState(); + expect(() => session.retractExpansion('a')).toThrow(/outside this investigation/); + await expect(session.restoreCheckpoint(saved)).rejects.toMatchObject({ code: 'untracked-expansion' }); + expect(instance.store.getState()).toBe(before); + expect(session.store.getState().expansions).toHaveLength(1); + expect(instance.getVisibleNodeIds()).toEqual(['a', 'b', 'c']); + } finally { session.destroy(); instance.destroy(); } + }); + + it('rejects untracked cursors before requesting or publishing anything', async () => { + const { service, requests } = pagedService(); + const { instance } = await rig(service); + const session = createInvestigationSession(instance); + try { + await expect(session.expandNode('a', { limit: 1, cursor: 'outside-session' })).rejects.toMatchObject({ code: 'untracked-cursor' }); + expect(requests).toEqual([]); + expect(session.store.getState().expansions).toEqual([]); + } finally { session.destroy(); instance.destroy(); } + }); + + it('cancels a replay request and leaves no restore-owned contribution', async () => { + const { service } = pagedService(); + const { instance } = await rig(service, { ...data, nodes: [data.nodes[0]!], edges: [] }); + const session = createInvestigationSession(instance); + try { + await session.expandNode('a', { limit: 1 }); + const saved = await session.checkpoint(); + let begun!: () => void; + const started = new Promise((resolve) => { begun = resolve; }); + service.queryNeighbors = async (_ids, _query, context) => new Promise((_resolve, reject) => { + begun(); + context.signal.addEventListener('abort', () => reject(new Error('request aborted')), { once: true }); + }); + // A different limit avoids the service cache populated while saving. + const altered = { ...saved, expansions: [{ ...saved.expansions[0]!, options: { limit: 2 } }] }; + const abort = new AbortController(); + const restoring = session.restoreCheckpoint(altered, { signal: abort.signal }); + await started; + abort.abort(); + await expect(restoring).rejects.toThrow(/abort|cancel/i); + expect(instance.getVisibleNodeIds()).toEqual(['a']); + expect(instance.store.getState().pendingExpansions.size).toBe(0); + expect(session.store.getState()).toMatchObject({ status: 'idle', expansions: [], activeCheckpointId: null }); + } finally { session.destroy(); instance.destroy(); } + }); +}); diff --git a/packages/react/package.json b/packages/react/package.json index f9a30bf..1143740 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -26,6 +26,7 @@ "./components/Minimap": "./src/components/Minimap/index.tsx", "./components/Tooltip": "./src/components/Tooltip/index.tsx", "./components/Inspector": "./src/components/Inspector/index.tsx", + "./components/Explorer": "./src/components/Explorer/index.tsx", "./components/Table": "./src/components/Table/index.tsx", "./components/SimControls": "./src/components/SimControls/index.tsx" }, @@ -80,6 +81,10 @@ "types": "./dist/components/Inspector.d.ts", "default": "./dist/components/Inspector.js" }, + "./components/Explorer": { + "types": "./dist/components/Explorer.d.ts", + "default": "./dist/components/Explorer.js" + }, "./components/Table": { "types": "./dist/components/Table.d.ts", "default": "./dist/components/Table.js" diff --git a/packages/react/src/Graph.tsx b/packages/react/src/Graph.tsx index 81c9a30..7eb8d10 100644 --- a/packages/react/src/Graph.tsx +++ b/packages/react/src/Graph.tsx @@ -34,6 +34,7 @@ import type { CrossfilterSession, DimensionSpec, ExpandNodeResult, + ExpansionOptions, FilterExpr, FilterSpec, GraphDiagnostic, @@ -52,9 +53,12 @@ import type { MetaEdge, MetricColumn, PathOptions, + PathOutcome, PathResult, MetricName, NodeId, + NeighborhoodOptions, + NeighborhoodResult, ResolvedCluster, ResolvedGroup, Revisions, @@ -339,6 +343,8 @@ export interface GraphHandle, E = Record; + /** Explain a bounded connection query without changing camera or emphasis. */ + findPathDetailed(sourceId: NodeId, targetId: NodeId, options?: PathOptions): Promise; clearPath(): void; // --- selection algebra --- /** Expand to the 1-hop neighborhood of `id` (or of the current selection). */ @@ -364,7 +370,11 @@ export interface GraphHandle, E = Record; + expandNode(id: NodeId, opts?: ExpansionOptions): Promise; + /** Stop a pending expansion without retracting admitted relationships. */ + cancelExpansion(id: NodeId): void; + /** Passive, bounded relationship read; never moves the camera. */ + getNeighborhood(id: NodeId, options?: NeighborhoodOptions): NeighborhoodResult; /** Abort `id`'s pending expansion and remove its committed expansion * overlays. */ retractExpansion(id: NodeId): void; @@ -1101,6 +1111,8 @@ function GraphInner( }, findPath: (sourceId: NodeId, targetId: NodeId, options?: PathOptions) => instance.findPath(sourceId, targetId, options), + findPathDetailed: (sourceId: NodeId, targetId: NodeId, options?: PathOptions) => + instance.findPathDetailed(sourceId, targetId, options), clearPath: () => { instance.clearPath(); }, @@ -1141,8 +1153,10 @@ function GraphInner( resetIsolation: () => { instance.resetIsolation(); }, - expandNode: (id: NodeId, opts?: { hops?: number }): Promise => + expandNode: (id: NodeId, opts?: ExpansionOptions): Promise => instance.expandNode(id, opts), + cancelExpansion: (id: NodeId) => instance.cancelExpansion(id), + getNeighborhood: (id: NodeId, options?: NeighborhoodOptions) => instance.getNeighborhood(id, options), retractExpansion: (id: NodeId) => { instance.retractExpansion(id); }, diff --git a/packages/react/src/components/Explorer/index.tsx b/packages/react/src/components/Explorer/index.tsx new file mode 100644 index 0000000..d56ce71 --- /dev/null +++ b/packages/react/src/components/Explorer/index.tsx @@ -0,0 +1,369 @@ +/** Optional exploration workspace. Source loading and host-owned filters stay + * explicit callbacks; the graph remains the owner of graph state, and an + * investigation session owns durable notes, paths, and checkpoints. */ +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import type { CSSProperties, ReactElement } from 'react'; +import { createInvestigationSession } from '@modernrelay/orbit-core'; +import type { + ExpansionPage, GraphStoreState, InvestigationSession, InvestigationExpansion, PathOptions, + SearchResult, SearchUnavailableReason, +} from '@modernrelay/orbit-core'; +import type { AnyGraphInstance } from '../../GraphProvider'; +import { mergeStyle, useResolvedInstance } from '../shared'; +import { GraphSearch } from '../Search'; +import { GraphTable } from '../Table'; +import type { GraphTableHandle, GraphTableProps } from '../Table'; +import { cellText } from '../Table/model'; +import { GraphInspector } from '../Inspector'; +import type { GraphInspectionSubject } from '../Inspector'; + +export interface GraphExplorerConstraint { + id: string; + label: string; + /** Explicit host-controlled transition; put reversible host state in the + * investigation session's captureHostState/restoreHostState hooks. */ + onClear(): void | Promise; +} + +export interface GraphExplorerProps { + instance?: AnyGraphInstance; + /** External sessions can survive graph remounts and persist checkpoints. + * Omission creates an in-memory session owned by this component. */ + investigation?: InvestigationSession; + typeField?: string; + onRecoverSearchResult?: (result: SearchResult, reason: SearchUnavailableReason) => void | Promise; + constraints?: readonly GraphExplorerConstraint[]; + columns?: GraphTableProps['columns']; + title?: string; + layout?: 'dock' | 'panel'; + height?: number | string; + className?: string; + style?: CSSProperties; +} + +const box: CSSProperties = { padding: 10, border: '1px solid #48505d', borderRadius: 8, minWidth: 0 }; +const row: CSSProperties = { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }; +const input: CSSProperties = { minWidth: 0, maxWidth: '100%', padding: '5px 7px', boxSizing: 'border-box' }; +const sectionTitle: CSSProperties = { margin: '0 0 8px', fontSize: 14 }; +const EMPTY_CONSTRAINTS: readonly GraphExplorerConstraint[] = []; +function message(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function nodeLabel(instance: AnyGraphInstance, id: string): string { + return cellText(instance.getNode(id)?.attrs?.label ?? id); +} +function download(name: string, text: string, type: string): void { + const url = URL.createObjectURL(new Blob([text], { type })); + const link = document.createElement('a'); + link.href = url; + link.download = name; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); +} + +/** Subscribe only to exploration state: viewport animation never rerenders + * the table, relationship lists, or checkpoint controls. */ +function useExplorationState(instance: AnyGraphInstance): GraphStoreState { + const cached = useRef(null); + const subscribe = useCallback((onChange: () => void) => instance.store.subscribe(onChange), [instance]); + const snapshot = useCallback(() => { + const next = instance.store.getState(); + const old = cached.current; + if (old !== null && old.revisions.model === next.revisions.model && old.revisions.scope === next.revisions.scope && + old.selection === next.selection && old.hiddenNodeIds === next.hiddenNodeIds && old.scope === next.scope && + old.history === next.history && old.pendingExpansions === next.pendingExpansions && old.folds === next.folds && + old.groups === next.groups && old.pinnedNodeIds === next.pinnedNodeIds && old.theme === next.theme && old.status === next.status) return old; + cached.current = next; + return next; + }, [instance]); + return useSyncExternalStore(subscribe, snapshot, snapshot); +} + +export function GraphExplorer(props: GraphExplorerProps): ReactElement { + const instance = useResolvedInstance(props.instance, ''); + const [owned, setOwned] = useState<{ instance: AnyGraphInstance; session: InvestigationSession } | null>(null); + const activeOwned = useRef | null>(null); + useEffect(() => { + if (props.investigation !== undefined) return undefined; + const session = createInvestigationSession(instance); + activeOwned.current = session; + setOwned({ instance, session }); + return () => { if (activeOwned.current === session) activeOwned.current = null; session.destroy(); }; + }, [instance, props.investigation]); + const investigation = props.investigation ?? (owned?.instance === instance && activeOwned.current === owned.session ? owned.session : null); + if (investigation === null) return
Preparing exploration…
; + return ; +} + +function ExplorerContent(props: GraphExplorerProps & { instance: AnyGraphInstance; investigation: InvestigationSession }): ReactElement { + const { instance, investigation } = props; + const state = useExplorationState(instance); + const subscribe = useCallback((onChange: () => void) => investigation.store.subscribe(onChange), [investigation]); + const snapshot = useCallback(() => investigation.store.getState(), [investigation]); + const saved = useSyncExternalStore(subscribe, snapshot, snapshot); + const [subject, setSubject] = useState(null); + const tableQuery = saved.tableQuery; + const setTableQuery = (query: string): void => investigation.setTableQuery(query); + const [unavailable, setUnavailable] = useState<{ result: SearchResult; reason: SearchUnavailableReason } | null>(null); + const [notice, setNotice] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + const epoch = useRef(0); + const pathEpoch = useRef(0); + const tableRef = useRef(null); + const [direction, setDirection] = useState>('either'); + const [relationshipType, setRelationshipType] = useState(''); + const [limit, setLimit] = useState(25); + const [expansion, setExpansion] = useState<{ seed: string; key: string; recipes: readonly InvestigationExpansion[]; page?: ExpansionPage } | null>(null); + const [pathFrom, setPathFrom] = useState(''); + const [pathTo, setPathTo] = useState(''); + const [pathDirection, setPathDirection] = useState>('either'); + const [pathUniverse, setPathUniverse] = useState<'visible' | 'loaded'>('visible'); + const [pathHops, setPathHops] = useState(6); + const [pathType, setPathType] = useState(''); + const [pathBusy, setPathBusy] = useState(false); + const queryRef = useRef(saved.searchQuery); + queryRef.current = saved.searchQuery; + const typeField = props.typeField ?? 'type'; + const sourceKey = JSON.stringify(instance.getSource()); + const seed = subject?.kind === 'node' ? subject.id : state.selection.nodeIds[0]; + const neighborhood = useMemo(() => seed === undefined ? null : instance.getNeighborhood(seed, { + direction, relationshipTypeField: typeField, limit: 1, edgeLimit: 1, + ...(relationshipType === '' ? {} : { relationshipTypes: [relationshipType] }), + }), [instance, seed, direction, typeField, relationshipType, state.revisions.model, state.revisions.scope]); + const expansionKey = JSON.stringify([seed, direction, relationshipType, typeField, limit]); + const expansionPage = expansion?.key === expansionKey && expansion.recipes === saved.expansions ? expansion.page : undefined; + const expansionBusy = seed !== undefined && state.pendingExpansions.has(seed); + + useEffect(() => { investigation.refreshSource(); }, [instance, investigation]); + + useEffect(() => { + epoch.current += 1; + pathEpoch.current += 1; + setSubject(null); setUnavailable(null); setError(null); setNotice(''); setExpansion(null); + setPathFrom(''); setPathTo(''); setBusy(null); setPathBusy(false); + return () => { epoch.current += 1; pathEpoch.current += 1; }; + }, [instance, investigation, sourceKey]); + useEffect(() => { + const selected = state.selection; + if (selected.edgeIds.length === 1 && selected.nodeIds.length === 0) setSubject({ kind: 'edge', id: selected.edgeIds[0]! }); + else if (selected.nodeIds.length === 1) setSubject({ kind: 'node', id: selected.nodeIds[0]! }); + else if (selected.nodeIds.length > 1) setSubject({ kind: 'selection', nodeIds: selected.nodeIds }); + }, [state.selection]); + useEffect(() => { + const offNode = instance.on('nodeClick', ({ node }) => setSubject({ kind: 'node', id: node.id })); + const offEdge = instance.on('edgeClick', ({ edge }) => setSubject({ kind: 'edge', id: edge.id })); + return () => { offNode(); offEdge(); }; + }, [instance]); + useEffect(() => { setUnavailable(null); }, [saved.searchQuery]); + + const run = async (label: string, action: () => void | Promise): Promise => { + const issued = epoch.current; + setBusy(label); setError(null); + try { await action(); } + catch (cause) { if (issued === epoch.current) setError(message(cause)); } + finally { if (issued === epoch.current) setBusy(null); } + }; + const recover = (): void => { + if (unavailable === null || props.onRecoverSearchResult === undefined) return; + const target = unavailable; + const issued = epoch.current; + const issuedQuery = saved.searchQuery; + void run('Recover search result', async () => { + await props.onRecoverSearchResult!(target.result, target.reason); + if (issued !== epoch.current || issuedQuery !== queryRef.current) return; + const outcome = instance.activateSearchResult(target.result); + if (outcome.status === 'focused') { + setSubject({ kind: 'node', id: target.result.id }); setUnavailable(null); + } else setUnavailable({ result: target.result, reason: outcome.reason }); + }); + }; + const expand = (cursor?: string): void => { + if (seed === undefined) return; + const issued = epoch.current; + const id = seed; + void run('Expand relationships', async () => { + const result = await investigation.expandNode(id, { + hops: 1, limit, edgeLimit: 1000, direction, relationshipTypeField: typeField, preserveLayout: true, + ...(relationshipType === '' ? {} : { relationshipTypes: [relationshipType] }), + ...(cursor === undefined ? {} : { cursor }), + onProgress: (progress) => { + if (issued === epoch.current) setNotice(`Loading ${progress.receivedNodes} nodes · ${progress.receivedEdges} relationships`); + }, + }); + if (issued !== epoch.current) return; + const page = 'page' in result ? result.page : undefined; + setExpansion({ seed: id, key: expansionKey, recipes: investigation.store.getState().expansions, ...(page === undefined ? {} : { page }) }); + setNotice(page === undefined ? 'Expansion complete.' : `Added page: ${page.returnedNodes} nodes · ${page.returnedEdges} relationships${page.truncated ? ' · more may be available' : ''}`); + }); + }; + const findPath = (): void => { + if (pathFrom.trim() === '' || pathTo.trim() === '') return; + const issued = ++pathEpoch.current; + const sourceId = pathFrom.trim(); const targetId = pathTo.trim(); + const options: PathOptions = { + direction: pathDirection, universe: pathUniverse, maxHops: pathHops, + relationshipTypeField: typeField, + ...(pathType === '' ? {} : { relationshipTypes: [pathType] }), + }; + setPathBusy(true); setError(null); setNotice('Finding connection…'); + void instance.findPathDetailed(sourceId, targetId, options).then((outcome) => { + if (issued !== pathEpoch.current) return; + if (outcome.status !== 'found') { + setNotice(outcome.status === 'not-loaded' ? `Load the missing endpoints: ${outcome.nodeIds.join(', ')}` + : outcome.status === 'filtered' ? `Connection blocked by hidden or out-of-scope entities: ${outcome.nodeIds.join(', ')}. Choose loaded graph or reveal them.` + : outcome.status === 'hop-limit' ? `No connection within ${pathHops} hops. Increase the hop limit to continue.` + : 'No path found in the current graph.'); + return; + } + investigation.savePath({ title: `${nodeLabel(instance, sourceId)} → ${nodeLabel(instance, targetId)}`, sourceId, targetId, options, path: outcome.path }); + setNotice(`Connection saved: ${outcome.path.edgeIds.length} relationships.`); + }).catch((cause: unknown) => { + if (issued === pathEpoch.current) setError(message(cause)); + }).finally(() => { if (issued === pathEpoch.current) setPathBusy(false); }); + }; + + const constraints: GraphExplorerConstraint[] = [...(props.constraints ?? EMPTY_CONSTRAINTS)]; + if (state.scope !== null) constraints.push({ id: 'scope', label: `Around ${state.scope.seedIds.map((id) => nodeLabel(instance, id)).slice(0, 3).join(', ')} · ${state.scope.hops ?? 0} hops`, onClear: () => instance.resetIsolation() }); + if (state.hiddenNodeIds.size > 0) constraints.push({ id: 'hidden', label: `${state.hiddenNodeIds.size} manually hidden`, onClear: () => instance.showAll() }); + if (tableQuery !== '') constraints.push({ id: 'table-query', label: `Table: ${tableQuery}`, onClear: () => setTableQuery('') }); + for (const [id, count] of state.folds) constraints.push({ id: `fold:${id}`, label: `${nodeLabel(instance, id)} · ${count} folded`, onClear: () => instance.unfoldNode(id) }); + for (const group of state.groups) if (group.collapsed) constraints.push({ id: `group:${group.id}`, label: `Collapsed: ${group.label ?? group.id}`, onClear: () => instance.setGroupCollapsed(group.id, false) }); + const view = useMemo(() => instance.getViewState(), [instance, state]); + for (const brush of view.crossfilter) { + if (brush.key === 'table' && tableQuery !== '') continue; + const description = brush.state.kind === 'categorical' ? `${brush.state.excluded.length} excluded` : brush.state.range.join(' – '); + constraints.push({ id: `brush:${brush.key}`, label: `${brush.key}: ${description}`, onClear: async () => { await instance.getCrossfilterSession()?.setBrush(brush.key, null); } }); + } + const rootStyle: CSSProperties = { + display: 'flex', flexDirection: 'column', gap: 12, padding: 12, boxSizing: 'border-box', + overflow: 'auto', colorScheme: 'dark', background: '#10131a', color: '#e8eaf0', borderRadius: 10, + font: '13px/1.5 system-ui, sans-serif', pointerEvents: 'auto', minWidth: 0, + ...(props.layout === 'panel' ? { position: 'relative', width: '100%' } : { + position: 'absolute', top: 12, right: 12, bottom: 12, width: 'min(720px, calc(100% - 24px))', zIndex: 2, + }), + ...(props.height === undefined ? {} : { height: props.height, bottom: 'auto' }), + }; + return
+

{props.title ?? 'Graph exploration'}

+
{ if (saved.status === 'restoring') { event.preventDefault(); event.stopPropagation(); } }} + onKeyDownCapture={(event) => { if (saved.status === 'restoring' && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); event.stopPropagation(); } }} + style={{ border: 0, padding: 0, margin: 0, minWidth: 0, display: 'contents' }}> + investigation.setSearchQuery(value)} + placeholder="Find an entity…" style={{ maxWidth: '100%' }} + onResultActivate={(result) => { setSubject({ kind: 'node', id: result.id }); setUnavailable(null); }} + onResultUnavailable={(result, reason) => setUnavailable({ result, reason })} /> + {unavailable !== null ?
+ {unavailable.result.label ?? unavailable.result.id}: {unavailable.reason === 'not-loaded' ? 'not loaded' : unavailable.reason === 'filtered' ? 'hidden by active filters' : 'outside the current scope'}. + {props.onRecoverSearchResult !== undefined ? : Adjust the source or active constraints to reveal this entity.} +
: null} +
+

Current view · {state.visible.nodes} nodes · {state.visible.edges} relationships

+
{constraints.length === 0 ? No active constraints : constraints.map((constraint) => )}
+
+ + + + + +
+
+
+
+

Entities

+ { if (entry.id !== null) setSubject({ kind: entry.edge === undefined ? 'node' : 'edge', id: entry.id }); }} + style={{ padding: 0, border: 0, maxWidth: '100%', overflowX: 'auto' }} /> + +
+ +
+
+

Expand {seed === undefined ? 'an entity' : nodeLabel(instance, seed)}

+

{neighborhood === null ? 'Inspect an entity or select a node to begin.' : `${neighborhood.totalNeighbors} matching neighbors already loaded. Expansion queries the configured source.`}

+
+ + + +
+ {neighborhood !== null && neighborhood.relationshipTypes.length > 0 ?

Loaded types: {neighborhood.relationshipTypes.map((entry) => `${entry.type || '(untyped)'} (${entry.count})`).join(', ')}

: null} +
+ + {expansionPage?.nextCursor !== undefined ? : null} + {expansionBusy ? : null} + + +
+
+
+

Explain a connection

+
{ event.preventDefault(); findPath(); }} style={row}> + + + + + + + + +
+ {saved.paths.map((entry) =>
+

{entry.title}

+
    {entry.path.nodeIds.map((id, index) =>
  1. + + {entry.path.edgeIds[index] !== undefined ? <> + {' → '} + : null} +
  2. )}
+ {' '} + +
)} +
+
+

Investigation

+ +