diff --git a/src/brainy.ts b/src/brainy.ts index b18834d8..a188ad87 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -999,6 +999,17 @@ export class Brainy implements BrainyInterface { }) } + // Fact-scan capability: wire the storage seam through which index + // providers (which hold only `storage`) reach the fact log. A closure + // over the LIVE log — restore/reopen swaps the instance transparently — + // so a provider's heal can switch from the enumeration walk to one + // sequential fact scan whenever the log exists. + if (typeof (this.storage as BaseStorage).setFactScanSource === 'function') { + ;(this.storage as BaseStorage).setFactScanSource( + () => this.generationStore?.getFactLog() ?? null + ) + } + // Entity-tree stamp coherence: compare the stamped sourceGeneration + // rollup invariants against the log head + live counters. Loud on // genuine incoherence (repairIndex heals), silent on absent/coherent, diff --git a/src/coreTypes.ts b/src/coreTypes.ts index dc4b3860..26783e0f 100644 --- a/src/coreTypes.ts +++ b/src/coreTypes.ts @@ -1159,6 +1159,36 @@ export interface StorageAdapter { */ rawByteSize?(path: string): Promise + /** + * @description OPTIONAL fact-scan capability — how an index provider that + * holds only `storage` reaches the generation fact log (the host brain + * wires it at init; providers must never construct their own fact-log + * reader — the log's open path is writer-side). Present ⟺ this store hosts + * a fact log AND the host wired the capability. Returns a scan handle over + * committed facts (heal-grade telemetry included), or `null` when no fact + * log exists — callers fall back to the canonical enumeration walk. + */ + scanFacts?(options?: { + fromGeneration?: number + toGeneration?: number + kinds?: Array<'noun' | 'verb'> + batchSize?: number + }): import('./db/factLog.js').FactScanHandle | null + + /** + * @description OPTIONAL (rides the fact-scan capability): the fact log's + * head generation — the replay target for `stamp.sourceGeneration + 1` + * catch-ups. `null` when no fact log exists. + */ + factLogHeadGeneration?(): number | null + + /** + * @description OPTIONAL (rides the fact-scan capability): the immutable, + * sealed fact-segment file paths covering `fromGeneration` — the zero-copy + * handoff. The mutable tail is excluded (read it via `scanFacts`). + */ + factSegmentPaths?(options?: { fromGeneration?: number }): string[] + /** * Save statistics data * @param statistics The statistics data to save diff --git a/src/db/familyStamp.ts b/src/db/familyStamp.ts index 728ad5bb..98342884 100644 --- a/src/db/familyStamp.ts +++ b/src/db/familyStamp.ts @@ -41,10 +41,15 @@ export interface EnumeratedMember { bytes: number } -/** The stamp's verified surface, in one of the two member modes. */ +/** + * The stamp's verified surface, in one of the two member modes. Rollup + * invariant values may be numbers (counts, byte sizes) or strings (content + * fingerprints, e.g. a per-tree SHA-256) — the verifier compares by strict + * equality either way, so a type mismatch reads as incoherence, never a pass. + */ export type StampMembers = | { mode: 'enumerated'; files: EnumeratedMember[] } - | { mode: 'rollup'; invariants: Record } + | { mode: 'rollup'; invariants: Record } /** The generalized family stamp (one shape, one verifier, both engines). */ export interface FamilyStamp { @@ -109,7 +114,7 @@ export async function writeFamilyStamp( export function verifyFamilyStamp( stamp: FamilyStamp | null, head: number, - actual: Record + actual: Record ): StampVerdict { if (stamp === null) return { state: 'absent' } if (stamp.sourceGeneration > head) { diff --git a/src/internals.ts b/src/internals.ts index 00a24638..009124fe 100644 --- a/src/internals.ts +++ b/src/internals.ts @@ -19,3 +19,29 @@ export type { MemoryInfo, CacheAllocationStrategy } from './utils/memoryDetectio // HNSWNounWithMetadata. First-party plugins (Cor) use this to stay in // lockstep with the entity shape contract. export { resolveEntityField, STANDARD_ENTITY_FIELDS } from './coreTypes.js' + +// The generalized family stamp — ONE verifier, both member modes, shared +// verbatim with native providers so stamp verification is literally one +// function, never two synchronized copies. Providers write the same shape +// (their set-swap rewrites stamps, so adoption is migration-free). +export { + readFamilyStamp, + writeFamilyStamp, + verifyFamilyStamp, + FAMILY_STAMPS_PREFIX, + ENTITY_TREE_STAMP_PATH +} from './db/familyStamp.js' +export type { + FamilyStamp, + StampMembers, + StampVerdict, + EnumeratedMember, + StampStorage +} from './db/familyStamp.js' + +// Generation fact-log types — the scan surface a provider reaches through the +// storage capability (`storage.scanFacts` / `storage.factLogHeadGeneration` / +// `storage.factSegmentPaths`, wired by the host brain at init). Providers +// never construct a FactLog themselves: open() is writer-side (it reconciles +// by truncating/rewriting) and there is exactly one writer. +export type { CommitFact, FactOp, FactScanBatch, FactScanHandle } from './db/factLog.js' diff --git a/src/storage/baseStorage.ts b/src/storage/baseStorage.ts index db8ffaa6..1983b4bc 100644 --- a/src/storage/baseStorage.ts +++ b/src/storage/baseStorage.ts @@ -842,6 +842,46 @@ export abstract class BaseStorage extends BaseStorageAdapter { return this.deleteObjectFromPath(path) } + // ========================================================================== + // Fact-scan capability — the seam through which an index provider holding + // only `storage` reaches the generation fact log. The HOST brain wires the + // source at init (a closure over its live fact log, so restore/reopen stays + // transparent); providers call storage.scanFacts?.(...) and fall back to the + // enumeration walk when it returns null. Providers never construct a + // fact-log reader themselves — the log's open path is writer-side. + // ========================================================================== + + /** The host-wired fact-scan source (a closure over the live fact log). */ + private _factScanSource: (() => import('../db/factLog.js').FactLog | null) | null = null + + /** HOST-ONLY: wire (or clear) the fact-scan capability's source. */ + public setFactScanSource(source: (() => import('../db/factLog.js').FactLog | null) | null): void { + this._factScanSource = source + } + + /** Open a scan over committed facts, or `null` when no fact log exists. */ + public scanFacts(options?: { + fromGeneration?: number + toGeneration?: number + kinds?: Array<'noun' | 'verb'> + batchSize?: number + }): import('../db/factLog.js').FactScanHandle | null { + const log = this._factScanSource?.() + return log ? log.scanFacts(options) : null + } + + /** The fact log's head generation, or `null` when no fact log exists. */ + public factLogHeadGeneration(): number | null { + const log = this._factScanSource?.() + return log ? log.headGeneration() : null + } + + /** Sealed fact-segment paths (zero-copy handoff); empty when none. */ + public factSegmentPaths(options?: { fromGeneration?: number }): string[] { + const log = this._factScanSource?.() + return log ? log.segmentPaths(options) : [] + } + /** * @description Remove the container that held a canonical entity's leg files, * called after both legs are deleted so a delete leaves NOTHING behind — no diff --git a/tests/integration/entity-tree-stamp.test.ts b/tests/integration/entity-tree-stamp.test.ts index f76c5094..deefc5e6 100644 --- a/tests/integration/entity-tree-stamp.test.ts +++ b/tests/integration/entity-tree-stamp.test.ts @@ -140,5 +140,24 @@ describe('entity-tree family stamp', () => { expect(verifyFamilyStamp(enumerated, 2, { 'a.bin': 128 })).toEqual({ state: 'coherent' }) expect(verifyFamilyStamp(enumerated, 2, { 'a.bin': 64 }).state).toBe('incoherent') expect(verifyFamilyStamp(enumerated, 2, {}).state).toBe('incoherent') // missing member + + // Rollup invariants may be STRING fingerprints (e.g. a per-tree SHA-256): + // strict equality either way; a type mismatch reads as incoherence. + const fingerprinted: FamilyStamp = { + family: 'z', + generation: 1, + committedAt: new Date().toISOString(), + sourceGeneration: 3, + members: { mode: 'rollup', invariants: { treeDigest: 'abc123', rows: 42 } } + } + expect(verifyFamilyStamp(fingerprinted, 3, { treeDigest: 'abc123', rows: 42 })).toEqual({ + state: 'coherent' + }) + expect(verifyFamilyStamp(fingerprinted, 3, { treeDigest: 'deadbeef', rows: 42 }).state).toBe( + 'incoherent' + ) + expect(verifyFamilyStamp(fingerprinted, 3, { treeDigest: 'abc123', rows: '42' }).state).toBe( + 'incoherent' // type mismatch never passes + ) }) }) diff --git a/tests/integration/fact-log-dual-write.test.ts b/tests/integration/fact-log-dual-write.test.ts index 9df1b908..5ec66273 100644 --- a/tests/integration/fact-log-dual-write.test.ts +++ b/tests/integration/fact-log-dual-write.test.ts @@ -108,6 +108,25 @@ describe('fact log dual-write (memory adapter)', () => { } }) + it('the storage fact-scan capability serves a provider holding only `storage`', async () => { + // An index provider receives `storage` — never the brain — and reaches the + // fact log through the host-wired capability (it must never construct its + // own fact-log reader: the log's open path is writer-side). + const id = await brain.add({ data: 'via storage', type: 'document', metadata: { s: 1 } }) + await brain.remove(id) + + const storage = brain.storage + expect(typeof storage.scanFacts).toBe('function') + expect(storage.factLogHeadGeneration()).toBe(brain.scanFacts()!.headGeneration) + + const viaStorage: CommitFact[] = [] + for await (const b of storage.scanFacts()!.batches()) viaStorage.push(...b.facts) + const viaBrain: CommitFact[] = [] + for await (const b of brain.scanFacts()!.batches()) viaBrain.push(...b.facts) + expect(viaStorage.map((f) => f.generation)).toEqual(viaBrain.map((f) => f.generation)) + expect(storage.factSegmentPaths()).toEqual(brain.factSegmentPaths()) + }) + it('scan telemetry carries the frozen shape end-to-end', async () => { for (let i = 0; i < 5; i++) await brain.add({ data: `t${i}`, type: 'document', metadata: { i } })