feat(8.0): API simplification — remove neural()/Db.search, one storage path key, integration→0

8.0 RC cleanup toward "one place per thing, zero-config, no deprecation":

- Remove the `brain.neural()` clustering namespace (ImprovedNeuralAPI + the dead
  legacy NeuralAPI + the neural CLI + neural-only types). Similarity is `find({vector})`
  / `similar({to})`; attribute grouping is the aggregation `GROUP BY` engine. The separate
  entity-extraction / smart-import feature (NeuralImport, NeuralEntityExtractor, SmartExtractor,
  NaturalLanguageProcessor, `brain.extract()`/`brain.nlp()`) is kept.
- Remove `Db.search()`; `find()` is the one query verb (accepts a bare string or FindParams).
  Fix the bundled MCP client, which called a non-existent `brain.search(query, limit)` →
  now `find({ query, limit })`.
- Storage config: collapse to one canonical top-level `path` key. The pre-8.0 aliases
  (`rootDirectory`, `options.*`, `fileSystemStorage.*`) are removed and now THROW with the
  exact rename instead of silently defaulting to `./brainy-data` on upgrade. A single resolver
  feeds createStorage, the 7.x→8.0 migration probe, and the plugin-factory handoff, so a native
  storage provider resolves the identical root (no split-brain).
- Fix `similar({ threshold })`: the min-similarity filter was silently dropped; it is now
  applied as a post-filter on `result.score` (the documented way to bound semantic results).
- Fix `vfs.rename()` on a directory: child path updates spread the entity vector into `update()`
  and failed dimension validation; they are metadata-only updates now.
- Fix `vfs.move()`: copy+delete orphaned the content-addressed content blob (the destination
  shared the source hash, then unlink removed it). `move()` now delegates to `rename()` — an
  in-place path change that preserves the blob and the entity id, for files and directories.
- Fix streaming import: the bulk fast path never flushed mid-import nor signalled queryability.
  Entity writes are now chunked by a progressive flush interval (100 → 1000 → 5000); each chunk
  flushes and emits `progress.queryable`, so imported data is queryable during the import.
- Sweep all docs, comments, and JSDoc for the removed/changed APIs.

Integration suite: 49 files / 588 passed / 0 failed. Unit: 80 files / 1456 passed, no type errors.
This commit is contained in:
David Snelling 2026-06-20 13:31:11 -07:00
parent 0c4a51c24e
commit 606445cd61
74 changed files with 712 additions and 7470 deletions

View file

@ -0,0 +1,68 @@
/**
* @module tests/unit/brainy/similar-threshold
* @description Pins `brain.similar({ threshold })` the min-similarity filter.
*
* Before 8.0 `similar()` accepted a `threshold` but silently DROPPED it (it was
* never forwarded to the query, so callers got unfiltered results). 8.0 applies
* it as a post-filter on `result.score` the canonical way to impose a minimum
* score on plain semantic results (top-level vector search does not honor a
* `threshold`; see the `find({ near })` guidance). These tests use explicit
* vectors so the embedder is never invoked.
*/
import { describe, it, expect, afterEach } from 'vitest'
import { Brainy } from '../../../src/brainy.js'
import { NounType } from '../../../src/types/graphTypes.js'
/** Deterministic 384-dim vectors — no embedder, distinct per seed. */
function vec(seed: number): number[] {
return Array.from({ length: 384 }, (_, i) => ((seed * 31 + i * 13) % 100) / 100)
}
describe('brain.similar() — threshold post-filter (8.0)', () => {
const brains: Brainy[] = []
afterEach(async () => {
for (const b of brains.splice(0)) await b.close().catch(() => {})
})
it('honors the min-similarity threshold (was silently dropped before 8.0)', async () => {
const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } })
brains.push(brain)
await brain.init()
for (let i = 0; i < 12; i++) {
await brain.add({ data: `e${i}`, type: NounType.Thing, vector: vec(i) })
}
const target = vec(0)
const all = await brain.similar({ to: target, limit: 100 })
expect(all.length).toBe(12)
const scores = all.map((r) => r.score)
const min = Math.min(...scores)
const max = Math.max(...scores)
// The corpus has a real score spread (one vector is identical to the target).
expect(max).toBeGreaterThan(min)
const threshold = (min + max) / 2
const filtered = await brain.similar({ to: target, limit: 100, threshold })
// THE invariant the fix guarantees: every result meets the threshold.
expect(filtered.every((r) => r.score >= threshold)).toBe(true)
// The threshold is actually applied — weaker matches dropped, strong kept.
expect(filtered.length).toBeGreaterThan(0)
expect(filtered.length).toBeLessThan(all.length)
})
it('returns the full set when no threshold is given (unchanged behavior)', async () => {
const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } })
brains.push(brain)
await brain.init()
for (let i = 0; i < 6; i++) {
await brain.add({ data: `n${i}`, type: NounType.Thing, vector: vec(i + 50) })
}
const all = await brain.similar({ to: vec(50), limit: 100 })
expect(all.length).toBe(6)
})
})

View file

@ -27,7 +27,7 @@ describe('createEntities Default Value (v4.3.2 Bug Fix)', () => {
brain = new Brainy({ requireSubtype: false,
storage: {
type: 'filesystem',
rootDirectory: testDir
path: testDir
}
})
await brain.init()

View file

@ -260,7 +260,7 @@ describe('8.0 export includeContent (VFS blobs, filesystem)', () => {
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'brainy-blob-'))
brain = new Brainy({ storage: { type: 'filesystem', rootDirectory: dir } })
brain = new Brainy({ storage: { type: 'filesystem', path: dir } })
await brain.init()
})
@ -281,7 +281,7 @@ describe('8.0 export includeContent (VFS blobs, filesystem)', () => {
expect(Buffer.from(b64, 'base64').toString()).toBe('Hello blobs')
const dir2 = await fs.mkdtemp(path.join(os.tmpdir(), 'brainy-blob2-'))
const target = new Brainy({ storage: { type: 'filesystem', rootDirectory: dir2 } })
const target = new Brainy({ storage: { type: 'filesystem', path: dir2 } })
await target.init()
try {
const result = await target.import(backup)

View file

@ -1,108 +0,0 @@
/**
* Domain and Time Clustering Tests
*
* Tests for clusterByDomain() and clusterByTime() methods
* that were previously stub implementations.
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { Brainy } from '../../../src/brainy'
import { NounType } from '../../../src/types/graphTypes'
import { createAddParams } from '../../helpers/test-factory'
describe('Domain and Time Clustering', () => {
let brain: Brainy
beforeEach(async () => {
brain = new Brainy({ requireSubtype: false,
enableCache: false,
storage: { type: 'memory' } // Use memory storage for tests
})
await brain.init()
})
describe('clusterByTime() - Temporal clustering', () => {
it('should cluster entities by createdAt timestamps', async () => {
// These will use the auto-generated createdAt timestamps
const id1 = await brain.add(createAddParams({
data: 'First item'
}))
// Wait a bit to ensure different timestamps
await new Promise(resolve => setTimeout(resolve, 10))
const id2 = await brain.add(createAddParams({
data: 'Second item'
}))
const now = new Date()
const timeWindows = [
{
start: new Date(now.getTime() - 60 * 60 * 1000), // Last hour
end: new Date(now.getTime() + 60 * 60 * 1000), // Next hour (to include all)
label: 'Now'
}
]
const clusters = await brain.neural().clusterByTime('createdAt', timeWindows, {
timeField: 'createdAt',
windows: timeWindows
})
expect(Array.isArray(clusters)).toBe(true)
// Both items should be in the 'Now' time window
const nowCluster = clusters.find(c => c.timeWindow?.label === 'Now')
expect(nowCluster).toBeDefined()
if (nowCluster) {
expect(nowCluster.members.length).toBeGreaterThanOrEqual(2)
}
})
it('should handle empty time windows gracefully', async () => {
const futureStart = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) // 1 year from now
const futureEnd = new Date(Date.now() + 2 * 365 * 24 * 60 * 60 * 1000) // 2 years from now
const timeWindows = [
{
start: futureStart,
end: futureEnd,
label: 'Future'
}
]
const clusters = await brain.neural().clusterByTime('createdAt', timeWindows, {
timeField: 'createdAt',
windows: timeWindows
})
// Should return empty array or array with empty clusters
expect(Array.isArray(clusters)).toBe(true)
})
})
describe('Cross-domain functionality', () => {
it('should find cross-domain clusters when enabled', async () => {
// Add entities from different domains with similar content
await brain.add(createAddParams({
data: 'Machine learning and artificial intelligence',
type: NounType.Document,
metadata: { category: 'tech' }
}))
await brain.add(createAddParams({
data: 'AI and neural networks',
type: NounType.Concept,
metadata: { category: 'science' }
}))
const clusters = await brain.neural().clusterByDomain('category', {
minClusterSize: 1,
preserveDomainBoundaries: false, // Enable cross-domain clustering
crossDomainThreshold: 0.5
})
expect(Array.isArray(clusters)).toBe(true)
expect(clusters.length).toBeGreaterThan(0)
})
})
})

View file

@ -1,455 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { Brainy } from '../../../src/brainy'
import { createAddParams } from '../../helpers/test-factory'
import { NounType } from '../../../src/types/graphTypes'
/**
* Neural API Test Suite - Testing Production Neural Functionality
* Tests the actual neural methods available in brain.neural()
*/
describe('Neural API - Production Testing', () => {
let brain: Brainy<any>
// v5.1.0: Use memory storage and disable augmentations for faster, reliable tests
beforeEach(async () => {
brain = new Brainy({ requireSubtype: false,
storage: { type: 'memory' },
silent: true
})
await brain.init()
})
describe('1. Neural API Access', () => {
it('should provide neural API access', async () => {
const neural = brain.neural()
expect(neural).toBeDefined()
expect(typeof neural.similar).toBe('function')
expect(typeof neural.clusters).toBe('function')
expect(typeof neural.neighbors).toBe('function')
expect(typeof neural.hierarchy).toBe('function')
expect(typeof neural.outliers).toBe('function')
expect(typeof neural.visualize).toBe('function')
})
it('should provide clustering methods', async () => {
const neural = brain.neural()
expect(typeof neural.clusterFast).toBe('function')
expect(typeof neural.clusterLarge).toBe('function')
expect(typeof neural.clusterByDomain).toBe('function')
expect(typeof neural.clusterByTime).toBe('function')
expect(typeof neural.updateClusters).toBe('function')
})
it('should provide streaming and advanced methods', async () => {
const neural = brain.neural()
expect(typeof neural.clusterStream).toBe('function')
expect(typeof neural.clustersWithRelationships).toBe('function')
})
})
describe('2. Similarity Calculations', () => {
it('should calculate similarity between text strings', async () => {
const result = await brain.neural().similar(
'artificial intelligence',
'machine learning'
)
expect(typeof result).toBe('number')
expect(result).toBeGreaterThanOrEqual(0)
expect(result).toBeLessThanOrEqual(1)
})
it('should calculate similarity with different text', async () => {
const result = await brain.neural().similar(
'programming languages',
'cooking recipes'
)
expect(typeof result).toBe('number')
expect(result).toBeGreaterThanOrEqual(0)
expect(result).toBeLessThanOrEqual(1)
})
it('should handle similarity with vectors', async () => {
const vector1 = Array(384).fill(0.1)
const vector2 = Array(384).fill(0.2)
const result = await brain.neural().similar(vector1, vector2)
expect(typeof result).toBe('number')
expect(result).toBeGreaterThanOrEqual(0)
expect(result).toBeLessThanOrEqual(1)
})
it('should provide detailed similarity results with options', async () => {
const result = await brain.neural().similar(
'data science',
'statistics',
{
returnDetails: true,
metric: 'cosine'
}
)
expect(result).toBeDefined()
if (typeof result === 'object') {
expect(result).toHaveProperty('similarity')
expect(typeof result.similarity).toBe('number')
}
})
})
describe('3. Basic Clustering', () => {
it('should perform basic clustering with no items', async () => {
const clusters = await brain.neural().clusters()
expect(Array.isArray(clusters)).toBe(true)
})
it('should perform fast clustering', async () => {
// Add some test data first
await brain.add(createAddParams({ data: 'Machine learning algorithm' }))
await brain.add(createAddParams({ data: 'Deep neural networks' }))
await brain.add(createAddParams({ data: 'Cooking recipes' }))
await brain.add(createAddParams({ data: 'Food preparation' }))
const clusters = await brain.neural().clusterFast({
level: 0,
maxClusters: 10
})
expect(Array.isArray(clusters)).toBe(true)
clusters.forEach(cluster => {
expect(cluster).toHaveProperty('id')
expect(cluster).toHaveProperty('members')
expect(cluster).toHaveProperty('centroid')
expect(Array.isArray(cluster.members)).toBe(true)
})
})
it('should perform large-scale clustering with sampling', async () => {
// Add test data
const promises = Array.from({ length: 20 }, (_, i) =>
brain.add(createAddParams({
data: `Test document ${i}`,
metadata: { category: i % 3 === 0 ? 'tech' : 'other' }
}))
)
await Promise.all(promises)
const clusters = await brain.neural().clusterLarge({
sampleSize: 10,
strategy: 'random'
})
expect(Array.isArray(clusters)).toBe(true)
})
it('should handle empty clustering gracefully', async () => {
const clusters = await brain.neural().clusters([])
expect(Array.isArray(clusters)).toBe(true)
expect(clusters.length).toBe(0)
})
})
describe('4. Domain-Aware Clustering', () => {
it('should cluster by metadata domain', async () => {
// Add entities with different categories
await brain.add(createAddParams({
data: 'Python programming',
metadata: { category: 'tech', language: 'python' }
}))
await brain.add(createAddParams({
data: 'JavaScript development',
metadata: { category: 'tech', language: 'javascript' }
}))
await brain.add(createAddParams({
data: 'Pasta recipe',
metadata: { category: 'food', cuisine: 'italian' }
}))
const clusters = await brain.neural().clusterByDomain('category', {
minClusterSize: 1,
maxClusters: 5
})
expect(Array.isArray(clusters)).toBe(true)
})
it('should handle missing domain field gracefully', async () => {
await brain.add(createAddParams({ data: 'No category' }))
const clusters = await brain.neural().clusterByDomain('nonexistent', {
minClusterSize: 1
})
expect(Array.isArray(clusters)).toBe(true)
})
})
describe('5. Neighbors and Relationships', () => {
it('should find neighbors for non-existent ID gracefully', async () => {
const result = await brain.neural().neighbors('non-existent-id', {
limit: 5
})
expect(result).toBeDefined()
expect(result).toHaveProperty('neighbors')
expect(Array.isArray(result.neighbors)).toBe(true)
})
it('should find neighbors with options', async () => {
const id = await brain.add(createAddParams({
data: 'Central document for neighbor search'
}))
// Add some potential neighbors
await brain.add(createAddParams({ data: 'Related document 1' }))
await brain.add(createAddParams({ data: 'Related document 2' }))
const result = await brain.neural().neighbors(id, {
limit: 3,
threshold: 0.1
})
expect(result).toBeDefined()
expect(result).toHaveProperty('neighbors')
expect(Array.isArray(result.neighbors)).toBe(true)
})
})
describe('6. Semantic Hierarchy', () => {
it('should build hierarchy for entity', async () => {
const id = await brain.add(createAddParams({
data: 'Root concept for hierarchy'
}))
const hierarchy = await brain.neural().hierarchy(id, {
depth: 2,
maxChildren: 5
})
expect(hierarchy).toBeDefined()
expect(hierarchy).toHaveProperty('root')
expect(hierarchy).toHaveProperty('levels')
expect(Array.isArray(hierarchy.levels)).toBe(true)
})
it('should handle hierarchy for non-existent ID', async () => {
const hierarchy = await brain.neural().hierarchy('non-existent', {
depth: 1
})
expect(hierarchy).toBeDefined()
expect(hierarchy).toHaveProperty('root')
expect(hierarchy).toHaveProperty('levels')
})
})
describe('7. Outlier Detection', () => {
it('should detect outliers in dataset', async () => {
// Add some normal documents
await brain.add(createAddParams({ data: 'Normal document about AI' }))
await brain.add(createAddParams({ data: 'Another AI document' }))
await brain.add(createAddParams({ data: 'Machine learning text' }))
// Add an outlier
await brain.add(createAddParams({ data: 'Completely unrelated content about medieval history' }))
const outliers = await brain.neural().outliers({
threshold: 0.5,
method: 'cluster'
})
expect(Array.isArray(outliers)).toBe(true)
outliers.forEach(outlier => {
expect(outlier).toHaveProperty('id')
expect(outlier).toHaveProperty('score')
expect(typeof outlier.score).toBe('number')
})
})
it('should handle empty dataset for outlier detection', async () => {
const outliers = await brain.neural().outliers()
expect(Array.isArray(outliers)).toBe(true)
})
})
describe('8. Visualization Data', () => {
it('should generate visualization data', async () => {
// Add some test data
await brain.add(createAddParams({ data: 'Node 1' }))
await brain.add(createAddParams({ data: 'Node 2' }))
await brain.add(createAddParams({ data: 'Node 3' }))
const visualization = await brain.neural().visualize({
maxNodes: 10,
algorithm: 'force',
dimensions: 2
})
expect(visualization).toBeDefined()
expect(visualization).toHaveProperty('nodes')
expect(visualization).toHaveProperty('edges')
expect(Array.isArray(visualization.nodes)).toBe(true)
expect(Array.isArray(visualization.edges)).toBe(true)
})
it('should handle 3D visualization', async () => {
await brain.add(createAddParams({ data: '3D visualization test' }))
const visualization = await brain.neural().visualize({
maxNodes: 5,
dimensions: 3
})
expect(visualization).toBeDefined()
expect(visualization).toHaveProperty('nodes')
expect(visualization).toHaveProperty('edges')
})
})
describe('9. Incremental Clustering', () => {
it('should update clusters with new items', async () => {
// Create initial entities
const id1 = await brain.add(createAddParams({ data: 'Initial cluster item 1' }))
const id2 = await brain.add(createAddParams({ data: 'Initial cluster item 2' }))
// Create new items to add
const id3 = await brain.add(createAddParams({ data: 'New item to cluster' }))
const id4 = await brain.add(createAddParams({ data: 'Another new item' }))
const updatedClusters = await brain.neural().updateClusters([id3, id4], {
algorithm: 'auto',
minClusterSize: 1
})
expect(Array.isArray(updatedClusters)).toBe(true)
})
it('should handle empty new items list', async () => {
const clusters = await brain.neural().updateClusters([])
expect(Array.isArray(clusters)).toBe(true)
})
})
describe('10. Advanced Clustering Features', () => {
it('should perform clustering with relationships', async () => {
// Add entities with potential relationships
const id1 = await brain.add(createAddParams({ data: 'Entity with relationships 1' }))
const id2 = await brain.add(createAddParams({ data: 'Entity with relationships 2' }))
const clusters = await brain.neural().clustersWithRelationships([id1, id2], {
includeRelationships: true,
algorithm: 'graph'
})
expect(Array.isArray(clusters)).toBe(true)
})
})
describe('11. Streaming Clustering', () => {
it('should handle streaming clustering', async () => {
// Add test data
const promises = Array.from({ length: 10 }, (_, i) =>
brain.add(createAddParams({ data: `Streaming item ${i}` }))
)
await Promise.all(promises)
const stream = brain.neural().clusterStream({
batchSize: 3,
maxBatches: 2
})
let batchCount = 0
for await (const batch of stream) {
expect(batch).toBeDefined()
expect(batch).toHaveProperty('clusters')
expect(Array.isArray(batch.clusters)).toBe(true)
batchCount++
// Prevent infinite loop in tests
if (batchCount >= 2) break
}
})
})
describe('12. Error Handling', () => {
it('should handle invalid similarity inputs gracefully', async () => {
await expect(brain.neural().similar(null as any, undefined as any))
.rejects.toThrow()
})
it('should handle invalid clustering options', async () => {
const clusters = await brain.neural().clusters({
minClusterSize: -1, // Invalid
maxClusters: 0 // Invalid
})
expect(Array.isArray(clusters)).toBe(true)
})
it('should handle invalid neighbor requests', async () => {
await expect(brain.neural().neighbors('', {
limit: -1 // Invalid
})).rejects.toThrow()
})
})
describe('13. Performance and Scalability', () => {
it('should handle moderate dataset sizes efficiently', async () => {
// Create 50 entities
const promises = Array.from({ length: 50 }, (_, i) =>
brain.add(createAddParams({
data: `Performance test document ${i}`,
metadata: { index: i, category: i % 5 }
}))
)
await Promise.all(promises)
const start = Date.now()
const clusters = await brain.neural().clusterFast({
maxClusters: 10
})
const duration = Date.now() - start
expect(Array.isArray(clusters)).toBe(true)
expect(duration).toBeLessThan(5000) // Should complete in under 5 seconds
})
})
describe('14. Configuration and Options', () => {
it('should respect different similarity metrics', async () => {
const metrics = ['cosine', 'euclidean', 'manhattan']
for (const metric of metrics) {
const result = await brain.neural().similar(
'test text one',
'test text two',
{ metric: metric as any }
)
expect(typeof result).toBe('number')
expect(result).toBeGreaterThanOrEqual(0)
}
})
it('should handle different clustering configurations', async () => {
await brain.add(createAddParams({ data: 'Config test 1' }))
await brain.add(createAddParams({ data: 'Config test 2' }))
const configurations = [
{ algorithm: 'auto', minClusterSize: 1 },
{ algorithm: 'semantic', maxClusters: 3 },
{ algorithm: 'hierarchical', threshold: 0.5 }
]
for (const config of configurations) {
const clusters = await brain.neural().clusters(config as any)
expect(Array.isArray(clusters)).toBe(true)
}
})
})
})

View file

@ -1,53 +0,0 @@
/**
* Storage root-directory resolution from `StorageOptions`.
*
* `storage: { type: 'filesystem', path: '…' }` is a widely-used, doc-promoted
* config shape. A refactor once dropped the top-level `path` key from the
* resolution chain, so it was silently ignored and every brain wrote to the
* default `./brainy-data` instead a quiet data-misplacement footgun on
* upgrade. These tests pin every accepted spelling to the directory it must
* resolve to.
*/
import { describe, it, expect } from 'vitest'
import { createStorage } from '../../../src/storage/storageFactory.js'
/** Read the resolved root directory off the concrete FileSystemStorage. */
function rootDirOf(storage: unknown): string {
return (storage as { rootDir: string }).rootDir
}
describe('createStorage — filesystem root-directory resolution', () => {
it('honors top-level rootDirectory', async () => {
const storage = await createStorage({ type: 'filesystem', rootDirectory: '/tmp/brainy-rd' })
expect(rootDirOf(storage)).toBe('/tmp/brainy-rd')
})
it('honors top-level path (the documented shorthand)', async () => {
const storage = await createStorage({ type: 'filesystem', path: '/tmp/brainy-path' })
expect(rootDirOf(storage)).toBe('/tmp/brainy-path')
})
it('honors nested options.rootDirectory', async () => {
const storage = await createStorage({ type: 'filesystem', options: { rootDirectory: '/tmp/brainy-ord' } })
expect(rootDirOf(storage)).toBe('/tmp/brainy-ord')
})
it('honors nested options.path', async () => {
const storage = await createStorage({ type: 'filesystem', options: { path: '/tmp/brainy-opath' } })
expect(rootDirOf(storage)).toBe('/tmp/brainy-opath')
})
it('prefers top-level rootDirectory over a nested options.path', async () => {
const storage = await createStorage({
type: 'filesystem',
rootDirectory: '/tmp/brainy-win',
options: { path: '/tmp/brainy-lose' }
})
expect(rootDirOf(storage)).toBe('/tmp/brainy-win')
})
it('falls back to ./brainy-data only when no directory is supplied', async () => {
const storage = await createStorage({ type: 'filesystem' })
expect(rootDirOf(storage)).toBe('./brainy-data')
})
})

View file

@ -0,0 +1,171 @@
/**
* @module tests/unit/storage/storage-path-resolution
* @description Acceptance suite for the consolidated filesystem storage-path
* surface (8.0). Brainy resolves the on-disk root through ONE function,
* {@link resolveFilesystemRoot}. 8.0 is a clean break: `path` is the ONE
* supported key (the rest of the API already speaks it: `persist(path)`,
* `Brainy.load(path)`, `asOf(path)`, `restore(path)`). The pre-8.0 aliases
* (`rootDirectory`, `options.*`, `fileSystemStorage.*`) were REMOVED and now
* THROW with the rename never a silent default that would misplace data.
* Three things must hold and are pinned here:
* 1. CLEAN BREAK `path` resolves; any removed alias throws.
* 2. TYPE INFERENCE a bare `path` (no `type`) implies filesystem.
* 3. COR-SAFETY a plugin storage factory receives the RESOLVED `path`, so a
* native side (mmap / getBinaryBlobPath) can never split-brain onto a
* different directory than the one brainy itself uses.
*/
import { describe, it, expect, afterEach } from 'vitest'
import {
resolveFilesystemRoot,
createStorage,
DEFAULT_FILESYSTEM_ROOT
} from '../../../src/storage/storageFactory.js'
import { Brainy } from '../../../src/brainy.js'
import type { BrainyPlugin, BrainyPluginContext, StorageAdapterFactory } from '../../../src/plugin.js'
import { MemoryStorage } from '../../../src/storage/adapters/memoryStorage.js'
import type { StorageAdapter } from '../../../src/coreTypes.js'
/** Read the resolved root directory off a concrete FileSystemStorage. */
function rootDirOf(storage: unknown): string {
return (storage as { rootDir: string }).rootDir
}
const TARGET = '/tmp/brainy-resolve-target'
describe('resolveFilesystemRoot — clean break: path resolves, removed aliases throw', () => {
it('resolves the canonical top-level path', () => {
expect(resolveFilesystemRoot({ path: TARGET })).toBe(TARGET)
})
it.each([
['rootDirectory', { rootDirectory: TARGET }],
['options.path', { options: { path: TARGET } }],
['options.rootDirectory', { options: { rootDirectory: TARGET } }],
['fileSystemStorage.path', { fileSystemStorage: { path: TARGET } }],
['fileSystemStorage.rootDirectory', { fileSystemStorage: { rootDirectory: TARGET } }]
])('throws (naming `path`) for the removed alias %s', (_label, config) => {
expect(() => resolveFilesystemRoot(config as any)).toThrow(/removed in 8\.0|'path'/)
})
it('a removed alias throws rather than silently falling through to the default', () => {
// The footgun this guards: a 7.x `{ rootDirectory }` config must NOT land
// on ./brainy-data on upgrade — it must fail loudly with the rename.
expect(() => resolveFilesystemRoot({ rootDirectory: TARGET } as any)).toThrow(/'path'/)
})
it('the canonical path wins and does NOT throw even if a stale alias is also present', () => {
expect(resolveFilesystemRoot({ path: '/win', rootDirectory: '/lose' } as any)).toBe('/win')
})
})
describe('resolveFilesystemRoot — zero-config default', () => {
it('returns ./brainy-data when no directory is supplied', () => {
expect(resolveFilesystemRoot({})).toBe(DEFAULT_FILESYSTEM_ROOT)
expect(resolveFilesystemRoot({})).toBe('./brainy-data')
})
it('returns ./brainy-data for type:filesystem with no path ("persist, default location")', () => {
expect(resolveFilesystemRoot({ type: 'filesystem' })).toBe('./brainy-data')
})
it('ignores empty-string paths and falls through to the default', () => {
expect(resolveFilesystemRoot({ path: '' } as any)).toBe('./brainy-data')
})
})
describe('createStorage — type inference (path implies filesystem)', () => {
it('a bare { path } (no type) produces a FileSystemStorage at that path', async () => {
const storage = await createStorage({ path: TARGET })
expect(storage.constructor.name).toBe('FileSystemStorage')
expect(rootDirOf(storage)).toBe(TARGET)
})
it('a bare { rootDirectory } (removed alias) throws via createStorage', async () => {
await expect(createStorage({ rootDirectory: TARGET } as any)).rejects.toThrow(
/removed in 8\.0|'path'/
)
})
it('an explicit type:filesystem with no path lands on ./brainy-data', async () => {
const storage = await createStorage({ type: 'filesystem' })
expect(storage.constructor.name).toBe('FileSystemStorage')
expect(rootDirOf(storage)).toBe('./brainy-data')
})
it('type:memory always produces MemoryStorage regardless of path', async () => {
const storage = await createStorage({ type: 'memory', path: TARGET } as any)
expect(storage.constructor.name).toBe('MemoryStorage')
})
})
/**
* A boundary-safe fake plugin storage factory. It is NOT @soulcraft/cor it
* stands in for any native/plugin storage backend that re-resolves the on-disk
* directory itself. It records the EXACT config object handed to `create()` so
* the test can assert brainy normalized the canonical `path` before the handoff.
*/
class RecordingStorageFactory implements StorageAdapterFactory {
name = 'recording-filesystem'
received: Record<string, unknown> | null = null
create(config: Record<string, unknown>): StorageAdapter {
this.received = config
return new MemoryStorage() as unknown as StorageAdapter
}
}
/** A minimal plugin that registers the recording factory under `storage:filesystem`. */
function makeRecordingPlugin(factory: RecordingStorageFactory): BrainyPlugin {
return {
name: 'test-recording-storage',
async activate(ctx: BrainyPluginContext): Promise<boolean> {
ctx.registerProvider('storage:filesystem', factory)
return true
}
}
}
describe('cor-safety — plugin storage factory receives the RESOLVED path', () => {
const brains: Brainy[] = []
afterEach(async () => {
for (const b of brains.splice(0)) {
try {
await b.close()
} catch {
/* best-effort */
}
}
})
it('normalizes a canonical top-level { path } before handing it to the factory', async () => {
const factory = new RecordingStorageFactory()
const brain = new Brainy({
requireSubtype: false,
silent: true,
storage: { type: 'filesystem', path: TARGET }
})
brain.use(makeRecordingPlugin(factory))
brains.push(brain)
await brain.init()
expect(factory.received).not.toBeNull()
expect(factory.received!.path).toBe(TARGET)
})
it('a removed { rootDirectory } alias throws at init and never reaches the factory', async () => {
// Clean break: the normalize step (resolveFilesystemRoot) throws on the
// removed alias BEFORE the plugin factory is ever called — no split-brain,
// no silent ./brainy-data.
const factory = new RecordingStorageFactory()
const brain = new Brainy({
requireSubtype: false,
silent: true,
storage: { type: 'filesystem', rootDirectory: TARGET }
})
brain.use(makeRecordingPlugin(factory))
brains.push(brain)
await expect(brain.init()).rejects.toThrow(/removed in 8\.0|'path'/)
expect(factory.received).toBeNull()
})
})

View file

@ -21,7 +21,7 @@ describe('VFS restart persistence', () => {
try {
// === SESSION 1: Write data ===
let brain = new Brainy({ requireSubtype: false,
storage: { type: 'filesystem', rootDirectory: dir },
storage: { type: 'filesystem', path: dir },
disableAutoRebuild: true,
plugins: [],
silent: true,
@ -48,7 +48,7 @@ describe('VFS restart persistence', () => {
// === SESSION 2: Read data after restart ===
brain = new Brainy({ requireSubtype: false,
storage: { type: 'filesystem', rootDirectory: dir },
storage: { type: 'filesystem', path: dir },
disableAutoRebuild: true,
plugins: [],
silent: true,
@ -87,7 +87,7 @@ describe('VFS restart persistence', () => {
try {
// === SESSION 1: Write multiple files ===
let brain = new Brainy({ requireSubtype: false,
storage: { type: 'filesystem', rootDirectory: dir },
storage: { type: 'filesystem', path: dir },
disableAutoRebuild: true,
plugins: [],
silent: true,
@ -112,7 +112,7 @@ describe('VFS restart persistence', () => {
// === SESSION 2: Verify all data persisted ===
brain = new Brainy({ requireSubtype: false,
storage: { type: 'filesystem', rootDirectory: dir },
storage: { type: 'filesystem', path: dir },
disableAutoRebuild: true,
plugins: [],
silent: true,

View file

@ -29,7 +29,7 @@ describe('VFS Unified BlobStorage (v5.2.0)', () => {
brain = new Brainy({ requireSubtype: false,
storage: {
type: 'filesystem',
options: { path: testDir }
path: testDir
},
silent: true
})