fix(8.0): close GA-blocking correctness gaps from the readiness audit

- Version-coupling cold-init: getBrainyVersion() returned a stale build-time
  default ('3.14.0') on the FIRST synchronous call — the one loadPlugins() makes
  to build context.version — because the package.json read was async. A native
  provider declaring a realistic `>=8.0.0` range was therefore rejected on cold
  init. version.ts now reads package.json synchronously (8.0 targets Node-like
  runtimes only); added a cold-init coupling regression with a `^8.0.0` plugin.
- No-silent-failures on the highest-fan-in read path: getNouns()/getVerbs()
  converted a storage read failure into a success-shaped empty page, which the
  cold-start rebuild then read as "store empty" and skipped the rebuild — booting
  a permanently-empty index with no signal (the same silent-failure class as the
  phantom bug). Both now re-throw a named BrainyError; the rebuild path re-throws
  read failures (fail loud) and records a queryable degraded state, surfaced via
  checkHealth(), for non-fatal rebuild hiccups.
- LSM SSTable leak: graph compaction wrote a merged SSTable but only dropped the
  old ones from the manifest, orphaning their payloads forever ("In production
  we'd add a cleanup mechanism"). Added StorageAdapter.deleteMetadata() and now
  reclaim each compacted-away SSTable.
- Documented the two headline methods add() and find() (the only undocumented
  public methods on the class).

Full gate green: build, unit 1512, integration 607.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
David Snelling 2026-06-29 10:03:38 -07:00
parent 40d2cd5419
commit 47e8031124
7 changed files with 291 additions and 133 deletions

View file

@ -1,8 +1,10 @@
/** /**
* 🧠 Brainy 3.0 - The Future of Neural Databases * @module brainy
* * @description The `Brainy` class the Triple-Intelligence database that unifies
* Beautiful, Professional, Planet-Scale, Fun to Use * vector similarity, graph traversal, and metadata filtering behind a single API
* NO STUBS, NO MOCKS, REAL IMPLEMENTATION * (`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' import { v4 as uuidv4, v7 as uuidv7 } from './universal/uuid.js'
@ -159,6 +161,7 @@ import {
import { GenerationStore } from './db/generationStore.js' import { GenerationStore } from './db/generationStore.js'
import { isDeterministicEmbedMode } from './embeddings/deterministicEmbedMode.js' import { isDeterministicEmbedMode } from './embeddings/deterministicEmbedMode.js'
import { GenerationConflictError } from './db/errors.js' import { GenerationConflictError } from './db/errors.js'
import { BrainyError } from './errors/brainyError.js'
import { MemoryStorage } from './storage/adapters/memoryStorage.js' import { MemoryStorage } from './storage/adapters/memoryStorage.js'
import type { import type {
CompactHistoryOptions, CompactHistoryOptions,
@ -390,6 +393,14 @@ export class Brainy<T = any> implements BrainyInterface<T> {
* (use the TS fallback); otherwise the provider instance. * (use the TS fallback); otherwise the provider instance.
*/ */
private _graphAccelProvider: GraphAccelerationProvider | null | undefined = undefined 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) // Sub-APIs (lazy-loaded)
private _nlp?: NaturalLanguageProcessor private _nlp?: NaturalLanguageProcessor
@ -1291,9 +1302,11 @@ export class Brainy<T = any> implements BrainyInterface<T> {
/** /**
* @description Persist one single-operation write (`add`/`update`/`remove`/ * @description Persist one single-operation write (`add`/`update`/`remove`/
* `relate`/`updateRelation`/`unrelate` and the per-item calls of their `*Many` * `relate`/`updateRelation`/`unrelate` and the per-item calls of `addMany`/
* variants) as its OWN immutable generation the Model-B "every write * `updateMany`/`relateMany`) as its OWN immutable generation the Model-B
* versioned" contract. Wraps the write's existing `executeTransaction` batch in * "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 * {@link GenerationStore.commitSingleOp}, which captures byte-identical
* before-images of `touched`, runs the batch with the generation watermark * 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 * pinned to the reserved generation (so this write's graph-index ops stamp cor
@ -1326,6 +1339,26 @@ export class Brainy<T = any> implements BrainyInterface<T> {
}) })
} }
/**
* @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<T>): Promise<string> { async add(params: AddParams<T>): Promise<string> {
this.assertWritable('add') this.assertWritable('add')
await this.ensureInitialized() await this.ensureInitialized()
@ -4665,6 +4698,30 @@ export class Brainy<T = any> implements BrainyInterface<T> {
return new Set(ids) 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<T>): Promise<Result<T>[]> { async find(query: string | FindParams<T>): Promise<Result<T>[]> {
await this.ensureInitialized() await this.ensureInitialized()
@ -5152,11 +5209,19 @@ export class Brainy<T = any> implements BrainyInterface<T> {
// `entityMatchesFind` (db.ts). On a healthy index it is a no-op; on a // `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. // corrupted one it drops the bad row instead of returning a phantom.
if (result.length > 0) { if (result.length > 0) {
result = result.filter( result = result.filter((r) => {
(r) => if (r.entity == null) return false
r.entity != null && try {
entityMatchesFind(r.entity as unknown as Entity, params as unknown as FindParams) 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 // includeVectors — opt-in vector hydration. Default (false) keeps the perf
@ -10896,7 +10961,20 @@ export class Brainy<T = any> implements BrainyInterface<T> {
recommendation: string | null recommendation: string | null
}> { }> {
await this.ensureInitialized() 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<T = any> implements BrainyInterface<T> {
} }
} catch (error) { } catch (error) {
console.warn('Warning: Could not rebuild indexes:', error) // A storage READ failure here is surfaced by getNouns/getVerbs as a named
// Don't throw - allow system to start even if rebuild fails // 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<T = any> implements BrainyInterface<T> {
recommendation: string | null recommendation: string | null
}> { }> {
await this.ensureInitialized() 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
} }
/** /**

View file

@ -888,6 +888,16 @@ export interface StorageAdapter {
*/ */
getMetadata(id: string): Promise<NounMetadata | null> getMetadata(id: string): Promise<NounMetadata | null>
/**
* 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<void>
/** /**
* Get multiple metadata objects in batches (prevents socket exhaustion) * Get multiple metadata objects in batches (prevents socket exhaustion)
* @param ids Array of IDs to get metadata for * @param ids Array of IDs to get metadata for

View file

@ -1,21 +1,21 @@
/** /**
* LSMTree - Log-Structured Merge Tree for Graph Storage * @module graph/lsm/LSMTree
* * @description Log-Structured Merge tree for the JS (open-core) graph store the
* Production-grade LSM-tree implementation that reduces memory usage * fallback used when no native graph provider is registered. Verb-id postings are
* from 500GB to 1.3GB for 1 billion relationships while maintaining * buffered in an in-memory MemTable, flushed to immutable sorted SSTables, and
* sub-5ms read performance. * background-compacted; bloom filters give fast negative lookups.
* *
* Architecture: * Architecture:
* - MemTable: In-memory write buffer (100K relationships, ~24MB) * - MemTable: in-memory write buffer (flush threshold configurable, default 100K)
* - SSTables: Immutable sorted files on disk (10K relationships each) * - SSTables: immutable sorted segments, persisted via the StorageAdapter
* - Bloom Filters: In-memory filters for fast negative lookups * - Bloom filters: in-memory membership pre-checks
* - Compaction: Background merging of SSTables * - Compaction: background merge of SSTables (reclaims superseded segments)
* *
* Key Properties: * Complexity: O(1) MemTable writes; O(log n) reads with bloom-filter pre-filtering.
* - Write-optimized: O(1) writes to MemTable * Note: this JS implementation loads its SSTables into memory after open (it is the
* - Read-efficient: O(log n) reads with bloom filter optimization * fallback engine). Billion-scale, on-disk-resident operation is the native graph
* - Memory-efficient: 385x less memory than all-in-RAM approach * provider's role behind the provider boundary, not this fallback's; no absolute
* - Storage-agnostic: Works with any StorageAdapter * memory/latency figures are claimed here without a cited benchmark.
*/ */
import { StorageAdapter } from '../../coreTypes.js' 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) { for (const sstable of sstables) {
const oldKey = `${this.config.storagePrefix}-${sstable.metadata.id}` const oldKey = `${this.config.storagePrefix}-${sstable.metadata.id}`
this.manifest.sstables.delete(sstable.metadata.id)
try { try {
// StorageAdapter doesn't have deleteMetadata, so we'll leave orphaned data await this.storage.deleteMetadata(oldKey)
// In production, we'd add a cleanup mechanism
this.manifest.sstables.delete(sstable.metadata.id)
} catch (error) { } 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)
} }
} }

View file

@ -53,6 +53,8 @@ export abstract class BaseStorageAdapter implements StorageAdapter {
abstract getMetadata(id: string): Promise<any | null> abstract getMetadata(id: string): Promise<any | null>
abstract deleteMetadata(id: string): Promise<void>
abstract getNounMetadata(id: string): Promise<any | null> abstract getNounMetadata(id: string): Promise<any | null>
abstract getVerbMetadata(id: string): Promise<any | null> abstract getVerbMetadata(id: string): Promise<any | null>

View file

@ -30,6 +30,7 @@ import { getShardId } from './sharding.js'
import { BlobStorage, type BlobStoreAdapter } from './blobStorage.js' import { BlobStorage, type BlobStoreAdapter } from './blobStorage.js'
import { unwrapBinaryData } from './binaryDataCodec.js' import { unwrapBinaryData } from './binaryDataCodec.js'
import { prodLog } from '../utils/logger.js' import { prodLog } from '../utils/logger.js'
import { BrainyError } from '../errors/brainyError.js'
import { MetadataWriteBuffer } from '../utils/metadataWriteBuffer.js' import { MetadataWriteBuffer } from '../utils/metadataWriteBuffer.js'
import { import {
splitNounMetadataRecord, splitNounMetadataRecord,
@ -1568,23 +1569,24 @@ export abstract class BaseStorage extends BaseStorageAdapter {
} }
} }
// Storage adapter does not support pagination // Storage adapter does not support pagination. This is a hard
prodLog.error( // misconfiguration — find()/rebuild/aggregation all read through this path,
'Storage adapter does not support pagination. The deprecated getAllNouns_internal() method has been removed. Please implement getNounsWithPagination() in your storage adapter.' // 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) { } catch (error) {
prodLog.error('Error getting nouns with pagination:', error) // Never convert a genuine read failure into a success-shaped empty page:
return { // this is the highest-fan-in read path (find() fallback, cold-start rebuild
items: [], // gating, aggregation backfill, getNounsByNounType), so a swallowed error
totalCount: 0, // would propagate as "zero rows" everywhere — indistinguishable from a truly
hasMore: false // 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 : undefined
} }
} catch (error) { } catch (error) {
prodLog.error('Error getting verbs with pagination:', error) // Same no-silent-failure contract as getNouns: a genuine read failure must
return { // surface as a named, catchable error, not a success-shaped empty page that
items: [], // callers read as "this graph has no verbs."
totalCount: 0, if (error instanceof BrainyError) throw error
hasMore: false throw BrainyError.storage(
} 'getVerbs pagination read failed',
error instanceof Error ? error : undefined
)
} }
} }
@ -2576,6 +2580,26 @@ export abstract class BaseStorage extends BaseStorageAdapter {
return null 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<void> {
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) * Save noun metadata to storage (now typed)
* Routes to correct sharded location based on UUID * Routes to correct sharded location based on UUID
@ -3175,15 +3199,11 @@ export abstract class BaseStorage extends BaseStorageAdapter {
await this.ensureInitialized() await this.ensureInitialized()
// Direct O(1) lookup with ID-first paths - no type search needed! // 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) const path = getVerbMetadataPath(id)
return this.readCanonicalObject(path)
try {
const metadata = await this.readCanonicalObject(path)
return metadata || null
} catch (error) {
// Entity not found
return null
}
} }
/** /**

View file

@ -1,96 +1,81 @@
/** /**
* 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 brainyprovider 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' 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 cachedVersion: string | null = null
let versionPromise: Promise<string> | 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<string> { function readVersionSync(): string {
if (!isNode()) { if (!isNode()) return UNKNOWN_VERSION
return DEFAULT_VERSION
}
try { try {
// Dynamic imports for Node.js modules - modern approach const here = dirname(fileURLToPath(import.meta.url))
const [{ readFileSync }, { join, dirname }, { fileURLToPath }] = await Promise.all([ const pkg = JSON.parse(readFileSync(join(here, '../../package.json'), 'utf8'))
import('node:fs'), return typeof pkg.version === 'string' && pkg.version.length > 0
import('node:path'), ? pkg.version
import('node:url') : UNKNOWN_VERSION
]) } catch {
return UNKNOWN_VERSION
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
} }
} }
/** /**
* Get the current Brainy package version * @description The running Brainy package version, read synchronously from
* In Node.js, attempts to read from package.json * `package.json` and cached. Correct on the **first** call including the
* In browser, returns the default version * version-coupling check during `init()` with no async warm-up.
* @returns The current version string * @returns The semver version string (e.g. `"8.0.0"`).
* @example
* const v = getBrainyVersion() // "8.0.0"
*/ */
export function getBrainyVersion(): string { export function getBrainyVersion(): string {
if (cachedVersion) { if (cachedVersion === null) cachedVersion = readVersionSync()
return 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
} }
/** /**
* Get the current Brainy package version asynchronously * @description Async accessor retained for API compatibility. The version is
* Guaranteed to attempt loading from package.json in Node.js * resolved synchronously, so this returns the same value as
* @returns Promise resolving to the current version string * {@link getBrainyVersion} there is no longer any deferred state.
* @returns A promise resolving to the version string.
*/ */
export async function getBrainyVersionAsync(): Promise<string> { export async function getBrainyVersionAsync(): Promise<string> {
if (cachedVersion) { return getBrainyVersion()
return cachedVersion
}
if (!versionPromise) {
versionPromise = loadVersionFromPackageJson()
}
const version = await versionPromise
cachedVersion = version
return version
} }
/** /**
* Get version information for augmentation metadata * @description Build the version-metadata object stamped onto augmentation /
* @param service The service/augmentation name * provenance records.
* @returns Version metadata object * @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 } { export function getAugmentationVersion(service: string): { augmentation: string; version: string } {
return { return {

View file

@ -12,9 +12,17 @@
* 6. a graceful decline (activate()false) no throw, loud warn * 6. a graceful decline (activate()false) no throw, loud warn
*/ */
import { describe, it, expect } from 'vitest' 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 { Brainy } from '../../src/brainy.js'
import { getBrainyVersion } from '../../src/utils/version.js'
import { pluginRangeSatisfies, type BrainyPlugin, type BrainyPluginContext } from '../../src/plugin.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 { function memBrain(): Brainy {
return new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true }) 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. */ /** A minimal fake plugin; `onActivate` lets a test register providers / throw. */
function fakePlugin( function fakePlugin(
name: string, name: string,
@ -72,6 +95,17 @@ describe('version coupling at init() — no silent fallback', () => {
await brain.close() 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 () => { it('throws (does not swallow) when activate() throws', async () => {
const brain = memBrain() const brain = memBrain()
brain.use(fakePlugin('@fake/boom', { onActivate: () => { throw new Error('kaboom') } })) brain.use(fakePlugin('@fake/boom', { onActivate: () => { throw new Error('kaboom') } }))