brainy/tests/integration/get-relations-fix.test.ts
David Snelling 780fb6444b feat(8.0)!: flip requireSubtype default to true (BRAINY-8.0-SUBTYPE-CONTRACT § C-1)
Brainy 8.0 makes subtype required by default on every public write path
(`add`, `addMany`, `update`, `relate`, `relateMany`, `updateRelation`,
import). Per the locked C-1 contract, every entity and relation gets a
non-empty subtype string by the time the storage layer sees it.

OPT-OUT REMAINS FULLY SUPPORTED

The runtime flag is still consumer-controlled. Three opt-out paths
cover migration / legacy fixtures / typed escape:

- `new Brainy({ requireSubtype: false })` — last-resort: turn off the
  contract entirely. Recommended only for migration windows or test
  fixtures that legitimately can't supply a subtype.
- `new Brainy({ requireSubtype: { except: [NounType.Thing, ...] } })` —
  per-type allowlist: strict everywhere except the listed types.
- `brain.requireSubtype(type, options)` — per-type registration with
  optional vocabulary. Composes with the brain-wide flag.

Default is now `true`. Opt-out is explicit and documented; nothing
silently degrades.

TEST SWEEP

Bulk-applied `requireSubtype: false` to every `new Brainy({...})` call
site across 120 test files. Three sed patterns covered the shapes:
  - `new Brainy({` → `new Brainy({ requireSubtype: false,`
  - `new Brainy<T>({` → `new Brainy<T>({ requireSubtype: false,`
  - `new Brainy()` → `new Brainy({ requireSubtype: false })`

tests/helpers/test-factory.ts → createTestConfig() defaults
`requireSubtype: false` so test files using the helper inherit the
opt-out without per-site edits.

The test sites that DO exercise subtype semantics (the
subtype-and-facets suite, the strict-mode-self-test suite, the verb-
subtype-and-enforcement suite, etc.) already pass real subtypes — they
were the 7.30.x acceptance tests for this contract. Those tests
continue to pass unchanged.

CHANGES

src/brainy.ts
- normalizeConfig() — `requireSubtype` default `false` → `true`.
  Comment refreshed to document the three opt-out paths.

tests/* (120 files)
- Bulk-edited brain construction sites. No functional test changes; the
  opt-out preserves the test author's original intent.

tests/helpers/test-factory.ts
- createTestConfig() base config gains `requireSubtype: false`.

NO-OP for consumers who were already passing subtype on every write.

For consumers who weren't, the upgrade path is one of the three opt-out
forms above. Migration recipe documented in 8.0 release notes (next
commit).

VERIFICATION

- npx tsc --noEmit: clean
- npm test: 1408 / 1409 (same pre-existing race-condition outstanding;
  no other regressions from the flip)
2026-06-09 14:58:25 -07:00

424 lines
15 KiB
TypeScript

/**
* Integration Tests for getRelations() Fix (v4.1.3)
*
* Tests for Bug: getRelations() returns empty array when called without parameters
* This validates that the fix allows retrieving all relationships with proper pagination
*
* NO MOCKS - Real integration tests with actual storage
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { Brainy } from '../../src/brainy.js'
import { NounType, VerbType } from '../../src/types/graphTypes.js'
import * as fs from 'fs/promises'
describe('getRelations() Fix (v4.1.3)', () => {
let brain: Brainy
const testPath = './test-brainy-get-relations-fix'
beforeEach(async () => {
// Clean up test directory
try {
await fs.rm(testPath, { recursive: true, force: true })
} catch {
// Ignore if doesn't exist
}
// Create fresh Brainy instance
brain = new Brainy({ requireSubtype: false,
storage: { type: 'filesystem', path: testPath }
})
await brain.init()
})
afterEach(async () => {
// Clean up test directory
try {
await fs.rm(testPath, { recursive: true, force: true })
} catch {
// Ignore cleanup errors
}
})
describe('Get All Relationships (No Parameters)', () => {
it('should return all relationships when called with no parameters', async () => {
// Create entities
const person1 = await brain.add({ data: 'Alice', type: NounType.Person })
const person2 = await brain.add({ data: 'Bob', type: NounType.Person })
const person3 = await brain.add({ data: 'Charlie', type: NounType.Person })
// Create relationships
await brain.relate({ from: person1, to: person2, type: VerbType.FriendOf })
await brain.relate({ from: person2, to: person3, type: VerbType.FriendOf })
await brain.relate({ from: person1, to: person3, type: VerbType.WorksWith })
// Flush to storage
await brain.flush()
// Get all relationships - THIS IS THE BUG FIX!
const relations = await brain.getRelations()
// Should return all 3 relationships
expect(relations).toHaveLength(3)
expect(relations.every(r => r.id && r.from && r.to && r.type)).toBe(true)
})
it('should return empty array when no relationships exist', async () => {
// Create entities but no relationships
await brain.add({ data: 'Entity 1', type: NounType.Document })
await brain.add({ data: 'Entity 2', type: NounType.Document })
await brain.flush()
// Get all relationships
const relations = await brain.getRelations()
// Should return empty array
expect(relations).toHaveLength(0)
})
it('should support pagination for large relationship sets', async () => {
// Create entities
const entities = []
for (let i = 0; i < 10; i++) {
entities.push(await brain.add({ data: `Entity ${i}`, type: NounType.Document }))
}
// Create 20 relationships
for (let i = 0; i < 10; i++) {
await brain.relate({
from: entities[i],
to: entities[(i + 1) % 10],
type: VerbType.RelatedTo
})
await brain.relate({
from: entities[i],
to: entities[(i + 2) % 10],
type: VerbType.ChildOf
})
}
await brain.flush()
// Get first page (limit: 10)
const page1 = await brain.getRelations({ limit: 10 })
expect(page1).toHaveLength(10)
// Get second page (offset: 10, limit: 10)
const page2 = await brain.getRelations({ offset: 10, limit: 10 })
expect(page2).toHaveLength(10)
// Ensure no duplicates between pages
const page1Ids = new Set(page1.map(r => r.id))
const page2Ids = new Set(page2.map(r => r.id))
const intersection = [...page1Ids].filter(id => page2Ids.has(id))
expect(intersection).toHaveLength(0)
})
it('should filter by type when getting all relationships', async () => {
// Create entities
const person1 = await brain.add({ data: 'Alice', type: NounType.Person })
const person2 = await brain.add({ data: 'Bob', type: NounType.Person })
const person3 = await brain.add({ data: 'Charlie', type: NounType.Person })
// Create different types of relationships
await brain.relate({ from: person1, to: person2, type: VerbType.FriendOf })
await brain.relate({ from: person2, to: person3, type: VerbType.FriendOf })
await brain.relate({ from: person1, to: person3, type: VerbType.WorksWith })
await brain.flush()
// Get only FriendOf relationships
const friendRelations = await brain.getRelations({ type: VerbType.FriendOf })
expect(friendRelations).toHaveLength(2)
expect(friendRelations.every(r => r.type === VerbType.FriendOf)).toBe(true)
// Get only WorksWith relationships
const workRelations = await brain.getRelations({ type: VerbType.WorksWith })
expect(workRelations).toHaveLength(1)
expect(workRelations[0].type).toBe(VerbType.WorksWith)
})
it('should filter by multiple types when getting all relationships', async () => {
// Create entities
const entities = []
for (let i = 0; i < 5; i++) {
entities.push(await brain.add({ data: `Entity ${i}`, type: NounType.Person }))
}
// Create relationships of different types
await brain.relate({ from: entities[0], to: entities[1], type: VerbType.FriendOf })
await brain.relate({ from: entities[1], to: entities[2], type: VerbType.WorksWith })
await brain.relate({ from: entities[2], to: entities[3], type: VerbType.ChildOf })
await brain.relate({ from: entities[3], to: entities[4], type: VerbType.FriendOf })
await brain.flush()
// Get all relationships first to verify total count
const allRelations = await brain.getRelations()
expect(allRelations).toHaveLength(4)
// Get FriendOf and WorksWith relationships using type array filter
// NOTE: Current storage layer may not support array filters directly
// So we test each type separately and combine
const friendRelations = await brain.getRelations({ type: VerbType.FriendOf })
const workRelations = await brain.getRelations({ type: VerbType.WorksWith })
expect(friendRelations).toHaveLength(2)
expect(workRelations).toHaveLength(1)
expect(friendRelations.every(r => r.type === VerbType.FriendOf)).toBe(true)
expect(workRelations.every(r => r.type === VerbType.WorksWith)).toBe(true)
})
})
describe('String ID Shorthand Syntax', () => {
it('should support string ID shorthand: getRelations(id)', async () => {
// Create entities
const person1 = await brain.add({ data: 'Alice', type: NounType.Person })
const person2 = await brain.add({ data: 'Bob', type: NounType.Person })
const person3 = await brain.add({ data: 'Charlie', type: NounType.Person })
// Create relationships
await brain.relate({ from: person1, to: person2, type: VerbType.FriendOf })
await brain.relate({ from: person1, to: person3, type: VerbType.WorksWith })
await brain.flush()
// Use string shorthand - THIS IS THE NEW SIGNATURE!
const relations = await brain.getRelations(person1)
// Should return both relationships from person1
expect(relations).toHaveLength(2)
expect(relations.every(r => r.from === person1)).toBe(true)
})
it('should be equivalent to getRelations({ from: id })', async () => {
// Create entities and relationships
const person1 = await brain.add({ data: 'Alice', type: NounType.Person })
const person2 = await brain.add({ data: 'Bob', type: NounType.Person })
await brain.relate({ from: person1, to: person2, type: VerbType.FriendOf })
await brain.flush()
// Both syntaxes should return the same results
const shorthand = await brain.getRelations(person1)
const explicit = await brain.getRelations({ from: person1 })
expect(shorthand).toEqual(explicit)
})
})
describe('Backward Compatibility', () => {
it('should maintain existing behavior for from parameter', async () => {
// Create entities
const person1 = await brain.add({ data: 'Alice', type: NounType.Person })
const person2 = await brain.add({ data: 'Bob', type: NounType.Person })
const person3 = await brain.add({ data: 'Charlie', type: NounType.Person })
// Create relationships
await brain.relate({ from: person1, to: person2, type: VerbType.FriendOf })
await brain.relate({ from: person1, to: person3, type: VerbType.WorksWith })
await brain.relate({ from: person2, to: person3, type: VerbType.FriendOf })
await brain.flush()
// Get relationships from person1
const relations = await brain.getRelations({ from: person1 })
expect(relations).toHaveLength(2)
expect(relations.every(r => r.from === person1)).toBe(true)
})
it('should maintain existing behavior for to parameter', async () => {
// Create entities
const person1 = await brain.add({ data: 'Alice', type: NounType.Person })
const person2 = await brain.add({ data: 'Bob', type: NounType.Person })
const person3 = await brain.add({ data: 'Charlie', type: NounType.Person })
// Create relationships
await brain.relate({ from: person1, to: person3, type: VerbType.FriendOf })
await brain.relate({ from: person2, to: person3, type: VerbType.WorksWith })
await brain.flush()
// Get relationships to person3
const relations = await brain.getRelations({ to: person3 })
expect(relations).toHaveLength(2)
expect(relations.every(r => r.to === person3)).toBe(true)
})
it('should maintain existing behavior for type filtering with from', async () => {
// Create entities
const person1 = await brain.add({ data: 'Alice', type: NounType.Person })
const person2 = await brain.add({ data: 'Bob', type: NounType.Person })
const person3 = await brain.add({ data: 'Charlie', type: NounType.Person })
// Create relationships
await brain.relate({ from: person1, to: person2, type: VerbType.FriendOf })
await brain.relate({ from: person1, to: person3, type: VerbType.WorksWith })
await brain.flush()
// Get only FriendOf relationships from person1
const relations = await brain.getRelations({
from: person1,
type: VerbType.FriendOf
})
expect(relations).toHaveLength(1)
expect(relations[0].type).toBe(VerbType.FriendOf)
})
})
describe('Production Safety', () => {
it('should handle large relationship queries efficiently', async () => {
// Create entities for 100 unique relationships
const entities = []
for (let i = 0; i < 50; i++) {
entities.push(await brain.add({ data: `Entity ${i}`, type: NounType.Document }))
}
// Create 100 unique relationships (each entity connects to 2+ others)
for (let i = 0; i < 50; i++) {
// First connection: i -> (i+1)
await brain.relate({
from: entities[i],
to: entities[(i + 1) % 50],
type: VerbType.RelatedTo
})
// Second connection: i -> (i+2) with different type to ensure uniqueness
await brain.relate({
from: entities[i],
to: entities[(i + 2) % 50],
type: VerbType.ChildOf
})
}
await brain.flush()
// Should handle query without issues
const startTime = Date.now()
const relations = await brain.getRelations({ limit: 100 })
const duration = Date.now() - startTime
expect(relations).toHaveLength(100)
// Should complete reasonably fast (< 1 second)
expect(duration).toBeLessThan(1000)
})
it('should respect default limit of 100', async () => {
// Create many unique relationships (more than default limit)
const entities = []
for (let i = 0; i < 60; i++) {
entities.push(await brain.add({ data: `Entity ${i}`, type: NounType.Document }))
}
// Create 150 unique relationships (each entity gets 2-3 connections)
for (let i = 0; i < 60; i++) {
// Connection 1: i -> (i+1)
await brain.relate({
from: entities[i],
to: entities[(i + 1) % 60],
type: VerbType.RelatedTo
})
// Connection 2: i -> (i+2) with different type
await brain.relate({
from: entities[i],
to: entities[(i + 2) % 60],
type: VerbType.ChildOf
})
// Connection 3: for first 30 entities, add third connection
if (i < 30) {
await brain.relate({
from: entities[i],
to: entities[(i + 3) % 60],
type: VerbType.WorksWith
})
}
}
await brain.flush()
// Verify we have 150 total relationships
const allRelations = await brain.getRelations({ limit: 200 })
expect(allRelations.length).toBe(150)
// Call without limit - should default to 100
const relations = await brain.getRelations()
// Should return exactly 100 (default limit)
expect(relations).toHaveLength(100)
})
it('should support custom limits', async () => {
// Create entities for 50 unique relationships
const entities = []
for (let i = 0; i < 25; i++) {
entities.push(await brain.add({ data: `Entity ${i}`, type: NounType.Document }))
}
// Create 50 unique relationships (2 per entity to different targets)
for (let i = 0; i < 25; i++) {
await brain.relate({
from: entities[i],
to: entities[(i + 1) % 25],
type: VerbType.RelatedTo
})
await brain.relate({
from: entities[i],
to: entities[(i + 2) % 25],
type: VerbType.ChildOf
})
}
await brain.flush()
// Verify we have 50 total
const all = await brain.getRelations({ limit: 100 })
expect(all.length).toBe(50)
// Custom limit of 25
const relations = await brain.getRelations({ limit: 25 })
expect(relations).toHaveLength(25)
})
})
describe('Comparison with Internal Bug Report', () => {
it('should reproduce and fix the a consumer team bug scenario', async () => {
// Reproduce the exact scenario from the bug report:
// - 524 relationships exist in GraphAdjacencyIndex
// - brain.getRelations() was returning empty array
// Create entities similar to Consumer import
const entities = []
for (let i = 0; i < 50; i++) {
entities.push(await brain.add({
data: `Consumer Entity ${i}`,
type: NounType.Document
}))
}
// Create multiple relationships (simulating import)
for (let i = 0; i < 50; i++) {
await brain.relate({
from: entities[i],
to: entities[(i + 1) % 50],
type: VerbType.RelatedTo
})
if (i % 5 === 0) {
await brain.relate({
from: entities[i],
to: entities[(i + 3) % 50],
type: VerbType.ChildOf
})
}
}
await brain.flush()
// BUG FIX TEST: This should now return relationships, not empty array!
const relations = await brain.getRelations()
// CRITICAL: Should NOT be empty
expect(relations.length).toBeGreaterThan(0)
// Should have at least the relationships we created
expect(relations.length).toBeGreaterThanOrEqual(50)
// All relations should be valid
expect(relations.every(r =>
r.id && r.from && r.to && r.type
)).toBe(true)
})
})
})