feat: canonical enumeration mode for export — storage-walked, canon-complete, with an index-drift report

This commit is contained in:
David Snelling 2026-07-27 11:08:19 -07:00
parent 999d0ebbcf
commit 4d196af41b
6 changed files with 543 additions and 20 deletions

View file

@ -23,8 +23,12 @@
* serve the full query surface via at-generation index materialization.
* - {@link GenerationCompactedError} `asOf()` asked for a generation whose
* immutable records were reclaimed by `compactHistory()`.
* - {@link CanonicalEnumerationUnavailableError} `export()`'s
* `enumeration:'canonical'` mode was called on a historical `asOf()` view or a
* speculative `with()` overlay; the canonical storage walk only ever answers
* "what is live right now."
*
* All three are exported from the package root (`@soulcraft/brainy`).
* All are exported from the package root (`@soulcraft/brainy`).
*/
/**
@ -160,6 +164,64 @@ export class GenerationCompactedError extends Error {
}
}
/**
* @description Thrown by `db.export(selector, { enumeration: 'canonical' })` when
* the `Db` it is called on is not the live, current-generation view: a historical
* `brain.asOf(g)` pin, or a speculative `db.with()` overlay.
*
* Canonical enumeration mode walks the storage adapter's canonical shard layout
* directly (`storage.getNouns()`/`getVerbs()`) instead of the metadata/graph
* indexes but that walk has no generation parameter, it can only ever answer
* "what is live right now." Serving it against a historical pin would silently
* mix generations (today's canonical records under yesterday's selector), and
* against a speculative overlay it would silently miss the overlay's own
* in-memory entities (which never touched storage). Both are exactly the kind of
* silently-wrong result canonical mode exists to prevent elsewhere so this
* boundary throws instead.
*
* `enumeration: 'index'` (the default) is unaffected: it composes with
* `asOf()`/`with()` exactly as before, via the generation-correct `find()` walk.
*
* @example
* const past = await brain.asOf(g1)
* try {
* await past.export({}, { enumeration: 'canonical' })
* } catch (err) {
* if (err instanceof CanonicalEnumerationUnavailableError) {
* // Time-travel export: use the default index-based enumeration instead.
* await past.export({}, { enumeration: 'index' })
* }
* }
*/
export class CanonicalEnumerationUnavailableError extends Error {
/** The view's pinned generation. */
public readonly generation: number
/** Why canonical mode cannot serve this view. */
public readonly reason: 'historical' | 'overlay'
/**
* @param generation - The view's pinned generation.
* @param reason - `'historical'` (a past `asOf()` pin) or `'overlay'` (a speculative `with()`).
*/
constructor(generation: number, reason: 'historical' | 'overlay') {
const what =
reason === 'historical'
? `a historical view pinned at generation ${generation}`
: `a speculative with() overlay (base generation ${generation})`
super(
`export()'s enumeration:'canonical' requires the live, current-generation view — ` +
`it was called on ${what}. The canonical storage walk has no generation parameter, ` +
`so it can only answer "what is live right now"; serving it here would silently ` +
`mix generations (historical) or miss the overlay's own in-memory entities ` +
`(overlay). Use enumeration:'index' (the default) for a time-travel or what-if ` +
`export, or pin brain.now() for a live canonical export.`
)
this.name = 'CanonicalEnumerationUnavailableError'
this.generation = generation
this.reason = reason
}
}
/** One entity/relationship left in an unreconciled state by a failed rollback. */
export interface UnreconciledRecord {
/** The entity or relationship id. */