The read gate stops consulting the unnamed isReady() boolean: every provider
may expose healthReport() (sync, O(1), composed from exact ledgers —
HealthReport with a monotonic generation, per-invariant source
ledger|deep|unledgered, missing {count, sample}), and one readiness
authority (assessProviderHealth) derives the verdict. Unledgered families
are UNKNOWN — never healthy, never broken; a report that throws is a loud
not-ready, never a shrug. Reads at the four index choke points refuse with
the typed NotReady errors, narrated once per (provider, generation) — a
read NEVER starts a store walk:
- the first-read lazy build retires (open builds instead, regardless of
size — the ≥10k deferral and the "lazy loading on first query" branch go;
disableAutoRebuild is re-meant honestly in its docs);
- the verify*Live read-path rebuild triggers retire (refuse-or-serve);
- the read-time consistency probe that could launch a dark rebuild from an
ordinary find() retires;
- repairIndex({ rebuild: ['metadata'|'graph'|'vector'] | 'all' }) is the
one explicit door: rebuilds the named leg unconditionally and reports
rebuilt per family; bare repairIndex() stays report-driven.
test(lifecycle): the biography lane — a store's whole life, refereed
tests/lifecycle/: an independent shadow model referees every read after
every chapter (founding, a working day, clean restart, crash, repair,
second life). Chapters 1-3 green. Chapters 4-6 assert the true contract and
are marked .fails as a release-blocking finding (the kill-matrix
convention): after a crash + adopt reopen the metadata index computes its
'catchup' watermark verdict and nothing consumes it — find() serves the
pre-crash index while canonical and counts recover. The catchup wiring is
the cure; a passing .fails will force the marker's removal. The lane runs
in the integration gate (config + coverage guard).
155 lines
6.8 KiB
TypeScript
155 lines
6.8 KiB
TypeScript
/**
|
|
* @module indexReadiness
|
|
* @description The single honest-readiness classifier shared by the vector,
|
|
* graph and metadata index sites. It exists to kill "Pattern A" — the dishonest
|
|
* readiness proxy where `size() > 0` / `isInitialized` is treated as "this index
|
|
* actually serves queries." A cold native index that loaded its COUNT but not its
|
|
* SERVING structure passes those proxies and silently returns `[]`.
|
|
*
|
|
* This classifier reads ONLY the provider's OPTIONAL, honest `isReady()` signal
|
|
* (see {@link import('../plugin.js').VectorIndexProvider.isReady},
|
|
* {@link import('../plugin.js').GraphIndexProvider.isReady},
|
|
* {@link import('../plugin.js').MetadataIndexProvider.isReady}). It NEVER inspects
|
|
* `size()` or `isInitialized`. When `isReady()` is absent, callers must fall back
|
|
* to a KNOWN-ITEM PROBE (a real search/lookup that must return a known-present
|
|
* datum) before trusting an empty result — never a `size()` proxy.
|
|
*
|
|
* {@link assessProviderHealth} is the NEWER, PREFERRED authority: it reads a
|
|
* provider's NAMED, synchronous, O(1) {@link import('../plugin.js').HealthReport}
|
|
* when one is exposed, and falls back to this file's `isReady()` classifier only
|
|
* when the provider does not (yet) expose a health report. Read paths in
|
|
* `brainy.ts` call `assessProviderHealth` exclusively — `assessIndexReadiness`
|
|
* stays exported for the other call sites (`storage/baseStorage.ts`) and for the
|
|
* fallback branch inside `assessProviderHealth` itself.
|
|
*/
|
|
|
|
import type { HealthReport } from '../plugin.js'
|
|
|
|
/** A provider that MAY expose the honest cold-load readiness signal. */
|
|
export interface MaybeReadyProvider {
|
|
isReady?: () => boolean
|
|
}
|
|
|
|
/** A provider that MAY expose the named, synchronous, O(1) health report. */
|
|
export interface MaybeHealthReportingProvider {
|
|
healthReport?: () => HealthReport
|
|
}
|
|
|
|
/** Three-valued honest-readiness verdict. */
|
|
export type IndexReadiness = 'ready' | 'not-ready' | 'unknown'
|
|
|
|
/**
|
|
* @description Classify an index provider's honest readiness.
|
|
* @param provider - Any index provider (vector / graph / metadata) or `null`.
|
|
* @returns
|
|
* - `'ready'` when `isReady() === true` (serving structure loaded — trust it);
|
|
* - `'not-ready'` when `isReady() === false` (count/manifest loaded, NOT serving — rebuild);
|
|
* - `'unknown'` when the provider exposes no `isReady()` (caller must probe / keep the JS heuristic).
|
|
*/
|
|
export function assessIndexReadiness(provider: unknown): IndexReadiness {
|
|
const p = provider as MaybeReadyProvider | null | undefined
|
|
if (p == null || typeof p.isReady !== 'function') return 'unknown'
|
|
return p.isReady() ? 'ready' : 'not-ready'
|
|
}
|
|
|
|
/**
|
|
* @description Which signal {@link assessProviderHealth} actually consulted to
|
|
* produce its verdict — surfaced so callers can narrate (and tests can pin) how
|
|
* a provider was judged, not just what the judgment was.
|
|
* - `'health-report'` — the provider's `healthReport()` was called (the authority).
|
|
* - `'is-ready'` — no `healthReport()`; fell back to the provider's `isReady()`.
|
|
* - `'size-heuristic'` — no `healthReport()` and no `isReady()`; caller must keep its own size-based heuristic.
|
|
* - `'none'` — there was no provider to assess (`null`/`undefined`).
|
|
*/
|
|
export type ProviderHealthVia = 'health-report' | 'is-ready' | 'size-heuristic' | 'none'
|
|
|
|
/** The result of {@link assessProviderHealth}. */
|
|
export interface ProviderHealthAssessment {
|
|
/** The honest readiness verdict — see {@link IndexReadiness}. */
|
|
readiness: IndexReadiness
|
|
/** The provider's raw {@link HealthReport}, when one was obtained; `null` otherwise. */
|
|
report: HealthReport | null
|
|
/** Which signal produced the verdict — see {@link ProviderHealthVia}. */
|
|
via: ProviderHealthVia
|
|
/** Human-readable reasons: named failing invariants (with `heal`), unledgered families, or the fallback-path explanation. Empty when the provider is healthy and ready. */
|
|
reasons: string[]
|
|
}
|
|
|
|
/**
|
|
* @description THE read-gate authority. Prefers a provider's NAMED,
|
|
* synchronous, O(1) {@link HealthReport} over the older `isReady()` / size
|
|
* heuristics; falls back to {@link assessIndexReadiness}'s semantics only when
|
|
* a provider does not (yet) expose `healthReport()`.
|
|
*
|
|
* Derivation:
|
|
* - `healthReport()` present → call it (wrapped in try/catch). A THROW is a
|
|
* CONTRACT VIOLATION, not "unknown": returns `readiness: 'not-ready'`,
|
|
* `via: 'health-report'`, and a reason naming the throw — never swallowed
|
|
* into `'unknown'`.
|
|
* - Otherwise → `readiness = report.serving ? 'ready' : 'not-ready'`; `reasons`
|
|
* names every invariant with `holds: false` (with its `heal`), plus an
|
|
* `unledgered: [...]` line when {@link HealthReport.unledgered} is non-empty.
|
|
* UNLEDGERED IS UNKNOWN: an unledgered family never flips a serving provider
|
|
* to not-ready, and never flips a not-serving provider to ready — `serving`
|
|
* is always the provider's own verdict, verbatim.
|
|
* - No `healthReport()` → fall back to {@link assessIndexReadiness}'s semantics:
|
|
* `via: 'is-ready'` when `isReady()` exists, `via: 'size-heuristic'` when
|
|
* neither hook exists (caller must keep its own size-based heuristic),
|
|
* `via: 'none'` when there is no provider at all.
|
|
* @param provider - Any index provider (vector / graph / metadata) or `null`/`undefined`.
|
|
*/
|
|
export function assessProviderHealth(provider: unknown): ProviderHealthAssessment {
|
|
const p = provider as (MaybeHealthReportingProvider & MaybeReadyProvider) | null | undefined
|
|
|
|
if (p == null) {
|
|
return { readiness: 'unknown', report: null, via: 'none', reasons: ['no provider to assess'] }
|
|
}
|
|
|
|
if (typeof p.healthReport === 'function') {
|
|
let report: HealthReport
|
|
try {
|
|
report = p.healthReport()
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
return {
|
|
readiness: 'not-ready',
|
|
report: null,
|
|
via: 'health-report',
|
|
reasons: [`healthReport() threw: ${message} — a health-report throw is a contract violation, never read as healthy`]
|
|
}
|
|
}
|
|
|
|
const reasons: string[] = []
|
|
for (const invariant of report.invariants) {
|
|
if (!invariant.holds) {
|
|
reasons.push(`${invariant.name} (heal:${invariant.heal}): ${invariant.detail}`)
|
|
}
|
|
}
|
|
if (report.unledgered.length > 0) {
|
|
reasons.push(`unledgered: ${report.unledgered.join(', ')}`)
|
|
}
|
|
|
|
return {
|
|
readiness: report.serving ? 'ready' : 'not-ready',
|
|
report,
|
|
via: 'health-report',
|
|
reasons
|
|
}
|
|
}
|
|
|
|
const readiness = assessIndexReadiness(p)
|
|
if (readiness === 'unknown') {
|
|
return {
|
|
readiness,
|
|
report: null,
|
|
via: 'size-heuristic',
|
|
reasons: ['provider exposes neither healthReport() nor isReady() — falling back to the size heuristic']
|
|
}
|
|
}
|
|
return {
|
|
readiness,
|
|
report: null,
|
|
via: 'is-ready',
|
|
reasons: readiness === 'not-ready' ? ['isReady() returned false'] : []
|
|
}
|
|
}
|