feat(8.0): related({ node }) — one-call both-direction incident edges

related({ from }) / related({ to }) each return one direction; related({ node })
unions both (every edge where the node is source OR target), deduped, in a single
O(degree) call — the indexed equivalent of merging two related() calls by hand.
The 'all edges on a noun' query the graph-viz path needs.

- RelatedParams.node: mutually exclusive with from/to (throws if combined);
  composes with type/subtype/visibility filters; natural-key ids resolved.
- Implemented as a parallel out-edge (sourceId) + in-edge (targetId) fetch through
  the O(degree) graph-index fast paths, merged + deduped by id (a self-loop appears
  once), sorted for a stable page. Full incidence is the intended O(degree) cost;
  deep offset paging of a very-high-degree node is best-effort (use the native
  edgesForNode / a graph cursor for exhaustive paging).
- db.related() parity: node resolved + honored in relationMatchesParams (matches an
  edge incident in either direction) for the historical/overlay paths.

Test: related-node-bidirectional.test.ts — both directions, equals from∪to, self-loop
dedupe, type-filter composition, default-hides-internal, node+from/to throws.
This commit is contained in:
David Snelling 2026-06-21 08:43:55 -07:00
parent a3d6fdb8b3
commit d4de48d004
4 changed files with 161 additions and 19 deletions

View file

@ -3058,31 +3058,32 @@ export class Brainy<T = any> implements BrainyInterface<T> {
const params: RelatedParams = { const params: RelatedParams = {
...rawParams, ...rawParams,
...(rawParams.from !== undefined && { from: resolveEntityId(rawParams.from) }), ...(rawParams.from !== undefined && { from: resolveEntityId(rawParams.from) }),
...(rawParams.to !== undefined && { to: resolveEntityId(rawParams.to) }) ...(rawParams.to !== undefined && { to: resolveEntityId(rawParams.to) }),
...(rawParams.node !== undefined && { node: resolveEntityId(rawParams.node) })
} }
const limit = params.limit || 100 const limit = params.limit || 100
const offset = params.offset || 0 const offset = params.offset || 0
// Production safety: warn for large unfiltered queries // `node` is the both-direction shorthand; it cannot also constrain one direction.
if (!params.from && !params.to && !params.type && limit > 10000) { if (params.node !== undefined && (params.from !== undefined || params.to !== undefined)) {
console.warn( throw new Error(
`[Brainy] related(): Fetching ${limit} relationships without filters. ` + "related(): 'node' is mutually exclusive with 'from'/'to' — pass 'node' for both-direction incident edges, or 'from'/'to' for one direction."
`Consider adding 'from', 'to', or 'type' filter for better performance.`
) )
} }
// Build filter for storage query // Production safety: warn for large unfiltered queries
if (!params.from && !params.to && !params.node && !params.type && limit > 10000) {
console.warn(
`[Brainy] related(): Fetching ${limit} relationships without filters. ` +
`Consider adding 'from', 'to', 'node', or 'type' filter for better performance.`
)
}
// Shared filter (type / subtype / service / visibility). Endpoint ids
// (`sourceId` / `targetId`) are added per-direction below.
const filter: any = {} const filter: any = {}
if (params.from) {
filter.sourceId = params.from
}
if (params.to) {
filter.targetId = params.to
}
if (params.type) { if (params.type) {
filter.verbType = Array.isArray(params.type) ? params.type : [params.type] filter.verbType = Array.isArray(params.type) ? params.type : [params.type]
} }
@ -3095,9 +3096,8 @@ export class Brainy<T = any> implements BrainyInterface<T> {
filter.service = params.service filter.service = params.service
} }
// Visibility (8.0): exclude hidden tiers by default. The exclusion is applied in the // Visibility (8.0): exclude hidden tiers by default. Applied on the O(degree)
// storage scan AFTER metadata load (where verb.visibility is known), so the storage // candidate set in the graph-index fast path (see baseStorage.applyVerbMetadataFilters).
// limit stays exact. Like subtype, it disqualifies the metadata-less fast paths.
const excludedTiers = this.excludedVisibilityTiers(params) const excludedTiers = this.excludedVisibilityTiers(params)
if (excludedTiers) { if (excludedTiers) {
filter.excludeVisibility = excludedTiers filter.excludeVisibility = excludedTiers
@ -3106,6 +3106,42 @@ export class Brainy<T = any> implements BrainyInterface<T> {
// VFS relationships are no longer filtered // VFS relationships are no longer filtered
// VFS is part of the knowledge graph - users can filter explicitly if needed // VFS is part of the knowledge graph - users can filter explicitly if needed
// Both-direction incident-edge query: union the node's out-edges (it is the
// source) and in-edges (it is the target) — each an O(degree) indexed lookup —
// then dedupe a self-loop that appears in both. Sorted by id for a stable page,
// sliced from the merged set. Fetching the node's full incidence is the intended
// O(degree) cost; for a very-high-degree node, deep `offset` paging is best-effort
// (use the native edgesForNode / a graph cursor for exhaustive paging).
if (params.node !== undefined) {
const endLimit = offset + limit
const [outEdges, inEdges] = await Promise.all([
this.storage.getVerbs({
pagination: { limit: endLimit },
filter: { ...filter, sourceId: params.node }
}),
this.storage.getVerbs({
pagination: { limit: endLimit },
filter: { ...filter, targetId: params.node }
})
])
const byId = new Map<string, (typeof outEdges)['items'][number]>()
for (const verb of outEdges.items) byId.set(verb.id, verb)
for (const verb of inEdges.items) byId.set(verb.id, verb)
const merged = [...byId.values()].sort((a, b) =>
a.id < b.id ? -1 : a.id > b.id ? 1 : 0
)
return this.verbsToRelations(merged.slice(offset, offset + limit))
}
// Single-direction query (from / to).
if (params.from) {
filter.sourceId = params.from
}
if (params.to) {
filter.targetId = params.to
}
// Fetch from storage with pagination at storage layer (efficient!) // Fetch from storage with pagination at storage layer (efficient!)
const result = await this.storage.getVerbs({ const result = await this.storage.getVerbs({
pagination: { pagination: {

View file

@ -460,7 +460,8 @@ export class Db<T = any> {
const params: RelatedParams = { const params: RelatedParams = {
...rawParams, ...rawParams,
...(rawParams.from !== undefined && { from: resolveEntityId(rawParams.from) }), ...(rawParams.from !== undefined && { from: resolveEntityId(rawParams.from) }),
...(rawParams.to !== undefined && { to: resolveEntityId(rawParams.to) }) ...(rawParams.to !== undefined && { to: resolveEntityId(rawParams.to) }),
...(rawParams.node !== undefined && { node: resolveEntityId(rawParams.node) })
} }
const historical = this.isHistorical() const historical = this.isHistorical()
@ -1024,6 +1025,10 @@ function sortResultsBy<T>(results: Result<T>[], orderBy: string, order: 'asc' |
* `to` targetId, type/subtype set membership, service equality). * `to` targetId, type/subtype set membership, service equality).
*/ */
function relationMatchesParams<T>(relation: Relation<T>, params: RelatedParams): boolean { function relationMatchesParams<T>(relation: Relation<T>, params: RelatedParams): boolean {
// `node` matches an edge incident in EITHER direction (the both-direction shorthand).
if (params.node !== undefined && relation.from !== params.node && relation.to !== params.node) {
return false
}
if (params.from !== undefined && relation.from !== params.from) return false if (params.from !== undefined && relation.from !== params.from) return false
if (params.to !== undefined && relation.to !== params.to) return false if (params.to !== undefined && relation.to !== params.to) return false
if (params.type !== undefined) { if (params.type !== undefined) {

View file

@ -684,6 +684,21 @@ export interface RelatedParams {
*/ */
to?: string to?: string
/**
* Filter by INCIDENT entity ID every relationship touching this entity in
* EITHER direction (where it is the source OR the target), deduped. The
* one-call "all edges on a noun" query: equivalent to merging
* `related({ from: id })` and `related({ to: id })` but in a single
* O(degree) call. Mutually exclusive with `from` / `to` (pass `node` for
* both directions, or `from` / `to` for one). Composes with `type` /
* `subtype` / visibility filters.
*
* @example
* // Everything connected to this person, in or out.
* const edges = await brain.related({ node: personId })
*/
node?: string
/** /**
* Filter by relationship type(s) * Filter by relationship type(s)
* *

View file

@ -0,0 +1,86 @@
/**
* @module tests/unit/brainy/related-node-bidirectional
* @description Graph-perf #4 (8.0): `related({ node })` the one-call,
* both-direction "all edges on a noun" query.
*
* `related({ from })` / `related({ to })` each return one direction; `related({ node })`
* unions both (edges where the node is source OR target), deduped, in a single
* O(degree) call the indexed counterpart of merging two `related()` calls by hand.
* It composes with `type` / `subtype` / visibility filters and is mutually exclusive
* with `from` / `to`.
*/
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'
describe('related({ node }) both-direction incident edges (graph-perf #4)', () => {
let brain: Brainy
let a: string, b: string, c: string, d: 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.Person, subtype: 'employee', data: 'C' })
d = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'D' })
// a's incident edges: a→b (out), c→a (in), a→d (out, ReportsTo), c→a internal (in, hidden)
await brain.relate({ from: a, to: b, type: VerbType.RelatedTo, subtype: 'colleague' })
await brain.relate({ from: c, to: a, type: VerbType.RelatedTo, subtype: 'colleague' })
await brain.relate({ from: a, to: d, type: VerbType.ReportsTo, subtype: 'direct' })
})
afterEach(async () => {
await brain.close()
})
it('returns edges incident in BOTH directions (source OR target)', async () => {
const edges = await brain.related({ node: a })
expect(edges).toHaveLength(3)
// Both out-edges (a is `from`) and the in-edge (a is `to`) are present.
const pairs = edges.map((e) => `${e.from}->${e.to}`).sort()
expect(pairs).toEqual([`${a}->${b}`, `${a}->${d}`, `${c}->${a}`].sort())
})
it('matches the union of related({from}) + related({to})', async () => {
const out = await brain.related({ from: a })
const inc = await brain.related({ to: a })
const expected = new Set([...out, ...inc].map((e) => e.id))
const node = await brain.related({ node: a })
expect(new Set(node.map((e) => e.id))).toEqual(expected)
})
it('dedupes a self-loop (an edge where the node is both source and target)', async () => {
await brain.relate({ from: a, to: a, type: VerbType.RelatedTo, subtype: 'self' })
const edges = await brain.related({ node: a })
const selfLoops = edges.filter((e) => e.from === a && e.to === a)
expect(selfLoops).toHaveLength(1) // appears once, not twice
})
it('composes with a type filter (both directions)', async () => {
const related = await brain.related({ node: a, type: VerbType.RelatedTo })
// a→b and c→a are RelatedTo; a→d is ReportsTo (excluded).
expect(related.map((e) => `${e.from}->${e.to}`).sort()).toEqual(
[`${a}->${b}`, `${c}->${a}`].sort()
)
})
it('excludes internal edges by default, includes them with includeInternal', async () => {
const e = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'E' })
await brain.relate({ from: e, to: a, type: VerbType.RelatedTo, subtype: 'colleague', visibility: 'internal' })
const visible = await brain.related({ node: a })
expect(visible.some((r) => r.from === e)).toBe(false)
const all = await brain.related({ node: a, includeInternal: true })
const internalEdge = all.find((r) => r.from === e)
expect(internalEdge?.visibility).toBe('internal')
})
it("throws when 'node' is combined with 'from' or 'to'", async () => {
await expect(brain.related({ node: a, from: b })).rejects.toThrow(/mutually exclusive/)
await expect(brain.related({ node: a, to: b })).rejects.toThrow(/mutually exclusive/)
})
})