open-brainy/src/db/errors.ts
David Snelling 711d2f046a fix: honest response when a transaction rollback cannot complete
When a transaction failed and its rollback ALSO failed to undo a canonical
write (retries exhausted), the old path logged, continued, threw
TransactionRollbackError, and set state='rolled_back' — while the record it
could not undo stayed durable on disk. The caller got an error implying the
write was undone; a read-back showed the record. A failed rollback had no
truthful response (the post-commit response lie).

Give a failed rollback a two-branch honest contract, decided by observation:
both commit paths already hold byte-identical before-images, so after a failed
rollback the store compares current canonical state to them and classifies each
touched record as reconciled, an additive orphan (present when it should be
gone), or a restorative loss (gone/wrong when it should have been restored).

- Adopt-forward: a single-op write whose only damage is a durably-present
  orphan — the record the caller asked for — is committed forward (its
  generation buffered) and returns success with a loud warning that the derived
  index may be incomplete for that id until the next rebuild/repairIndex(). No
  error, no double-write; the record is durable and get-able immediately.
- Fail loud + quarantine: a multi-op batch, or ANY restorative loss, throws the
  new StoreInconsistentError naming every unreconciled record and its
  disposition, and puts the brain into write-quarantine (reads keep working,
  writes refused via assertWritable) until repairIndex() reconciles the derived
  indexes against canonical and lifts it. The counter is not advanced, and
  Transaction.rollback now reports state 'inconsistent' instead of the
  'rolled_back' lie when any undo failed.

New: StoreInconsistentError + UnreconciledRecord exported from the root;
repairIndex() forces a rebuild and clears the quarantine. Regression
tests/integration/rollback-trapdoor.test.ts injects index-add-throws +
canonical-delete-undo-fails and pins adopt-forward (durable, get-able, not
quarantined), fail-loud (StoreInconsistentError + quarantine + reads work +
repairIndex lifts it), and the error's record naming.
2026-07-12 12:19:09 -07:00

232 lines
10 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} — 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()`.
*
* All three 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
}
}
/** 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
}
}