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.
97 lines
4.2 KiB
TypeScript
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)
|
|
})
|
|
})
|