A TypeScript/JavaScript library for validating MassBank record files. This library provides validation for MassBank format 2.6.0, ensuring compliance with MassBank standards for automated submission to the MassBank-data repository.
npm install massbankimport { validate } from 'massbank';
// Validate a single file
const result = await validate('path/to/MSBNK-test-TST00001.txt');
if (result.success) {
console.log('Validation passed!');
console.log('Accession:', result.accessions[0]);
} else {
console.error('❌ Validation failed:');
result.errors.forEach((error) => {
console.error(` Line ${error.line}: ${error.message}`);
});
}import { validateContent } from 'massbank';
// Validate record text without file I/O
const recordText = `ACCESSION: MSBNK-test-TST00001
RECORD_TITLE: Test Record
//
`;
const result = await validateContent(recordText, 'MSBNK-test-TST00001.txt');import { validate } from 'massbank';
import { FifoLogger } from 'fifo-logger';
const logger = new FifoLogger({ level: 'info' });
const result = await validate('record.txt', {
legacy: true, // Enable legacy mode for less strict validation
logger: logger, // Optional logger for validation messages
});import { buildRecord, validateRecord } from 'massbank';
const record = await buildRecord({
ACCESSION: 'MSBNK-test-TST00001',
RECORD_TITLE: 'Caffeine; LC-ESI-QFT; MS2',
DATE: '2026.07.29',
AUTHORS: 'Doe J',
LICENSE: 'CC BY',
CH$NAME: ['Caffeine'],
CH$FORMULA: 'C8H10N4O2',
CH$EXACT_MASS: '194.0804',
CH$SMILES: 'Cn1cnc2c1c(=O)n(C)c(=O)n2C',
CH$IUPAC: 'InChI=1S/C8H10N4O2',
AC$INSTRUMENT: 'Thermo Q Exactive',
AC$INSTRUMENT_TYPE: 'LC-ESI-QFT',
AC$MASS_SPECTROMETRY: ['MS_TYPE MS2', 'ION_MODE POSITIVE'],
PK$PEAK: [
{ mz: 300.5, intensity: 10, relativeIntensity: 100 },
{ mz: 100.25, intensity: 100, relativeIntensity: 999 },
],
});
// record.PK$PEAK is now sorted ascending by m/z; PK$NUM_PEAK and PK$SPLASH
// are derived from the sorted peaks, not trusted from the draft.
const result = await validateRecord(record);See Builder API below for what a green result does NOT mean.
The validator performs the following checks:
- Parse Validation - Ensures the record can be parsed correctly according to MassBank format 2.6.0
- ACCESSION Matching - Validates that ACCESSION field matches the filename (CRITICAL for MassBank-data repository)
- Example: File
MSBNK-test-TST00001.txtmust containACCESSION: MSBNK-test-TST00001
- Example: File
- Unrecognized Fields - Warns about unrecognized field names (helps catch typos like
RECRD_TITLEinstead ofRECORD_TITLE) - Non-Standard Characters - Warns about non-standard ASCII characters (non-blocking)
- Serialization Round-Trip - Ensures parse → serialize → compare matches exactly (guarantees no data loss)
- SPLASH Verification - Recomputes the peak-list SPLASH hash locally (offline, no network call) and compares it to the declared
PK$SPLASH; a mismatch is a blocking error. Skipped when a record has noPK$SPLASH, has no peaks, or has a peak list that can't be hashed (degenerate/unhashable peak data — such a record is still caught by the serialization round-trip check).
Annotation values containing a colon — lipid nomenclature such as [lyso_PC(alkyl-18:0,-)]-, common in metabolomics MassBank data — parse and round-trip correctly. Warnings are only computed after a successful parse, so a record must parse before it can surface any warnings.
buildRecord and validateRecord let you construct and check records programmatically instead of hand-rolling MassBank format text.
buildRecord(draft: RecordDraft) normalizes a draft into a canonical MassBankRecord. Only ACCESSION is required. It:
- Sorts both
PK$PEAKandPK$ANNOTATIONascending bymz. Peaks keep their ownintensityandrelativeIntensityattached; annotation rows keep their ownannotation/exactMass/errorPpmattached. A caller supplying either table in a deliberate order gets it silently reordered. - Derives
PK$NUM_PEAKfrom the sorted peak count — a draft-supplied value is discarded. - Recomputes
PK$SPLASHfrom the sorted peaks — a stale declared value is discarded, because a wrong SPLASH breaks cross-database matching silently, which is worse than a missing one. - Always strips
_originalfrom peaks, so the serializer can't fall back to stale round-trip text under a freshly recomputedPK$SPLASH— a peak's_originalfeeds that hash, so printing it verbatim could disagree with it. - Peaks have the same discarded-column hazard as
PK$ANNOTATION(see below), but no guard against it. The peak parser only reads the first three tokens (mz,intensity,relativeIntensity); a fourth token on a source row is silently dropped at parse time, and a non-numeric third token becomesrelativeIntensity: 0with its real text surviving only in_original.relativeIntensity. A source row100 999 abcparses torelativeIntensity: 0with_original.relativeIntensity: "abc"— plainserializeRecordreprintsabcbyte-exactly, butbuildRecordreprints0, because the bullet above always strips a peak's_original. UnlikePK$ANNOTATION, this cannot be fixed by preserving_originalinstead — a peak's_originalfeedsPK$SPLASH, so keeping it around risks the same staleness the strip above exists to prevent. This is a documentation note, not a guard: nothing currently rejects it. - Keeps
_originalon annotation rows (and the table's_PK$ANNOTATION_HEADER) when the whole table round-trips unedited, and discards both, table-wide, the moment any row's fields diverge from what its own_originalreparses to. An annotation row never feedsPK$SPLASH, so — unlike peaks — there is no staleness risk in printing an unedited row's real source text verbatim, column count and all. This is what letsbuildRecord(parseRecord(file))round-trip a real MassBank PK$ANNOTATION table with more than 4 columns (see the next bullet) instead of rejecting it outright. Editing even one row falls back to rebuilding every row from typed fields under a fresh canonical header (m/z annotation exact_mass error(ppm)), since a parsed source's custom header (e.g.m/z tentative_formula formula_count mass error(ppm)) no longer describes rebuilt rows. - Drops an empty
PK$PEAKorPK$ANNOTATIONtable, keeping the returned object's shape consistent with thePK$NUM_PEAK/PK$SPLASHdeletes below rather than carrying an empty array. An empty peak list is dropped, not hashed — it never reaches the SPLASH computation and never throws. - Drops
PK$NUM_PEAKandPK$SPLASHwhen there are no peaks, so a stale count or hash can't survive a peakless draft. - Preserves duplicate
mzvalues deliberately. A duplicate can be a real instrument artifact; dropping the row loses data, and summing it invents a reading that was never measured. - Rejects
PK$ANNOTATIONrows the format cannot express.PK$ANNOTATIONis read back by token count, not by a fixed field order, so the legal combinations ofannotation/exactMass/errorPpmare not simply "a prefix":{},{annotation}(any text),{exactMass, errorPpm},{annotation, exactMass}(only whenannotationdoesn't parse as a leading number — see below), and the full{annotation, exactMass, errorPpm}all round-trip;exactMassorerrorPpmalone, and{annotation, errorPpm}withoutexactMass, do not.annotationmust also be non-empty with no whitespace, andmz,exactMass, anderrorPpmmust all be finite. The check isNumber.parseFloat-based, not "looks like text vs. looks like a number": a numeric-leading name such as2-hydroxybenzoateis rejected in this shape, common as that is in metabolomics nomenclature. See the throwing/legal combinations below. - Rejects an edited row parsed from a real PK$ANNOTATION table whose source columns the parser did not fully map into typed fields. The parser's token-count branches are positional but not all of them account for every token: a 3-column row is only fully captured when its third column looks numeric (otherwise the parser reads
[mz, annotation]and drops the third column — a real shape in lipid nomenclature, e.g."494.35 1 [lyso_PC(alkyl-18:0,-)]-"), and a row of 5 or more columns is always read as[mz, annotation]only. An unedited row like this builds successfully — it prints as its own source text, columns and all, per the bullet above. Only once a row has been edited does rebuilding from the parsed fields become necessary, and only then would it silently drop the uncaptured column(s); this only applies to rows carrying that raw source text — a caller building a draft by hand cannot trigger it. - Rejects a non-finite or negative
relativeIntensity.relativeIntensitynever reaches the SPLASH computation, so it is the one numeric peak field that would otherwise pass through unchecked.relativeIntensityis caller-owned:buildRecordvalidates it but never computes or rescales it. The MassBank convention is intensity scaled against the base peak (commonly to 999 or to 100), but the format does not fix which scale a given record uses, and deriving it on abuildRecord(parseRecord(file))round trip would silently rescale a value that was already correct in the source. - Rejects a peak that
calculateSplashcannot hash. A peak'smzorintensitymust be finite,intensitymust not be negative, and the wholePK$PEAKtable must not have everyintensityat0(no base peak left to normalize against) — the same conditionscalculateSplashitself refuses to hash, folded in here as ordinaryBuildErrors (PEAK_MZ_NOT_FINITE,PEAK_INTENSITY_NOT_FINITE,PEAK_INTENSITY_NEGATIVE,PEAK_ALL_ZERO_INTENSITY) rather than a separate exception type from the same call. A negativemzis the one related condition NOT reused fromcalculateSplash: its histogram binning aliases a negativemzonto the same bin as a small non-negative one instead of raising an error, sobuildRecordrejects it (PEAK_MZ_NEGATIVE) precisely becausecalculateSplash's own answer there would be silently wrong, not because it agrees with it. - Rejects a
PK$ANNOTATIONrow's_originalthat cannot be trusted to reparse safely._originalis written verbatim into the output whenever a table round-trips unedited (see the bullet above), so a caller-smuggled_originalcontaining a newline or carriage return would inject the text that follows it as forged annotation rows or a forged header field once reparsed (ANNOTATION_ORIGINAL_LINE_INJECTION); one that reparses as a real header field with an invalid value, or that reparses to more than one row, is rejected asANNOTATION_ORIGINAL_UNREADABLEinstead of letting the parser's own exception escapebuildRecorduncaught. Neither is reachable through the ordinary construction path —Annotation(the caller-facing type) has no_originalfield at all — only when a parsedMassBankRecordis passed through as a wider-typed variable. - Rejects an
ACCESSIONthat could not be read back. This covers a newline or carriage return (ACCESSIONis written as the record's first line verbatim, so either would inject the following text as forged header lines once serialized), being empty or whitespace-only (parseRecord treats an emptyACCESSIONas missing and throws on reparse), and leading or trailing whitespace (trimmed away on reparse, so the reparsed value would differ from the one supplied). - Rejects a newline or carriage return in any other field the serializer writes verbatim — a single-value field on its own line, or an element of an array-valued field (
COMMENT,CH$NAME,CH$LINK,AC$MASS_SPECTROMETRY,AC$CHROMATOGRAPHY,MS$FOCUSED_ION,MS$DATA_PROCESSING,SP$LINK, and the rest of the single-value header/CH$/AC$/SP$ fields). A newline would inject the text that follows it as forged lines once the record is reparsed. A bare carriage return with no newline reparses back to the identical string at this layer, but is rejected anyway:validateRecord's serialization round-trip rule normalizes any\rto\nbefore comparing but not on the freshly reserialized side, so a record containing one would fail that check every time. - Never mutates the draft passed in, but the returned record shares array references with it for every array-valued field it doesn't rebuild (e.g.
CH$NAME,COMMENT,AC$MASS_SPECTROMETRY) — those are copied by reference, not deep-cloned. Mutating one of those arrays on the returned record mutates the same array on the original draft.PK$PEAKandPK$ANNOTATIONare the exception: they're always rebuilt into fresh arrays.
buildRecord reports every failure it finds in one pass, not just the first — a draft with three unrelated problems throws one BuildException carrying all three, so a caller building a record editor can show the user everything wrong at once instead of fixing and resubmitting one error at a time. Each failure in error.buildErrors carries a machine-readable code (see BuildErrorCode), the top-level fieldName it's about, a rowIndex and property when the failure concerns one row or one property of a row (both absent for a whole-field failure), and a pre-formatted field display string (e.g. 'PK$ANNOTATION[3].mz') built from the three. rowIndex is draft order, not output order — buildRecord sorts PK$PEAK/PK$ANNOTATION by mz only in the record it successfully returns, and a draft that fails validation is never built, so PK$ANNOTATION[0] in an error can name a different row than record.PK$ANNOTATION[0] after a later, successful build.
import { BuildException, buildRecord } from 'massbank';
// Duplicate m/z survive intact.
await buildRecord({
ACCESSION: 'MSBNK-test-TST00001',
PK$PEAK: [
{ mz: 100.25, intensity: 100, relativeIntensity: 999 },
{ mz: 100.25, intensity: 50, relativeIntensity: 500 },
],
});
// { exactMass, errorPpm } without annotation round-trips fine — the parser
// has a dedicated 3-token recovery for "both remaining tokens are numeric".
await buildRecord({
ACCESSION: 'MSBNK-test-TST00001',
PK$ANNOTATION: [{ mz: 100.25, exactMass: 194.0804, errorPpm: 1.2 }],
});
// exactMass alone (no annotation, no errorPpm) does NOT round-trip: the
// parser reads a 2-token row as [mz, annotation] unconditionally, so this
// value would come back as annotation text, not exactMass.
try {
await buildRecord({
ACCESSION: 'MSBNK-test-TST00001',
PK$ANNOTATION: [{ mz: 100.25, exactMass: 194.0804 }],
});
} catch (error) {
if (error instanceof BuildException) {
console.log(error.buildErrors[0]);
// {
// code: 'ANNOTATION_EXACT_MASS_WITHOUT_ANNOTATION',
// fieldName: 'PK$ANNOTATION',
// rowIndex: 0,
// field: 'PK$ANNOTATION[0]',
// message: 'PK$ANNOTATION row 0 (mz 100.25): exactMass is set without annotation or errorPpm. ...',
// }
}
}
// Every failure is reported, not just the first: an empty ACCESSION next to a
// negative mz and an invalid relativeIntensity all land in one BuildException.
try {
await buildRecord({
ACCESSION: '',
PK$PEAK: [{ mz: -50, intensity: 100, relativeIntensity: -1 }],
});
} catch (error) {
if (error instanceof BuildException) {
console.log(error.buildErrors.map((e) => e.code));
// ['ACCESSION_EMPTY', 'PEAK_RELATIVE_INTENSITY_NEGATIVE', 'PEAK_MZ_NEGATIVE']
}
}
// A negative mz is rejected outright: it isn't a real peak position, and
// calculateSplash's histogram binning would otherwise alias it onto the same
// bin as a small non-negative mz instead of catching it.
try {
await buildRecord({
ACCESSION: 'MSBNK-test-TST00001',
PK$PEAK: [{ mz: -50, intensity: 100, relativeIntensity: 999 }],
});
} catch (error) {
if (error instanceof BuildException) {
console.log(error.buildErrors[0]);
// { code: 'PEAK_MZ_NEGATIVE', fieldName: 'PK$PEAK', rowIndex: 0, property: 'mz', field: 'PK$PEAK[0].mz', message: '...' }
}
}
// An all-zero-intensity spectrum can't be hashed, so buildRecord throws
// instead of silently producing a record with no PK$SPLASH. This mirrors
// calculateSplash's own refusal to hash it, folded in as a BuildError rather
// than a separate RangeError from the same call — SplashRule, on the
// validation side, calls calculateSplash on the identical condition and does
// the opposite (it skips the check, since it cannot edit an
// already-serialized record); buildRecord is about to publish a fresh one,
// so it refuses instead.
try {
await buildRecord({
ACCESSION: 'MSBNK-test-TST00001',
PK$PEAK: [{ mz: 100.25, intensity: 0, relativeIntensity: 0 }],
});
} catch (error) {
if (error instanceof BuildException) {
console.log(error.buildErrors[0]);
// { code: 'PEAK_ALL_ZERO_INTENSITY', fieldName: 'PK$PEAK', field: 'PK$PEAK', message: '...' }
}
}validateRecord(record: MassBankRecord, options?: ValidationOptions) validates a structured record by serializing it and delegating to validateContent, so the same bytes get the same verdict through either entry point.
Two limits are worth knowing:
- The filename is derived from the raw
ACCESSIONvalue (as`${record.ACCESSION}.txt`), because aMassBankRecordcarries no filename of its own — butAccessionMatchRulecompares that filename against the record'sACCESSIONfield AFTER a serialize→parse round trip, andparseRecordtrims. So this can fail on either of two unrelated things, neither needing the other: leading/trailing whitespace inACCESSION(measured:validateRecord({ ACCESSION: ' MSBNK-test-TST00001' })reports "ACCESSION mismatch" with no path separator anywhere), or a path separator inACCESSION(e.g.foo/barorfoo\bar, which trips the rule against a basename it never saw). This path never has a real external file to check against, so neither a pass nor a fail here says anything about whether a real file's name would match — a green result is not evidence that it would. Usevalidate()orvalidateContent()with the real filename to check that. - Mandatory fields and controlled vocabularies are not checked, same as
validate/validateContenttoday (see MassBank Format 2.6.0 Compliance). A record containing onlyACCESSIONreturnssuccess: true. A green result means "round-trips and passes the current rule set," not "submittable to MassBank."
This package also exports, from the package root:
parseRecordandserializeRecord— the parser and serializerbuildRecord/validateRecordare built onParseException— the errorparseRecordthrows on malformed inputBuildException— the errorbuildRecordthrows when a draft fails one or more guards- Types:
Annotation,BuildError,BuildErrorCode,MassBankRecord,ParseError,Peak, andRecordDraft
Four things to keep straight when working with these directly:
PeakandSplashPeakare different shapes.Peak(used by records and the builder) is{ mz, intensity, relativeIntensity }.SplashPeak(used by thesplashmodule, also exported from the root) is{ mz, intensity }— the SPLASH algorithm never readsrelativeIntensity. APeaksatisfiesSplashPeakstructurally, but they are declared separately — don't assume one is the other.resolveSplashFromRecordtakes record text;validateRecordtakes a record object.resolveSplashFromRecord(content)parses the text itself to reconcilePK$SPLASHagainst the peaks.validateRecord(record, options?)takes an already-structured record and serializes it before validating. The two aren't interchangeable — passing text tovalidateRecord, or a record object toresolveSplashFromRecord, is a type error.parseRecordthrowsParseException, not a plainError. It carries a structuredparseError: ParseErrorwithline,column,position, andmessage, so a caller caninstanceof ParseExceptionand read the failure location instead of string-matching the message.buildRecordthrowsBuildException, not a plainError, and carries every failure, not just the first. It carries a structuredbuildErrors: readonly BuildError[]— one entry per failure, each with a machine-readablecode: BuildErrorCode, the structuredfieldName/rowIndex/propertya caller can route on directly, and a pre-formattedfielddisplay string — so a caller caninstanceof BuildExceptionand handle every problem with a draft in one pass instead of fixing and resubmitting once per failure. See Builder API above for the fullBuildErrorCodeunion and whatrowIndexmeans.
Validate a single MassBank record file.
Parameters:
filePath: string- Path to the .txt file to validateoptions?: ValidationOptions- Optional validation options
Returns: Promise<ValidationResult>
ValidationResult:
interface ValidationResult {
success: boolean; // true if no errors
errors: ValidationError[]; // Array of validation errors
warnings: ValidationWarning[]; // Array of warnings (non-blocking)
accessions: string[]; // Extracted ACCESSION values
filesProcessed: number; // Number of files processed (always 1)
}Validate in-memory MassBank record content (no file I/O).
Parameters:
text: string- The MassBank record textfilename: string- Logical filename for error reporting (e.g., 'user-upload.txt')options?: ValidationOptions- Optional validation options
Returns: Promise<ValidationResult>
Normalize a record draft into a canonical record. See Builder API above for what it normalizes and why.
Parameters:
draft: RecordDraft- A partialMassBankRecordrequiring onlyACCESSION;PK$PEAK/PK$ANNOTATIONaccept the caller-facingPeak/Annotationshapes (no_original)
Returns: Promise<MassBankRecord>
Throws: BuildException if ACCESSION contains a newline or carriage return, is empty or whitespace-only, or has leading or trailing whitespace; if any other field the serializer writes verbatim (or an element of an array-valued one) is whitespace-padded or contains a newline or carriage return (an empty single-value field is not one of these failures; it is dropped instead, see Builder API above); if a peak's relativeIntensity, mz, or intensity is not finite, if a peak's relativeIntensity, mz, or intensity is negative, or if the whole PK$PEAK table has every intensity at 0 (the last three mirror exactly what calculateSplash itself refuses to hash — see Builder API above for how that's kept from drifting out of sync); if a PK$ANNOTATION row's _original cannot be trusted to reparse safely; or if a PK$ANNOTATION row cannot survive a round-trip (including an edited parsed row whose source columns the parser did not fully map into typed fields — an unedited one builds successfully instead, preserving its real source text) — error.buildErrors carries every failure found, not only the first, each with a code: BuildErrorCode plus the structured fieldName/rowIndex/property location described above; see Builder API above for the full legal/illegal combinations. Every condition calculateSplash would raise for is refused above first, so buildRecord never throws a plain RangeError.
Validate a structured record. See Builder API above for the two limits this entry point has.
Parameters:
record: MassBankRecord- The structured record to validateoptions?: ValidationOptions- Optional validation options, forwarded tovalidateContent
Returns: Promise<ValidationResult> (same shape as validate/validateContent)
This library enforces MassBank format 2.6.0 standards, including:
- ACCESSION format:
MSBNK-[ContributorID]-[RecordID]- Contributor ID: up to 32 characters (letters, digits, underscore)
- Record ID: up to 64 characters (capital letters, digits, underscore)
- Shown for reference; this structure is not itself validated — only that ACCESSION matches the filename (see Validation Rules above)
- Filename matching: File must be named
{ACCESSION}.txt - Required fields: ACCESSION (parsing fails without it); RECORD_TITLE, DATE, AUTHORS, LICENSE, and other format fields are not currently enforced as mandatory by this library —
validateRecord(see Builder API) shares this limit - SPLASH validation: Local, offline recomputation of the peak-list SPLASH hash, compared against the declared
PK$SPLASH(no network call)
- Node.js 20+ (see
enginesinpackage.json) - Runtime dependencies:
camelcase,cheminfo-types,ensure-string,fifo-logger.cheminfo-typesandfifo-loggerare only ever imported as types (ValidationOptions.loggerand a few others) — no runtime code from either ships inlib/— but they are listed asdependenciesrather thandevDependenciesbecause they appear in the package's shipped.d.tsfiles, so a consumer's own type-check needs them resolvable too.