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).
389 lines
18 KiB
TypeScript
389 lines
18 KiB
TypeScript
/**
|
|
* @module tests/lifecycle/biographyHarness
|
|
* @description The referee for the LIFECYCLE LANE (see `biography.test.ts`):
|
|
* a plain in-memory SHADOW MODEL of a brain's contents, updated by every act
|
|
* the biography performs (add/update/remove/relate/updateRelation/vfs writes),
|
|
* plus `verifyChapter()`, which asserts the live brain agrees with the model
|
|
* after every chapter. No engine code runs inside the model — it is an
|
|
* independent ledger, not a mirror of the implementation under test.
|
|
*
|
|
* COUNT SEMANTICS this harness encodes (verified against the live engine,
|
|
* not assumed — see the module-level comments below for how each was
|
|
* confirmed):
|
|
*
|
|
* - `getNounCount()` / `getVerbCount()` count PUBLIC-tier alive records only
|
|
* (visibility absent or `'public'`) — `'internal'` and `'system'` are both
|
|
* excluded. `storage.getCanonicalCounts()` mirrors that same PUBLIC-only
|
|
* scalar as `counted`, and additionally reports `all` — every tier,
|
|
* unfiltered — as the coverage-ledger denominator (see
|
|
* tests/integration/canonical-count-ledger.test.ts).
|
|
* - `brain.vfs.writeFile()` for a brand-new file at a path directly under the
|
|
* VFS root creates exactly ONE new File noun plus ONE new `Contains` verb
|
|
* (root -> file), and BOTH are ordinary PUBLIC records (no visibility
|
|
* field is set) — so they count toward `getNounCount()`/`getVerbCount()`
|
|
* as well as the canonical `all` scalars. Only the VFS ROOT entity itself
|
|
* is `'system'`-tier (created once, at `init()`, before any biography
|
|
* chapter runs) — that lone record is the only hidden-tier footprint the
|
|
* model does not construct explicitly, so it is captured empirically via
|
|
* `snapshotVfsBaseline()` immediately after `init()` rather than hardcoded.
|
|
* - `related()` filters edges by the RELATION's own visibility tier, not by
|
|
* the visibility of the entities the edge connects — flipping an entity to
|
|
* `'internal'` does not hide its edges from `related()`. This lane never
|
|
* sets relation visibility, so every relation the model tracks is exactly
|
|
* as reachable as its presence in `model.relations` implies.
|
|
* - `remove()` cascades: every relation touching the removed entity (as
|
|
* `from` or `to`) is hard-deleted along with it. The model mirrors this by
|
|
* deleting the relation entirely from `model.relations` (no relation
|
|
* "alive" flag — presence in the map IS aliveness).
|
|
*/
|
|
import { expect } from 'vitest'
|
|
import type { Brainy } from '../../src/brainy.js'
|
|
import type { NounType, VerbType } from '../../src/types/graphTypes.js'
|
|
import type { EntityVisibility, StorageAdapter } from '../../src/coreTypes.js'
|
|
|
|
/**
|
|
* One entity's complete lifecycle-relevant state, as the biography's acts
|
|
* leave it. `alive: false` means the model believes the id has been removed
|
|
* — the entry is KEPT (never deleted from the map) so `verifyChapter` can
|
|
* assert the negative half of the contract: a dead id must read as `null`.
|
|
*/
|
|
export interface ShadowEntity {
|
|
type: NounType
|
|
subtype?: string
|
|
metadata: Record<string, unknown>
|
|
visibility?: EntityVisibility
|
|
alive: boolean
|
|
}
|
|
|
|
/**
|
|
* One relation's complete lifecycle-relevant state. There is no `alive`
|
|
* flag here — presence in {@link ShadowModel.relations} IS aliveness,
|
|
* mirroring the engine's hard delete of the canonical verb record on
|
|
* cascade (see the module header).
|
|
*/
|
|
export interface ShadowRelation {
|
|
from: string
|
|
to: string
|
|
type: VerbType
|
|
subtype?: string
|
|
metadata: Record<string, unknown>
|
|
}
|
|
|
|
/**
|
|
* The independent truth ledger the biography updates on every act it
|
|
* performs. `verifyChapter` checks the live brain against this — never the
|
|
* other way around.
|
|
*/
|
|
export interface ShadowModel {
|
|
entities: Map<string, ShadowEntity>
|
|
relations: Map<string, ShadowRelation>
|
|
/**
|
|
* `getCanonicalCounts()` nouns.all / verbs.all captured right after
|
|
* `init()`, before chapter 1 — the VFS root's own system-tier footprint.
|
|
* Set once via {@link snapshotVfsBaseline}; never hardcoded.
|
|
*/
|
|
vfsBaselineNouns: number
|
|
vfsBaselineVerbs: number
|
|
/**
|
|
* Public nouns/verbs created by `vfs.writeFile()` for a brand-new file at
|
|
* a flat top-level path: exactly one File noun + one Contains verb per
|
|
* call (see the module header). Bumped by {@link recordVfsFileWrite}.
|
|
*/
|
|
vfsFileNouns: number
|
|
vfsContainsVerbs: number
|
|
}
|
|
|
|
/** A fresh, empty shadow model — call once before chapter 1. */
|
|
export function createModel(): ShadowModel {
|
|
return {
|
|
entities: new Map(),
|
|
relations: new Map(),
|
|
vfsBaselineNouns: 0,
|
|
vfsBaselineVerbs: 0,
|
|
vfsFileNouns: 0,
|
|
vfsContainsVerbs: 0
|
|
}
|
|
}
|
|
|
|
/** Narrow, documented private-storage access (the same style already used by
|
|
* `tests/helpers/durabilityKillMatrix.ts`'s `storeOf()`), needed because
|
|
* `getCanonicalCounts()` lives on the storage adapter, not on `Brainy`. */
|
|
function storageOf(brain: Brainy): StorageAdapter {
|
|
return (brain as unknown as { storage: StorageAdapter }).storage
|
|
}
|
|
|
|
/** Public wrapper around the private-storage `getCanonicalCounts()` read, so
|
|
* callers never need their own private-access cast — used internally by
|
|
* {@link snapshotVfsBaseline} and {@link verifyChapter}, and by
|
|
* `biography.test.ts` for its final standalone exactness check. */
|
|
export async function getCanonicalCountsFor(brain: Brainy): ReturnType<NonNullable<StorageAdapter['getCanonicalCounts']>> {
|
|
const storage = storageOf(brain)
|
|
if (!storage.getCanonicalCounts) {
|
|
throw new Error(
|
|
'lifecycle lane: the storage adapter under test has no getCanonicalCounts() — the canonical-count-exactness leg of this lane is unrepresentable without it.'
|
|
)
|
|
}
|
|
return storage.getCanonicalCounts()
|
|
}
|
|
|
|
/**
|
|
* Snapshot the VFS root's own hidden-tier footprint. Call exactly once,
|
|
* immediately after `init()` and before chapter 1 does anything — this is
|
|
* the ONE baseline offset the model does not construct by hand (see the
|
|
* module header for why: the root is `'system'`-tier plumbing the biography
|
|
* never explicitly creates).
|
|
*/
|
|
export async function snapshotVfsBaseline(brain: Brainy, model: ShadowModel): Promise<void> {
|
|
const counts = await getCanonicalCountsFor(brain)
|
|
model.vfsBaselineNouns = counts.nouns.all
|
|
model.vfsBaselineVerbs = counts.verbs.all
|
|
}
|
|
|
|
/**
|
|
* Record one `brain.vfs.writeFile()` call for a brand-new file at a flat
|
|
* top-level path (no intermediate directories). Bumps both the noun and verb
|
|
* VFS counters by one, matching the engine's actual write path exactly (see
|
|
* the module header) — never call this for an overwrite of an existing path,
|
|
* a nested path (which would also vivify intermediate directory nouns/edges,
|
|
* a different, unmodeled shape), or the biography loses its exactness.
|
|
*/
|
|
export function recordVfsFileWrite(model: ShadowModel): void {
|
|
model.vfsFileNouns += 1
|
|
model.vfsContainsVerbs += 1
|
|
}
|
|
|
|
/** Record a fresh `add()` (or a Ch6 resurrection — `Map.set` fully replaces
|
|
* whatever a prior dead entry held, which is exactly the "new metadata only"
|
|
* contract a resurrection must honor). */
|
|
export function modelAdd(
|
|
model: ShadowModel,
|
|
id: string,
|
|
entity: { type: NounType; subtype?: string; metadata: Record<string, unknown>; visibility?: EntityVisibility }
|
|
): void {
|
|
model.entities.set(id, {
|
|
type: entity.type,
|
|
subtype: entity.subtype,
|
|
metadata: { ...entity.metadata },
|
|
visibility: entity.visibility,
|
|
alive: true
|
|
})
|
|
}
|
|
|
|
/** Record an `update()` — merges metadata by default, matching the engine's
|
|
* `merge: true` default; pass `merge: false` to mirror a full replace. */
|
|
export function modelUpdate(
|
|
model: ShadowModel,
|
|
id: string,
|
|
patch: { metadata?: Record<string, unknown>; merge?: boolean; visibility?: EntityVisibility }
|
|
): void {
|
|
const existing = model.entities.get(id)
|
|
if (!existing || !existing.alive) {
|
|
throw new Error(`shadow model: update() targeted ${id}, which the model does not have alive — biography sequencing bug`)
|
|
}
|
|
if (patch.metadata) {
|
|
existing.metadata = patch.merge === false ? { ...patch.metadata } : { ...existing.metadata, ...patch.metadata }
|
|
}
|
|
if (patch.visibility !== undefined) {
|
|
existing.visibility = patch.visibility
|
|
}
|
|
}
|
|
|
|
/** Record a `remove()` — marks the entity dead (entry retained, per
|
|
* {@link ShadowEntity}) and cascades: every relation touching it, in either
|
|
* direction, is hard-deleted from the model too (matching the engine). */
|
|
export function modelDelete(model: ShadowModel, id: string): void {
|
|
const existing = model.entities.get(id)
|
|
if (!existing || !existing.alive) {
|
|
throw new Error(`shadow model: remove() targeted ${id}, which the model does not have alive — biography sequencing bug`)
|
|
}
|
|
existing.alive = false
|
|
for (const [relId, rel] of model.relations) {
|
|
if (rel.from === id || rel.to === id) model.relations.delete(relId)
|
|
}
|
|
}
|
|
|
|
/** Record a `relate()` — `id` is the relation id the real call returned. */
|
|
export function modelRelate(
|
|
model: ShadowModel,
|
|
id: string,
|
|
relation: { from: string; to: string; type: VerbType; subtype?: string; metadata?: Record<string, unknown> }
|
|
): void {
|
|
model.relations.set(id, {
|
|
from: relation.from,
|
|
to: relation.to,
|
|
type: relation.type,
|
|
subtype: relation.subtype,
|
|
metadata: { ...(relation.metadata ?? {}) }
|
|
})
|
|
}
|
|
|
|
/** Record an `updateRelation()` metadata patch — merges by default. */
|
|
export function modelUpdateRelation(
|
|
model: ShadowModel,
|
|
id: string,
|
|
patch: { metadata?: Record<string, unknown>; merge?: boolean }
|
|
): void {
|
|
const existing = model.relations.get(id)
|
|
if (!existing) {
|
|
throw new Error(`shadow model: updateRelation() targeted ${id}, which the model does not have — biography sequencing bug`)
|
|
}
|
|
if (patch.metadata) {
|
|
existing.metadata = patch.merge === false ? { ...patch.metadata } : { ...existing.metadata, ...patch.metadata }
|
|
}
|
|
}
|
|
|
|
/** Order-independent structural equality for plain JSON-shaped metadata. */
|
|
function deepEqual(a: unknown, b: unknown): boolean {
|
|
if (a === b) return true
|
|
if (typeof a !== typeof b) return false
|
|
if (a === null || b === null) return a === b
|
|
if (typeof a !== 'object') return false
|
|
const aKeys = Object.keys(a as Record<string, unknown>)
|
|
const bKeys = Object.keys(b as Record<string, unknown>)
|
|
if (aKeys.length !== bKeys.length) return false
|
|
for (const k of aKeys) {
|
|
if (!deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k])) return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
/** One hub entity to sample for the `related()` adjacency check, plus the
|
|
* verb type(s) it is known (by biography construction) to have OUT-edges
|
|
* of, so the type-filtered variant is exercised too. */
|
|
export interface HubCheck {
|
|
id: string
|
|
typeFilters: VerbType[]
|
|
}
|
|
|
|
/** Options steering one `verifyChapter()` call. */
|
|
export interface VerifyOptions {
|
|
/** Hub entities to sample for the `related()` adjacency check. */
|
|
hubs: HubCheck[]
|
|
/** The metadata field `find()` bucket-checks against (a bare string field
|
|
* every alive entity may or may not carry — distinct values present among
|
|
* ALIVE model entities are discovered automatically each call, so a
|
|
* chapter that moves entities across buckets is re-checked exactly). */
|
|
bucketField: string
|
|
}
|
|
|
|
/**
|
|
* Assert the live brain agrees with the model, in full, after one chapter.
|
|
* Every failure message names the chapter `label`, the id (where
|
|
* applicable), and expected-vs-actual — a red here must be diagnosable from
|
|
* the assertion message alone, with no need to re-read this file.
|
|
*/
|
|
export async function verifyChapter(brain: Brainy, model: ShadowModel, label: string, opts: VerifyOptions): Promise<void> {
|
|
// (a) + (b): every alive entity reads back exactly as modeled; every dead
|
|
// entity reads as null.
|
|
for (const [id, entity] of model.entities) {
|
|
const live = await brain.get(id)
|
|
if (entity.alive) {
|
|
expect(live, `[${label}] alive entity ${id} (type=${entity.type}) must be readable via get(), got null`).not.toBeNull()
|
|
const e = live!
|
|
expect(e.type, `[${label}] entity ${id} .type mismatch: expected ${entity.type}, got ${e.type}`).toBe(entity.type)
|
|
expect(e.subtype, `[${label}] entity ${id} .subtype mismatch: expected ${JSON.stringify(entity.subtype)}, got ${JSON.stringify(e.subtype)}`).toBe(entity.subtype)
|
|
expect(
|
|
e.visibility,
|
|
`[${label}] entity ${id} .visibility mismatch: expected ${JSON.stringify(entity.visibility)}, got ${JSON.stringify(e.visibility)}`
|
|
).toBe(entity.visibility)
|
|
const metaMatches = deepEqual(e.metadata ?? {}, entity.metadata)
|
|
expect(
|
|
metaMatches,
|
|
`[${label}] entity ${id} .metadata mismatch: expected ${JSON.stringify(entity.metadata)}, got ${JSON.stringify(e.metadata)}`
|
|
).toBe(true)
|
|
} else {
|
|
expect(live, `[${label}] dead entity ${id} (type=${entity.type}) must read as null, got ${JSON.stringify(live)}`).toBeNull()
|
|
}
|
|
}
|
|
|
|
// (c) find({ where: { <bucketField>: value } }) returns exactly the
|
|
// model's matching alive set, per distinct value currently present.
|
|
const bucketValues = new Set<string>()
|
|
for (const entity of model.entities.values()) {
|
|
if (!entity.alive) continue
|
|
const v = entity.metadata[opts.bucketField]
|
|
if (typeof v === 'string') bucketValues.add(v)
|
|
}
|
|
for (const value of bucketValues) {
|
|
const expectedIds = [...model.entities.entries()]
|
|
.filter(([, e]) => e.alive && e.metadata[opts.bucketField] === value)
|
|
.map(([id]) => id)
|
|
.sort()
|
|
const results = await brain.find({
|
|
where: { [opts.bucketField]: value } as Record<string, unknown>,
|
|
includeInternal: true,
|
|
limit: 100000
|
|
})
|
|
const actualIds = results.map((r) => r.id).sort()
|
|
expect(
|
|
actualIds,
|
|
`[${label}] find({ where: { ${opts.bucketField}: ${JSON.stringify(value)} } }) mismatch: expected ${expectedIds.length} ids ${JSON.stringify(expectedIds)}, got ${actualIds.length} ids ${JSON.stringify(actualIds)}`
|
|
).toEqual(expectedIds)
|
|
}
|
|
|
|
// (d) related(id) / related(id, { type }) for the hub sample matches the
|
|
// model's adjacency exactly (out-edges — related(id) is shorthand for
|
|
// { from: id }).
|
|
for (const hub of opts.hubs) {
|
|
const expectedAll = [...model.relations.entries()]
|
|
.filter(([, r]) => r.from === hub.id)
|
|
.map(([id]) => id)
|
|
.sort()
|
|
const liveAll = await brain.related({ from: hub.id, limit: 100000 })
|
|
const actualAllIds = liveAll.map((r) => r.id).sort()
|
|
expect(
|
|
actualAllIds,
|
|
`[${label}] related(${hub.id}) mismatch: expected ${expectedAll.length} ids ${JSON.stringify(expectedAll)}, got ${actualAllIds.length} ids ${JSON.stringify(actualAllIds)}`
|
|
).toEqual(expectedAll)
|
|
|
|
for (const typeFilter of hub.typeFilters) {
|
|
const expectedTyped = [...model.relations.entries()]
|
|
.filter(([, r]) => r.from === hub.id && r.type === typeFilter)
|
|
.map(([id]) => id)
|
|
.sort()
|
|
const liveTyped = await brain.related({ from: hub.id, type: typeFilter, limit: 100000 })
|
|
const actualTypedIds = liveTyped.map((r) => r.id).sort()
|
|
expect(
|
|
actualTypedIds,
|
|
`[${label}] related(${hub.id}, { type: '${typeFilter}' }) mismatch: expected ${expectedTyped.length} ids ${JSON.stringify(expectedTyped)}, got ${actualTypedIds.length} ids ${JSON.stringify(actualTypedIds)}`
|
|
).toEqual(expectedTyped)
|
|
}
|
|
}
|
|
|
|
// (e) getNounCount() / getVerbCount(): PUBLIC-tier alive records
|
|
// (visibility absent/'public'; 'internal' and 'system' both excluded — see
|
|
// the module header) plus the VFS's own public contributions.
|
|
const alivePublicNouns = [...model.entities.values()].filter((e) => e.alive && (e.visibility ?? 'public') === 'public').length
|
|
const aliveVerbs = model.relations.size
|
|
const expectedNounCount = alivePublicNouns + model.vfsFileNouns
|
|
const expectedVerbCount = aliveVerbs + model.vfsContainsVerbs
|
|
expect(
|
|
await brain.getNounCount(),
|
|
`[${label}] getNounCount() mismatch: expected ${expectedNounCount} (alive public entities ${alivePublicNouns} + vfs file nouns ${model.vfsFileNouns})`
|
|
).toBe(expectedNounCount)
|
|
expect(
|
|
await brain.getVerbCount(),
|
|
`[${label}] getVerbCount() mismatch: expected ${expectedVerbCount} (alive relations ${aliveVerbs} + vfs contains verbs ${model.vfsContainsVerbs})`
|
|
).toBe(expectedVerbCount)
|
|
|
|
// (f) getCanonicalCounts(): ALL-visibility scalars (every tier) equal the
|
|
// model's alive totals including hidden tiers, plus the VFS's own
|
|
// contributions (both file nouns/verbs AND the once-measured root
|
|
// baseline). suspect must be false — every delete in this biography goes
|
|
// through brain.remove(), which always proves the record it decrements.
|
|
const ledger = await getCanonicalCountsFor(brain)
|
|
const aliveAllNouns = [...model.entities.values()].filter((e) => e.alive).length
|
|
const expectedNounsAll = aliveAllNouns + model.vfsFileNouns + model.vfsBaselineNouns
|
|
const expectedVerbsAll = aliveVerbs + model.vfsContainsVerbs + model.vfsBaselineVerbs
|
|
expect(
|
|
ledger.nouns.all,
|
|
`[${label}] getCanonicalCounts().nouns.all mismatch: expected ${expectedNounsAll} (alive incl. internal ${aliveAllNouns} + vfs file nouns ${model.vfsFileNouns} + vfs root baseline ${model.vfsBaselineNouns})`
|
|
).toBe(expectedNounsAll)
|
|
expect(
|
|
ledger.verbs.all,
|
|
`[${label}] getCanonicalCounts().verbs.all mismatch: expected ${expectedVerbsAll} (alive relations ${aliveVerbs} + vfs contains verbs ${model.vfsContainsVerbs} + vfs root baseline ${model.vfsBaselineVerbs})`
|
|
).toBe(expectedVerbsAll)
|
|
expect(ledger.nouns.counted, `[${label}] getCanonicalCounts().nouns.counted mismatch (should mirror getNounCount())`).toBe(expectedNounCount)
|
|
expect(ledger.verbs.counted, `[${label}] getCanonicalCounts().verbs.counted mismatch (should mirror getVerbCount())`).toBe(expectedVerbCount)
|
|
expect(ledger.suspect, `[${label}] getCanonicalCounts().suspect must be false — every delete in this biography proves its record`).toBe(false)
|
|
}
|