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 = {
...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<T = any> implements BrainyInterface<T> {
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<T = any> implements BrainyInterface<T> {
// 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<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!)
const result = await this.storage.getVerbs({
pagination: {

View file

@ -460,7 +460,8 @@ export class Db<T = any> {
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<T>(results: Result<T>[], orderBy: string, order: 'asc' |
* `to` targetId, type/subtype set membership, service equality).
*/
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.to !== undefined && relation.to !== params.to) return false
if (params.type !== undefined) {

View file

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