open-brainy/tests/unit/brainy/graph-export.test.ts

98 lines
4.2 KiB
TypeScript
Raw Normal View History

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
/**
* @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)
})
})