/** * @module storage/tornRecordError * @description Typed surface for TORN records — files that EXIST in storage but * cannot be decoded (invalid JSON, truncated/garbled gzip). A torn record is * disk corruption, not absence: reading it as `null` ("not found") makes the * consumer unable to distinguish "never existed" from "exists but unreadable", * so nothing ever heals it. Mandate: loud errors, never quiet losses. * * Contract implemented across the storage layer: * - Genuine absence (ENOENT) still reads as clean `null` — no error, no noise. * - A torn record ALWAYS registers here (error log + per-process gauge), then: * - entity read paths (get/getBatch/pagination/enumeration hydration) throw * {@link TornRecordError} to the caller — a row is never silently dropped; * - system-artifact read paths whose machinery is designed for * absent-artifact degradation (manifests with recovery paths, markers * whose verdict is "rescan", rebuildable statistics) map torn → their * existing degrade AFTER the encounter is logged and counted. */ import { prodLog } from '../utils/logger.js' /** * @description Thrown when a stored object EXISTS but cannot be decoded — * corrupt/torn bytes on disk (invalid JSON, undecodable gzip). Deliberately * distinct from absence: `readObjectFromPath` returns `null` only for ENOENT. * Catchable by type (`instanceof`), by `name === 'TornRecordError'`, or by * `code === 'TORN_RECORD'` (cross-realm safe; never matches `isAbsentError`). */ export class TornRecordError extends Error { /** Stable machine-checkable discriminator (errno-style). */ public readonly code = 'TORN_RECORD' /** Storage-root-relative path of the torn object. */ public readonly path: string /** The underlying decode failure (SyntaxError, zlib error, …). */ public override readonly cause: unknown /** * @param path - Storage-root-relative path of the torn object. * @param cause - The underlying decode failure. */ constructor(path: string, cause: unknown) { const causeMessage = cause instanceof Error ? cause.message : String(cause) super( `Torn record at '${path}': file exists but cannot be decoded (${causeMessage}). ` + `This is storage corruption, not absence — the record was not silently skipped.` ) this.name = 'TornRecordError' this.path = path this.cause = cause } } /** * @description True IFF `e` is a torn-record error — matches by `instanceof` * first, then by `name`/`code` so errors crossing module-duplication or realm * boundaries are still recognized. * @param e - The caught value. * @returns Whether `e` denotes an existing-but-undecodable stored object. */ export function isTornRecordError(e: unknown): e is TornRecordError { if (e instanceof TornRecordError) return true if (e === null || typeof e !== 'object') return false const { name, code } = e as { name?: unknown; code?: unknown } return name === 'TornRecordError' || code === 'TORN_RECORD' } /** * @description True IFF `e` is a payload-decode failure — the file's BYTES were * read fine but could not be turned back into an object: `SyntaxError` from * `JSON.parse`, or a zlib error (`Z_DATA_ERROR`, `Z_BUF_ERROR`, …) from gunzip. * Distinguishes "torn record" from real I/O faults (EIO/EACCES/…), which must * propagate as themselves. * @param e - The caught value. * @returns Whether the error means "bytes present, content undecodable". */ export function isUnparseablePayloadError(e: unknown): boolean { if (e === null || typeof e !== 'object') return false if (e instanceof SyntaxError) return true const { name, code } = e as { name?: unknown; code?: unknown } if (name === 'SyntaxError') return true return typeof code === 'string' && code.startsWith('Z_') } /** Per-process torn-record gauge state (module-scoped; see the accessors). */ let tornRecordCount = 0 let lastTornRecordPath: string | null = null /** * @description Register a torn-record encounter: logs a production ERROR * naming the path, increments the per-process gauge, and returns the typed * error for the caller to throw (or to map into a documented loud degrade). * EVERY torn encounter goes through here, whatever the caller decides — * the floor is: never silent. * @param path - Storage-root-relative path of the torn object. * @param cause - The underlying decode failure. * @returns The constructed {@link TornRecordError}. */ export function registerTornRecordEncounter( path: string, cause: unknown ): TornRecordError { tornRecordCount++ lastTornRecordPath = path const error = new TornRecordError(path, cause) prodLog.error( `[Storage] TORN RECORD #${tornRecordCount}: '${path}' exists but cannot be decoded — ` + `corrupt or partially written bytes. Cause: ${ cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause) }` ) return error } /** * @description Read the per-process torn-record gauge: how many torn records * this process has encountered and the most recent path. Observability seam — * lets operators and tests confirm that corruption was seen, not swallowed. * @returns The current gauge snapshot. */ export function getTornRecordGauge(): { count: number; lastPath: string | null } { return { count: tornRecordCount, lastPath: lastTornRecordPath } } /** * @description Reset the per-process torn-record gauge to zero. Test seam only * (the gauge is process-lifetime state); production code never resets it. */ export function resetTornRecordGauge(): void { tornRecordCount = 0 lastTornRecordPath = null }