2025-12-04 11:22:30 -08:00
|
|
|
/**
|
|
|
|
|
* VFS mkdir() Bug Fix Test (v6.2.9)
|
|
|
|
|
*
|
|
|
|
|
* Tests for the bug where vfs.mkdir() caused previously created directories
|
|
|
|
|
* to disappear from vfs.readdir('/').
|
|
|
|
|
*
|
|
|
|
|
* Root cause: The verbCountsByType optimization skipped verb types with count 0,
|
|
|
|
|
* which could happen when statistics were stale after restart.
|
|
|
|
|
*
|
|
|
|
|
* Fix:
|
|
|
|
|
* 1. Option A: Never skip a verb type that's explicitly requested in the filter
|
|
|
|
|
* 2. Option B: Added fast path for sourceId + verbType combo (common VFS pattern)
|
|
|
|
|
*
|
2026-06-11 14:51:00 -07:00
|
|
|
* Originally reported by a downstream application using the VFS.
|
2025-12-04 11:22:30 -08:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
|
|
|
import { Brainy, VerbType } from '../../../src/index.js'
|
|
|
|
|
|
|
|
|
|
describe('VFS mkdir() Bug Fix (v6.2.9)', () => {
|
|
|
|
|
let brain: Brainy
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
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
|
|
|
brain = new Brainy({ requireSubtype: false,
|
2025-12-04 11:22:30 -08:00
|
|
|
storage: { type: 'memory' }
|
|
|
|
|
})
|
|
|
|
|
await brain.init()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
await brain.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('mkdir() should not corrupt VFS index', () => {
|
|
|
|
|
it('should show all directories after mkdir() is called', async () => {
|
|
|
|
|
const vfs = brain.vfs
|
|
|
|
|
await vfs.init()
|
|
|
|
|
|
|
|
|
|
// Step 1: Create directory via writeFile (auto-creates parent directories)
|
|
|
|
|
await vfs.writeFile('/Personas/user.md', 'content')
|
|
|
|
|
console.log('Created /Personas/user.md')
|
|
|
|
|
|
|
|
|
|
// Verify /Personas is visible
|
|
|
|
|
const entries1 = await vfs.readdir('/', { withFileTypes: true })
|
|
|
|
|
const names1 = entries1.map((e: any) => e.name)
|
|
|
|
|
console.log('After writeFile - root entries:', names1)
|
|
|
|
|
expect(names1).toContain('Personas')
|
|
|
|
|
|
|
|
|
|
// Step 2: Create another directory via mkdir
|
|
|
|
|
await vfs.mkdir('/apps/my-app', { recursive: true })
|
|
|
|
|
console.log('Created /apps/my-app')
|
|
|
|
|
|
|
|
|
|
// Step 3: Verify BOTH directories are visible (THIS IS THE BUG)
|
|
|
|
|
const entries2 = await vfs.readdir('/', { withFileTypes: true })
|
|
|
|
|
const names2 = entries2.map((e: any) => e.name)
|
|
|
|
|
console.log('After mkdir - root entries:', names2)
|
|
|
|
|
|
|
|
|
|
// This assertion failed before the fix
|
|
|
|
|
expect(names2).toContain('Personas')
|
|
|
|
|
expect(names2).toContain('apps')
|
|
|
|
|
expect(names2.length).toBe(2)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should handle multiple nested directories', async () => {
|
|
|
|
|
const vfs = brain.vfs
|
|
|
|
|
await vfs.init()
|
|
|
|
|
|
|
|
|
|
// Create multiple directories via different methods
|
|
|
|
|
await vfs.writeFile('/docs/readme.md', 'readme')
|
|
|
|
|
await vfs.mkdir('/config', { recursive: true })
|
|
|
|
|
await vfs.writeFile('/data/users/user1.json', '{}')
|
|
|
|
|
await vfs.mkdir('/logs/archive', { recursive: true })
|
|
|
|
|
|
|
|
|
|
// Verify all root directories are visible
|
|
|
|
|
const entries = await vfs.readdir('/', { withFileTypes: true })
|
|
|
|
|
const names = entries.map((e: any) => e.name)
|
|
|
|
|
console.log('All root entries:', names)
|
|
|
|
|
|
|
|
|
|
expect(names).toContain('docs')
|
|
|
|
|
expect(names).toContain('config')
|
|
|
|
|
expect(names).toContain('data')
|
|
|
|
|
expect(names).toContain('logs')
|
|
|
|
|
expect(names.length).toBe(4)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-11 14:51:00 -07:00
|
|
|
describe('related should query Contains relationships correctly', () => {
|
2025-12-04 11:22:30 -08:00
|
|
|
it('should find Contains relationships via sourceId + verbType filter', async () => {
|
|
|
|
|
// Create some entities and relationships
|
|
|
|
|
const parent = await brain.add({
|
|
|
|
|
data: 'Parent entity',
|
|
|
|
|
type: 'collection',
|
|
|
|
|
metadata: { name: 'parent' }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const child1 = await brain.add({
|
|
|
|
|
data: 'Child 1',
|
|
|
|
|
type: 'document',
|
|
|
|
|
metadata: { name: 'child1' }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const child2 = await brain.add({
|
|
|
|
|
data: 'Child 2',
|
|
|
|
|
type: 'document',
|
|
|
|
|
metadata: { name: 'child2' }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Create Contains relationships
|
|
|
|
|
await brain.relate({ from: parent, to: child1, type: VerbType.Contains })
|
|
|
|
|
await brain.relate({ from: parent, to: child2, type: VerbType.Contains })
|
|
|
|
|
|
|
|
|
|
// Query relationships (this is what getChildren() does)
|
2026-06-11 14:51:00 -07:00
|
|
|
const relations = await brain.related({
|
2025-12-04 11:22:30 -08:00
|
|
|
from: parent,
|
|
|
|
|
type: VerbType.Contains
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
console.log('Relations found:', relations.length)
|
|
|
|
|
expect(relations.length).toBe(2)
|
|
|
|
|
expect(relations.map(r => r.to)).toContain(child1)
|
|
|
|
|
expect(relations.map(r => r.to)).toContain(child2)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should return relationships even with mixed verb types', async () => {
|
|
|
|
|
// Create entities
|
|
|
|
|
const entityA = await brain.add({ data: 'Entity A', type: 'concept' })
|
|
|
|
|
const entityB = await brain.add({ data: 'Entity B', type: 'concept' })
|
|
|
|
|
const entityC = await brain.add({ data: 'Entity C', type: 'concept' })
|
|
|
|
|
|
|
|
|
|
// Create different types of relationships
|
|
|
|
|
await brain.relate({ from: entityA, to: entityB, type: VerbType.Contains })
|
|
|
|
|
await brain.relate({ from: entityA, to: entityC, type: VerbType.RelatedTo })
|
|
|
|
|
|
|
|
|
|
// Query only Contains relationships
|
2026-06-11 14:51:00 -07:00
|
|
|
const containsRelations = await brain.related({
|
2025-12-04 11:22:30 -08:00
|
|
|
from: entityA,
|
|
|
|
|
type: VerbType.Contains
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(containsRelations.length).toBe(1)
|
|
|
|
|
expect(containsRelations[0].to).toBe(entityB)
|
|
|
|
|
|
|
|
|
|
// Query only RelatedTo relationships
|
2026-06-11 14:51:00 -07:00
|
|
|
const relatedRelations = await brain.related({
|
2025-12-04 11:22:30 -08:00
|
|
|
from: entityA,
|
|
|
|
|
type: VerbType.RelatedTo
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(relatedRelations.length).toBe(1)
|
|
|
|
|
expect(relatedRelations[0].to).toBe(entityC)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('Fast path for sourceId + verbType filter', () => {
|
|
|
|
|
it('should use fast path for VFS-style queries', async () => {
|
|
|
|
|
// Create parent and children
|
|
|
|
|
const parent = await brain.add({
|
|
|
|
|
data: 'Parent',
|
|
|
|
|
type: 'collection',
|
|
|
|
|
metadata: { name: 'parent' }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Create many children to verify performance
|
|
|
|
|
const children: string[] = []
|
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
|
|
|
const child = await brain.add({
|
|
|
|
|
data: `Child ${i}`,
|
|
|
|
|
type: 'document',
|
|
|
|
|
metadata: { name: `child${i}` }
|
|
|
|
|
})
|
|
|
|
|
children.push(child)
|
|
|
|
|
await brain.relate({ from: parent, to: child, type: VerbType.Contains })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Query should use the new fast path
|
|
|
|
|
const startTime = performance.now()
|
2026-06-11 14:51:00 -07:00
|
|
|
const relations = await brain.related({
|
2025-12-04 11:22:30 -08:00
|
|
|
from: parent,
|
|
|
|
|
type: VerbType.Contains
|
|
|
|
|
})
|
|
|
|
|
const elapsed = performance.now() - startTime
|
|
|
|
|
|
|
|
|
|
console.log(`Query returned ${relations.length} relations in ${elapsed.toFixed(2)}ms`)
|
|
|
|
|
expect(relations.length).toBe(10)
|
|
|
|
|
|
|
|
|
|
// Verify all children are found
|
|
|
|
|
for (const child of children) {
|
|
|
|
|
expect(relations.map(r => r.to)).toContain(child)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('Delete and recreate folder (v6.2.9 cache invalidation fix)', () => {
|
|
|
|
|
it('should handle delete folder → recreate folder without corruption', async () => {
|
|
|
|
|
const vfs = brain.vfs
|
|
|
|
|
await vfs.init()
|
|
|
|
|
|
|
|
|
|
// Step 1: Create a folder with content
|
|
|
|
|
await vfs.mkdir('/test-folder', { recursive: true })
|
|
|
|
|
await vfs.writeFile('/test-folder/file1.txt', 'content1')
|
|
|
|
|
console.log('Created /test-folder with file1.txt')
|
|
|
|
|
|
|
|
|
|
// Verify folder exists
|
|
|
|
|
const entries1 = await vfs.readdir('/', { withFileTypes: true })
|
|
|
|
|
expect(entries1.map((e: any) => e.name)).toContain('test-folder')
|
|
|
|
|
|
|
|
|
|
// Step 2: Delete the folder
|
|
|
|
|
await vfs.rmdir('/test-folder', { recursive: true })
|
|
|
|
|
console.log('Deleted /test-folder')
|
|
|
|
|
|
|
|
|
|
// Verify folder is gone
|
|
|
|
|
const entries2 = await vfs.readdir('/', { withFileTypes: true })
|
|
|
|
|
expect(entries2.map((e: any) => e.name)).not.toContain('test-folder')
|
|
|
|
|
|
|
|
|
|
// Step 3: Recreate the folder (THIS IS WHERE THE BUG WAS)
|
|
|
|
|
await vfs.mkdir('/test-folder', { recursive: true })
|
|
|
|
|
console.log('Recreated /test-folder')
|
|
|
|
|
|
|
|
|
|
// Step 4: Create new content
|
|
|
|
|
await vfs.writeFile('/test-folder/file2.txt', 'content2')
|
|
|
|
|
console.log('Created /test-folder/file2.txt')
|
|
|
|
|
|
|
|
|
|
// Step 5: Verify everything works
|
|
|
|
|
const entries3 = await vfs.readdir('/', { withFileTypes: true })
|
|
|
|
|
expect(entries3.map((e: any) => e.name)).toContain('test-folder')
|
|
|
|
|
|
|
|
|
|
const folderContents = await vfs.readdir('/test-folder', { withFileTypes: true })
|
|
|
|
|
expect(folderContents.map((e: any) => e.name)).toContain('file2.txt')
|
|
|
|
|
console.log('Folder recreate test passed!')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should handle multiple delete/recreate cycles', async () => {
|
|
|
|
|
const vfs = brain.vfs
|
|
|
|
|
await vfs.init()
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
|
|
|
// Create
|
|
|
|
|
await vfs.mkdir('/cycle-test', { recursive: true })
|
|
|
|
|
await vfs.writeFile(`/cycle-test/file${i}.txt`, `content${i}`)
|
|
|
|
|
|
|
|
|
|
// Verify
|
|
|
|
|
const contents = await vfs.readdir('/cycle-test', { withFileTypes: true })
|
|
|
|
|
expect(contents.length).toBe(1)
|
|
|
|
|
expect(contents[0].name).toBe(`file${i}.txt`)
|
|
|
|
|
|
|
|
|
|
// Delete
|
|
|
|
|
await vfs.rmdir('/cycle-test', { recursive: true })
|
|
|
|
|
|
|
|
|
|
console.log(`Cycle ${i + 1} completed`)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|