brainy/src/db/errors.ts

147 lines
6.5 KiB
TypeScript
Raw Normal View History

feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist) One mechanism replaces the COW and versioning subsystems: immutable generation-stamped records behind a Datomic-style database value. Record layer (src/db/generationStore.ts): - Monotonic generation counter in _system/generation.json; bumped once per transact() commit and once per single-operation write (storage hook), so brain.generation() is always a meaningful watermark. - Commit protocol: stage before-images + tx.json delta -> fsync -> execute batch via TransactionManager -> atomic tmp+rename of _system/manifest.json (the rename IS the commit point) -> append _system/tx-log.jsonl. - Crash recovery on open rolls uncommitted generations back byte-identically and forces an index rebuild; refcounted pins gate compactHistory(), which records a horizon (asOf below it throws GenerationCompactedError). Db API: - Db: get/find/search/related pinned at a generation, with() speculative overlays, since() diffs, persist() hard-link-farm snapshots, timestamp, generation, release() + FinalizationRegistry backstop. - Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch (GenerationConflictError CAS), asOf(generation|Date|path), restore(path, {confirm}), compactHistory(), generation(), static open() + load(). - get()/metadata find()/related() are fully correct at any reachable pinned generation; index-accelerated queries at historical generations throw NotYetSupportedAtHistoricalGenerationError - never silently-wrong results. - VersionedIndexProvider (generation/isGenerationVisible/pin/release) in plugin.ts: feature-detected, balanced pin/release in lockstep with Db lifecycle, post-commit applier + replay-gap model documented. Storage primitives (BaseStorage + filesystem/memory adapters): raw-object read/write/list/remove, fsync barrier, noun/verb raw before-image capture, tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and append-in-place exceptions), restoreFromDirectory + derived-state reload. Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation across 200 mutations, batch atomicity under injected execution failure, ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety under pins, with() overlay isolation, generation monotonicity across reopen, crash consistency through the real recovery path, and versioned provider pin/release balance. Design record in docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
/**
* @module db/errors
* @description Error types for the 8.0 generational-MVCC Db API.
*
* Three failure modes have dedicated classes so callers can branch on them
* with `instanceof` instead of string matching:
*
* - {@link GenerationConflictError} a `transact()` compare-and-swap
* (`ifAtGeneration`) observed a different current generation than the
* caller expected. The standard retry pattern is: re-read via
* `brain.now()`, re-derive the transaction, and re-submit.
* - {@link NotYetSupportedAtHistoricalGenerationError} an index-accelerated
* query (vector / hybrid / graph-traversal search) was issued against a
* `Db` pinned at a generation the live indexes no longer represent.
* `get()` and metadata-filter `find()` remain fully supported at pinned
* generations; index-accelerated queries at historical generations require
* an index rebuild from the pinned record set, which ships as a follow-up
* (`asOf` rebuild). The error message names the unsupported capability and
* points at the supported alternatives.
* - {@link GenerationCompactedError} `asOf()` asked for a generation whose
* immutable records were reclaimed by `compactHistory()`.
*
* All three are exported from the package root (`@soulcraft/brainy`).
*/
/**
* @description Thrown by `brain.transact(ops, { ifAtGeneration })` when the
* store's current generation does not equal the caller-supplied expectation.
* This is the whole-store compare-and-swap counterpart to the per-entity
* `ifRev` / `RevisionConflictError` pair: it guarantees that *nothing* was
* committed between the caller's read (`brain.now()`) and this transaction.
*
* The transaction is rejected before any record is staged the store is
* untouched and the generation counter is unchanged.
*
* @example
* const db = brain.now()
* try {
* await brain.transact(ops, { ifAtGeneration: db.generation })
* } catch (err) {
* if (err instanceof GenerationConflictError) {
* // Someone committed since we pinned — re-read and retry.
* console.log(`expected ${err.expected}, store is at ${err.actual}`)
* }
* }
*/
export class GenerationConflictError extends Error {
/** The generation the caller expected the store to be at. */
public readonly expected: number
/** The generation the store was actually at when the transaction arrived. */
public readonly actual: number
/**
* @param expected - The generation supplied via `ifAtGeneration`.
* @param actual - The store's current generation at submission time.
*/
constructor(expected: number, actual: number) {
super(
`Generation conflict: transact() was submitted with ifAtGeneration: ${expected}, ` +
`but the store is at generation ${actual}. Another write committed in between. ` +
`Re-read with brain.now(), rebuild the transaction, and retry.`
)
this.name = 'GenerationConflictError'
this.expected = expected
this.actual = actual
}
}
/**
* @description Thrown when an operation on a pinned `Db` requires
* index-accelerated machinery (vector search, hybrid search, graph
* traversal, cursor pagination, aggregation) that the live indexes cannot
* answer for a *historical* generation. Storage-record reads (`get()`) and
* metadata-filter `find()` are always supported at pinned generations.
*
* Rebuilding indexes from a pinned record set (the standard LSM answer for
* historical index queries) is the documented `asOf`-rebuild follow-up; this
* error is the honest boundary until it ships.
*
* @example
* const db = brain.now()
* await brain.transact([{ op: 'update', id, metadata: { v: 2 } }])
* await db.get(id) // ✅ pinned read — supported
* await db.find({ where: { v: 1 } }) // ✅ metadata find — supported
* await db.search('semantic query') // ❌ throws this error
*/
export class NotYetSupportedAtHistoricalGenerationError extends Error {
/** The pinned generation the operation was attempted against. */
public readonly generation: number
/** The capability that is not supported at historical generations. */
public readonly capability: string
/**
* @param capability - Human-readable name of the unsupported capability
* (e.g. `'vector search'`, `'graph traversal (connected)'`).
* @param generation - The Db's pinned generation.
* @param currentGeneration - The store's current generation, for context.
*/
constructor(capability: string, generation: number, currentGeneration: number) {
super(
`${capability} is not yet supported at a historical generation ` +
`(Db pinned at generation ${generation}; store is at ${currentGeneration}). ` +
`Supported at pinned generations: get() and metadata-filter find(). ` +
`For index-accelerated queries over historical state, persist() the generation ` +
`you need and open it with Brainy.load(path) — the loaded snapshot rebuilds ` +
`its indexes and supports the full query surface.`
)
this.name = 'NotYetSupportedAtHistoricalGenerationError'
this.capability = capability
this.generation = generation
}
}
/**
* @description Thrown by `brain.asOf(generationOrTimestamp)` when the
* requested generation's history was reclaimed by `brain.compactHistory()`.
* The store records the compaction horizon (the highest reclaimed
* generation) in its manifest; any generation BELOW the horizon is
* unreachable its reads would need the reclaimed before-images. The
* horizon itself stays reachable, resolved from the record-sets above it.
*
* To keep a generation readable forever, `persist()` it to a snapshot
* directory before compacting snapshots are self-contained and unaffected
* by compaction of the source store.
*/
export class GenerationCompactedError extends Error {
/** The generation that was requested. */
public readonly requested: number
/** The compaction horizon — generations < this value are unreachable. */
public readonly horizon: number
/**
* @param requested - The generation the caller asked for.
* @param horizon - The store's current compaction horizon.
*/
constructor(requested: number, horizon: number) {
super(
`Generation ${requested} has been compacted away (compaction horizon: ${horizon}). ` +
`Only generations at or above the horizon are reachable via asOf(). ` +
`Use db.persist(path) before compactHistory() to keep a generation readable.`
)
this.name = 'GenerationCompactedError'
this.requested = requested
this.horizon = horizon
}
}