/** * The error hierarchy. * * Layer 1. One rule decides which of these you get: if edfcore cannot proceed without * inventing something, it throws; if it can proceed truthfully, it records an `EdfDiagnostic`. * There is no third category, and there is no `console` call anywhere in this package. * * `edfErrorKind` exists so consumers can discriminate without `instanceof`, which fails across * realms — an iframe, a worker, or two copies of the package in one dependency tree. */ import type { EdfDiagnostic, EdfDiagnosticCode, RecordRange } from './types.js'; /** * The supported way to tell edfcore's errors apart. Branch on `error.edfErrorKind`, not on * `instanceof`: a class identity is false across a realm boundary — an iframe, a worker, two * copies of the package in one tree — where these six strings are not. */ export type EdfErrorKind = 'format' | 'scaling' | 'range' | 'source' | 'budget' | 'channel'; const EMPTY_DIAGNOSTICS: readonly EdfDiagnostic[] = Object.freeze([]); /** * The base every error edfcore throws extends — abstract, so it is a category rather than * something to construct. A plain `RangeError` from this package is therefore deliberate and * means the opposite: the file is fine and the call was wrong. `isEdfError` is the check. */ export abstract class EdfError extends Error { abstract readonly edfErrorKind: EdfErrorKind; constructor(message: string, options?: { cause?: unknown }) { super(message, options as ErrorOptions | undefined); // `new.target.name` is the FALLBACK, for a consumer who subclasses `EdfError` themselves. // Every class in this file overwrites it with a literal in its own constructor, because // `new.target.name` reads `Function.prototype.name` — which a minifier rewrites. Bundled // with esbuild --minify, `new EdfFormatError(...).name` came out as `"t"`, in exactly the // browser build where `error.name` is what a consumer branches on (fixed in 0.3.12). this.name = new.target.name; } } /** * Every concrete error edfcore throws. * * This exists so `edfErrorKind` actually discriminates in TypeScript. `EdfError` alone * declares only the kind, so narrowing against the abstract class leaves the extra fields * (`code`, `budgetBytes`, `matchingIndices`, …) unreachable and forces a cast at every call * site. Switching on `edfErrorKind` over this union reaches them without one. */ export type AnyEdfError = | EdfFormatError | EdfScalingError | EdfRangeError | EdfSourceError | EdfBudgetError | EdfAmbiguousChannelError | EdfChannelNotFoundError; /** * Returns true for any error thrown by edfcore, across realms. * * Checks the `edfErrorKind` brand rather than `instanceof`, which fails whenever the error * crossed a realm boundary — an iframe, a worker, or two copies of the package in one * dependency tree. * * Note that a handful of caller-error paths deliberately throw a plain `RangeError` instead * (asking for records that cannot exist, or handing an annotation channel to a sample read). * Those are bugs in the calling code rather than problems with the file, and this returns * false for them. */ export function isEdfError(value: unknown): value is AnyEdfError { return ( typeof value === 'object' && value !== null && typeof (value as { edfErrorKind?: unknown }).edfErrorKind === 'string' ); } /** * What an `EdfFormatError` is constructed from. Public because the fields become properties on * the thrown error and are worth reading there — `collected` in particular, which keeps the * diagnostics found before the fatal one rather than discarding them with the parse. */ export interface EdfFormatErrorInit { readonly code: EdfDiagnosticCode; readonly diagnostic?: EdfDiagnostic; /** Everything already found when this became fatal. See `EdfFormatError.collected`. */ readonly collected?: readonly EdfDiagnostic[]; readonly field?: string; readonly byteOffset?: number; readonly signalIndex?: number; readonly recordIndex?: number; readonly cause?: unknown; } /** * The file is wrong. Also what `strict: true` throws, carrying the diagnostic that would * otherwise have been collected. */ export class EdfFormatError extends EdfError { readonly edfErrorKind = 'format' as const; readonly code: EdfDiagnosticCode; readonly diagnostic: EdfDiagnostic | undefined; /** * The diagnostics already collected when this one turned out to be fatal, in the order they * were found. Empty when the fatal was raised before any collection existed. * * A header parse accumulates as it goes and reaches its fatal checks last, so by the time one * throws it may have found several defects that have nothing to do with the fatal — and the * fatal is often the least informative of the set. `inspectEdf` reports these alongside it; * before 0.3.18 they were discarded with the sink and only the fatal survived. */ readonly collected: readonly EdfDiagnostic[]; readonly field: string | undefined; readonly byteOffset: number | undefined; readonly signalIndex: number | undefined; readonly recordIndex: number | undefined; constructor(message: string, init: EdfFormatErrorInit) { super(message, { cause: init.cause }); this.name = 'EdfFormatError'; this.code = init.code; this.diagnostic = init.diagnostic; this.collected = init.collected ?? EMPTY_DIAGNOSTICS; this.field = init.field ?? init.diagnostic?.field; this.byteOffset = init.byteOffset ?? init.diagnostic?.byteOffset; this.signalIndex = init.signalIndex ?? init.diagnostic?.signalIndex; this.recordIndex = init.recordIndex ?? init.diagnostic?.recordIndex; } } /** * Physical units are unavailable for one signal, because the header's ranges do not define a * scale. `decodeDigital` still works on that signal — edfcore will not invent a gain. */ export class EdfScalingError extends EdfError { readonly edfErrorKind = 'scaling' as const; readonly code: EdfDiagnosticCode; readonly signalIndex: number; readonly label: string; constructor( message: string, init: { code: EdfDiagnosticCode; signalIndex: number; label: string; cause?: unknown }, ) { super(message, { cause: init.cause }); this.name = 'EdfScalingError'; this.code = init.code; this.signalIndex = init.signalIndex; this.label = init.label; } } /** Your bug, not the file's: you asked for records that do not exist. */ export class EdfRangeError extends EdfError { readonly edfErrorKind = 'range' as const; readonly requested: RecordRange; readonly available: RecordRange; constructor( message: string, init: { requested: RecordRange; available: RecordRange; cause?: unknown }, ) { super(message, { cause: init.cause }); this.name = 'EdfRangeError'; /* * The two FIELDS, not the argument they came from. * * `requested` is declared a `RecordRange` and was whatever the caller passed, because the range * guards hand over the value they refused. So a chunk given where its own `.records` belongs put * the entire chunk on the error — `signals`, and every sample in every `digital` array — under a * field a handler reads `.start` off and gets `undefined` from. A structured log or a * `JSON.stringify(error)` then writes a recording's samples into one line, which is the outcome * `describeRecordRange` and `quoteLabels` both exist to prevent: "printing its contents is how a * 512-signal selection ends up on one line". * * Narrowed here rather than at each guard, so no later one can reintroduce it. A well-formed * range is unchanged; a wrong shape keeps whatever it had under those two names and nothing * else, which is the same pair the message prints. */ const requested = init.requested as { start?: number; count?: number } | null | undefined; const available = init.available as { start?: number; count?: number } | null | undefined; this.requested = { start: requested?.start, count: requested?.count } as RecordRange; this.available = { start: available?.start, count: available?.count } as RecordRange; } } /** A `ByteSource` broke its contract: it returned a different number of bytes than asked. */ export class EdfSourceError extends EdfError { readonly edfErrorKind = 'source' as const; readonly offset: number; readonly requestedLength: number; readonly receivedLength: number | undefined; constructor( message: string, init: { offset: number; requestedLength: number; receivedLength?: number | undefined; cause?: unknown; }, ) { super(message, { cause: init.cause }); this.name = 'EdfSourceError'; this.offset = init.offset; this.requestedLength = init.requestedLength; this.receivedLength = init.receivedLength; } } /** * An allocation was refused before it happened. Float64 physical output is four times the * on-disk size for EDF, so without this one honest call can take down a browser tab. */ export class EdfBudgetError extends EdfError { readonly edfErrorKind = 'budget' as const; readonly requiredBytes: number; readonly budgetBytes: number; readonly optionName = 'maxMaterializeBytes' as const; constructor( message: string, init: { requiredBytes: number; budgetBytes: number; cause?: unknown }, ) { super(message, { cause: init.cause }); this.name = 'EdfBudgetError'; this.requiredBytes = init.requiredBytes; this.budgetBytes = init.budgetBytes; } } /** * Two or more signals share the requested label. Real files do this: CHB-MIT ships `T8-P8` * twice. Silently returning the first is how the wrong channel ends up in a paper. */ export class EdfAmbiguousChannelError extends EdfError { readonly edfErrorKind = 'channel' as const; readonly label: string; readonly matchingIndices: readonly number[]; constructor( message: string, init: { label: string; matchingIndices: readonly number[]; cause?: unknown }, ) { super(message, { cause: init.cause }); this.name = 'EdfAmbiguousChannelError'; this.label = init.label; this.matchingIndices = init.matchingIndices; } } /** * A signal was asked for that the file does not have, by label or by index. It carries * `availableLabels` because the useful next step is almost always to look at what the file * actually declares — recovering that from the error beats re-reading the header to find out. */ export class EdfChannelNotFoundError extends EdfError { readonly edfErrorKind = 'channel' as const; readonly selector: string | number; readonly availableLabels: readonly string[]; constructor( message: string, init: { selector: string | number; availableLabels: readonly string[]; cause?: unknown }, ) { super(message, { cause: init.cause }); this.name = 'EdfChannelNotFoundError'; /* * Only what the declared type can hold. * * `selector` is the label or index that was asked for, and every guard that raises this hands * over the value it REFUSED. One of them refuses a whole `EdfSignal`: * `signalIndices: matchSignals(header, /EEG/)` is the selection 0.6.174 exists for, and the * error for it carried the signal — `label`, `scale`, `recordByteOffset`, `raw` and the rest, * 850 bytes of it — in a field a handler reads as a number, prints beside `availableLabels` * and puts in a log line. * * A signal's own `index` is the selector the message tells the caller to pass, so that is what * the field keeps. Anything else is left empty rather than holding a value the type never * described. Narrowed here rather than at each guard, so no later one can reintroduce it — * which is the argument 0.6.213 makes for `EdfRangeError`'s `requested` and `available`. */ const selector = init.selector as unknown; const index = (selector as { index?: unknown } | null | undefined)?.index; this.selector = ( typeof selector === 'string' || typeof selector === 'number' ? selector : typeof index === 'number' ? index : undefined ) as string | number; this.availableLabels = init.availableLabels; } }