fix: find()/stats() correctness + Cortex compat (BR-FIND-WHERE-ZERO, BR-DEFENSIVE-INTERFACE)

Two production correctness defects fixed together so consumers can land
one upgrade.

BR-FIND-WHERE-ZERO — find() returned [] and stats() reported 0 entities
for any workspace whose data was written after the 7.20.0 column-store
refactor. Root cause: getStats() and the getIds() fallback still read
from the deleted sparse-index path. Separately, BaseStorage.getNounType()
was hardcoded to return 'thing', poisoning type-statistics.json with
every noun attributed to that bucket.

  - MetadataIndex.getStats() reads from ColumnStore + idMapper.
  - MetadataIndex.getIdsFromChunks() throws BrainyError(FIELD_NOT_INDEXED)
    when neither store has the field. getIdsForFilter() catches per
    clause, logs once, returns [].
  - BaseStorage.nounTypeByIdCache populated in saveNounMetadata_internal
    and consumed in saveNoun_internal. flushCounts() now persists the
    Uint32Array counters too, so readers see fresh per-type counts.
  - Self-heal at init: loadTypeStatistics() auto-rebuilds when the
    poisoned signature is detected. rebuildTypeCounts() is now public for
    use from `brainy inspect repair`.
  - Dead state removed: dirtyChunks, dirtySparseIndices,
    flushDirtyMetadata().

BR-DEFENSIVE-INTERFACE — 7.21.0 called supportsMultiProcessLocking()
unconditionally, crashing on older Cortex storage adapters that predate
the method. New hasStorageMethod(name) helper gates every new-method
call site. Older adapter triggers a one-line warning at init pointing
at the recommended plugin version.

Tests:
  - new tests/integration/find-where-zero.test.ts (7 cases)
  - new tests/integration/cortex-compat.test.ts (5 cases)
  - multi-process-safety.test.ts updated to enforce correct counts
  - 1329/1329 unit + 24/24 new integration tests passing
This commit is contained in:
David Snelling 2026-05-15 12:31:28 -07:00
parent 79f58cb87b
commit 70263113ad
9 changed files with 895 additions and 144 deletions

View file

@ -302,6 +302,20 @@ export class Brainy<T = any> implements BrainyInterface<T> {
return !this.operationalMode.canWrite
}
/**
* Whether the active storage adapter implements a given optional method.
*
* Brainy ships new storage-adapter capabilities over time (multi-process
* locking, cross-process flush RPC, etc.). Older plugins (notably
* `@soulcraft/cortex` 2.2.0) bundle their own `BaseStorage` from an
* earlier Brainy version that doesn't have those methods. Rather than crash
* at runtime, every call site that uses an optional storage method goes
* through this helper and degrades gracefully when the method is absent.
*/
private hasStorageMethod(name: string): boolean {
return !!this.storage && typeof (this.storage as unknown as Record<string, unknown>)[name] === 'function'
}
/**
* Throw if this instance cannot perform writes. Called at the top of every
* mutation method. The check is cheap (a property lookup + boolean test) and
@ -385,24 +399,41 @@ export class Brainy<T = any> implements BrainyInterface<T> {
// Acquire the writer lock for filesystem (and other locking-capable) backends.
// Skipped in reader mode and on backends that don't support multi-process locking.
// Throws if another live writer holds the directory (unless force: true).
if (this.config.mode !== 'reader' && this.storage.supportsMultiProcessLocking()) {
await this.storage.acquireWriterLock({ force: this.config.force })
// Watch for cross-process flush requests so inspectors can see fresh
// state on demand. Reader instances don't run a watcher (nothing to flush).
this.storage.startFlushRequestWatcher(async () => {
if (this.initialized) {
await this.flush()
//
// Defensive call: older storage adapters (e.g. `@soulcraft/cortex@2.2.0`
// and earlier) bundle a pre-7.21 `BaseStorage` that doesn't define these
// methods. We feature-detect each one rather than fail boot.
if (this.config.mode !== 'reader') {
const canLock = this.hasStorageMethod('supportsMultiProcessLocking') &&
(this.storage as any).supportsMultiProcessLocking()
if (canLock && this.hasStorageMethod('acquireWriterLock')) {
await (this.storage as any).acquireWriterLock({ force: this.config.force })
if (this.hasStorageMethod('startFlushRequestWatcher')) {
(this.storage as any).startFlushRequestWatcher(async () => {
if (this.initialized) {
await this.flush()
}
})
}
} else if (!this.config.silent) {
// Older adapter OR a backend that doesn't enforce locking (cloud / memory).
// Surface this so operators know the multi-process protections aren't active.
const backendName = (this.storage.constructor as any).name || 'storage'
if (backendName === 'MemoryStorage') {
// Memory is single-process by construction — no warning needed.
} else if (!this.hasStorageMethod('supportsMultiProcessLocking')) {
console.warn(
`[brainy] Storage adapter \`${backendName}\` predates the 7.21 ` +
`multi-process methods. Writer locking and the flush-request RPC ` +
`are disabled for this directory. Upgrade the plugin (e.g. ` +
`\`@soulcraft/cortex\` ≥2.3.0) for full enforcement.`
)
} else {
console.warn(
`[brainy] Multi-process writer protection is not enforced on ${backendName}. ` +
`See docs/concepts/multi-process.md for the model.`
)
}
})
} else if (this.config.mode !== 'reader' && !this.config.silent) {
// Cloud / memory backends: multi-process safety not enforced. One-time
// warning so operators know what they're getting.
const backendName = (this.storage.constructor as any).name || 'storage'
if (backendName !== 'MemoryStorage') {
console.warn(
`[brainy] Multi-process writer protection is not enforced on ${backendName}. ` +
`See docs/concepts/multi-process.md for the model.`
)
}
}