open-brainy/src/transaction/operations/StorageOperations.ts
David Snelling 0e3facf4a8
All checks were successful
CI / Node 22 (push) Successful in 12m16s
CI / Node 24 (push) Successful in 12m13s
CI / Bun (latest) (push) Successful in 12m20s
fix(recovery): walks are healers — the typed/tolerant boundary redrawn where block-layer fault injection proved it belonged
The quiet-loss cure regressed recovery: the new typed torn-record error
was correct at identity-read time but threw inside init-time recovery
walks, killing opens that previously survived. The boundary, redrawn:

- IDENTITY READS (get-by-id of a specific record, CAS blob point-get):
  typed TornRecordError, unchanged — a caller who asked for THAT record
  can act on the answer.
- SET-SHAPED READS AND WALKS (enumeration, pagination, batch hydration —
  the paths recovery rebuilds and finds page over): HEAL PAST the torn
  victim. The adapter's loud floor (error log + counted gauge) fires at
  the encounter; the walk serves the remaining rows. One crash casualty
  can no longer kill every query on its shard — or the open itself.
- WRITES OVER TORN RECORDS ARE THE CURE: the save path's read-merge, the
  commit path's before-image capture, and the operations' rollback
  captures all treat a torn prior as the create sentinel, narrated — the
  incoming bytes replace the unreadable ones, and history for the id
  honestly restarts at that generation. Corruption can never block its
  own heal.
- THE NaN SOURCE: torn mapper state (nextId/entries carrying garbage)
  discards with narration and re-derives via the existing rebuild path;
  the mint gains a source guard healing a non-integer counter from the
  live map. The reopen and first-write RangeError shapes are dead at the
  source, both authority branches.

Pinned with the exact fault-injection scenarios: a torn entity record
(including the VFS root) no longer kills the open — walks heal past it,
the keeper rows serve, and the identity read of the victim itself is
typed-or-healed; a torn mapper reopens and mints sanely on the first
post-recovery write.

Gates: tsc 0 · unit 2065/2065 · integration 828 · conformance 31/31.
2026-08-11 09:20:30 -07:00

390 lines
13 KiB
TypeScript

/**
* Storage Operations with Rollback Support
*
* Provides transactional operations for all storage adapters.
* Each operation can be executed and rolled back atomically.
*
* Supports:
* - Both storage adapters (FileSystem, Memory)
* - Nouns (entities) and Verbs (relationships)
* - Metadata and vector data
*/
import type { StorageAdapter, HNSWNoun, HNSWVerb, NounMetadata, VerbMetadata } from '../../coreTypes.js'
import type { Operation, RollbackAction } from '../types.js'
import { prodLog } from '../../utils/logger.js'
/**
* Save noun metadata with rollback support
*
* Rollback strategy:
* - If metadata existed: Restore previous metadata
* - If metadata was new: Delete metadata
*/
/**
* Torn-tolerant previous-state read for ROLLBACK CAPTURE: a write or delete
* landing on a TORN record (power-loss survivor) HEALS it — the incoming
* bytes replace (or remove) the unreadable ones, and the rollback target is
* the create sentinel (null). The adapter's loud floor (error + gauge)
* already fired at throw time; this narrates the heal and proceeds. Real
* storage faults still propagate.
*/
async function tornHealsToNull<T>(read: Promise<T>, what: string): Promise<T | null> {
try {
return await read
} catch (err) {
if ((err as { code?: string }).code === 'TORN_RECORD') {
prodLog.warn(
`[StorageOperations] previous ${what} is TORN — the incoming operation ` +
`heals it; rollback target is the create sentinel`
)
return null
}
throw err
}
}
export class SaveNounMetadataOperation implements Operation {
readonly name = 'SaveNounMetadata'
constructor(
private readonly storage: StorageAdapter,
private readonly id: string,
private readonly metadata: NounMetadata,
private readonly isNew: boolean = false
) {}
async execute(): Promise<RollbackAction> {
// Skip read for new entities — nothing to rollback to (saves 1 storage round-trip)
const previousMetadata = this.isNew
? null
: await tornHealsToNull(this.storage.getNounMetadata(this.id), 'noun metadata')
// Save new metadata
await this.storage.saveNounMetadata(this.id, this.metadata)
// Return rollback action
return async () => {
if (previousMetadata) {
// Restore previous metadata
await this.storage.saveNounMetadata(this.id, previousMetadata)
} else {
// Delete newly created metadata
await this.storage.deleteNounMetadata(this.id)
}
}
}
}
/**
* Save noun (vector data) with rollback support
*
* Rollback strategy:
* - If noun existed: Restore previous noun
* - If noun was new: Delete noun (if deleteNoun exists on adapter)
*
* Note: Not all adapters implement deleteNoun - this is acceptable
* because orphaned vector data without metadata is invisible to queries
*/
export class SaveNounOperation implements Operation {
readonly name = 'SaveNoun'
constructor(
private readonly storage: StorageAdapter,
private readonly noun: HNSWNoun,
private readonly isNew: boolean = false
) {}
async execute(): Promise<RollbackAction> {
// Skip read for new entities — nothing to rollback to (saves 1 storage round-trip)
const previousNoun = this.isNew
? null
: await tornHealsToNull(this.storage.getNoun(this.noun.id), 'noun record')
// PRESERVE stored graph state on updates. Callers stage this op with
// placeholder adjacency ({connections: empty, level: 0}) because the
// vector index owns those values and persists them at flush. Codec-era
// records (2.4.0+) carry an empty connections field by design (adjacency
// lives in a separate compressed blob — the placeholder is harmless), but
// LEGACY pre-codec records store adjacency INLINE: writing the
// placeholder over one stamped out its stored connections, leaving a
// crash window (until the next flush) where a reload found the node
// unreachable. Stale adjacency in that window is tolerable — HNSW
// self-corrects at the reindex flush; EMPTY adjacency is silent recall
// loss. The read above is already paid for rollback; preservation is free.
const toSave: HNSWNoun =
previousNoun && this.noun.connections.size === 0
? {
...this.noun,
connections: previousNoun.connections || this.noun.connections,
level: previousNoun.level ?? this.noun.level
}
: this.noun
await this.storage.saveNoun(toSave)
// Return rollback action
return async () => {
if (previousNoun) {
// Restore previous noun (extract just vector data)
const nounData: HNSWNoun = {
id: previousNoun.id,
vector: previousNoun.vector,
connections: previousNoun.connections || new Map(),
level: previousNoun.level || 0
}
await this.storage.saveNoun(nounData)
} else {
// Delete newly created noun (if adapter supports it)
// Note: Not all adapters implement deleteNoun
// This is acceptable - metadata deletion makes entity invisible
if ('deleteNoun' in this.storage && typeof this.storage.deleteNoun === 'function') {
await this.storage.deleteNoun(this.noun.id)
}
}
}
}
}
/**
* Delete a noun — FULL canonical removal, with rollback support.
*
* Despite the historical name, this removes the WHOLE entity: both canonical
* legs (metadata + vector) AND the entity's container. Previously it deleted
* only the metadata leg via `deleteNounMetadata`, leaving the canonical
* `vectors.json` leg and the `<id>/` directory orphaned on disk — a "ghost"
* that reads as absent (getNoun needs both legs) yet inflates the enumerated
* count and can never be told apart from a damage scar. Routing through
* `storage.deleteNoun` removes both legs + the container in one place.
*
* Immutability is preserved: this cleans only the live-HEAD projection; the
* generation store retains the before-image so `asOf()` still reconstructs the
* deleted entity until retention expires.
*
* Rollback strategy:
* - Restore BOTH legs from the before-image (vector leg raw, metadata leg via
* the count-aware save so deleteNoun()'s decrement is reversed).
*/
export class DeleteNounMetadataOperation implements Operation {
readonly name = 'DeleteNoun'
constructor(
private readonly storage: StorageAdapter,
private readonly id: string,
/**
* OPTIONAL already-known metadata of the entity being removed (the caller's
* pre-delete read). Removal must never REQUIRE re-reading the thing being
* removed: if the reads here return null (replace race, or a ghost left by
* an earlier version), the count decrement downstream falls back to this
* record instead of being silently skipped — the skip minted permanent
* counter inflation (adds counted, paired removals not decremented).
*/
private readonly priorMetadata?: NounMetadata | null
) {}
async execute(): Promise<RollbackAction> {
// Capture the FULL before-image (both legs) so the undo restores the whole
// entity — a metadata-only rollback would leave the vector leg unrestored.
// A null metadata read falls back to the caller's pre-delete read.
const previousNoun = await tornHealsToNull(this.storage.getNoun(this.id), 'noun record')
const previousMetadata =
(await tornHealsToNull(this.storage.getNounMetadata(this.id), 'noun metadata')) ??
this.priorMetadata ??
null
if (!previousNoun && !previousMetadata) {
// Nothing to delete - no rollback needed
return async () => {}
}
// Full removal: both canonical legs + the entity container + count decrement
// (the prior record keeps the decrement honest on a null canonical read).
await this.storage.deleteNoun(this.id, previousMetadata)
// Return rollback action
return async () => {
// Restore the vector leg, then the metadata leg through the count-aware
// save so deleteNoun()'s decrement is reversed.
if (previousNoun) {
await this.storage.saveNoun({
id: previousNoun.id,
vector: previousNoun.vector,
connections: previousNoun.connections || new Map(),
level: previousNoun.level || 0
})
}
if (previousMetadata) {
await this.storage.saveNounMetadata(this.id, previousMetadata)
}
}
}
}
/**
* Save verb metadata with rollback support
*
* Rollback strategy:
* - If metadata existed: Restore previous metadata
* - If metadata was new: Delete metadata
*/
export class SaveVerbMetadataOperation implements Operation {
readonly name = 'SaveVerbMetadata'
constructor(
private readonly storage: StorageAdapter,
private readonly id: string,
private readonly metadata: VerbMetadata
) {}
async execute(): Promise<RollbackAction> {
// Get existing metadata (for rollback)
const previousMetadata = await tornHealsToNull(this.storage.getVerbMetadata(this.id), 'verb metadata')
// Save new metadata
await this.storage.saveVerbMetadata(this.id, this.metadata)
// Return rollback action
return async () => {
if (previousMetadata) {
// Restore previous metadata
await this.storage.saveVerbMetadata(this.id, previousMetadata)
} else {
// Delete newly created verb (metadata + vector)
// Note: StorageAdapter has deleteVerb but not deleteVerbMetadata
await this.storage.deleteVerb(this.id)
}
}
}
}
/**
* Save verb (vector data) with rollback support
*
* Rollback strategy:
* - If verb existed: Restore previous verb
* - If verb was new: Delete verb (if deleteVerb exists on adapter)
*/
export class SaveVerbOperation implements Operation {
readonly name = 'SaveVerb'
constructor(
private readonly storage: StorageAdapter,
private readonly verb: HNSWVerb
) {}
async execute(): Promise<RollbackAction> {
// Get existing verb (for rollback)
const previousVerb = await tornHealsToNull(this.storage.getVerb(this.verb.id), 'verb record')
// Save new verb
await this.storage.saveVerb(this.verb)
// Return rollback action
return async () => {
if (previousVerb) {
// Restore previous verb (extract just vector data)
const verbData: HNSWVerb = {
id: previousVerb.id,
sourceId: previousVerb.sourceId,
targetId: previousVerb.targetId,
verb: previousVerb.verb,
vector: previousVerb.vector,
connections: previousVerb.connections || new Map()
}
await this.storage.saveVerb(verbData)
} else {
// Delete newly created verb (if adapter supports it)
if ('deleteVerb' in this.storage && typeof this.storage.deleteVerb === 'function') {
await this.storage.deleteVerb(this.verb.id)
}
}
}
}
}
/**
* Delete verb metadata with rollback support
*
* Rollback strategy:
* - Restore deleted metadata
*/
export class DeleteVerbMetadataOperation implements Operation {
readonly name = 'DeleteVerbMetadata'
constructor(
private readonly storage: StorageAdapter,
private readonly id: string
) {}
async execute(): Promise<RollbackAction> {
// Get metadata before deletion (for rollback)
const previousMetadata = await tornHealsToNull(this.storage.getVerbMetadata(this.id), 'verb metadata')
if (!previousMetadata) {
// Nothing to delete - no rollback needed
return async () => {}
}
// Delete verb (metadata + vector). The pre-read rides along so the count
// decrement never depends on re-reading the record being removed.
await this.storage.deleteVerb(this.id, previousMetadata)
// Return rollback action
return async () => {
// Restore deleted metadata
await this.storage.saveVerbMetadata(this.id, previousMetadata)
}
}
}
/**
* Update noun metadata with rollback support
*
* Rollback strategy:
* - Restore previous metadata
*
* Note: This is a convenience operation that wraps SaveNounMetadataOperation
* with explicit "update" semantics
*/
export class UpdateNounMetadataOperation implements Operation {
readonly name = 'UpdateNounMetadata'
private saveOperation: SaveNounMetadataOperation
constructor(
storage: StorageAdapter,
id: string,
metadata: NounMetadata
) {
this.saveOperation = new SaveNounMetadataOperation(storage, id, metadata)
}
async execute(): Promise<RollbackAction> {
return await this.saveOperation.execute()
}
}
/**
* Update verb metadata with rollback support
*
* Rollback strategy:
* - Restore previous metadata
*/
export class UpdateVerbMetadataOperation implements Operation {
readonly name = 'UpdateVerbMetadata'
private saveOperation: SaveVerbMetadataOperation
constructor(
storage: StorageAdapter,
id: string,
metadata: VerbMetadata
) {
this.saveOperation = new SaveVerbMetadataOperation(storage, id, metadata)
}
async execute(): Promise<RollbackAction> {
return await this.saveOperation.execute()
}
}