/** * 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. */ /* * And as NUMBERS, which the narrowing above did not make them. * * "A wrong shape keeps whatever it had under those two names" was the limit this stopped at, and * the two names are declared `number`. A range refused for being the wrong TYPE — the case * 0.6.243 wrote the `mistyped` branch for — put its string or its BigInt straight onto the * error: `requested.start` was `'0'` or `0n` under a field a handler reads as a count. * * So `error.requested.start + error.requested.count` was `'01'`, or threw V8's "Cannot mix * BigInt and other types" inside the handler. The message says the values cannot be counted — * "this counts and adds them rather than using them as keys, so a BigInt or a string is not the * same value here" — and the payload beside it handed them back as though they could be. A typed * payload is the one part of an error a program acts on rather than reads, which is the whole * reason `errors.ts` carries these fields at all. * * `NaN` rather than dropping the field: both are declared and required, `Number.isFinite` is * already how a handler has to check them, and `NaN` is the one number that cannot be mistaken * for a count. `Number('0')` is not used — coercing would make the payload agree with a value * the call refused. */ const numeric = (value: unknown): number => (typeof value === 'number' ? value : Number.NaN); const requested = init.requested as { start?: unknown; count?: unknown } | null | undefined; const available = init.available as { start?: unknown; count?: unknown } | null | undefined; this.requested = { start: numeric(requested?.start), count: numeric(requested?.count) }; this.available = { start: numeric(available?.start), count: numeric(available?.count) }; } } /** 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'; /* * As NUMBERS, for the reason `EdfRangeError` above gives for its own pair (0.6.277): these three * are declared `number`, and the guards that build this error hand over the value they refused. * * `ByteSource.read` is the one that reaches it. Its own refusal says the argument "is not a * non-negative safe integer" — and then put the string `'0'` or the BigInt `0n` on `offset`, * under the field `a-request-that-went-out-on-the-global-fetch.test.ts` calls "the fields an * EdfSourceError handler branches on". A handler adding `offset + requestedLength` to retry at * the next block got `'04'`, or V8's "Cannot mix BigInt and other types". * * `receivedLength` is optional and keeps `undefined`, which is a declared value for it rather * than a stand-in: it means no read completed, which is a different fact from a read that * returned an unusable count. */ const numeric = (value: unknown): number => (typeof value === 'number' ? value : Number.NaN); this.offset = numeric(init.offset); this.requestedLength = numeric(init.requestedLength); this.receivedLength = init.receivedLength === undefined ? undefined : numeric(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; /* * `undefined` is DECLARED, because it is a value this field holds. * * The narrowing below keeps only "what the declared type can hold" and leaves everything else * empty — and `undefined` was not in that type, so the field was cast to `string | number` while * holding neither. Every selector that is not a label, an index, or a signal carrying one lands * there: a BigInt index, a plain object, `null`. * * A handler then branches `typeof selector === 'number' ? byIndex : byLabel` and looked up * `undefined` as a label, or printed "undefined" beside `availableLabels` in a log line. The * cast is what hid it: the one part of an error a program acts on rather than reads was the one * part the compiler had been told to stop checking, which is what 0.6.277 and 0.6.278 closed on * the other two payloads. * * Widened rather than substituted. `NaN` would say "an index that is not a number", and these * selectors were not indices at all. `concepts.md` gives the reason this is the honest shape: * `sampleRateHz` is `number | undefined` so that "`strictNullChecks` makes you handle it". */ readonly selector: string | number | undefined; 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; this.availableLabels = init.availableLabels; } }