brainy/tests/unit/brainy/graph-export.test.ts
David Snelling c2a84c9d1f feat(8.0): brain.graph.export() + noun-walk cursor + noun visibility hydration
brain.graph.export() streams the WHOLE graph in one O(N+E) pass — node chunks
then edge chunks, async-iterable — the right primitive for visualizing all data
(vs. paging per node). Native snapshot-consistent graphCursor when present, else
a TS cursor walk over nouns + verbs. Building it surfaced two latent noun bugs,
both fixed here (each a correctness win beyond export):

- getNounsWithPagination IGNORED its cursor ('offset-based, cursor planned') —
  the noun mirror of the verb bug fixed in the cursor-pagination commit. Latent
  because the only multi-page consumer (aggregate backfill) uses one big page; a
  small chunkSize needed page 2 and, trusting the returned-but-ignored nextCursor,
  re-fetched page 0 forever (infinite loop). Ported the proven verb cursor:
  opaque cn1:<shard>:<id> token, stable within-shard id order, resume-after,
  O(N) at any chunk size. (Generalized verbIdFromVectorPath → idFromVectorPath.)
- hydrateNounWithMetadata DROPPED 'visibility' (noun mirror of the Fix #1 verb
  hydration bug) — so getNouns-fed visibility filters saw nothing and leaked
  system (VFS root) / internal nodes. Now hydrated.

- New: GraphApi.export + GraphExportOptions (chunkSize, includeInternal/System,
  includeNodes/Edges). hydrateNativeSubgraph extracted + shared by subgraph+export.

Test: graph-export.test.ts — full-graph completeness incl. isolated nodes,
default-hides-internal, node/edge include toggles, chunkSize chunking (the
case that exposed the cursor hang). Full gate green.
2026-06-21 10:22:12 -07:00

97 lines
4.2 KiB
TypeScript

/**
* @module tests/unit/brainy/graph-export
* @description Graph engine #22: `brain.graph.export()` — stream the whole graph
* in one O(N+E) pass (the right primitive for visualizing all data, vs. paging
* per node). Exercises the pure-TS fallback (no native provider in CI), which
* streams all nouns then all verbs via cursor pagination.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { Brainy } from '../../../src/index.js'
import { NounType, VerbType } from '../../../src/types/graphTypes.js'
import { createTestConfig } from '../../helpers/test-factory.js'
import type { GraphView } from '../../../src/index.js'
async function collect(stream: AsyncIterable<GraphView>): Promise<{
nodes: Map<string, { id: string; type?: NounType; subtype?: string }>
edges: Map<string, { id: string; from: string; to: string }>
chunks: number
}> {
const nodes = new Map<string, { id: string; type?: NounType; subtype?: string }>()
const edges = new Map<string, { id: string; from: string; to: string }>()
let chunks = 0
for await (const chunk of stream) {
chunks++
for (const n of chunk.nodes) nodes.set(n.id, n)
for (const e of chunk.edges) edges.set(e.id, e)
}
return { nodes, edges, chunks }
}
describe('brain.graph.export() (graph engine #22)', () => {
let brain: Brainy
let a: string, b: string, c: string, iso: string
beforeEach(async () => {
brain = new Brainy(createTestConfig())
await brain.init()
a = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'A' })
b = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'B' })
c = await brain.add({ type: NounType.Project, subtype: 'milestone', data: 'C' })
iso = await brain.add({ type: NounType.Concept, subtype: 'general', data: 'isolated' })
await brain.relate({ from: a, to: b, type: VerbType.RelatedTo, subtype: 'colleague' })
await brain.relate({ from: b, to: c, type: VerbType.ParticipatesIn, subtype: 'assignment' })
})
afterEach(async () => {
await brain.close()
})
it('streams the WHOLE graph — every node (incl. isolated) and every edge', async () => {
const { nodes, edges } = await collect(brain.graph.export())
// All four nouns, including the isolated one (the node stream catches it).
expect(new Set(nodes.keys())).toEqual(new Set([a, b, c, iso]))
// Node refs carry type/subtype straight from the noun records.
expect(nodes.get(c)?.type).toBe(NounType.Project)
expect(nodes.get(c)?.subtype).toBe('milestone')
// Both edges.
const pairs = [...edges.values()].map((e) => `${e.from}->${e.to}`).sort()
expect(pairs).toEqual([`${a}->${b}`, `${b}->${c}`].sort())
})
it('excludes internal-visibility nodes/edges by default; includeInternal surfaces them', async () => {
const hidden = await brain.add({
type: NounType.Person,
subtype: 'employee',
data: 'hidden',
visibility: 'internal'
})
await brain.relate({ from: a, to: hidden, type: VerbType.RelatedTo, subtype: 'colleague', visibility: 'internal' })
const visible = await collect(brain.graph.export())
expect(visible.nodes.has(hidden)).toBe(false)
expect([...visible.edges.values()].some((e) => e.to === hidden)).toBe(false)
const all = await collect(brain.graph.export({ includeInternal: true }))
expect(all.nodes.has(hidden)).toBe(true)
expect([...all.edges.values()].some((e) => e.to === hidden)).toBe(true)
})
it('includeNodes:false streams only edges; includeEdges:false streams only nodes', async () => {
const edgesOnly = await collect(brain.graph.export({ includeNodes: false }))
expect(edgesOnly.nodes.size).toBe(0)
expect(edgesOnly.edges.size).toBe(2)
const nodesOnly = await collect(brain.graph.export({ includeEdges: false }))
expect(nodesOnly.edges.size).toBe(0)
expect(nodesOnly.nodes.size).toBe(4)
})
it('chunks the stream by chunkSize (multiple passes, same total)', async () => {
const { nodes, edges, chunks } = await collect(brain.graph.export({ chunkSize: 1 }))
// 4 nodes + 2 edges at 1 per chunk → several chunks.
expect(chunks).toBeGreaterThan(1)
expect(nodes.size).toBe(4)
expect(edges.size).toBe(2)
})
})