147 lines
6.5 KiB
TypeScript
147 lines
6.5 KiB
TypeScript
|
|
/**
|
||
|
|
* @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
|
||
|
|
}
|
||
|
|
}
|