feat: provider access to the fact log + shared stamp verifier via internals
Three additive surfaces for native index providers, which hold only `storage` and must never construct their own fact-log reader (the log's open path is writer-side — it reconciles by truncating/rewriting): - Fact-scan capability on the storage adapter (the getBinaryBlobPath pattern): the host brain wires a closure over its LIVE fact log at init; providers call storage.scanFacts()/factLogHeadGeneration()/ factSegmentPaths() and fall back to the enumeration walk on null. Restore/reopen swaps the underlying log transparently. - The family-stamp trio (readFamilyStamp/writeFamilyStamp/ verifyFamilyStamp + types) exported from @soulcraft/brainy/internals, so 'one verifier' is literally one function shared with the native side, never two synchronized copies. - Rollup invariants widened to number|string: content fingerprints (e.g. a per-tree SHA-256) are valid invariant values; strict equality either way, and a type mismatch reads as incoherence, never a pass.
This commit is contained in:
parent
70886da548
commit
352e356fd5
7 changed files with 153 additions and 3 deletions
|
|
@ -999,6 +999,17 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
})
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
|
|
|||
|
|
@ -1159,6 +1159,36 @@ export interface StorageAdapter {
|
|||
*/
|
||||
rawByteSize?(path: string): Promise<number | null>
|
||||
|
||||
/**
|
||||
* @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
|
||||
|
|
|
|||
|
|
@ -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<string, number> }
|
||||
| { mode: 'rollup'; invariants: Record<string, number | string> }
|
||||
|
||||
/** 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<string, number>
|
||||
actual: Record<string, number | string>
|
||||
): StampVerdict {
|
||||
if (stamp === null) return { state: 'absent' }
|
||||
if (stamp.sourceGeneration > head) {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 } })
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue