refactor(8.0): API-surface + quality polish from the readiness audit

- find() search-mode: collapsed the two overlapping options to one canonical
  `searchMode: SearchMode` (the redundant `mode` alias was silently ignored on
  the primary find() path while honored on the historical path — a footgun) and
  removed the unwired `explain?` FindParams field (never read by find()).
- Documentation accuracy on the public type surface: entity ids documented as
  UUID v7 (auto) / v5 (natural key), not v4; dropped the stale "Brainy 3.0"
  banners and "backward compatibility" hedging on the fresh-8.0 Result type;
  documented the via/type alias; removed the dead GraphConstraints.bidirectional;
  fixed the no-op `EmbeddedGraphVerb = Omit<GraphVerb,'source'>` to omit the real
  sourceId; corrected the GraphIndexProvider.isInitialized JSDoc; documented
  removeMany's per-chunk generation granularity; JSDoc'd the public VFS surface.
- Honest perf comments in source: removed unmeasured billion-scale figures from
  the LSMTree / graphAdjacencyIndex headers (the JS fallback is in-memory after
  open; native is the scale path).
- Robustness: getVerbMetadata propagates read errors symmetrically with
  getNounMetadata; the find() egress integrity-guard keeps a row when the JS
  matcher doesn't implement an operator the provider already matched on; hardened
  the boundary-no-native CI guard to catch side-effect imports + re-exports.
- Tests: de-theatricalized a relateMany test to assert the real contract; fixed
  a stale Model-B header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
David Snelling 2026-06-29 10:04:19 -07:00
parent 47e8031124
commit a52dba2168
10 changed files with 346 additions and 45 deletions

View file

@ -88,6 +88,17 @@ export class VirtualFileSystem implements IVirtualFileSystem {
// Uses deterministic UUID format for storage compatibility
private static readonly VFS_ROOT_ID = '00000000-0000-0000-0000-000000000000'
/**
* Construct a VFS bound to a Brainy instance.
*
* The VFS stores every file and directory as a Brainy entity and models the
* directory tree with graph relationships. No I/O happens here call
* {@link init} (or rely on `brain.init()`, which auto-initializes the VFS)
* before using any filesystem operation.
*
* @param brain - The Brainy instance backing this filesystem. When omitted, a
* fresh `new Brainy()` is created and owned by this VFS.
*/
constructor(brain?: Brainy) {
this.brain = brain || new Brainy()
this.contentCache = new Map()
@ -1135,7 +1146,6 @@ export class VirtualFileSystem implements IVirtualFileSystem {
type: [NounType.File, NounType.Document, NounType.Media],
limit: options?.limit || 10,
offset: options?.offset,
explain: options?.explain,
where: {
vfsType: 'file' // Search VFS files
}
@ -1250,6 +1260,17 @@ export class VirtualFileSystem implements IVirtualFileSystem {
}
}
/**
* Fetch a Brainy entity by id and normalize it into a {@link VFSEntity}.
*
* Backfills VFS metadata for legacy entities that stored fields at the top
* level (pre-nested-metadata layout) and guarantees the root directory always
* carries valid directory metadata.
*
* @param id - The Brainy entity id to fetch.
* @returns The entity with a populated `metadata.vfsType` and related VFS fields.
* @throws {VFSError} ENOENT when no entity exists for the given id.
*/
async getEntityById(id: string): Promise<VFSEntity> {
const entity = await this.brain.get(id)
@ -1572,8 +1593,17 @@ export class VirtualFileSystem implements IVirtualFileSystem {
}
}
// ============= Not Yet Implemented =============
// ============= Lifecycle, POSIX & Extended Operations =============
/**
* Release all resources held by the VFS.
*
* Stops background cache eviction, tears down the path resolver, and clears the
* content cache and watcher registry. After close the VFS is marked
* uninitialized; call {@link init} again before reusing it.
*
* @returns A promise that resolves once cleanup is complete.
*/
async close(): Promise<void> {
// Cleanup PathResolver resources
if (this.pathResolver) {
@ -1595,6 +1625,13 @@ export class VirtualFileSystem implements IVirtualFileSystem {
this.initialized = false
}
/**
* Change the permission bits of a file or directory (POSIX `chmod`).
*
* @param path - The VFS path whose permissions should change.
* @param mode - The new permission bits (e.g. `0o644`), stored in entity metadata.
* @returns A promise that resolves once the permissions are persisted.
*/
async chmod(path: string, mode: number): Promise<void> {
await this.ensureInitialized()
@ -1616,6 +1653,14 @@ export class VirtualFileSystem implements IVirtualFileSystem {
this.invalidateCaches(path)
}
/**
* Change the owner and group of a file or directory (POSIX `chown`).
*
* @param path - The VFS path whose ownership should change.
* @param uid - The new owner user id, stored in entity metadata.
* @param gid - The new owning group id, stored in entity metadata.
* @returns A promise that resolves once the ownership is persisted.
*/
async chown(path: string, uid: number, gid: number): Promise<void> {
await this.ensureInitialized()
@ -1638,6 +1683,14 @@ export class VirtualFileSystem implements IVirtualFileSystem {
this.invalidateCaches(path)
}
/**
* Set the access and modification timestamps of a file or directory (POSIX `utimes`).
*
* @param path - The VFS path to update.
* @param atime - The new access time.
* @param mtime - The new modification time.
* @returns A promise that resolves once the timestamps are persisted.
*/
async utimes(path: string, atime: Date, mtime: Date): Promise<void> {
await this.ensureInitialized()
@ -1659,6 +1712,20 @@ export class VirtualFileSystem implements IVirtualFileSystem {
this.invalidateCaches(path)
}
/**
* Rename or move a file or directory in place.
*
* Updates the entity's path metadata without rewriting content (preserving the
* underlying blob and entity id). When the parent directory changes, a new
* `Contains` edge is added to the destination parent. Renaming a directory
* cascades the path change to every descendant.
*
* @param oldPath - The existing VFS path.
* @param newPath - The destination VFS path; must not already exist.
* @returns A promise that resolves once the rename is complete.
* @throws {VFSError} ENOENT when `oldPath` does not exist.
* @throws {VFSError} EEXIST when `newPath` already exists.
*/
async rename(oldPath: string, newPath: string): Promise<void> {
await this.ensureInitialized()
@ -1732,6 +1799,21 @@ export class VirtualFileSystem implements IVirtualFileSystem {
this.triggerWatchers(newPath, 'rename')
}
/**
* Copy a file or directory to a new path.
*
* Files are duplicated as new entities (sharing content via the content-addressed
* blob store); directories are copied recursively. Unless `overwrite` is set, an
* existing destination is rejected.
*
* @param src - The source VFS path to copy from.
* @param dest - The destination VFS path to copy to.
* @param options - Copy options such as `overwrite`, `deepCopy`, `preserveVector`,
* and `preserveRelationships`.
* @returns A promise that resolves once the copy is complete.
* @throws {VFSError} ENOENT when `src` does not exist.
* @throws {VFSError} EEXIST when `dest` exists and `overwrite` is not set.
*/
async copy(src: string, dest: string, options?: CopyOptions): Promise<void> {
await this.ensureInitialized()
@ -1919,6 +2001,20 @@ export class VirtualFileSystem implements IVirtualFileSystem {
}
}
/**
* Move a file or directory to a new path.
*
* Implemented as an in-place {@link rename} rather than copy-then-delete: on the
* content-addressed blob store a copy would share the source content hash, so
* deleting the source would orphan the destination. Renaming preserves the blob
* and entity id and handles directory descendants.
*
* @param src - The source VFS path to move from.
* @param dest - The destination VFS path to move to; must not already exist.
* @returns A promise that resolves once the move is complete.
* @throws {VFSError} ENOENT when `src` does not exist.
* @throws {VFSError} EEXIST when `dest` already exists.
*/
async move(src: string, dest: string): Promise<void> {
await this.ensureInitialized()
@ -1932,6 +2028,18 @@ export class VirtualFileSystem implements IVirtualFileSystem {
await this.rename(src, dest)
}
/**
* Create a symbolic link at `path` that points to `target` (POSIX `symlink`).
*
* The link is stored as a dedicated `vfs-symlink` entity whose
* `metadata.symlinkTarget` records the target path; it is linked into its parent
* directory with a `Contains` edge.
*
* @param target - The path the symlink should resolve to.
* @param path - The VFS path at which to create the symlink; must not already exist.
* @returns A promise that resolves once the symlink is created.
* @throws {VFSError} EEXIST when `path` already exists.
*/
async symlink(target: string, path: string): Promise<void> {
await this.ensureInitialized()
@ -1987,6 +2095,13 @@ export class VirtualFileSystem implements IVirtualFileSystem {
await this.pathResolver.createPath(path, entity)
}
/**
* Read the target of a symbolic link (POSIX `readlink`).
*
* @param path - The VFS path of the symlink.
* @returns The stored target path, or an empty string when no target is recorded.
* @throws {VFSError} EINVAL when `path` is not a symbolic link.
*/
async readlink(path: string): Promise<string> {
await this.ensureInitialized()
@ -2001,6 +2116,17 @@ export class VirtualFileSystem implements IVirtualFileSystem {
return entity.metadata.symlinkTarget || ''
}
/**
* Resolve a path to its canonical form, following symbolic links (POSIX `realpath`).
*
* Symlinks are followed iteratively until a non-symlink target is reached, up to a
* fixed depth limit that guards against link cycles.
*
* @param path - The VFS path to resolve.
* @returns The fully resolved (non-symlink) path.
* @throws {VFSError} ENOENT when the path (or a link in the chain) does not exist.
* @throws {VFSError} ELOOP when too many symbolic links are encountered.
*/
async realpath(path: string): Promise<string> {
await this.ensureInitialized()
@ -2030,12 +2156,27 @@ export class VirtualFileSystem implements IVirtualFileSystem {
throw new VFSError(VFSErrorCode.ELOOP, `Too many symbolic links: ${path}`, path, 'realpath')
}
/**
* Read a single extended attribute of a file or directory (POSIX `getxattr`).
*
* @param path - The VFS path to read.
* @param name - The extended-attribute key to fetch.
* @returns The attribute value, or `undefined` when the attribute is not set.
*/
async getxattr(path: string, name: string): Promise<any> {
const entityId = await this.pathResolver.resolve(path)
const entity = await this.getEntityById(entityId)
return entity.metadata.attributes?.[name]
}
/**
* Set a single extended attribute on a file or directory (POSIX `setxattr`).
*
* @param path - The VFS path to update.
* @param name - The extended-attribute key to set.
* @param value - The value to store under `name`.
* @returns A promise that resolves once the attribute is persisted.
*/
async setxattr(path: string, name: string, value: any): Promise<void> {
await this.ensureInitialized()
@ -2059,12 +2200,25 @@ export class VirtualFileSystem implements IVirtualFileSystem {
this.invalidateCaches(path)
}
/**
* List the extended-attribute names set on a file or directory (POSIX `listxattr`).
*
* @param path - The VFS path to inspect.
* @returns The list of extended-attribute keys (empty when none are set).
*/
async listxattr(path: string): Promise<string[]> {
const entityId = await this.pathResolver.resolve(path)
const entity = await this.getEntityById(entityId)
return Object.keys(entity.metadata.attributes || {})
}
/**
* Remove a single extended attribute from a file or directory (POSIX `removexattr`).
*
* @param path - The VFS path to update.
* @param name - The extended-attribute key to remove.
* @returns A promise that resolves once the attribute is removed.
*/
async removexattr(path: string, name: string): Promise<void> {
await this.ensureInitialized()
@ -2089,6 +2243,17 @@ export class VirtualFileSystem implements IVirtualFileSystem {
this.invalidateCaches(path)
}
/**
* List the paths related to a file or directory via graph edges (both directions).
*
* Walks outgoing and incoming relationships and maps each connected entity back to
* its VFS path, recording the relationship type and direction.
*
* @param path - The VFS path whose relationships to list.
* @param options - Reserved relationship-filtering options.
* @returns Related entries, each with the related `path`, `relationship` type, and
* `direction` (`'from'` for outgoing edges, `'to'` for incoming).
*/
async getRelated(path: string, options?: RelatedOptions): Promise<Array<{
path: string
relationship: string
@ -2132,6 +2297,15 @@ export class VirtualFileSystem implements IVirtualFileSystem {
return results
}
/**
* Get the non-hierarchical relationships of a file or directory.
*
* Returns both outgoing and incoming edges as {@link Relation} objects, excluding
* the `Contains` edges that model the directory tree itself.
*
* @param path - The VFS path whose relationships to return.
* @returns The list of semantic relationships connected to the path.
*/
async getRelationships(path: string): Promise<Relation[]> {
await this.ensureInitialized()
@ -2179,6 +2353,14 @@ export class VirtualFileSystem implements IVirtualFileSystem {
return relationships
}
/**
* Create a graph relationship between two VFS paths.
*
* @param from - The source VFS path.
* @param to - The target VFS path.
* @param type - The relationship (verb) type to create; coerced to a {@link VerbType}.
* @returns A promise that resolves once the relationship is created.
*/
async addRelationship(from: string, to: string, type: string): Promise<void> {
await this.ensureInitialized()
@ -2198,6 +2380,18 @@ export class VirtualFileSystem implements IVirtualFileSystem {
this.invalidateCaches(to)
}
/**
* Remove a graph relationship between two VFS paths.
*
* Deletes outgoing edges from `from` to `to`; when `type` is given, only edges of
* that relationship type are removed.
*
* @param from - The source VFS path.
* @param to - The target VFS path.
* @param type - Optional relationship type to match; when omitted, all matching
* edges to `to` are removed.
* @returns A promise that resolves once the relationship(s) are removed.
*/
async removeRelationship(from: string, to: string, type?: string): Promise<void> {
await this.ensureInitialized()
@ -2220,12 +2414,25 @@ export class VirtualFileSystem implements IVirtualFileSystem {
this.invalidateCaches(to)
}
/**
* Get the todo items attached to a file or directory.
*
* @param path - The VFS path whose todos to read.
* @returns The stored todo list, or `undefined` when none are attached.
*/
async getTodos(path: string): Promise<VFSMetadata['todos']> {
const entityId = await this.pathResolver.resolve(path)
const entity = await this.getEntityById(entityId)
return entity.metadata.todos
}
/**
* Replace the entire todo list attached to a file or directory.
*
* @param path - The VFS path to update.
* @param todos - The full set of todo items to store (overwrites any existing list).
* @returns A promise that resolves once the todos are persisted.
*/
async setTodos(path: string, todos: VFSTodo[]): Promise<void> {
await this.ensureInitialized()
@ -2247,6 +2454,16 @@ export class VirtualFileSystem implements IVirtualFileSystem {
this.invalidateCaches(path)
}
/**
* Append a single todo item to a file or directory.
*
* Generates an id when one is not supplied and applies default `priority`
* (`'medium'`) and `status` (`'pending'`).
*
* @param path - The VFS path to attach the todo to.
* @param todo - The todo to add; `id`, `priority`, and `status` are optional.
* @returns A promise that resolves once the todo is persisted.
*/
async addTodo(path: string, todo: VFSTodo): Promise<void> {
await this.ensureInitialized()
@ -2724,18 +2941,39 @@ export class VirtualFileSystem implements IVirtualFileSystem {
return results
}
/**
* Open a file as a Node-compatible readable stream.
*
* @param path - The VFS path of the file to read.
* @param options - Stream options (e.g. byte range, chunk size).
* @returns A readable stream that yields the file's bytes.
*/
createReadStream(path: string, options?: ReadStreamOptions): NodeJS.ReadableStream {
// Lazy import to avoid circular dependencies
const { VFSReadStream } = require('./streams/VFSReadStream.js')
return new VFSReadStream(this, path, options)
}
/**
* Open a file as a Node-compatible writable stream.
*
* @param path - The VFS path of the file to write.
* @param options - Stream options controlling how buffered data is flushed.
* @returns A writable stream whose contents are persisted to the file on finish.
*/
createWriteStream(path: string, options?: WriteStreamOptions): NodeJS.WritableStream {
// Lazy import to avoid circular dependencies
const { VFSWriteStream } = require('./streams/VFSWriteStream.js')
return new VFSWriteStream(this, path, options)
}
/**
* Watch a path for changes and invoke a listener on filesystem events.
*
* @param path - The VFS path to watch.
* @param listener - Called with the event type (`'rename'` or `'change'`) and the path.
* @returns A handle whose `close()` method deregisters the listener.
*/
watch(path: string, listener: WatchListener): { close(): void } {
if (!this.watchers.has(path)) {
this.watchers.set(path, new Set())
@ -2808,14 +3046,35 @@ export class VirtualFileSystem implements IVirtualFileSystem {
yield* importer.importStream(sourcePath, options)
}
/**
* Register a change listener for a path (Node `fs.watchFile`-style convenience).
*
* Delegates to {@link watch} without returning the watcher handle; remove the
* listener with {@link unwatchFile}.
*
* @param path - The VFS path to watch.
* @param listener - Called with the event type and path on each change.
*/
watchFile(path: string, listener: WatchListener): void {
this.watch(path, listener)
}
/**
* Stop watching a path, removing all listeners registered for it.
*
* @param path - The VFS path to stop watching.
*/
unwatchFile(path: string): void {
this.watchers.delete(path)
}
/**
* Resolve a path and return its backing {@link VFSEntity}.
*
* @param path - The VFS path to resolve.
* @returns The entity (with normalized VFS metadata) for the path.
* @throws {VFSError} ENOENT when the path cannot be resolved.
*/
async getEntity(path: string): Promise<VFSEntity> {
const entityId = await this.pathResolver.resolve(path)
return this.getEntityById(entityId)