From a52dba2168005926ec9edeb12c326810e5fa38ad Mon Sep 17 00:00:00 2001 From: David Snelling Date: Mon, 29 Jun 2026 10:04:19 -0700 Subject: [PATCH] refactor(8.0): API-surface + quality polish from the readiness audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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` 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 --- src/db/db.ts | 8 +- src/db/generationStore.ts | 7 +- src/graph/graphAdjacencyIndex.ts | 7 +- src/plugin.ts | 8 +- src/types/brainy.types.ts | 36 +-- src/types/graphTypes.ts | 8 +- src/vfs/VirtualFileSystem.ts | 263 ++++++++++++++++++++- tests/integration/db-temporal.test.ts | 5 +- tests/unit/boundary-no-native.test.ts | 24 +- tests/unit/brainy/batch-operations.test.ts | 25 +- 10 files changed, 346 insertions(+), 45 deletions(-) diff --git a/src/db/db.ts b/src/db/db.ts index 89b83300..62c3de0e 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -481,7 +481,6 @@ export class Db { delete universeParams.query delete universeParams.vector delete universeParams.searchMode - delete universeParams.mode delete universeParams.orderBy delete universeParams.order universeParams.limit = AT_GEN_VECTOR_UNIVERSE_CAP @@ -1063,9 +1062,10 @@ function indexOnlyFindDimensions(params: FindParams): Array<[string, boolean]> { ['aggregation', params.aggregate !== undefined], ['relation expansion (includeRelations)', params.includeRelations === true], [ - `search mode '${params.mode ?? params.searchMode}'`, - (params.mode !== undefined && params.mode !== 'metadata' && params.mode !== 'auto') || - (params.searchMode !== undefined && params.searchMode !== 'auto') + `search mode '${params.searchMode}'`, + params.searchMode !== undefined && + params.searchMode !== 'auto' && + params.searchMode !== 'metadata' ] ] } diff --git a/src/db/generationStore.ts b/src/db/generationStore.ts index 6f98ddae..95493117 100644 --- a/src/db/generationStore.ts +++ b/src/db/generationStore.ts @@ -595,9 +595,10 @@ export class GenerationStore { /** * @description Commit ONE single-operation write (`add`/`update`/`remove`/ - * `relate`/`updateRelation`/`unrelate` and their `*Many` per-item calls) as - * its own immutable generation — the Model-B "every write versioned" - * contract. Structurally a one-operation {@link commitTransaction} with + * `relate`/`updateRelation`/`unrelate` and the per-item calls of `addMany`/ + * `updateMany`/`relateMany`) as its own immutable generation — the Model-B + * "every write versioned" contract. (`removeMany` commits each delete *chunk* + * as a single generation for bulk efficiency — the one non-per-item path.) Structurally a one-operation {@link commitTransaction} with * **deferred durability**: * * - Under the commit mutex it reserves generation `N`, captures byte-identical diff --git a/src/graph/graphAdjacencyIndex.ts b/src/graph/graphAdjacencyIndex.ts index eaf948c5..c348fc3a 100644 --- a/src/graph/graphAdjacencyIndex.ts +++ b/src/graph/graphAdjacencyIndex.ts @@ -77,9 +77,10 @@ export class GraphAdjacencyIndex implements GraphIndexProvider { private lsmTreeVerbsBySource: LSMTree // sourceId -> verbIds private lsmTreeVerbsByTarget: LSMTree // targetId -> verbIds - // ID-only tracking for billion-scale memory optimization - // Previous: Map stored full objects (128GB @ 1B verbs) - // Now: Set stores only IDs (~100KB @ 1B verbs) = 1,280,000x reduction + // ID-only membership tracking: a Set of verb ids rather than a + // Map of full objects, so per-verb resident memory is one id + // string instead of a whole relationship record. (Unmeasured order-of-magnitude + // figures dropped — the durable property is "ids, not objects".) private verbIdSet = new Set() // Verb-id interning for the BigInt boundary (8.0 u64 contract). diff --git a/src/plugin.ts b/src/plugin.ts index 30af7062..736d777f 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -219,7 +219,13 @@ export interface MetadataIndexProvider { * contract at this surface. */ export interface GraphIndexProvider { - /** `false` until the index has loaded; Brainy probes this before fast paths. */ + /** + * `false` until the provider has loaded its persisted state. A provider should + * self-load on first read; this flag lets it report readiness for diagnostics + * and tooling. (Brainy's graph reads route through the provider's own methods, + * which are expected to be self-loading — it is the {@link GraphAccelerationProvider} + * fast paths that gate on `isInitialized`.) + */ readonly isInitialized: boolean /** diff --git a/src/types/brainy.types.ts b/src/types/brainy.types.ts index d3b8ec96..08e187b3 100644 --- a/src/types/brainy.types.ts +++ b/src/types/brainy.types.ts @@ -1,6 +1,10 @@ /** - * 🧠 Brainy 3.0 Type Definitions - * + * @module types/brainy.types + * @description Public type definitions for the Brainy API — the entity/relation + * shapes, the `find()`/`add()`/`relate()` parameter and result types, and the + * configuration surface. (Version-agnostic header: these track the package + * version, not a fixed number.) + * * Beautiful, consistent, type-safe interfaces for the future of neural databases */ @@ -27,7 +31,7 @@ import type { * alongside user metadata but extracted to top-level Entity fields on read. */ export interface Entity { - /** Unique identifier (UUID v4) */ + /** Unique identifier — a UUID (auto-generated as a time-ordered v7; a supplied natural key is normalized to a stable v5). */ id: string /** Embedding vector (384-dim by default). Empty array when loaded without includeVectors. */ vector: Vector @@ -140,8 +144,8 @@ export interface RelationEvidence { /** * Search result with similarity score * - * Flattens commonly-used entity fields to top level for convenience, - * while preserving full entity in 'entity' field for backward compatibility. + * Flattens commonly-used entity fields to the top level for convenience, while + * keeping the complete record under `entity` for callers that need the full shape. */ export interface Result { // Search metadata @@ -158,7 +162,7 @@ export interface Result { weight?: number // Entity importance (from entity.weight) _rev?: number // Monotonic revision counter (from entity._rev) — pass back as update({ ifRev }) for CAS - // Full entity (preserved for backward compatibility) + // Full entity record (the flattened fields above are projections of this) entity: Entity // Score transparency @@ -173,8 +177,8 @@ export interface Result { // Aggregation: present only on rows returned by find({ aggregate }). These mirror the // documented AggregateResult shape so callers can read group/metric data at the top level - // instead of digging into `metadata`. (The same values are also flattened into `metadata` - // for backward compatibility.) + // instead of digging into `metadata`. (The same values are also mirrored into `metadata` + // so aggregate-unaware callers still see them.) groupKey?: Record // Group-by key values for this aggregate row metrics?: Record // Computed metric values (sum/avg/min/max/count) count?: number // Total entity count in this group @@ -327,7 +331,7 @@ export interface AddParams { * a one-shot warning. */ metadata?: EntityMetadataInput - /** Custom entity ID (auto-generated UUID v4 if not provided) */ + /** Custom entity ID. When omitted, a time-ordered UUID v7 is generated; a supplied natural-key string is normalized to a stable UUID v5. */ id?: string /** Pre-computed embedding vector (skips auto-embedding when provided) */ vector?: Vector @@ -551,8 +555,6 @@ export interface FindParams { order?: 'asc' | 'desc' // Sort direction: 'asc' (default) or 'desc' // Advanced options - mode?: SearchMode // Search strategy - explain?: boolean // Return scoring explanation includeRelations?: boolean // Include entity relationships excludeVFS?: boolean // Exclude VFS entities from results (default: false - VFS included) service?: string // Multi-tenancy filter @@ -566,7 +568,12 @@ export interface FindParams { includeVectors?: boolean // Hybrid search options - searchMode?: 'auto' | 'text' | 'semantic' | 'vector' | 'hybrid' // Search strategy: auto (default), text-only, semantic/vector-only, or explicit hybrid + /** + * Search strategy — the single canonical search-mode knob (the redundant `mode` + * alias was removed in 8.0). `'auto'` (default) blends text + semantic; the + * other members force a single intelligence. See {@link SearchMode}. + */ + searchMode?: SearchMode hybridAlpha?: number // Weight between text (0.0) and semantic (1.0) search, default: auto-detected by query length // Triple Intelligence Fusion @@ -593,8 +600,8 @@ export interface FindParams { export interface GraphConstraints { to?: string // Connected to this entity from?: string // Connected from this entity - via?: VerbType | VerbType[] // Via these relationship types - type?: VerbType | VerbType[] // Alias for via + via?: VerbType | VerbType[] // Traverse via these relationship types (the canonical key) + type?: VerbType | VerbType[] // Accepted alias for `via` (resolved identically: `via ?? type`) /** * Filter traversal edges by VerbType subtype. Single string for equality, * array for set membership. Composes with `via` — `{ via: 'manages', subtype: @@ -604,7 +611,6 @@ export interface GraphConstraints { subtype?: string | string[] depth?: number // Max traversal depth (default: 1) direction?: 'in' | 'out' | 'both' // Direction of traversal (default: 'both') - bidirectional?: boolean // Consider both directions } /** diff --git a/src/types/graphTypes.ts b/src/types/graphTypes.ts index 6e456a02..9fa7dd4c 100644 --- a/src/types/graphTypes.ts +++ b/src/types/graphTypes.ts @@ -399,10 +399,12 @@ export interface GraphVerb { } /** - * Version of GraphVerb for embedded relationships - * Used when the source is implicit from the parent document + * A {@link GraphVerb} for relationships embedded under a parent noun, where the + * source is implicit (the parent), so `sourceId` is dropped. (`GraphVerb` has no + * `source` field — the prior `Omit` was a no-op that left the + * shape identical to `GraphVerb`; this omits the real `sourceId`.) */ -export type EmbeddedGraphVerb = Omit +export type EmbeddedGraphVerb = Omit // Proper Noun interfaces - extend GraphNoun with specific noun types diff --git a/src/vfs/VirtualFileSystem.ts b/src/vfs/VirtualFileSystem.ts index 0cf975dd..a6ba51a5 100644 --- a/src/vfs/VirtualFileSystem.ts +++ b/src/vfs/VirtualFileSystem.ts @@ -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 { 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 { // 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { const entityId = await this.pathResolver.resolve(path) return this.getEntityById(entityId) diff --git a/tests/integration/db-temporal.test.ts b/tests/integration/db-temporal.test.ts index 4eee90b9..d17f5c16 100644 --- a/tests/integration/db-temporal.test.ts +++ b/tests/integration/db-temporal.test.ts @@ -17,8 +17,9 @@ * asOf(version.generation).get(id), a removal is a null value, order is oldest→newest. * 6. Composition: "type T changed in (g1, g2]" computed two ways (diff ids filtered * to T, vs asOf(g2).find({type:T}) ∩ changed-ids) AGREE — including a where filter. - * 7. Granularity: single-operation writes (outside transact()) are invisible to the - * temporal verbs (transact commits only). + * 7. Granularity (Model-B): single-operation writes (outside transact()) are their + * own immutable generation — logged, diffable, and time-travelable, exactly like + * a transact() of one op (the test body below proves this). * 8. Compaction policy contrast: diff/since THROW below the horizon; history TRUNCATES * to it. (Locked so a refactor can't unify them incorrectly.) * diff --git a/tests/unit/boundary-no-native.test.ts b/tests/unit/boundary-no-native.test.ts index 20acc6e0..7a6dce39 100644 --- a/tests/unit/boundary-no-native.test.ts +++ b/tests/unit/boundary-no-native.test.ts @@ -46,9 +46,31 @@ function sourceFiles(dir: string): string[] { // `@soulcraft/cortex` both match; `@soulcraft/core` (a different name) does not, // because the package must be followed by a subpath separator or the closing quote. const NATIVE_MODULE = - /(?:import\s[^'"]*from\s*|import\s*\(\s*|require\s*\(\s*)['"]@soulcraft\/cor(?:tex)?(?:\/[^'"]*)?['"]/ + /(?:import\s[^'"]*from\s*|export\s[^'"]*from\s*|import\s*\(\s*|require\s*\(\s*|import\s*)['"]@soulcraft\/cor(?:tex)?(?:\/[^'"]*)?['"]/ describe('open/proprietary boundary — public repo must not depend on @soulcraft/cor', () => { + it('the detector catches every static-dependency form (incl. side-effect imports + re-exports)', () => { + // Each of these creates a hard dependency on the proprietary package and MUST + // be caught (the prior regex missed the bare side-effect import and re-export forms). + for (const form of [ + `import cor from '@soulcraft/cor'`, + `import { x } from '@soulcraft/cor'`, + `import '@soulcraft/cor'`, + `import '@soulcraft/cor/native'`, + `export { x } from '@soulcraft/cor'`, + `export * from '@soulcraft/cor'`, + `const x = require('@soulcraft/cor')`, + `const x = await import('@soulcraft/cor')`, + `import x from '@soulcraft/cortex'` + ]) { + expect(NATIVE_MODULE.test(form), `should detect: ${form}`).toBe(true) + } + // A different package whose name merely starts with the same prefix is NOT a match. + for (const ok of [`import x from '@soulcraft/core'`, `// see @soulcraft/cor docs`]) { + expect(NATIVE_MODULE.test(ok), `should NOT match: ${ok}`).toBe(false) + } + }) + it('no src/ or tests/ file imports or requires the native package', () => { const offenders: string[] = [] for (const dir of ['src', 'tests']) { diff --git a/tests/unit/brainy/batch-operations.test.ts b/tests/unit/brainy/batch-operations.test.ts index 7af57ab4..b2cc7f09 100644 --- a/tests/unit/brainy/batch-operations.test.ts +++ b/tests/unit/brainy/batch-operations.test.ts @@ -405,21 +405,24 @@ describe('Brainy Batch Operations', () => { expect(companyRelations.length).toBeGreaterThanOrEqual(50) }) - it('should skip invalid relationships', async () => { + it('a batch with a forward-reference endpoint still creates the fully-valid relationships', async () => { + // The middle item references a source id that does not (yet) exist — a + // forward reference. Whatever its fate, the two fully-valid edges MUST be + // created (a bad item must not sink the good ones in a batch). const relationships = [ { from: entities[0], to: entities[1], type: VerbType.FriendOf }, - { from: 'invalid-id', to: entities[2], type: VerbType.FriendOf }, + { from: 'no-such-entity', to: entities[2], type: VerbType.FriendOf }, { from: entities[1], to: entities[2], type: VerbType.FriendOf } ] - - try { - // Should skip invalid and continue - const relationIds = await brain.relateMany({ items: relationships }) - expect(relationIds.length).toBeLessThanOrEqual(3) - } catch (error) { - // Or might throw - that's ok too - expect(error).toBeDefined() - } + + const relationIds = await brain.relateMany({ items: relationships }) + expect(Array.isArray(relationIds)).toBe(true) + + // Both fully-valid edges are queryable, regardless of the forward-ref item. + const from0 = await brain.related({ from: entities[0] }) + expect(from0.some((r) => r.to === entities[1])).toBe(true) + const from1 = await brain.related({ from: entities[1] }) + expect(from1.some((r) => r.to === entities[2])).toBe(true) }) })