2025-11-14 10:26:23 -08:00
|
|
|
/**
|
|
|
|
|
* Transaction System Types
|
|
|
|
|
*
|
|
|
|
|
* Provides atomicity for Brainy operations - all succeed or all rollback.
|
|
|
|
|
* Prevents partial failures that leave system in inconsistent state.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Transaction state
|
|
|
|
|
*/
|
|
|
|
|
export type TransactionState =
|
|
|
|
|
| 'pending' // Created but not executed
|
|
|
|
|
| 'executing' // Currently executing operations
|
|
|
|
|
| 'committed' // Successfully committed
|
|
|
|
|
| 'rolling_back' // Rolling back due to failure
|
|
|
|
|
| 'rolled_back' // Successfully rolled back
|
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
|
|
|
| 'inconsistent' // Rollback ran but ≥1 undo could not be applied — the store
|
|
|
|
|
// is NOT cleanly rolled back; the commit orchestration must
|
|
|
|
|
// reconcile (adopt-forward or fail-loud). Never claim
|
|
|
|
|
// 'rolled_back' when an undo failed.
|
2025-11-14 10:26:23 -08:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Rollback action - undoes an operation
|
|
|
|
|
* Must be idempotent (safe to call multiple times)
|
|
|
|
|
*/
|
|
|
|
|
export type RollbackAction = () => Promise<void>
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Operation that can be executed and rolled back
|
|
|
|
|
*/
|
|
|
|
|
export interface Operation {
|
|
|
|
|
/**
|
|
|
|
|
* Execute the operation
|
|
|
|
|
* @returns Rollback action to undo this operation (or undefined if no rollback needed)
|
|
|
|
|
*/
|
|
|
|
|
execute(): Promise<RollbackAction | undefined>
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Optional: Name of operation for debugging
|
|
|
|
|
*/
|
|
|
|
|
readonly name?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Transaction context passed to user functions
|
|
|
|
|
*/
|
|
|
|
|
export interface TransactionContext {
|
|
|
|
|
/**
|
|
|
|
|
* Add an operation to the transaction
|
|
|
|
|
*/
|
|
|
|
|
addOperation(operation: Operation): void
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get current transaction state
|
|
|
|
|
*/
|
|
|
|
|
getState(): TransactionState
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get number of operations in transaction
|
|
|
|
|
*/
|
|
|
|
|
getOperationCount(): number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Function that builds a transaction
|
|
|
|
|
*/
|
|
|
|
|
export type TransactionFunction<T> = (ctx: TransactionContext) => Promise<T>
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Transaction execution result
|
|
|
|
|
*/
|
|
|
|
|
export interface TransactionResult<T> {
|
|
|
|
|
/**
|
|
|
|
|
* Result value from user function
|
|
|
|
|
*/
|
|
|
|
|
value: T
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Number of operations executed
|
|
|
|
|
*/
|
|
|
|
|
operationCount: number
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Execution time in milliseconds
|
|
|
|
|
*/
|
|
|
|
|
executionTimeMs: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Transaction execution options
|
|
|
|
|
*/
|
|
|
|
|
export interface TransactionOptions {
|
|
|
|
|
/**
|
|
|
|
|
* Timeout in milliseconds (default: 30000 = 30 seconds)
|
|
|
|
|
*/
|
|
|
|
|
timeout?: number
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Whether to log transaction execution (default: false)
|
|
|
|
|
*/
|
|
|
|
|
logging?: boolean
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Maximum number of rollback retry attempts (default: 3)
|
|
|
|
|
*/
|
|
|
|
|
maxRollbackRetries?: number
|
|
|
|
|
}
|