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
|
* @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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}`
|
||||||
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)
|
this.manifest.sstables.delete(sstable.metadata.id)
|
||||||
|
try {
|
||||||
|
await this.storage.deleteMetadata(oldKey)
|
||||||
} 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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'
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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') } }))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue