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:
parent
40d2cd5419
commit
47e8031124
7 changed files with 291 additions and 133 deletions
134
src/brainy.ts
134
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<T = any> implements BrainyInterface<T> {
|
|||
* (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<T = any> implements BrainyInterface<T> {
|
|||
|
||||
/**
|
||||
* @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<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> {
|
||||
this.assertWritable('add')
|
||||
await this.ensureInitialized()
|
||||
|
|
@ -4665,6 +4698,30 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
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>[]> {
|
||||
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
|
||||
// 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<T = any> implements BrainyInterface<T> {
|
|||
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<T = any> implements BrainyInterface<T> {
|
|||
}
|
||||
|
||||
} 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<T = any> implements BrainyInterface<T> {
|
|||
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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -888,6 +888,16 @@ export interface StorageAdapter {
|
|||
*/
|
||||
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)
|
||||
* @param ids Array of IDs to get metadata for
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
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)
|
||||
try {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ export abstract class BaseStorageAdapter implements StorageAdapter {
|
|||
|
||||
abstract getMetadata(id: string): Promise<any | null>
|
||||
|
||||
abstract deleteMetadata(id: string): Promise<void>
|
||||
|
||||
abstract getNounMetadata(id: string): Promise<any | null>
|
||||
|
||||
abstract getVerbMetadata(id: string): Promise<any | null>
|
||||
|
|
|
|||
|
|
@ -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<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)
|
||||
* 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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 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<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> {
|
||||
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) {
|
||||
if (cachedVersion === null) cachedVersion = readVersionSync()
|
||||
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
|
||||
* 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<string> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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') } }))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue