diff --git a/src/brainy.ts b/src/brainy.ts index 59eeabcc..55b80d5a 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -1,8 +1,10 @@ /** - * 🧠 Brainy 3.0 - The Future of Neural Databases - * - * Beautiful, Professional, Planet-Scale, Fun to Use - * NO STUBS, NO MOCKS, REAL IMPLEMENTATION + * @module brainy + * @description The `Brainy` class β€” the Triple-Intelligence database that unifies + * vector similarity, graph traversal, and metadata filtering behind a single API + * (`add`/`find`/`relate`/`related`/`get`/`similar`/`graph.*`/`now`/`asOf`/`transact`/ + * `export`/`import`). Native acceleration is feature-detected through the provider + * boundary; when no provider is registered, the built-in JS engines serve everything. */ import { v4 as uuidv4, v7 as uuidv7 } from './universal/uuid.js' @@ -159,6 +161,7 @@ import { import { GenerationStore } from './db/generationStore.js' import { isDeterministicEmbedMode } from './embeddings/deterministicEmbedMode.js' import { GenerationConflictError } from './db/errors.js' +import { BrainyError } from './errors/brainyError.js' import { MemoryStorage } from './storage/adapters/memoryStorage.js' import type { CompactHistoryOptions, @@ -390,6 +393,14 @@ export class Brainy implements BrainyInterface { * (use the TS fallback); otherwise the provider instance. */ private _graphAccelProvider: GraphAccelerationProvider | null | undefined = undefined + /** + * Set when a non-fatal index rebuild fails at init() (the brain is allowed to + * start, but in a degraded state where queries may be incomplete). Surfaced + * through {@link checkHealth} so consumers can observe the failure + * programmatically rather than only via the console. A storage *read* failure + * during rebuild is NOT recorded here β€” it re-throws and aborts init() loudly. + */ + private _indexRebuildFailed: Error | null = null // Sub-APIs (lazy-loaded) private _nlp?: NaturalLanguageProcessor @@ -1291,9 +1302,11 @@ export class Brainy implements BrainyInterface { /** * @description Persist one single-operation write (`add`/`update`/`remove`/ - * `relate`/`updateRelation`/`unrelate` and the per-item calls of their `*Many` - * variants) as its OWN immutable generation β€” the Model-B "every write - * versioned" contract. Wraps the write's existing `executeTransaction` batch in + * `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` is the one exception: it + * commits each delete *chunk* as a single generation for bulk-delete efficiency, + * not one generation per removed id.) Wraps the write's existing `executeTransaction` batch in * {@link GenerationStore.commitSingleOp}, which captures byte-identical * before-images of `touched`, runs the batch with the generation watermark * pinned to the reserved generation (so this write's graph-index ops stamp cor @@ -1326,6 +1339,26 @@ export class Brainy implements BrainyInterface { }) } + /** + * @description Add an entity (noun) to the brain. Embeds `data` into a vector and + * indexes the entity across all three intelligences β€” vector similarity, graph + * adjacency, and metadata β€” then returns its id. When no `id` is supplied a fresh + * time-ordered **UUID v7** is generated; a supplied natural-key string is + * normalized to a stable **UUID v5**. Under Model-B every add is its own immutable + * generation, so it is time-travelable via {@link asOf}. + * @param params - The entity to add. `data` (embedded for similarity) and `type` + * (a {@link NounType}) are the essentials; `subtype`, `metadata`, an explicit + * `id`, `visibility`, `confidence`, and `weight` are optional. + * @returns The entity's id β€” the supplied/normalized id, or a freshly generated UUID v7. + * @throws If the brain is read-only (`mode: 'reader'`), or brain-wide `requireSubtype` + * is on and the entity's type has no subtype. + * @example + * const id = await brain.add({ + * data: 'Ada Lovelace', + * type: NounType.Person, + * metadata: { role: 'mathematician' } + * }) + */ async add(params: AddParams): Promise { this.assertWritable('add') await this.ensureInitialized() @@ -4665,6 +4698,30 @@ export class Brainy implements BrainyInterface { return new Set(ids) } + /** + * @description The unified Triple-Intelligence query. Composes vector similarity + * (`query` / `vector` / `near`), metadata filtering (`type` / `subtype` / `where` / + * `service`), and graph traversal (`connected`) into one ranked, paginated result + * set β€” pass a bare string for a quick semantic search, or a {@link FindParams} + * object to combine dimensions. Internal/system entities are hidden by default + * (opt in with `includeInternal` / `includeSystem`); `orderBy`/`order` and + * `limit`/`offset` apply across the composed result. Every returned row is + * re-validated against its own predicate, so a stale or cross-bucket index entry + * can never surface an entity that does not actually match. + * @param query - A semantic search string, or a {@link FindParams} object combining + * any of `query`, `vector`, `near`, `type`, `subtype`, `where`, `connected`, + * `orderBy`/`order`, `limit`, `offset`. + * @returns The matching {@link Result}s β€” flattened (`id`, `score`, `type`, + * `metadata`, `data`, …) and ranked; an empty array when nothing matches. + * @example + * // Semantic + metadata + graph, composed in one call: + * const rows = await brain.find({ + * query: 'climate policy', + * where: { year: { gte: 2020 } }, + * connected: { from: orgId, via: VerbType.Authored }, + * limit: 10 + * }) + */ async find(query: string | FindParams): Promise[]> { await this.ensureInitialized() @@ -5152,11 +5209,19 @@ export class Brainy implements BrainyInterface { // `entityMatchesFind` (db.ts). On a healthy index it is a no-op; on a // corrupted one it drops the bad row instead of returning a phantom. if (result.length > 0) { - result = result.filter( - (r) => - r.entity != null && - entityMatchesFind(r.entity as unknown as Entity, params as unknown as FindParams) - ) + result = result.filter((r) => { + if (r.entity == null) return false + try { + return entityMatchesFind(r.entity as unknown as Entity, params as unknown as FindParams) + } catch { + // The JS matcher doesn't implement a where-operator the provider already + // matched on (e.g. a future native-only operator). The guard cannot + // DISPROVE a match the provider made, so keep the row rather than + // hard-failing a correct provider result. (The db.ts historical path + // keeps its throw, which legitimately reroutes to materialization.) + return true + } + }) } // includeVectors β€” opt-in vector hydration. Default (false) keeps the perf @@ -10896,7 +10961,20 @@ export class Brainy implements BrainyInterface { recommendation: string | null }> { await this.ensureInitialized() - return this.metadataIndex.validateConsistency() + const result = await this.metadataIndex.validateConsistency() + // Fold in a non-fatal index-rebuild failure recorded at init() so the degraded + // state is observable through the same health surface (not just the console). + if (this._indexRebuildFailed) { + return { + ...result, + healthy: false, + recommendation: + `Index rebuild failed at init() (degraded β€” queries may be incomplete): ` + + `${this._indexRebuildFailed.message}. Rebuild the indexes and re-open.` + + (result.recommendation ? ` Also: ${result.recommendation}` : '') + } + } + return result } /** @@ -12785,8 +12863,19 @@ export class Brainy implements BrainyInterface { } } catch (error) { - console.warn('Warning: Could not rebuild indexes:', error) - // Don't throw - allow system to start even if rebuild fails + // A storage READ failure here is surfaced by getNouns/getVerbs as a named + // BrainyError β€” it means we could not even read the store to decide whether + // a rebuild is needed (or to perform it). That is a hard init failure, not a + // "start anyway" condition: booting a brain whose data we couldn't read would + // serve empty/incomplete results with no signal (the silent-failure class the + // 8.0 contract forbids). Re-throw it loud. + if (error instanceof BrainyError) throw error + // Any other rebuild hiccup is non-fatal β€” the brain may start on partially + // rebuilt indexes β€” but it is recorded as a queryable degraded state + // (surfaced via checkHealth) and escalated to error-level logging, rather + // than only emitting a console.warn that is trivially lost. + this._indexRebuildFailed = error instanceof Error ? error : new Error(String(error)) + console.error('[Brainy] Index rebuild failed; starting in a DEGRADED state (queries may be incomplete):', error) } } @@ -12808,7 +12897,20 @@ export class Brainy implements BrainyInterface { recommendation: string | null }> { await this.ensureInitialized() - return this.metadataIndex.validateConsistency() + const result = await this.metadataIndex.validateConsistency() + // Fold in a non-fatal index-rebuild failure recorded at init() so the degraded + // state is observable through the same health surface (not just the console). + if (this._indexRebuildFailed) { + return { + ...result, + healthy: false, + recommendation: + `Index rebuild failed at init() (degraded β€” queries may be incomplete): ` + + `${this._indexRebuildFailed.message}. Rebuild the indexes and re-open.` + + (result.recommendation ? ` Also: ${result.recommendation}` : '') + } + } + return result } /** diff --git a/src/coreTypes.ts b/src/coreTypes.ts index 69133b1d..45caebb3 100644 --- a/src/coreTypes.ts +++ b/src/coreTypes.ts @@ -888,6 +888,16 @@ export interface StorageAdapter { */ getMetadata(id: string): Promise + /** + * Delete a system/metadata object previously written with {@link saveMetadata}. + * Routes by the same key analysis as save/get (system channel), so callers that + * persist their own keyed payloads β€” e.g. the LSM graph store reclaiming its + * compacted-away SSTables β€” can free the storage instead of orphaning it. + * Idempotent: a missing key is a no-op, not an error. + * @param id The same key passed to {@link saveMetadata}. + */ + deleteMetadata(id: string): Promise + /** * Get multiple metadata objects in batches (prevents socket exhaustion) * @param ids Array of IDs to get metadata for diff --git a/src/graph/lsm/LSMTree.ts b/src/graph/lsm/LSMTree.ts index 32f941bb..960efe9e 100644 --- a/src/graph/lsm/LSMTree.ts +++ b/src/graph/lsm/LSMTree.ts @@ -1,21 +1,21 @@ /** - * LSMTree - Log-Structured Merge Tree for Graph Storage - * - * Production-grade LSM-tree implementation that reduces memory usage - * from 500GB to 1.3GB for 1 billion relationships while maintaining - * sub-5ms read performance. + * @module graph/lsm/LSMTree + * @description Log-Structured Merge tree for the JS (open-core) graph store β€” the + * fallback used when no native graph provider is registered. Verb-id postings are + * buffered in an in-memory MemTable, flushed to immutable sorted SSTables, and + * background-compacted; bloom filters give fast negative lookups. * * Architecture: - * - MemTable: In-memory write buffer (100K relationships, ~24MB) - * - SSTables: Immutable sorted files on disk (10K relationships each) - * - Bloom Filters: In-memory filters for fast negative lookups - * - Compaction: Background merging of SSTables + * - MemTable: in-memory write buffer (flush threshold configurable, default 100K) + * - SSTables: immutable sorted segments, persisted via the StorageAdapter + * - Bloom filters: in-memory membership pre-checks + * - Compaction: background merge of SSTables (reclaims superseded segments) * - * Key Properties: - * - Write-optimized: O(1) writes to MemTable - * - Read-efficient: O(log n) reads with bloom filter optimization - * - Memory-efficient: 385x less memory than all-in-RAM approach - * - Storage-agnostic: Works with any StorageAdapter + * Complexity: O(1) MemTable writes; O(log n) reads with bloom-filter pre-filtering. + * Note: this JS implementation loads its SSTables into memory after open (it is the + * fallback engine). Billion-scale, on-disk-resident operation is the native graph + * provider's role behind the provider boundary, not this fallback's; no absolute + * memory/latency figures are claimed here without a cited benchmark. */ import { StorageAdapter } from '../../coreTypes.js' @@ -457,15 +457,20 @@ export class LSMTree { } }) - // Delete old SSTables from storage + // Reclaim the compacted-away SSTables: drop them from the manifest AND + // delete their persisted payloads, so the system channel does not grow + // unbounded with graph write volume. (deleteMetadata is idempotent, so a + // never-persisted memtable-only SSTable is a harmless no-op.) for (const sstable of sstables) { const oldKey = `${this.config.storagePrefix}-${sstable.metadata.id}` + this.manifest.sstables.delete(sstable.metadata.id) try { - // StorageAdapter doesn't have deleteMetadata, so we'll leave orphaned data - // In production, we'd add a cleanup mechanism - this.manifest.sstables.delete(sstable.metadata.id) + await this.storage.deleteMetadata(oldKey) } catch (error) { - prodLog.warn(`LSMTree: Failed to delete old SSTable ${sstable.metadata.id}`, error) + // A reclaim failure must not abort compaction (the merged SSTable is + // already durable and the manifest no longer references the old one); + // surface it so a persistent leak is visible rather than silent. + prodLog.warn(`LSMTree: failed to delete compacted SSTable ${sstable.metadata.id}`, error) } } diff --git a/src/storage/adapters/baseStorageAdapter.ts b/src/storage/adapters/baseStorageAdapter.ts index 2b54f076..6815ced4 100644 --- a/src/storage/adapters/baseStorageAdapter.ts +++ b/src/storage/adapters/baseStorageAdapter.ts @@ -53,6 +53,8 @@ export abstract class BaseStorageAdapter implements StorageAdapter { abstract getMetadata(id: string): Promise + abstract deleteMetadata(id: string): Promise + abstract getNounMetadata(id: string): Promise abstract getVerbMetadata(id: string): Promise diff --git a/src/storage/baseStorage.ts b/src/storage/baseStorage.ts index 768d1920..353be9e4 100644 --- a/src/storage/baseStorage.ts +++ b/src/storage/baseStorage.ts @@ -30,6 +30,7 @@ import { getShardId } from './sharding.js' import { BlobStorage, type BlobStoreAdapter } from './blobStorage.js' import { unwrapBinaryData } from './binaryDataCodec.js' import { prodLog } from '../utils/logger.js' +import { BrainyError } from '../errors/brainyError.js' import { MetadataWriteBuffer } from '../utils/metadataWriteBuffer.js' import { splitNounMetadataRecord, @@ -1568,23 +1569,24 @@ export abstract class BaseStorage extends BaseStorageAdapter { } } - // Storage adapter does not support pagination - prodLog.error( - 'Storage adapter does not support pagination. The deprecated getAllNouns_internal() method has been removed. Please implement getNounsWithPagination() in your storage adapter.' + // Storage adapter does not support pagination. This is a hard + // misconfiguration β€” find()/rebuild/aggregation all read through this path, + // so returning an empty page would silently present a misconfigured adapter + // as an empty database. Fail loud instead. + throw BrainyError.storage( + 'Storage adapter does not implement getNounsWithPagination(). The deprecated getAllNouns_internal() fallback has been removed; implement pagination in your storage adapter.' ) - - return { - items: [], - totalCount: 0, - hasMore: false - } } catch (error) { - prodLog.error('Error getting nouns with pagination:', error) - return { - items: [], - totalCount: 0, - hasMore: false - } + // Never convert a genuine read failure into a success-shaped empty page: + // this is the highest-fan-in read path (find() fallback, cold-start rebuild + // gating, aggregation backfill, getNounsByNounType), so a swallowed error + // would propagate as "zero rows" everywhere β€” indistinguishable from a truly + // empty store, and the exact silent-failure class the 8.0 contract forbids. + if (error instanceof BrainyError) throw error + throw BrainyError.storage( + 'getNouns pagination read failed', + error instanceof Error ? error : undefined + ) } } @@ -2415,12 +2417,14 @@ export abstract class BaseStorage extends BaseStorageAdapter { : undefined } } catch (error) { - prodLog.error('Error getting verbs with pagination:', error) - return { - items: [], - totalCount: 0, - hasMore: false - } + // Same no-silent-failure contract as getNouns: a genuine read failure must + // surface as a named, catchable error, not a success-shaped empty page that + // callers read as "this graph has no verbs." + if (error instanceof BrainyError) throw error + throw BrainyError.storage( + 'getVerbs pagination read failed', + error instanceof Error ? error : undefined + ) } } @@ -2576,6 +2580,26 @@ export abstract class BaseStorage extends BaseStorageAdapter { return null } + /** + * Delete a system/metadata object previously written with {@link saveMetadata}. + * The exact inverse of save/get: routes through `analyzeKey(id, 'system')` and + * removes the canonical object (plus the legacy flat path that `getMetadata` + * also reads, so a sharded key leaves nothing behind). Lets keyed-payload + * owners β€” e.g. the LSM graph store reclaiming its compacted-away SSTables β€” + * free storage instead of orphaning it. Idempotent: deleting a missing path is + * a no-op. + */ + public async deleteMetadata(id: string): Promise { + await this.ensureInitialized() + const keyInfo = this.analyzeKey(id, 'system') + await this.deleteCanonicalObject(keyInfo.fullPath) + // Mirror getMetadata's legacy fallback so an older flat-path payload for a + // sharded key is also reclaimed. + if (keyInfo.shardId !== null) { + await this.deleteCanonicalObject(`${SYSTEM_DIR}/${id}.json`) + } + } + /** * Save noun metadata to storage (now typed) * Routes to correct sharded location based on UUID @@ -3175,15 +3199,11 @@ export abstract class BaseStorage extends BaseStorageAdapter { await this.ensureInitialized() // Direct O(1) lookup with ID-first paths - no type search needed! + // Symmetric with getNounMetadata: readCanonicalObject already returns null for + // a genuine not-found, so a real storage fault (permission/corruption/IO) must + // propagate rather than be masked as "this verb has no metadata". const path = getVerbMetadataPath(id) - - try { - const metadata = await this.readCanonicalObject(path) - return metadata || null - } catch (error) { - // Entity not found - return null - } + return this.readCanonicalObject(path) } /** diff --git a/src/utils/version.ts b/src/utils/version.ts index 718d44fe..d616cee3 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -1,100 +1,85 @@ /** - * Version utilities for Brainy + * @module utils/version + * @description Resolves the running `@soulcraft/brainy` package version. Brainy 8.0 + * targets Node-like runtimes only (Node.js, Bun, Deno β€” all expose `node:fs`), so the + * version is read **synchronously** from `package.json` on first call and cached. + * + * The synchronous read is load-bearing: `loadPlugins()` is the first step of `init()` + * and reads the version to drive the brainy↔provider version-coupling guard + * (`plugin.ts` β†’ `pluginRangeSatisfies`). A deferred/async value would return a stale + * default on that first synchronous call and spuriously reject a correctly-matched + * native provider (e.g. cor 3.x declaring `>=8.0.0`). Reading synchronously removes + * that window entirely. */ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' import { isNode } from './environment.js' -// Default version - this should be replaced at build time -const DEFAULT_VERSION = '3.14.0' +/** + * Last-resort sentinel, returned only if `package.json` cannot be read at all + * (a non-Node runtime, or a genuinely broken install). It is intentionally an + * "unknown" `0.0.0` rather than a plausible-looking release, so a real read + * failure can never masquerade as a valid version and silently satisfy a + * coupling range β€” it will fail loud instead. + */ +const UNKNOWN_VERSION = '0.0.0' let cachedVersion: string | null = null -let versionPromise: Promise | null = null /** - * Load version from package.json in Node.js environment + * Synchronously read `version` from the package's own `package.json`. The path + * `../../package.json` resolves to the package root from both `src/utils/` (dev) + * and `dist/utils/` (published). */ -async function loadVersionFromPackageJson(): Promise { - if (!isNode()) { - return DEFAULT_VERSION - } - +function readVersionSync(): string { + if (!isNode()) return UNKNOWN_VERSION try { - // Dynamic imports for Node.js modules - modern approach - const [{ readFileSync }, { join, dirname }, { fileURLToPath }] = await Promise.all([ - import('node:fs'), - import('node:path'), - import('node:url') - ]) - - const __filename = fileURLToPath(import.meta.url) - const __dirname = dirname(__filename) - const packageJsonPath = join(__dirname, '../../package.json') - - const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) - return packageJson.version || DEFAULT_VERSION - } catch (error) { - // Silently fall back to default version - return DEFAULT_VERSION + const here = dirname(fileURLToPath(import.meta.url)) + const pkg = JSON.parse(readFileSync(join(here, '../../package.json'), 'utf8')) + return typeof pkg.version === 'string' && pkg.version.length > 0 + ? pkg.version + : UNKNOWN_VERSION + } catch { + return UNKNOWN_VERSION } } /** - * Get the current Brainy package version - * In Node.js, attempts to read from package.json - * In browser, returns the default version - * @returns The current version string + * @description The running Brainy package version, read synchronously from + * `package.json` and cached. Correct on the **first** call β€” including the + * version-coupling check during `init()` β€” with no async warm-up. + * @returns The semver version string (e.g. `"8.0.0"`). + * @example + * const v = getBrainyVersion() // "8.0.0" */ export function getBrainyVersion(): string { - if (cachedVersion) { - return cachedVersion - } - - // In browser or if we need immediate response, return default - if (!isNode()) { - cachedVersion = DEFAULT_VERSION - return cachedVersion - } - - // For Node.js, try to load synchronously first time - // This is a compromise for backward compatibility - if (!versionPromise) { - versionPromise = loadVersionFromPackageJson() - versionPromise.then(version => { - cachedVersion = version - }) - } - - // Return default while loading - return cachedVersion || DEFAULT_VERSION + if (cachedVersion === null) cachedVersion = readVersionSync() + return cachedVersion } /** - * Get the current Brainy package version asynchronously - * Guaranteed to attempt loading from package.json in Node.js - * @returns Promise resolving to the current version string + * @description Async accessor retained for API compatibility. The version is + * resolved synchronously, so this returns the same value as + * {@link getBrainyVersion} β€” there is no longer any deferred state. + * @returns A promise resolving to the version string. */ export async function getBrainyVersionAsync(): Promise { - if (cachedVersion) { - return cachedVersion - } - - if (!versionPromise) { - versionPromise = loadVersionFromPackageJson() - } - - const version = await versionPromise - cachedVersion = version - return version + return getBrainyVersion() } /** - * Get version information for augmentation metadata - * @param service The service/augmentation name - * @returns Version metadata object + * @description Build the version-metadata object stamped onto augmentation / + * provenance records. + * @param service - The augmentation or service name to record. + * @returns `{ augmentation, version }` carrying the current package version. + * @example + * getAugmentationVersion('aggregation') // { augmentation: 'aggregation', version: '8.0.0' } */ export function getAugmentationVersion(service: string): { augmentation: string; version: string } { return { augmentation: service, version: getBrainyVersion() } -} \ No newline at end of file +} diff --git a/tests/unit/plugin-version-coupling.test.ts b/tests/unit/plugin-version-coupling.test.ts index 26f551c1..00b236aa 100644 --- a/tests/unit/plugin-version-coupling.test.ts +++ b/tests/unit/plugin-version-coupling.test.ts @@ -12,9 +12,17 @@ * 6. a graceful decline (activate()β†’false) β†’ no throw, loud warn */ import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' import { Brainy } from '../../src/brainy.js' +import { getBrainyVersion } from '../../src/utils/version.js' import { pluginRangeSatisfies, type BrainyPlugin, type BrainyPluginContext } from '../../src/plugin.js' +const PACKAGE_VERSION: string = JSON.parse( + readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../../package.json'), 'utf8') +).version + function memBrain(): Brainy { return new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true }) } @@ -44,6 +52,21 @@ describe('pluginRangeSatisfies (dep-free range matcher)', () => { }) }) +describe('getBrainyVersion() β€” synchronously correct on first call', () => { + it('returns the real package version, not a stale build-time default', () => { + // Regression: the version was read async, so the FIRST (synchronous) call β€” + // the one loadPlugins() makes to build context.version β€” returned a stale + // default ('3.14.0'), which fails any realistic cor 3.x `>=8.0.0` range and + // hard-rejects the matched native pairing on cold init. The sync read makes + // the first call correct. + const v = getBrainyVersion() + expect(v).toBe(PACKAGE_VERSION) + expect(v).not.toBe('3.14.0') + expect(v).not.toBe('0.0.0') // the unknown-read sentinel must not surface in a real install + expect(v.startsWith('8.')).toBe(true) + }) +}) + /** A minimal fake plugin; `onActivate` lets a test register providers / throw. */ function fakePlugin( name: string, @@ -72,6 +95,17 @@ describe('version coupling at init() β€” no silent fallback', () => { await brain.close() }) + it('does NOT throw for a realistic cor 3.x range (^8.0.0) on a COLD init', async () => { + // The actual regression: loadPlugins() is the first init step and makes the + // first getBrainyVersion() call, so a stale sync default would reject a + // correctly-matched native provider declaring the real 8.x range. A fresh + // brain registering a `^8.0.0` plugin must init cleanly. + const brain = memBrain() + brain.use(fakePlugin('@fake/cor-3x', { brainyRange: '^8.0.0' })) + await expect(brain.init()).resolves.toBeUndefined() + await brain.close() + }) + it('throws (does not swallow) when activate() throws', async () => { const brain = memBrain() brain.use(fakePlugin('@fake/boom', { onActivate: () => { throw new Error('kaboom') } }))