brainy/src/storage/fileSystemStorage.ts
David Snelling f0db5b471f **feat(tests): add tests for TextEncoder, TensorFlow.js, and fallback mechanisms**
- Introduced `test-fallback-function.js` and `test-fallback-simple.js` to validate `executeInThread` fallback functionality with both named and anonymous compute-intensive functions.
- Added `test-tensorflow-textencoder.js` for TensorFlow.js and TextEncoder tests in a Node.js environment.
- Created `test-tensorflow-textencoder.html` for browser-based TensorFlow.js and TextEncoder tests.
- Implemented cross-environment test support in `cli-package/src/test-tensorflow-textencoder.ts` for CLI functionality.
- Enhanced `src/utils/embedding.ts`, `textEncoding.ts`, and `brainy-wrapper.js` to include updated global `TextEncoder` and `TextDecoder` utilities for compatibility and worker improvements.
- Standardized and expanded utility methods in `PlatformNode` for broader support, including `isFloat32Array` and `isTypedArray` checks.
- Updated Node.js requirement to `>= 24.4.0` across documentation and configuration files for compatibility improvements.

This update introduces comprehensive testing for fallback mechanisms, TensorFlow.js, and TextEncoder across multiple environments, ensuring robustness and compatibility.
2025-07-11 11:11:56 -07:00

491 lines
15 KiB
TypeScript

import { GraphVerb, HNSWNoun, StorageAdapter } from '../coreTypes.js'
// Dynamically and asynchronously load Node.js modules at the top level.
// This ensures they are available as soon as this module is imported,
// preventing race conditions with dependencies like TensorFlow.js.
let fs: any
let path: any
const nodeModulesPromise = (async () => {
// A reliable check for a Node.js environment.
const isNode =
typeof process !== 'undefined' &&
process.versions != null &&
process.versions.node != null
if (!isNode) {
return { fs: null, path: null }
}
try {
// Use the 'node:' prefix for unambiguous importing of built-in modules.
const fsModule = await import('node:fs')
const pathModule = await import('node:path')
// Return the modules, preferring the default export if it exists.
return {
fs: fsModule.default || fsModule,
path: pathModule.default || pathModule
}
} catch (error) {
console.error(
'FileSystemStorage: Failed to load Node.js modules. This adapter is not supported in this environment.',
error
)
return { fs: null, path: null }
}
})()
// Immediately assign the modules once the promise resolves.
nodeModulesPromise.then((modules) => {
fs = modules.fs
path = modules.path
})
// --- End of Refactored Code ---
// Constants for directory and file names
const ROOT_DIR = 'brainy-data'
const NOUNS_DIR = 'nouns'
const VERBS_DIR = 'verbs'
const METADATA_DIR = 'metadata'
// Constants for noun type directories
const PERSON_DIR = 'person'
const PLACE_DIR = 'place'
const THING_DIR = 'thing'
const EVENT_DIR = 'event'
const CONCEPT_DIR = 'concept'
const CONTENT_DIR = 'content'
const DEFAULT_DIR = 'default' // For nodes without a noun type
/**
* File system storage adapter for Node.js environments
*/
export class FileSystemStorage implements StorageAdapter {
private rootDir: string
private nounsDir: string
private verbsDir: string
private metadataDir: string
private personDir: string
private placeDir: string
private thingDir: string
private eventDir: string
private conceptDir: string
private contentDir: string
private defaultDir: string
private isInitialized = false
constructor(rootDirectory?: string) {
// We'll set the paths in the init method after dynamically importing the modules
this.rootDir = rootDirectory || ''
this.nounsDir = ''
this.verbsDir = ''
this.metadataDir = ''
this.personDir = ''
this.placeDir = ''
this.thingDir = ''
this.eventDir = ''
this.conceptDir = ''
this.contentDir = ''
this.defaultDir = ''
}
/**
* Initialize the storage adapter
*/
public async init(): Promise<void> {
if (this.isInitialized) {
return
}
// Wait for the top-level module loading to complete.
await nodeModulesPromise
// Check if the modules were loaded successfully.
if (!fs || !path) {
throw new Error(
'FileSystemStorage requires a Node.js environment, but `fs` and `path` modules could not be loaded.'
)
}
try {
// Now set up the directory paths
const rootDir = this.rootDir || process.cwd()
this.rootDir = path.resolve(rootDir, ROOT_DIR)
this.nounsDir = path.join(this.rootDir, NOUNS_DIR)
this.verbsDir = path.join(this.rootDir, VERBS_DIR)
this.metadataDir = path.join(this.rootDir, METADATA_DIR)
// Set up noun type directory paths
this.personDir = path.join(this.nounsDir, PERSON_DIR)
this.placeDir = path.join(this.nounsDir, PLACE_DIR)
this.thingDir = path.join(this.nounsDir, THING_DIR)
this.eventDir = path.join(this.nounsDir, EVENT_DIR)
this.conceptDir = path.join(this.nounsDir, CONCEPT_DIR)
this.contentDir = path.join(this.nounsDir, CONTENT_DIR)
this.defaultDir = path.join(this.nounsDir, DEFAULT_DIR)
// Create directories if they don't exist
await this.ensureDirectoryExists(this.rootDir)
await this.ensureDirectoryExists(this.nounsDir)
await this.ensureDirectoryExists(this.verbsDir)
await this.ensureDirectoryExists(this.metadataDir)
await this.ensureDirectoryExists(this.personDir)
await this.ensureDirectoryExists(this.placeDir)
await this.ensureDirectoryExists(this.thingDir)
await this.ensureDirectoryExists(this.eventDir)
await this.ensureDirectoryExists(this.conceptDir)
await this.ensureDirectoryExists(this.contentDir)
await this.ensureDirectoryExists(this.defaultDir)
this.isInitialized = true
} catch (error: any) {
console.error('Error initializing FileSystemStorage:', error)
throw error
}
}
private async ensureDirectoryExists(dirPath: string): Promise<void> {
try {
await fs.promises.mkdir(dirPath, { recursive: true })
} catch (error: any) {
// Ignore EEXIST error, which means the directory already exists
if (error.code !== 'EEXIST') {
throw error
}
}
}
private getNounPath(id: string, nounType?: string): string {
let typeDir = this.defaultDir
if (nounType) {
switch (nounType.toLowerCase()) {
case 'person':
typeDir = this.personDir
break
case 'place':
typeDir = this.placeDir
break
case 'thing':
typeDir = this.thingDir
break
case 'event':
typeDir = this.eventDir
break
case 'concept':
typeDir = this.conceptDir
break
case 'content':
typeDir = this.contentDir
break
default:
typeDir = this.defaultDir
}
}
return path.join(typeDir, `${id}.json`)
}
public async saveNoun(
noun: HNSWNoun & { metadata?: { noun?: string } }
): Promise<void> {
if (!this.isInitialized) await this.init()
const nounType = (noun as any).metadata?.noun
const filePath = this.getNounPath(noun.id, nounType)
await this.ensureDirectoryExists(path.dirname(filePath))
await fs.promises.writeFile(filePath, JSON.stringify(noun, null, 2))
}
public async getNoun(id: string): Promise<HNSWNoun | null> {
if (!this.isInitialized) await this.init()
const nounDirs = [
this.personDir,
this.placeDir,
this.thingDir,
this.eventDir,
this.conceptDir,
this.contentDir,
this.defaultDir
]
for (const dir of nounDirs) {
const filePath = path.join(dir, `${id}.json`)
try {
const data = await fs.promises.readFile(filePath, 'utf-8')
return JSON.parse(data)
} catch (error: any) {
if (error.code !== 'ENOENT') {
console.error(`Error reading noun ${id}:`, error)
}
}
}
return null
}
public async deleteNoun(id: string): Promise<void> {
if (!this.isInitialized) await this.init()
const noun = await this.getNoun(id)
if (noun) {
const nounType = (noun as any).metadata?.noun
const filePath = this.getNounPath(id, nounType)
try {
await fs.promises.unlink(filePath)
} catch (error: any) {
if (error.code !== 'ENOENT') {
console.error(`Error deleting noun file ${filePath}:`, error)
throw error
}
}
}
}
public async getAllNouns(): Promise<HNSWNoun[]> {
if (!this.isInitialized) await this.init()
const allNouns: HNSWNoun[] = []
const nounDirs = [
this.personDir,
this.placeDir,
this.thingDir,
this.eventDir,
this.conceptDir,
this.contentDir,
this.defaultDir
]
for (const dir of nounDirs) {
try {
const files = await fs.promises.readdir(dir)
for (const file of files) {
if (file.endsWith('.json')) {
const filePath = path.join(dir, file)
const data = await fs.promises.readFile(filePath, 'utf-8')
allNouns.push(JSON.parse(data))
}
}
} catch (error: any) {
if (error.code !== 'ENOENT') {
console.error(`Error reading directory ${dir}:`, error)
}
}
}
return allNouns
}
/**
* Get nouns by noun type
* @param nounType The noun type to filter by
* @returns Promise that resolves to an array of nouns of the specified noun type
*/
public async getNounsByNounType(nounType: string): Promise<HNSWNoun[]> {
if (!this.isInitialized) await this.init()
let typeDir: string
switch (nounType.toLowerCase()) {
case 'person':
typeDir = this.personDir
break
case 'place':
typeDir = this.placeDir
break
case 'thing':
typeDir = this.thingDir
break
case 'event':
typeDir = this.eventDir
break
case 'concept':
typeDir = this.conceptDir
break
case 'content':
typeDir = this.contentDir
break
default:
typeDir = this.defaultDir
}
const nouns: HNSWNoun[] = []
try {
const files = await fs.promises.readdir(typeDir)
for (const file of files) {
if (file.endsWith('.json')) {
const filePath = path.join(typeDir, file)
const data = await fs.promises.readFile(filePath, 'utf-8')
nouns.push(JSON.parse(data))
}
}
} catch (error: any) {
if (error.code !== 'ENOENT') {
console.error(`Error reading directory ${typeDir}:`, error)
}
}
return nouns
}
public async saveVerb(verb: GraphVerb): Promise<void> {
if (!this.isInitialized) await this.init()
const filePath = path.join(this.verbsDir, `${verb.id}.json`)
await fs.promises.writeFile(filePath, JSON.stringify(verb, null, 2))
}
/**
* Get a verb by its ID
* @param id The ID of the verb to retrieve
* @returns Promise that resolves to the verb or null if not found
*/
public async getVerb(id: string): Promise<GraphVerb | null> {
if (!this.isInitialized) await this.init()
const filePath = path.join(this.verbsDir, `${id}.json`)
try {
const data = await fs.promises.readFile(filePath, 'utf-8')
return JSON.parse(data)
} catch (error: any) {
if (error.code !== 'ENOENT') {
console.error(`Error reading verb ${id}:`, error)
}
return null
}
}
public async getVerbsBySource(sourceId: string): Promise<GraphVerb[]> {
if (!this.isInitialized) await this.init()
const allVerbs = await this.getAllVerbs()
return allVerbs.filter((verb) => verb.sourceId === sourceId)
}
/**
* Get verbs by target ID
* @param targetId The target ID to filter by
* @returns Promise that resolves to an array of verbs with the specified target ID
*/
public async getVerbsByTarget(targetId: string): Promise<GraphVerb[]> {
if (!this.isInitialized) await this.init()
const allVerbs = await this.getAllVerbs()
return allVerbs.filter((verb) => verb.targetId === targetId)
}
/**
* Get verbs by type
* @param type The verb type to filter by
* @returns Promise that resolves to an array of verbs of the specified type
*/
public async getVerbsByType(type: string): Promise<GraphVerb[]> {
if (!this.isInitialized) await this.init()
const allVerbs = await this.getAllVerbs()
return allVerbs.filter((verb) => verb.type === type)
}
public async getAllVerbs(): Promise<GraphVerb[]> {
if (!this.isInitialized) await this.init()
const allVerbs: GraphVerb[] = []
try {
const files = await fs.promises.readdir(this.verbsDir)
for (const file of files) {
if (file.endsWith('.json')) {
const filePath = path.join(this.verbsDir, file)
const data = await fs.promises.readFile(filePath, 'utf-8')
allVerbs.push(JSON.parse(data))
}
}
} catch (error: any) {
if (error.code !== 'ENOENT') {
console.error(`Error reading verbs directory ${this.verbsDir}:`, error)
}
}
return allVerbs
}
public async deleteVerb(id: string): Promise<void> {
if (!this.isInitialized) await this.init()
const filePath = path.join(this.verbsDir, `${id}.json`)
try {
await fs.promises.unlink(filePath)
} catch (error: any) {
if (error.code !== 'ENOENT') {
console.error(`Error deleting verb file ${filePath}:`, error)
throw error
}
}
}
/**
* Save metadata for an entity
* @param id The ID of the entity
* @param metadata The metadata to save
*/
public async saveMetadata(id: string, metadata: any): Promise<void> {
if (!this.isInitialized) await this.init()
const filePath = path.join(this.metadataDir, `${id}.json`)
await fs.promises.writeFile(filePath, JSON.stringify(metadata, null, 2))
}
/**
* Get metadata for an entity
* @param id The ID of the entity
* @returns Promise that resolves to the metadata or null if not found
*/
public async getMetadata(id: string): Promise<any | null> {
if (!this.isInitialized) await this.init()
const filePath = path.join(this.metadataDir, `${id}.json`)
try {
const data = await fs.promises.readFile(filePath, 'utf-8')
return JSON.parse(data)
} catch (error: any) {
if (error.code !== 'ENOENT') {
console.error(`Error reading metadata for ${id}:`, error)
}
return null
}
}
public async clear(): Promise<void> {
if (!this.isInitialized) await this.init()
await fs.promises.rm(this.rootDir, { recursive: true, force: true })
this.isInitialized = false // Reset state
await this.init() // Re-create directories
}
public async getStorageStatus(): Promise<{
type: string
used: number
quota: number | null
details?: Record<string, any>
}> {
if (!this.isInitialized) await this.init()
const calculateSize = async (dirPath: string): Promise<number> => {
let size = 0
try {
const files = await fs.promises.readdir(dirPath, {
withFileTypes: true
})
for (const file of files) {
const fullPath = path.join(dirPath, file.name)
if (file.isDirectory()) {
size += await calculateSize(fullPath)
} else {
const stats = await fs.promises.stat(fullPath)
size += stats.size
}
}
} catch (error: any) {
if (error.code !== 'ENOENT') {
console.error(`Could not calculate size for ${dirPath}:`, error)
}
}
return size
}
const totalSize = await calculateSize(this.rootDir)
const nouns = await this.getAllNouns()
const verbs = await this.getAllVerbs()
return {
type: 'FileSystem',
used: totalSize,
quota: null, // File system quota is not easily available from Node.js
details: {
rootDir: this.rootDir,
nounCount: nouns.length,
verbCount: verbs.length
}
}
}
}