feat(repair): repairIndex returns the per-family receipt and narrates its summary
repairIndex() now returns a RepairReport: one row per repair family (orphaned containers, count rollups, VFS containment, metadata corruption, write quarantine, each provider's invariant pass, degraded-read state) with checked / healed counts and an explicit skip reason for anything not run — no silent rows. A summary line narrates families checked and heals applied. This is the receipts half of the graph-trust program's ask: a repair that cannot show its work per store is a repair nobody can audit. Pinned: a healthy store yields a complete zero-heal receipt with every family accounted; a manufactured pre-8.3.1 ghost container appears in the receipt as a counted heal. Additive: void-callers are unaffected.
This commit is contained in:
parent
40e7119b85
commit
8d45f964e9
4 changed files with 168 additions and 7 deletions
|
|
@ -144,7 +144,9 @@ import {
|
|||
ScoreExplanation,
|
||||
FillSubtypeRule,
|
||||
FillSubtypeRules,
|
||||
FillSubtypesResult
|
||||
FillSubtypesResult,
|
||||
RepairReport,
|
||||
RepairFamilyReport
|
||||
} from './types/brainy.types.js'
|
||||
import { NounType, VerbType, TypeUtils } from './types/graphTypes.js'
|
||||
import {
|
||||
|
|
@ -17088,8 +17090,13 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
)
|
||||
}
|
||||
|
||||
async repairIndex(): Promise<void> {
|
||||
async repairIndex(): Promise<RepairReport> {
|
||||
await this.ensureInitialized()
|
||||
const startedAt = Date.now()
|
||||
const families: RepairFamilyReport[] = []
|
||||
const record = (family: string, entry: Omit<RepairFamilyReport, 'family'>): void => {
|
||||
families.push({ family, ...entry })
|
||||
}
|
||||
|
||||
// Prune orphaned canonical containers left by the pre-8.3.1 partial-delete
|
||||
// defect: a delete that removed the metadata (content) leg but left the
|
||||
|
|
@ -17105,13 +17112,23 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
}
|
||||
if (typeof pruner.pruneOrphanedEntities === 'function') {
|
||||
const orphans = await pruner.pruneOrphanedEntities()
|
||||
if (orphans.nouns.length + orphans.verbs.length > 0) {
|
||||
const pruned = orphans.nouns.length + orphans.verbs.length
|
||||
record('orphaned-containers', {
|
||||
checked: true,
|
||||
healed: pruned,
|
||||
...(pruned > 0
|
||||
? { detail: `${orphans.nouns.length} noun + ${orphans.verbs.length} verb container(s) pruned` }
|
||||
: {})
|
||||
})
|
||||
if (pruned > 0) {
|
||||
prodLog.warn(
|
||||
`[Brainy] repairIndex() pruned ${orphans.nouns.length} orphaned noun + ` +
|
||||
`${orphans.verbs.length} orphaned verb container(s) left by a pre-8.3.1 ` +
|
||||
`partial delete.`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
record('orphaned-containers', { checked: false, healed: 0, skipped: 'storage has no container model' })
|
||||
}
|
||||
// SANCTIONED RECOUNT — unconditional, not gated on orphans found: the
|
||||
// persisted counters can be inflated over perfectly clean shelves (deletes
|
||||
|
|
@ -17122,6 +17139,14 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
// canonical walk and persists them.
|
||||
await pruner.rebuildTypeCounts?.()
|
||||
await pruner.rebuildSubtypeCounts?.()
|
||||
record('count-rollups', {
|
||||
checked: typeof pruner.rebuildTypeCounts === 'function',
|
||||
healed: 0,
|
||||
detail: typeof pruner.rebuildTypeCounts === 'function'
|
||||
? 'recomputed from one canonical walk (unconditional)'
|
||||
: undefined,
|
||||
...(typeof pruner.rebuildTypeCounts !== 'function' ? { skipped: 'storage has no count rollups' } : {})
|
||||
})
|
||||
|
||||
// The recount changed the rollup truth — re-stamp the entity tree so the
|
||||
// stamp's invariants match the healed counters (repair leaves a coherent
|
||||
|
|
@ -17135,6 +17160,13 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
// containment edges are touched. Loud per repair.
|
||||
if (this._vfsInitialized && this._vfs) {
|
||||
const containment = await this._vfs.repairContainment()
|
||||
record('vfs-containment', {
|
||||
checked: true,
|
||||
healed: containment.removed + containment.restored,
|
||||
...(containment.removed + containment.restored > 0
|
||||
? { detail: `${containment.removed} stale edge(s) removed, ${containment.restored} restored` }
|
||||
: {})
|
||||
})
|
||||
if (containment.removed + containment.restored > 0) {
|
||||
prodLog.warn(
|
||||
`[Brainy] repairIndex() reconciled VFS containment: removed ${containment.removed} ` +
|
||||
|
|
@ -17143,13 +17175,19 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
}
|
||||
}
|
||||
|
||||
if (!this._vfsInitialized || !this._vfs) {
|
||||
record('vfs-containment', { checked: false, healed: 0, skipped: 'VFS not initialized' })
|
||||
}
|
||||
|
||||
await this.metadataIndex.detectAndRepairCorruption()
|
||||
record('metadata-corruption', { checked: true, healed: 0, detail: 'detect-and-repair pass ran (see its own narration for repairs)' })
|
||||
// Lift a failed-rollback write-quarantine: force a full rebuild so the
|
||||
// derived indexes are provably reconciled with canonical, then clear the
|
||||
// flag so writes resume.
|
||||
if (this.storeInconsistency) {
|
||||
await this.rebuildIndexesIfNeeded(true)
|
||||
const cleared = this.storeInconsistency
|
||||
record('write-quarantine', { checked: true, healed: 1, detail: `lifted (${cleared.records.length} record(s) reconciled)` })
|
||||
this.storeInconsistency = null
|
||||
prodLog.warn(
|
||||
`[Brainy] repairIndex() reconciled the store and LIFTED the write-quarantine ` +
|
||||
|
|
@ -17166,20 +17204,38 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
validateInvariants?: () => Promise<ProviderInvariantReport>
|
||||
rebuild?: () => Promise<void>
|
||||
} | null
|
||||
if (!p || typeof p.validateInvariants !== 'function' || typeof p.rebuild !== 'function') continue
|
||||
if (!p || typeof p.validateInvariants !== 'function' || typeof p.rebuild !== 'function') {
|
||||
record(`provider:${(provider as { constructor?: { name?: string } })?.constructor?.name ?? 'unknown'}`, {
|
||||
checked: false, healed: 0, skipped: 'no validateInvariants/rebuild contract'
|
||||
})
|
||||
continue
|
||||
}
|
||||
let report: ProviderInvariantReport
|
||||
try {
|
||||
report = await p.validateInvariants()
|
||||
} catch {
|
||||
} catch (err) {
|
||||
record(`provider:unknown`, { checked: false, healed: 0, skipped: `validateInvariants threw: ${(err as Error).message}` })
|
||||
continue // a throwing validateInvariants is surfaced by validateIndexConsistency; skip repair here
|
||||
}
|
||||
if (report.healthy) continue
|
||||
if (report.healthy) {
|
||||
record(`provider:${report.provider}`, { checked: true, healed: 0 })
|
||||
continue
|
||||
}
|
||||
if (report.invariants.some((i) => !i.holds && i.heal === 'rebuild')) {
|
||||
record(`provider:${report.provider}`, {
|
||||
checked: true, healed: 1,
|
||||
detail: `rebuilt from canonical (failing: ${report.invariants.filter((i) => !i.holds).map((i) => i.name).join(', ')})`
|
||||
})
|
||||
prodLog.warn(
|
||||
`[Brainy] repairIndex(): provider '${report.provider}' has a failing invariant ` +
|
||||
`requiring a rebuild — reconciling its derived state from canonical.`
|
||||
)
|
||||
await p.rebuild()
|
||||
} else {
|
||||
record(`provider:${report.provider}`, {
|
||||
checked: true, healed: 0,
|
||||
detail: `unhealthy without a rebuild verdict (failing: ${report.invariants.filter((i) => !i.holds).map((i) => `${i.name}→${i.heal}`).join(', ')})`
|
||||
})
|
||||
}
|
||||
}
|
||||
// detectAndRepairCorruption() above rebuilt the derived indexes from
|
||||
|
|
@ -17190,7 +17246,18 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
this._indexDegradedIds.clear()
|
||||
this._indexRebuildFailed = null
|
||||
this._degradedReadWarned = false
|
||||
record('degraded-read-state', { checked: true, healed: 1, detail: 'degraded ids cleared, read-path warning re-armed' })
|
||||
}
|
||||
|
||||
const healedTotal = families.reduce((n, f) => n + f.healed, 0)
|
||||
const report: RepairReport = { families, healedTotal, durationMs: Date.now() - startedAt }
|
||||
prodLog.warn(
|
||||
`[Brainy] repairIndex complete in ${report.durationMs}ms — ` +
|
||||
`${families.filter((f) => f.checked).length}/${families.length} families checked, ` +
|
||||
`${healedTotal} heal(s): ` +
|
||||
families.map((f) => `${f.family}=${f.checked ? f.healed : 'skipped'}`).join(', ')
|
||||
)
|
||||
return report
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -80,7 +80,9 @@ export type {
|
|||
AggregationOp,
|
||||
TimeWindowGranularity,
|
||||
GroupByDimension,
|
||||
AggregationProvider
|
||||
AggregationProvider,
|
||||
RepairReport,
|
||||
RepairFamilyReport,
|
||||
} from './types/brainy.types.js'
|
||||
|
||||
// Read-barrier contract (waitForIndexed): the leg names, the options, and
|
||||
|
|
|
|||
|
|
@ -1192,6 +1192,27 @@ export interface RelateManyParams<T = any> {
|
|||
/**
|
||||
* Batch result
|
||||
*/
|
||||
/**
|
||||
* One family's row in a {@link RepairReport} — what repairIndex() checked,
|
||||
* what it healed, and why anything was skipped. The receipts venue's graph
|
||||
* trust program asked for: a repair that cannot show its work is a repair
|
||||
* nobody can trust.
|
||||
*/
|
||||
export interface RepairFamilyReport {
|
||||
family: string
|
||||
checked: boolean
|
||||
healed: number
|
||||
detail?: string
|
||||
skipped?: string
|
||||
}
|
||||
|
||||
/** The full receipt returned by repairIndex(). */
|
||||
export interface RepairReport {
|
||||
families: RepairFamilyReport[]
|
||||
healedTotal: number
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
export interface BatchResult<T = any> {
|
||||
successful: T[] // Successfully processed items
|
||||
failed: Array<{ // Failed items with errors
|
||||
|
|
|
|||
71
tests/integration/repair-report.test.ts
Normal file
71
tests/integration/repair-report.test.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* @module tests/integration/repair-report
|
||||
* @description repairIndex() returns the per-family receipt (checked /
|
||||
* healed / skipped-with-reason per family) and narrates a summary — the
|
||||
* "repair that shows its work" half of the graph-trust program's ask. A
|
||||
* repair nobody can audit is a repair nobody can trust.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Brainy } from '../../src/index.js'
|
||||
import { NounType } from '../../src/types/graphTypes.js'
|
||||
|
||||
type RawBox = {
|
||||
storage: { writeNounRaw(id: string, r: { metadata: unknown; vector: unknown }): Promise<void> }
|
||||
}
|
||||
|
||||
const dirs: string[] = []
|
||||
const brains: Brainy[] = []
|
||||
afterEach(async () => {
|
||||
for (const b of brains.splice(0)) await b.close().catch(() => {})
|
||||
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('repairIndex per-family receipt', () => {
|
||||
it('a healthy store gets a complete zero-heal receipt — every family accounted, none silent', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'brainy-repair-clean-'))
|
||||
dirs.push(dir)
|
||||
const brain = new Brainy({ storage: { type: 'filesystem', path: dir }, requireSubtype: false, silent: true })
|
||||
await brain.init()
|
||||
brains.push(brain)
|
||||
await brain.add({ data: 'healthy row', type: NounType.Document, metadata: { n: 1 } })
|
||||
await brain.flush()
|
||||
|
||||
const report = await brain.repairIndex()
|
||||
expect(report.families.length, 'every family reports a row').toBeGreaterThanOrEqual(5)
|
||||
const names = report.families.map((f) => f.family)
|
||||
for (const expected of ['orphaned-containers', 'count-rollups', 'metadata-corruption']) {
|
||||
expect(names, `family ${expected} accounted`).toContain(expected)
|
||||
}
|
||||
// Every row is either checked or carries its skip reason — no silent rows.
|
||||
for (const f of report.families) {
|
||||
expect(f.checked || !!f.skipped, `${f.family} is checked or explains itself`).toBe(true)
|
||||
}
|
||||
expect(report.healedTotal).toBe(0)
|
||||
expect(report.durationMs).toBeGreaterThanOrEqual(0)
|
||||
}, 120000)
|
||||
|
||||
it('a manufactured ghost container appears in the receipt as a heal', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'brainy-repair-ghost-'))
|
||||
dirs.push(dir)
|
||||
const brain = new Brainy({ storage: { type: 'filesystem', path: dir }, requireSubtype: false, silent: true })
|
||||
await brain.init()
|
||||
brains.push(brain)
|
||||
await brain.add({ data: 'real row', type: NounType.Document, metadata: { n: 1 } })
|
||||
await brain.flush()
|
||||
// The pre-8.3.1 ghost shape: a vector leg with no content leg.
|
||||
const storage = (brain as unknown as RawBox).storage
|
||||
await storage.writeNounRaw('00000000-0000-7000-8000-00000000dead', {
|
||||
metadata: null,
|
||||
vector: { vector: [0.1, 0.2], noun: 'document' }
|
||||
})
|
||||
|
||||
const report = await brain.repairIndex()
|
||||
const orphans = report.families.find((f) => f.family === 'orphaned-containers')
|
||||
expect(orphans?.checked).toBe(true)
|
||||
expect(orphans!.healed, 'the ghost was pruned and receipted').toBeGreaterThan(0)
|
||||
expect(report.healedTotal).toBeGreaterThan(0)
|
||||
}, 120000)
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue