feat: multi-process safety + read-only inspector mode

Filesystem storage now enforces single-writer, many-reader semantics.
A second writer on the same data directory throws at init time with the
holder's PID, hostname, and heartbeat — replacing the previous silent
stale-reads failure mode.

- New: `Brainy.openReadOnly()` — coexists with a live writer, every
  mutation throws clearly.
- New: writer lock at `<rootDir>/locks/_writer.lock` with 10s heartbeat
  and stale-detection (PID liveness + heartbeat freshness).
- New: cross-process flush-request RPC (filesystem-based, no signals)
  so inspectors can force fresh state on demand.
- New: `brain.stats()`, `brain.explain(findParams)`, `brain.health()`
  for operator-facing introspection.
- New: `brainy inspect` CLI with 13 subcommands (stats, find, get,
  relations, explain, health, sample, fields, dump, watch, backup,
  repair, diff), all read-only by default.
- Same-PID re-opens allowed with a warning (preserves test "simulate
  restart" patterns).
- Storage instances passed directly via `storage: new MemoryStorage()`
  are now honoured instead of silently falling through to the
  filesystem auto-detect path.

Brainy + Cortex compose under this model — the lock covers both because
they share `rootDir`, Cortex segments are immutable mmap files, and
MANIFEST updates use atomic-rename.
This commit is contained in:
David Snelling 2026-05-15 11:25:05 -07:00
parent 1bc6a430c7
commit 4fcdc0fef3
12 changed files with 2342 additions and 7 deletions

View file

@ -1549,6 +1549,42 @@ export class MetadataIndexManager {
* @param value - Exact value to match
* @returns Array of matching entity UUID strings
*/
/**
* Report which index path a `where` clause on `field` will hit. Used by
* `brain.explain()` so an operator can see *before* running a query whether
* the field has any index entries at all. A `find({ where: { someField: ... } })`
* against a field with no index entries returns `[]` silently `explainField`
* surfaces that as `path: 'none'` so the empty result has an explanation.
*/
async explainField(field: string): Promise<{
path: 'column-store' | 'sparse-chunked' | 'none'
notes?: string
}> {
if (this.columnStore && this.columnStore.hasField(field)) {
return {
path: 'column-store',
notes: 'O(log n) binary search + roaring bitmap. Best path.'
}
}
const sparse = await this.loadSparseIndex(field)
if (sparse) {
return {
path: 'sparse-chunked',
notes: 'Chunked sparse index with zone maps and bloom filters.'
}
}
return {
path: 'none',
notes:
`No index entries for field "${field}". A find({ where: { ${field}: ... } }) ` +
`will return an empty result regardless of whether matching entities exist on disk. ` +
`Likely causes: (1) the writer registered the field in memory but has not flushed; ` +
`(2) the field name does not match what was written (typo or casing); ` +
`(3) the field is genuinely absent from all entities. Call requestFlush() on the ` +
`writer or call brain.flush() before relying on the result.`
}
}
async getIds(field: string, value: any): Promise<string[]> {
// Track exact query for field statistics
if (this.fieldStats.has(field)) {