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,
|
ScoreExplanation,
|
||||||
FillSubtypeRule,
|
FillSubtypeRule,
|
||||||
FillSubtypeRules,
|
FillSubtypeRules,
|
||||||
FillSubtypesResult
|
FillSubtypesResult,
|
||||||
|
RepairReport,
|
||||||
|
RepairFamilyReport
|
||||||
} from './types/brainy.types.js'
|
} from './types/brainy.types.js'
|
||||||
import { NounType, VerbType, TypeUtils } from './types/graphTypes.js'
|
import { NounType, VerbType, TypeUtils } from './types/graphTypes.js'
|
||||||
import {
|
import {
|
||||||
|
|
@ -17088,8 +17090,13 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async repairIndex(): Promise<void> {
|
async repairIndex(): Promise<RepairReport> {
|
||||||
await this.ensureInitialized()
|
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
|
// 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
|
// 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') {
|
if (typeof pruner.pruneOrphanedEntities === 'function') {
|
||||||
const orphans = await pruner.pruneOrphanedEntities()
|
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(
|
prodLog.warn(
|
||||||
`[Brainy] repairIndex() pruned ${orphans.nouns.length} orphaned noun + ` +
|
`[Brainy] repairIndex() pruned ${orphans.nouns.length} orphaned noun + ` +
|
||||||
`${orphans.verbs.length} orphaned verb container(s) left by a pre-8.3.1 ` +
|
`${orphans.verbs.length} orphaned verb container(s) left by a pre-8.3.1 ` +
|
||||||
`partial delete.`
|
`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
|
// SANCTIONED RECOUNT — unconditional, not gated on orphans found: the
|
||||||
// persisted counters can be inflated over perfectly clean shelves (deletes
|
// 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.
|
// canonical walk and persists them.
|
||||||
await pruner.rebuildTypeCounts?.()
|
await pruner.rebuildTypeCounts?.()
|
||||||
await pruner.rebuildSubtypeCounts?.()
|
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
|
// The recount changed the rollup truth — re-stamp the entity tree so the
|
||||||
// stamp's invariants match the healed counters (repair leaves a coherent
|
// 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.
|
// containment edges are touched. Loud per repair.
|
||||||
if (this._vfsInitialized && this._vfs) {
|
if (this._vfsInitialized && this._vfs) {
|
||||||
const containment = await this._vfs.repairContainment()
|
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) {
|
if (containment.removed + containment.restored > 0) {
|
||||||
prodLog.warn(
|
prodLog.warn(
|
||||||
`[Brainy] repairIndex() reconciled VFS containment: removed ${containment.removed} ` +
|
`[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()
|
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
|
// Lift a failed-rollback write-quarantine: force a full rebuild so the
|
||||||
// derived indexes are provably reconciled with canonical, then clear the
|
// derived indexes are provably reconciled with canonical, then clear the
|
||||||
// flag so writes resume.
|
// flag so writes resume.
|
||||||
if (this.storeInconsistency) {
|
if (this.storeInconsistency) {
|
||||||
await this.rebuildIndexesIfNeeded(true)
|
await this.rebuildIndexesIfNeeded(true)
|
||||||
const cleared = this.storeInconsistency
|
const cleared = this.storeInconsistency
|
||||||
|
record('write-quarantine', { checked: true, healed: 1, detail: `lifted (${cleared.records.length} record(s) reconciled)` })
|
||||||
this.storeInconsistency = null
|
this.storeInconsistency = null
|
||||||
prodLog.warn(
|
prodLog.warn(
|
||||||
`[Brainy] repairIndex() reconciled the store and LIFTED the write-quarantine ` +
|
`[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>
|
validateInvariants?: () => Promise<ProviderInvariantReport>
|
||||||
rebuild?: () => Promise<void>
|
rebuild?: () => Promise<void>
|
||||||
} | null
|
} | 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
|
let report: ProviderInvariantReport
|
||||||
try {
|
try {
|
||||||
report = await p.validateInvariants()
|
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
|
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')) {
|
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(
|
prodLog.warn(
|
||||||
`[Brainy] repairIndex(): provider '${report.provider}' has a failing invariant ` +
|
`[Brainy] repairIndex(): provider '${report.provider}' has a failing invariant ` +
|
||||||
`requiring a rebuild — reconciling its derived state from canonical.`
|
`requiring a rebuild — reconciling its derived state from canonical.`
|
||||||
)
|
)
|
||||||
await p.rebuild()
|
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
|
// detectAndRepairCorruption() above rebuilt the derived indexes from
|
||||||
|
|
@ -17190,7 +17246,18 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
this._indexDegradedIds.clear()
|
this._indexDegradedIds.clear()
|
||||||
this._indexRebuildFailed = null
|
this._indexRebuildFailed = null
|
||||||
this._degradedReadWarned = false
|
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,
|
AggregationOp,
|
||||||
TimeWindowGranularity,
|
TimeWindowGranularity,
|
||||||
GroupByDimension,
|
GroupByDimension,
|
||||||
AggregationProvider
|
AggregationProvider,
|
||||||
|
RepairReport,
|
||||||
|
RepairFamilyReport,
|
||||||
} from './types/brainy.types.js'
|
} from './types/brainy.types.js'
|
||||||
|
|
||||||
// Read-barrier contract (waitForIndexed): the leg names, the options, and
|
// Read-barrier contract (waitForIndexed): the leg names, the options, and
|
||||||
|
|
|
||||||
|
|
@ -1192,6 +1192,27 @@ export interface RelateManyParams<T = any> {
|
||||||
/**
|
/**
|
||||||
* Batch result
|
* 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> {
|
export interface BatchResult<T = any> {
|
||||||
successful: T[] // Successfully processed items
|
successful: T[] // Successfully processed items
|
||||||
failed: Array<{ // Failed items with errors
|
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