/** * @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} — the store's current generation differs * from what the caller's operation requires: a `transact()` * compare-and-swap (`ifAtGeneration`) observed a different generation than * expected, or `db.persist()` was called on a view that history has moved * past (snapshots capture current bytes, so persisting requires the view * to still be the latest generation). The standard retry pattern is: * re-read via `brain.now()`, re-derive the operation, and re-submit. * - {@link SpeculativeOverlayError} — an index-accelerated query (vector / * hybrid / graph-traversal search, cursors, aggregation) or `persist()` * was issued against a speculative `db.with()` overlay. Overlays are pure * in-memory values whose entities carry no embeddings (`with()` never * invokes the embedder), so a "full" index query over one would silently * miss the overlay's own entities — an honest error beats silently-wrong * results. Commit the operations with `brain.transact()` to get the full * query surface. Historical (non-overlay) views do NOT throw this: they * 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 are exported from the package root (`@soulcraft/brainy`). */ /** * @description Thrown when an operation requires the store to be at a * specific generation and it is not: * * - `brain.transact(ops, { ifAtGeneration })` — 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. * - `db.persist(path)` — snapshots capture current bytes, so persisting * requires the view to still be the store's **latest** generation. A view * that history has moved past throws this error: pin with `brain.now()` * and persist before further writes. * * @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 operation required the store to be at. */ public readonly expected: number /** The generation the store was actually at. */ public readonly actual: number /** * @param expected - The required generation (`ifAtGeneration`, or the * pinned generation of the view being persisted). * @param actual - The store's current generation. */ constructor(expected: number, actual: number) { super( `Generation conflict: the operation requires the store at generation ${expected}, ` + `but it is at generation ${actual}. Another write committed in between. ` + `Re-read with brain.now(), rebuild the operation, and retry.` ) this.name = 'GenerationConflictError' this.expected = expected this.actual = actual } } /** * @description Thrown when an operation on a speculative `db.with()` overlay * requires index machinery (vector / hybrid / graph-traversal search, cursor * pagination, aggregation) or durability (`persist()`). * * Why this single boundary exists: overlays are pure in-memory values — * `with()` never touches disk, the generation counter, or the embedder, so * overlay entities carry **no embeddings**. A "full" vector or hybrid query * over an overlay would silently exclude the overlay's own entities, and a * persisted overlay would be a store whose entities cannot be semantically * searched. An honest error beats silently-wrong results. Commit the * operations with `brain.transact()` to get the full query surface, or query * the overlay's base view (historical views serve the complete surface via * at-generation index materialization). * * `get()`, metadata-filter `find()`, and filter-based `related()` remain * fully supported on overlays. * * @example * const spec = await brain.now().with([{ op: 'add', type, data: 'draft' }]) * await spec.find({ where: { draft: true } }) // ✅ metadata find — supported * await spec.find({ query: 'semantic query' }) // ❌ throws this error * await brain.transact([{ op: 'add', type, data: 'draft' }]) // → full surface */ export class SpeculativeOverlayError extends Error { /** The capability that is not supported on speculative overlays. */ public readonly capability: string /** The overlay's pinned base generation. */ public readonly generation: number /** * @param capability - Human-readable name of the unsupported capability * (e.g. `'vector search'`, `'persist'`). * @param generation - The overlay's pinned base generation. */ constructor(capability: string, generation: number) { super( `${capability} is not supported on a speculative with() overlay ` + `(pinned at base generation ${generation}). Overlays are pure in-memory ` + `values whose entities carry no embeddings, so index-accelerated reads ` + `over them would be silently incomplete. Supported on overlays: get(), ` + `metadata-filter find(), and filter-based related(). Commit the operations ` + `with brain.transact() for the full query surface, or query the base view.` ) this.name = 'SpeculativeOverlayError' 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 } } /** * @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. */ id: string /** `'noun'` (entity) or `'verb'` (relationship). */ kind: 'noun' | 'verb' /** * `'orphan'` — the record is durably PRESENT but the transaction aborted * (an add whose delete-undo failed); `'loss'` — the record is durably GONE * or wrong when it should have been restored (a remove/update whose * restore-undo failed — actual data loss). */ disposition: 'orphan' | 'loss' } /** * @description Thrown when a transaction's rollback could not be fully applied * and the resulting inconsistency cannot be safely adopted forward — i.e. a * multi-operation batch, or ANY case where a record was lost (a remove/update * whose restore-undo failed). The store is left in a known-inconsistent state: * the {@link records} name every entity/relationship whose canonical state no * longer matches what the aborted transaction should have produced. * * On throw, the brain enters **write-quarantine** — reads continue to work, but * further writes are refused until {@link } `repairIndex()` reconciles the * derived indexes against canonical storage and lifts the quarantine. This is * the honest, loud failure: a visible inconsistency the operator must repair, * never a silent partial write. The generation counter is NOT advanced. * * (A SINGLE-op add whose only damage is a durably-present orphan is NOT this * error: it is adopted forward as a committed generation and returned as a * degraded-but-successful write — the record the caller asked for exists.) * * @example * try { * await brain.transact(ops) * } catch (err) { * if (err instanceof StoreInconsistentError) { * console.error('store inconsistent:', err.records) // ids + dispositions * await brain.repairIndex() // reconcile + lift the write-quarantine * } * } */ export class StoreInconsistentError extends Error { /** Every record left in an unreconciled state by the failed rollback. */ public readonly records: UnreconciledRecord[] /** The original error that triggered the (then-failed) rollback. */ public override readonly cause: Error /** * @param records - The unreconciled entities/relationships (ids + disposition). * @param cause - The error that triggered the rollback. */ constructor(records: UnreconciledRecord[], cause: Error) { const orphans = records.filter((r) => r.disposition === 'orphan').length const losses = records.filter((r) => r.disposition === 'loss').length super( `Store left inconsistent by a failed transaction rollback: ` + `${records.length} record(s) could not be reconciled ` + `(${orphans} orphaned, ${losses} lost). ` + `The brain is now WRITE-QUARANTINED (reads still work) — run repairIndex() ` + `to reconcile the derived indexes against canonical storage and lift the ` + `quarantine. Ids: ${records.map((r) => `${r.id}:${r.disposition}`).join(', ')}. ` + `Cause: ${cause.message}` ) this.name = 'StoreInconsistentError' this.records = records this.cause = cause } } /** * @description Thrown by a write when the store cannot make single-op * generation **history** durable: the asynchronous group-commit flush that * persists buffered before-images to disk has failed repeatedly (a real * storage fault — a full disk, an EIO, a permission loss — not a transient * blip). Rather than keep accepting writes whose history silently piles up in * memory and is never persisted — an invisible durability loss, and an * unbounded leak — the store LATCHES this failure and refuses further writes * until the pending tier drains. * * This is the loud, honest response to a broken history-durability path: * - Live canonical data is unaffected (single-op live bytes are written before * the history flush; only the immutable before-image history is stuck). * - The background flush keeps retrying with backoff; when the underlying fault * clears and a flush succeeds, the latch lifts and writes resume * automatically. An explicit `flush()` also clears it on success. * - {@link cause} is the underlying storage error from the last failed flush. * * A caller seeing this should treat it exactly like a full disk: stop writing, * resolve the storage fault, and retry. It is NOT a data-corruption error — no * committed generation is lost — it is a refusal to *promise* durability the * store currently cannot deliver. * * @example * try { * await brain.add({ ... }) * } catch (err) { * if (err instanceof PendingFlushDurabilityError) { * // History can't be persisted right now (disk fault). Resolve storage, * // then retry — the store self-heals once a flush succeeds. * console.error('history durability stalled:', err.cause) * } * } */ export class PendingFlushDurabilityError extends Error { /** The underlying storage error from the most recent failed flush. */ public override readonly cause: Error /** How many consecutive flush attempts had failed when the latch tripped. */ public readonly failedAttempts: number /** * @param cause - The storage error from the last failed pending-tier flush. * @param failedAttempts - Consecutive failed flush attempts at latch time. */ constructor(cause: Error, failedAttempts: number) { super( `Write refused: single-op generation history could not be made durable ` + `after ${failedAttempts} consecutive flush attempts (${cause.message}). ` + `Live data is intact, but buffered history is not yet persisted — the ` + `store refuses further writes rather than silently accumulate ` + `un-durable history. Resolve the underlying storage fault; the store ` + `self-heals and resumes writes once a flush succeeds. Cause: ${cause.message}` ) this.name = 'PendingFlushDurabilityError' this.cause = cause this.failedAttempts = failedAttempts } }