diff --git a/src/brainy.ts b/src/brainy.ts index 97c9e0b4..b133ee8e 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -3058,31 +3058,32 @@ export class Brainy implements BrainyInterface { const params: RelatedParams = { ...rawParams, ...(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 offset = params.offset || 0 - // Production safety: warn for large unfiltered queries - if (!params.from && !params.to && !params.type && limit > 10000) { - console.warn( - `[Brainy] related(): Fetching ${limit} relationships without filters. ` + - `Consider adding 'from', 'to', or 'type' filter for better performance.` + // `node` is the both-direction shorthand; it cannot also constrain one direction. + if (params.node !== undefined && (params.from !== undefined || params.to !== undefined)) { + throw new Error( + "related(): 'node' is mutually exclusive with 'from'/'to' — pass 'node' for both-direction incident edges, or 'from'/'to' for one direction." ) } - // 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 = {} - if (params.from) { - filter.sourceId = params.from - } - - if (params.to) { - filter.targetId = params.to - } - if (params.type) { filter.verbType = Array.isArray(params.type) ? params.type : [params.type] } @@ -3095,9 +3096,8 @@ export class Brainy implements BrainyInterface { filter.service = params.service } - // Visibility (8.0): exclude hidden tiers by default. The exclusion is applied in the - // storage scan AFTER metadata load (where verb.visibility is known), so the storage - // limit stays exact. Like subtype, it disqualifies the metadata-less fast paths. + // Visibility (8.0): exclude hidden tiers by default. Applied on the O(degree) + // candidate set in the graph-index fast path (see baseStorage.applyVerbMetadataFilters). const excludedTiers = this.excludedVisibilityTiers(params) if (excludedTiers) { filter.excludeVisibility = excludedTiers @@ -3106,6 +3106,42 @@ export class Brainy implements BrainyInterface { // VFS relationships are no longer filtered // 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() + 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!) const result = await this.storage.getVerbs({ pagination: { diff --git a/src/db/db.ts b/src/db/db.ts index 041e9b95..560c3111 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -460,7 +460,8 @@ export class Db { const params: RelatedParams = { ...rawParams, ...(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() @@ -1024,6 +1025,10 @@ function sortResultsBy(results: Result[], orderBy: string, order: 'asc' | * `to` → targetId, type/subtype set membership, service equality). */ function relationMatchesParams(relation: Relation, 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.to !== undefined && relation.to !== params.to) return false if (params.type !== undefined) { diff --git a/src/types/brainy.types.ts b/src/types/brainy.types.ts index 69c04646..32b99715 100644 --- a/src/types/brainy.types.ts +++ b/src/types/brainy.types.ts @@ -684,6 +684,21 @@ export interface RelatedParams { */ 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) * diff --git a/tests/unit/brainy/related-node-bidirectional.test.ts b/tests/unit/brainy/related-node-bidirectional.test.ts new file mode 100644 index 00000000..0c46232e --- /dev/null +++ b/tests/unit/brainy/related-node-bidirectional.test.ts @@ -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/) + }) +})