• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

graphty-org / graphty-monorepo / 37393139561

06 Oct 2026 12:11AM UTC coverage: 92.357% (+0.001%) from 92.356%
37393139561

push

github

web-flow
Merge pull request #990 from graphty-org/feat/graph-io-user-facing

feat(graph-io): simple load and save functions, OBO, CX and CYS exporters, and full documentation

49645 of 55704 branches covered (89.12%)

Branch coverage included in aggregate %.

3683 of 3796 new or added lines in 74 files covered. (97.02%)

572 existing lines in 52 files now uncovered.

77876 of 82370 relevant lines covered (94.54%)

321935.27 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.14
/graphty-element/src/data/DataSource.ts
1
import type { GraphSnapshot } from "@graphty/graph-format";
2
import {
3
    type CommonImportOptions,
4
    type GraphChoiceOptions,
5
    type GraphImporter,
6
    type GraphListing,
7
    type ImportReport,
8
    ImportReportBuilder,
9
    readText,
10
} from "@graphty/graph-io";
11
import { z } from "zod/v4";
12
import * as z4 from "zod/v4/core";
13

14
import { MIN_CONTENT_CONFIDENCE } from "../catalog/detect";
15
import { type GraphLister, publishFormatDescriptor } from "../catalog/formatRegistry";
16
import { FORMAT_DESCRIPTORS } from "../catalog/formats";
17
import { resolveOptionValues } from "../catalog/options";
18
import { type RegisterOptions, SharedImplementationMap } from "../catalog/pluginRegistry";
19
import type { FormatDescriptor } from "../catalog/types";
20
import { assertReaderAgreesWithWriter } from "../catalog/writerRegistry";
21
import { AdHocData } from "../config";
22
import { GraphtyError } from "../errors";
23
import { ErrorAggregator } from "./ErrorAggregator.js";
24
import { columnsMapping, emptyLoad, importWhole, toRecords } from "./graph-io-import.js";
25
import { type SourceData, type SourceInput, toSourceInput } from "./source-bytes.js";
26

27
/** What every reader is configured with, whatever its format. */
28
export interface BaseDataSourceConfig {
29
    /**
30
     * The file's contents, inline: text, or its bytes. Bytes are decoded by the importer, which
31
     * reads a byte-order mark and an encoding declaration; a binary format (a zip) needs them.
32
     */
33
    data?: SourceData;
34
    /** A file to read, as bytes. */
35
    file?: File;
36
    /** A URL to fetch, as bytes. */
37
    url?: string;
38
    chunkSize?: number;
39
    errorLimit?: number;
40
    /**
41
     * Which graph to read from a file that holds several, by its 0-based position. The first
42
     * when neither this nor `graphName` is given. `listGraphs` from `./catalog` lists them.
43
     */
44
    graphIndex?: number;
45
    /** Which graph to read from a file that holds several, by its name. */
46
    graphName?: string;
47
}
48

49
/**
50
 * What {@link DataSource.register} reads off a reader class.
51
 *
52
 * All three are `unknown` because registration is handed a class it has no reason to trust. A
53
 * class that forgot its `static type` used to be filed under the string "undefined", which is a
54
 * name a consumer can neither type nor debug; a class with no `static descriptor` used to be
55
 * filed as a format no picker could offer and no dropped file could be recognised as. Both are
56
 * now refused at the door, where the author can see them.
57
 */
58
interface FormatStatics {
59
    /** The name the class registers under, and the name a host asks for the format by. */
60
    readonly type?: unknown;
61
    /** What the catalogue publishes about the format. */
62
    readonly descriptor?: unknown;
63
    /** The optional content sniffer, asked after every built-in one. */
64
    readonly detect?: unknown;
65
    /** The optional lister of the graphs a file holds, for a format whose file can hold several. */
66
    readonly listGraphs?: unknown;
67
}
68

69
type DataSourceClass = (new (opts: object) => DataSource) & FormatStatics;
70
// Shared with every other copy of graphty-element on the page, so a plugin registered through one
71
// reaches them all.
72
const dataSourceRegistry = new SharedImplementationMap<DataSourceClass>("format");
490 ✔
73

74
/**
75
 * The keys in an options object that belong to the ELEMENT rather than to the format.
76
 *
77
 * Three of them are how bytes arrive and two are the limits the base class applies, all declared
78
 * by {@link BaseDataSourceConfig}. The rest are added on the caller's behalf by
79
 * `Graph.loadFromFile` and `Graph.loadFromUrl`, which pass the file's name and size and the
80
 * configured identity paths to whichever format was chosen. A format never declares any of them,
81
 * so {@link DataSource.resolveOptions} skips them rather than reporting them as options the
82
 * format has never heard of -- while a format that DOES declare one of these names (the JSON
83
 * reader declares all three identity paths) still has the caller's value checked and returned.
84
 */
85
const ELEMENT_OWNED_OPTIONS: ReadonlySet<string> = new Set([
490 ✔
86
    "data",
87
    "file",
88
    "url",
89
    "chunkSize",
90
    "errorLimit",
91
    "filename",
92
    "size",
93
    "format",
94
    "nodeIdPath",
95
    "edgeSrcIdPath",
96
    "edgeDstIdPath",
97
    "graphIndex",
98
    "graphName",
99
]);
100

101
/**
102
 * Refuse a registration, naming the field its author has to fix.
103
 * @param field - The static or the descriptor member that is wrong.
104
 * @param message - What is wrong with it, in a sentence.
105
 * @throws Always: a `GraphtyError` with `E_BAD_COMMAND`.
106
 */
107
function refuseRegistration(field: string, message: string): never {
108
    throw new GraphtyError({
8 ✔
109
        code: "E_BAD_COMMAND",
110
        message,
111
        source: "registry",
112
        details: { kind: "format", field },
113
    });
114
}
115

116
/**
117
 * Check the description a reader class carries before the catalogue publishes it.
118
 *
119
 * Everything checked here is something a consumer would otherwise meet much later and much
120
 * further away: a format with no extensions is one no dropped file can be recognised as, and a
121
 * format claiming it can be written is a "Save as" menu entry that saves nothing, because there
122
 * is no writer seam to register into.
123
 * @param type - The name the class registers under.
124
 * @param value - The class's `static descriptor`, untrusted.
125
 * @returns The descriptor.
126
 * @throws A `GraphtyError` with `E_BAD_COMMAND` naming the field that is wrong.
127
 */
128
function readFormatDescriptor(type: string, value: unknown): FormatDescriptor {
129
    if (value === null || typeof value !== "object") {
19 ✔
130
        refuseRegistration(
1 ✔
131
            "descriptor",
132
            `the format "${type}" registered no description, so nothing could offer it: declare ` +
133
                "`static descriptor: FormatDescriptor` on the class",
134
        );
135
    }
136

137
    const fields = value as { id?: unknown; extensions?: unknown; mimeTypes?: unknown; canExport?: unknown };
18 ✔
138

139
    if (fields.id !== type) {
18 ✔
140
        refuseRegistration(
1 ✔
141
            "descriptor.id",
142
            `the reader registered as "${type}" describes itself as "${String(fields.id)}"; ` +
143
                "one format has one name, and the two halves disagreeing is how a catalogue entry " +
144
                "comes to name a reader nothing registered",
145
        );
146
    }
147

148
    if (!Array.isArray(fields.extensions) || fields.extensions.length === 0) {
17 ✔
149
        refuseRegistration(
1 ✔
150
            "descriptor.extensions",
151
            `the format "${type}" claims no file extension, so no file could ever be recognised as it`,
152
        );
153
    }
154

155
    for (const extension of fields.extensions) {
16 ✔
156
        if (typeof extension !== "string" || !extension.startsWith(".") || extension !== extension.toLowerCase()) {
17 !
157
            refuseRegistration(
×
158
                "descriptor.extensions",
159
                `the format "${type}" claims the extension ${JSON.stringify(extension)}; an extension is a ` +
160
                    "lower-case string beginning with a dot, because that is what a file name is compared against",
161
            );
162
        }
163
    }
164

165
    if (!Array.isArray(fields.mimeTypes) || fields.mimeTypes.length === 0) {
16 !
166
        refuseRegistration(
×
167
            "descriptor.mimeTypes",
168
            `the format "${type}" declares no media type, which is what a file picker filters on`,
169
        );
170
    }
171

172
    if (fields.canExport === true) {
16 ✔
173
        refuseRegistration(
1 ✔
174
            "descriptor.canExport",
175
            `the reader "${type}" says its format can be written; a writer is registered with ` +
176
                "registerFormatWriter, which marks the catalogue entry, so a reader's descriptor keeps canExport: false",
177
        );
178
    }
179

180
    return value as FormatDescriptor;
15 ✔
181
}
182

183
/**
184
 * Check the optional content sniffer a reader class carries.
185
 * @param type - The name the class registers under.
186
 * @param value - The class's `static detect`, untrusted.
187
 * @returns The sniffer, or undefined when the class declares none.
188
 * @throws A `GraphtyError` with `E_BAD_COMMAND` when it is declared and is not a function.
189
 */
190
function readFormatDetector(type: string, value: unknown): ((sample: string) => boolean) | undefined {
191
    if (value === undefined) {
21 ✔
192
        return undefined;
11 ✔
193
    }
194

195
    if (typeof value !== "function") {
10 ✔
196
        refuseRegistration(
1 ✔
197
            "detect",
198
            `the format "${type}" declares a \`static detect\` that is not a function, so no file could be sniffed`,
199
        );
200
    }
201

202
    return value as (sample: string) => boolean;
9 ✔
203
}
204

205
/**
206
 * Check the optional graph lister a reader class carries.
207
 * @param type - The name the class registers under.
208
 * @param value - The class's `static listGraphs`, untrusted.
209
 * @returns The lister, or undefined when the class declares none.
210
 * @throws A `GraphtyError` with `E_BAD_COMMAND` when it is declared and is not a function.
211
 */
212
function readGraphLister(type: string, value: unknown): GraphLister | undefined {
213
    if (value === undefined) {
20 ✔
214
        return undefined;
17 ✔
215
    }
216

217
    if (typeof value !== "function") {
3 ✔
218
        refuseRegistration(
1 ✔
219
            "listGraphs",
220
            `the format "${type}" declares a \`static listGraphs\` that is not a function, so no file's graphs could be listed`,
221
        );
222
    }
223

224
    return value as GraphLister;
2 ✔
225
}
226

227
export interface DataSourceChunk {
228
    nodes: AdHocData[];
229
    edges: AdHocData[];
230
}
231

232
/** How {@link DataSource.fromImporter} runs the importer it wraps. */
233
export interface ImporterSourceOptions<Opts> {
234
    /**
235
     * Options handed to the importer on every load. A descriptor option the host passes is laid
236
     * over them; a descriptor option the host leaves out fills its default only where these name
237
     * no value. `ids` defaults to "string", so an id stays the text the file wrote.
238
     */
239
    readonly importOptions?: Partial<Opts & CommonImportOptions>;
240
    /**
241
     * The words in the file that stated its direction, which the element shows beside it
242
     * (`directednessSource.statedBy`). Return null when the file stated none: the element's own
243
     * `data.directed` setting then stands. Left out, no file states a direction: a graph-io
244
     * importer applies its own default when the file is silent (CSV's is directed), and the
245
     * adapter cannot tell that default from a statement, so it claims none on the file's behalf.
246
     * @param snapshot - the imported graph; `snapshot.directed` is the direction the importer read
247
     * @param report - the importer's report
248
     * @returns the words, or null
249
     */
250
    readonly statedBy?: (snapshot: GraphSnapshot, report: ImportReport) => string | null;
251
}
252

253
/** The registrable reader {@link DataSource.fromImporter} returns. */
254
export type ImporterDataSourceClass = (new (config: BaseDataSourceConfig) => DataSource) & {
255
    readonly type: string;
256
    readonly descriptor: FormatDescriptor;
257
};
258

259
/**
260
 * What a file said about its own direction, as its parser read it.
261
 *
262
 * A graph is directed or it is not, and the snapshot carries exactly one flag for the whole
263
 * graph, so this is one boolean and not a per-edge answer. `null` from a data source means the
264
 * FILE said nothing -- an edge list with no header that names direction, a JSON document with no
265
 * `directed` key -- and a source that says nothing leaves the element's own `data.directed`
266
 * configuration standing, which is what "auto" means.
267
 */
268
export interface DeclaredDirection {
269
    /** True when the file declares a directed graph. */
270
    readonly directed: boolean;
271
    /**
272
     * The part of the file that said so, spelled the way the file spells it
273
     * (`defaultedgetype="undirected"`, `digraph`, `*Arcs`), so that a log line naming a
274
     * disagreement can point at the text a reader can go and look at.
275
     */
276
    readonly statedBy: string;
277
    /**
278
     * How many edges carried a direction of their own that disagrees with `directed`.
279
     *
280
     * Formats that can state direction twice -- GEXF's per-edge `type`, GraphML's per-edge
281
     * `directed`, a Pajek file with both an `*Arcs` and an `*Edges` section, a Gephi CSV whose
282
     * `Type` column varies by row -- can describe a MIXED graph, which neither graph-format nor
283
     * the element can represent: the snapshot has one direction flag. The importers resolve the
284
     * disagreement rather than dropping half of it, and count what they overrode here so the
285
     * element can say out loud that it did.
286
     *
287
     * Zero for a format that cannot state direction per edge, and for one that can but did not.
288
     */
289
    readonly conflictingEdges: number;
290
}
291

292
/**
293
 * Base class for all data source implementations that load graph data from various formats.
294
 * Provides common functionality for validation, chunking, error handling, and data fetching.
295
 */
296
export abstract class DataSource {
297
    static readonly type: string;
298
    static readonly DEFAULT_CHUNK_SIZE = 1000;
490 ✔
299

300
    /**
301
     * What the catalogue publishes about this format: its plain name, the extensions and media
302
     * types its files carry, and the options a host can configure reading it with.
303
     *
304
     * REQUIRED OF A REGISTERED FORMAT and refused without it, because a reader filed with no
305
     * description is one a picker cannot offer, `formatDescriptor` cannot find and a dropped file
306
     * cannot be recognised as. It is a static on the class so that one registration is the only
307
     * registration: a description filed separately from its reader would be a catalogue entry a
308
     * consumer can see, select, and then be told does not exist.
309
     *
310
     * The element's own seven do not set it -- their descriptions are the frozen built-in table
311
     * `./catalog` publishes, which is what keeps that table meaning "what the element ships".
312
     */
313
    static descriptor?: FormatDescriptor;
314

315
    /**
316
     * Reads the first bytes of a file and says whether this format claims it.
317
     *
318
     * Optional, and asked only after every built-in sniffer has been asked, so a registered format
319
     * can claim a file the element could not already read and can never take one a built-in
320
     * claims. It is also what tells two formats apart when both claim an extension -- which is how
321
     * a third party's XML dialect gets the disambiguation GraphML and GEXF get by namespace.
322
     * A sniffer that throws is treated as "no" rather than failing the import.
323
     */
324
    static detect?: (sample: string) => boolean;
325

326
    /**
327
     * Lists the graphs a file of this format holds, for a format whose file can hold several (a
328
     * Cytoscape session's networks). Optional: a format that declares it accepts the
329
     * `graphIndex` and `graphName` options, and `listGraphs` from `./catalog` asks it; a format
330
     * that does not reads its one graph and refuses a choice of any other. `fromImporter` sets it
331
     * from the importer's own `listGraphs`.
332
     */
333
    static listGraphs?: GraphLister;
334

335
    edgeSchema: z4.$ZodObject | null = null;
1,629 ✔
336
    nodeSchema: z4.$ZodObject | null = null;
1,629 ✔
337
    protected errorAggregator: ErrorAggregator;
338
    protected chunkSize: number;
339
    /** Backing field of {@link declaredDirection}; written only by {@link declareDirection}. */
340
    private declaration: DeclaredDirection | null = null;
1,629 ✔
341

342
    /**
343
     * Creates a new DataSource instance.
344
     * @param errorLimit - Maximum number of errors before stopping data processing
345
     * @param chunkSize - Number of nodes to process per chunk
346
     */
347
    constructor(errorLimit = 100, chunkSize = DataSource.DEFAULT_CHUNK_SIZE) {
3,258 ✔
348
        this.errorAggregator = new ErrorAggregator(errorLimit);
1,629 ✔
349
        this.chunkSize = chunkSize;
1,629 ✔
350
    }
351

352
    // abstract init(): Promise<void>;
353
    abstract sourceFetchData(): AsyncGenerator<DataSourceChunk, void, unknown>;
354

355
    /**
356
     * Subclasses must implement this to expose their config
357
     * Used by getContent() and other shared methods
358
     */
359
    protected abstract getConfig(): BaseDataSourceConfig;
360

361
    /**
362
     * Standardized error message templates
363
     * @returns Object containing error message template functions
364
     */
365
    protected get errorMessages(): {
366
        missingInput: () => string;
367
        fetchFailed: (url: string, attempts: number, error: string) => string;
368
        parseFailed: (error: string) => string;
369
        invalidFormat: (reason: string) => string;
370
        extractionFailed: (path: string, error: string) => string;
371
    } {
372
        return {
9 ✔
373
            missingInput: () => `${this.type}DataSource requires data, file, or url`,
9 ✔
374

375
            fetchFailed: (url: string, attempts: number, error: string) =>
376
                `Failed to fetch ${this.type} from ${url} after ${attempts} attempts: ${error}`,
×
377

378
            parseFailed: (error: string) => `Failed to parse ${this.type}: ${error}`,
×
379

380
            invalidFormat: (reason: string) => `Invalid ${this.type} format: ${reason}`,
×
381

382
            extractionFailed: (path: string, error: string) => `Failed to extract data using path '${path}': ${error}`,
×
383
        };
384
    }
385

386
    /**
387
     * Fetch with retry logic and timeout
388
     * Protected method for use by all DataSources
389
     * @param url - URL to fetch from
390
     * @param retries - Number of retry attempts on failure
391
     * @param timeout - Timeout in milliseconds for each attempt
392
     * @returns Promise resolving to the fetch Response
393
     */
394
    protected async fetchWithRetry(url: string, retries = 3, timeout = 30000): Promise<Response> {
280 ✔
395
        // Data URLs don't need retries or timeouts
396
        if (url.startsWith("data:")) {
140 ✔
397
            return await fetch(url);
11 ✔
398
        }
399

400
        // The status of the last response that came back but was not ok, so the coded failure can
401
        // carry it: a consumer that wants to tell "not found" from "forbidden" reads details.status
402
        // rather than parsing the message.
403
        let lastStatus: number | undefined;
404

405
        for (let attempt = 0; attempt < retries; attempt++) {
129 ✔
406
            try {
142 ✔
407
                // Create AbortController for timeout
408
                const controller = new AbortController();
142 ✔
409
                const timeoutId = setTimeout(() => {
142 ✔
410
                    controller.abort();
×
411
                }, timeout);
412

413
                try {
142 ✔
414
                    const response = await fetch(url, { signal: controller.signal });
142 ✔
415
                    clearTimeout(timeoutId);
128 ✔
416

417
                    if (!response.ok) {
128 ✔
418
                        lastStatus = response.status;
5 ✔
419
                        const { status } = response;
5 ✔
420
                        // A client error (404, 401, 403...) will answer the same on every retry,
421
                        // so fail at once and say the same call cannot succeed. 408 and 429 are
422
                        // the two client statuses that mean "try again later".
423
                        if (status >= 400 && status < 500 && status !== 408 && status !== 429) {
5 ✔
424
                            throw new GraphtyError({
1 ✔
425
                                code: "E_FETCH_FAILED",
426
                                message: `Failed to fetch from ${url}: HTTP ${status}`,
427
                                source: "data",
428
                                recoverable: false,
429
                                details: { url, attempts: attempt + 1, status },
430
                            });
431
                        }
432

433
                        throw new Error(`HTTP error! status: ${status}`);
4 ✔
434
                    }
435

436
                    return response;
123 ✔
437
                } catch (error) {
438
                    clearTimeout(timeoutId);
19 ✔
439

440
                    if (error instanceof Error && error.name === "AbortError") {
19 !
441
                        throw new Error(`Request timeout after ${timeout}ms`);
×
442
                    }
443

444
                    throw error;
19 ✔
445
                }
446
            } catch (error) {
447
                if (error instanceof GraphtyError) {
19 ✔
448
                    throw error;
1 ✔
449
                }
450

451
                const isLastAttempt = attempt === retries - 1;
18 ✔
452

453
                if (isLastAttempt) {
18 ✔
454
                    const errorMsg = error instanceof Error ? error.message : String(error);
5 !
455
                    throw new GraphtyError({
5 ✔
456
                        code: "E_FETCH_FAILED",
457
                        message: `Failed to fetch from ${url} after ${retries} attempts: ${errorMsg}`,
458
                        source: "data",
459
                        // A server that was down, a network that dropped: the same call unchanged
460
                        // could succeed later, which is exactly what this flag is for.
461
                        recoverable: true,
462
                        details: { url, attempts: retries, status: lastStatus },
463
                        cause: error,
464
                    });
465
                }
466

467
                // Exponential backoff: wait 1s, 2s, 4s...
468
                const delay = Math.pow(2, attempt) * 1000;
13 ✔
469
                await new Promise((resolve) => setTimeout(resolve, delay));
13 ✔
470
            }
471
        }
472

473
        // Should never reach here
474
        throw new GraphtyError({
13 ✔
475
            code: "E_FETCH_FAILED",
476
            message: `Failed to fetch from ${url} after ${retries} attempts`,
477
            source: "data",
478
            recoverable: true,
479
            details: { url, attempts: retries, status: lastStatus },
480
        });
481
    }
482

483
    /**
484
     * The source's contents as text: inline text as it is, and anything else -- bytes, a file, a
485
     * URL -- decoded the way graph-io decodes it (a byte-order mark names the encoding, UTF-8
486
     * otherwise, the mark itself dropped). For a reader that parses text itself; one that hands
487
     * the file to a graph-io importer calls {@link getInput} instead, so the importer can read
488
     * an encoding declaration too.
489
     * @returns The text.
490
     * @throws A `GraphtyError` with `E_PARSE_FAILED` when the bytes cannot be decoded, `E_EMPTY_LOAD` when there are none.
491
     */
492
    protected async getContent(): Promise<string> {
493
        const input = await this.getInput();
1,257 ✔
494
        if (typeof input === "string") {
1,252 ✔
495
            return input;
1,086 ✔
496
        }
497

498
        try {
166 ✔
499
            return await readText(input, new ImportReportBuilder(this.type, 0));
166 ✔
500
        } catch (error) {
501
            const empty = emptyLoad(error, this.type);
502
            if (empty !== null) {
503
                throw empty;
504
            }
1 ✔
505

506
            // Bytes that are not text in any encoding the decoder could settle on (invalid UTF-8
507
            // after valid non-ASCII UTF-8): the file cannot be read, and the caller is told so
1 !
508
            // with a code, not graph-io's bare ImportError.
509
            throw GraphtyError.wrap(error, {
510
                code: "E_PARSE_FAILED",
511
                source: "data",
512
                message: `Failed to read the ${this.type} file as text: ${error instanceof Error ? error.message : String(error)}`,
513
                details: { format: this.type },
514
            });
515
        }
516
    }
517

518
    /**
519
     * The source's contents as bytes: inline bytes as they are, inline text as UTF-8, a file's
520
     * bytes, or a URL's (fetched with retries). Nothing is decoded, so a binary format -- a zip --
521
     * arrives intact.
2 ✔
522
     * @returns The bytes.
2 ✔
523
     * @throws An `Error` naming the reader when the configuration has no data, file or URL.
524
     */
525
    protected async getBytes(): Promise<Uint8Array> {
526
        const input = await this.getInput();
527
        return typeof input === "string" ? new TextEncoder().encode(input) : input;
528
    }
529

530
    /**
531
     * The source's contents as a graph-io importer's input: inline text as the caller gave it,
532
     * and everything else as bytes for the importer to decode.
1,562 ✔
533
     * @returns The text or the bytes.
534
     * @throws An `Error` naming the reader when the configuration has no data, file or URL.
1,562 ✔
535
     */
1,396 ✔
536
    protected async getInput(): Promise<SourceInput> {
537
        const config = this.getConfig();
538

166 ✔
539
        if (config.data !== undefined) {
20 ✔
540
            return toSourceInput(config.data);
541
        }
542

146 ✔
543
        if (config.file) {
137 ✔
544
            return new Uint8Array(await config.file.arrayBuffer());
131 ✔
545
        }
546

547
        if (config.url) {
9 ✔
548
            const response = await this.fetchWithRetry(config.url);
549
            return new Uint8Array(await response.arrayBuffer());
550
        }
551

552
        throw new Error(this.errorMessages.missingInput());
553
    }
554

555
    /**
556
     * The graph the caller chose, checked.
557
     *
558
     * A format whose file can hold several graphs declares `static listGraphs` and reads the
559
     * one this names. Any other format holds one graph, so `graphIndex: 0` is accepted and any
560
     * other choice is refused rather than quietly loading the one graph there is.
561
     * @returns The choice, with only the keys the caller set.
562
     * @throws A `GraphtyError` with `E_OPTION_RANGE` for a `graphIndex` that is not a
2,645 ✔
563
     * non-negative integer, a `graphName` that is not a string, both at once, or a choice this
2,645 ✔
564
     * format cannot honour.
7 ✔
565
     */
566
    protected graphChoice(): GraphChoiceOptions {
567
        const { graphIndex, graphName } = this.getConfig() as { graphIndex?: unknown; graphName?: unknown };
568
        const refuse = (option: string, value: unknown, message: string): never => {
569
            throw new GraphtyError({
570
                code: "E_OPTION_RANGE",
571
                message,
572
                source: "config",
2,645 ✔
573
                details: { kind: "format", id: this.type, option, value },
2,693 ✔
574
            });
575
        };
576

3 ✔
577
        if (
578
            graphIndex !== undefined &&
579
            (typeof graphIndex !== "number" || !Number.isInteger(graphIndex) || graphIndex < 0)
580
        ) {
581
            refuse(
582
                "graphIndex",
583
                graphIndex,
2,642 ✔
584
                `"graphIndex" takes a non-negative integer, not ${JSON.stringify(graphIndex)}`,
1 ✔
585
            );
586
        }
587

2,641 ✔
588
        if (graphName !== undefined && typeof graphName !== "string") {
1 ✔
589
            refuse("graphName", graphName, `"graphName" takes a string, not ${JSON.stringify(graphName)}`);
590
        }
591

2,640 ✔
592
        if (graphIndex !== undefined && graphName !== undefined) {
2,640 ✔
593
            refuse("graphName", graphName, 'pass "graphIndex" or "graphName" to choose a graph, not both');
2 ✔
594
        }
2 !
595

4 ✔
596
        const lists = (this.constructor as typeof DataSource).listGraphs !== undefined;
597
        if (!lists && (graphName !== undefined || (graphIndex !== undefined && graphIndex !== 0))) {
598
            refuse(
599
                graphName === undefined ? "graphIndex" : "graphName",
600
                graphName ?? graphIndex,
2,638 ✔
601
                `a ${this.type} file holds one graph, so there is no other graph to choose`,
2,638 ✔
602
            );
2,638 ✔
603
        }
604

605
        return {
606
            ...(graphIndex === undefined ? {} : { graphIndex: graphIndex as number }),
607
            ...(graphName === undefined ? {} : { graphName: graphName as string }),
608
        };
609
    }
610

611
    /**
612
     * Fill in the defaults this format declares and refuse a value it would not accept.
613
     *
614
     * ONE OPTIONS MECHANISM. A format declares its options as `OptionDescriptor[]` on its
615
     * `static descriptor`, which is the same plain-JSON declaration `session.catalog.formats()`
616
     * hands a host building an import dialog, and this checks the host's values against that one
617
     * declaration. The form a host renders, the catalogue entry it renders from and the values
618
     * this accepts therefore cannot disagree, and a failure is reported in the one vocabulary
619
     * every other extension point uses: `E_UNKNOWN_OPTION` with the declared names and the
620
     * nearest few, `E_OPTION_RANGE` with the range and the value passed.
621
     *
622
     * CALL IT WHEN THE OPTION SET IS CLOSED. The keys the element itself puts in an options
623
     * object are skipped, but any other undeclared key is reported -- so a format that quietly
624
     * accepts more than it declares (the CSV reader takes a separate node file and edge file it
625
     * never lists) should declare those first.
626
     * @param passed - What the host handed the source; a constructor's own argument.
627
     * @returns Every declared option, the host's value where it gave one and the declared default
628
     * everywhere else.
87 ✔
629
     * @throws A `GraphtyError` with `E_UNKNOWN_OPTION` for a name this format does not declare, or
87 !
630
     * `E_OPTION_RANGE` for a value it would not accept.
94 ✔
631
     */
87 ✔
632
    protected resolveOptions(passed: object): Record<string, unknown> {
633
        const statics = this.constructor as typeof DataSource;
87 ✔
634
        const declared = statics.descriptor?.options ?? [];
635
        const declaredNames = new Set(declared.map((option) => option.name));
636
        const relevant: Record<string, unknown> = {};
122 ✔
637

8 ✔
638
        for (const [key, value] of Object.entries(passed)) {
639
            // A declared name is checked even when the element also uses it -- the JSON reader
640
            // declares all three identity paths -- so this test comes first.
641
            if (declaredNames.has(key) || !ELEMENT_OWNED_OPTIONS.has(key)) {
87 ✔
642
                relevant[key] = value;
643
            }
644
        }
645

646
        return resolveOptionValues(declared, relevant, { kind: "format", id: this.type });
647
    }
648

649
    /**
650
     * Shared chunking helper
651
     * Yields nodes in chunks, with all edges in the first chunk
652
     * @param nodes - Array of node data objects
653
     * @param edges - Array of edge data objects
1,502 ✔
654
     * @yields DataSourceChunk objects containing chunked nodes and edges
1,559 ✔
655
     */
1,559 ✔
656
    protected *chunkData(nodes: AdHocData[], edges: AdHocData[]): Generator<DataSourceChunk, void, unknown> {
1,559 ✔
657
        // Yield nodes in chunks
658
        for (let i = 0; i < nodes.length; i += this.chunkSize) {
659
            const nodeChunk = nodes.slice(i, i + this.chunkSize);
660
            const edgeChunk = i === 0 ? edges : [];
1,378 ✔
661
            yield { nodes: nodeChunk, edges: edgeChunk };
22 ✔
662
        }
663

664
        // If no nodes but edges exist, yield edges-only chunk
665
        if (nodes.length === 0 && edges.length > 0) {
666
            yield { nodes: [], edges };
667
        }
668
    }
669

670
    /**
1,249 ✔
671
     * Get the error aggregator for this data source
672
     * @returns The ErrorAggregator instance tracking validation errors
673
     */
674
    getErrorAggregator(): ErrorAggregator {
675
        return this.errorAggregator;
676
    }
677

678
    /**
679
     * The direction this file declared, or null when it declared none.
680
     *
681
     * READ IT PER CHUNK, not once before the loop. Parsing does not begin until the first chunk
682
     * is pulled -- `getData()` is a generator over a generator -- so this is null until then, and
683
     * the caller that reads it before iterating will always read null. Reading it at the top of
684
     * each loop body gets the declaration before that chunk's edges are pushed, which is the one
1,382 ✔
685
     * thing the builder requires: it accepts a direction change only while it holds no edges.
686
     * @returns the declaration, or null when the file was silent
687
     */
688
    get declaredDirection(): DeclaredDirection | null {
689
        return this.declaration;
690
    }
691

692
    /**
693
     * Record what the file being parsed said about its own direction.
694
     *
695
     * Call it from the parse, BEFORE the first chunk is yielded, and only when the file actually
696
     * states the direction -- through a header that carries it, or through a default the format's
697
     * own specification assigns to a file that omits that header. A format that leaves direction
698
     * open must not call this at all: guessing here would overrule the element's configuration
699
     * with an invention.
256 ✔
700
     * @param directed - true when the file declares a directed graph
256 ✔
701
     * @param statedBy - the text in the file that said so, for a log line
702
     * @param conflictingEdges - how many edges declared the opposite; see {@link DeclaredDirection}
703
     */
704
    protected declareDirection(directed: boolean, statedBy: string, conflictingEdges = 0): void {
705
        this.declaration = { directed, statedBy, conflictingEdges };
706
    }
707

708
    /**
709
     * Fetches, validates, and yields graph data in chunks.
710
     * Filters out invalid nodes and edges based on schema validation.
1,525 ✔
711
     * @yields DataSourceChunk objects containing validated nodes and edges
1,525 ✔
712
     */
713
    async *getData(): AsyncGenerator<DataSourceChunk, void, unknown> {
1,560 ✔
714
        // Checked before anything is read, so a bad choice costs no download.
1,560 ✔
715
        this.graphChoice();
38 ✔
716
        for await (const chunk of this.sourceFetchData()) {
472 ✔
717
            // Filter out invalid nodes
472 ✔
718
            const validNodes: AdHocData[] = [];
238 ✔
719
            if (this.nodeSchema) {
720
                for (const n of chunk.nodes) {
721
                    const isValid = await this.dataValidator(this.nodeSchema, n);
722
                    if (isValid) {
723
                        validNodes.push(n);
1,522 ✔
724
                    }
725
                    // Invalid nodes are logged to errorAggregator but skipped
726
                }
727
            } else {
1,560 ✔
728
                validNodes.push(...chunk.nodes);
1,560 ✔
729
            }
35 ✔
730

130 ✔
731
            // Filter out invalid edges
130 ✔
732
            const validEdges: AdHocData[] = [];
129 ✔
733
            if (this.edgeSchema) {
734
                for (const e of chunk.edges) {
735
                    const isValid = await this.dataValidator(this.edgeSchema, e);
736
                    if (isValid) {
1,525 ✔
737
                        validEdges.push(e);
738
                    }
739
                }
740
            } else {
1,560 ✔
741
                validEdges.push(...chunk.edges);
1,558 ✔
742
            }
743

744
            // Only yield if we have data (or if we're not filtering)
745
            if (validNodes.length > 0 || validEdges.length > 0) {
1,458 ✔
746
                yield { nodes: validNodes, edges: validEdges };
3 ✔
747
            }
748

749
            // Stop if we've hit the error limit
750
            if (this.errorAggregator.hasReachedLimit()) {
751
                break;
752
            }
753
        }
754
    }
755

756
    /**
757
     * Validate data against schema
758
     * Returns false if validation fails (and adds error to aggregator)
759
     * Returns true if validation succeeds
760
     * @param schema - Zod schema to validate against
602 ✔
761
     * @param obj - Data object to validate
762
     * @returns Promise resolving to true if validation succeeds, false otherwise
602 ✔
763
     */
235 ✔
764
    async dataValidator(schema: z4.$ZodObject, obj: object): Promise<boolean> {
765
        const res = await z4.safeParseAsync(schema, obj);
235 ✔
766

767
        if (!res.success) {
768
            const errMsg = z.prettifyError(res.error);
769

770
            this.errorAggregator.addError({
235 ✔
771
                message: `Validation failed: ${errMsg}`,
772
                category: "validation-error",
773
            });
367 ✔
774

775
            return false; // Validation failed
776
        }
777

778
        return true; // Validation passed
779
    }
780

781
    /**
276 ✔
782
     * Gets the type identifier for this data source instance.
783
     * @returns The type string identifier
784
     */
785
    get type(): string {
786
        return (this.constructor as typeof DataSource).type;
787
    }
788

789
    /**
790
     * Register a file format: its reader, and the description everything else finds it by.
791
     *
792
     * ONE ACT, NOT TWO. Filing the class is what makes the format loadable; publishing its
793
     * `static descriptor` is what puts it in `session.catalog.formats()`, in `formatDescriptor`,
794
     * in `formatsForExtension` and in detection. They happen together because a reader with no
795
     * description is invisible to every picker and to every dropped file, and a description
796
     * without its reader is a catalogue entry a consumer can see, select, and then be told does
797
     * not exist.
798
     *
799
     * THE ELEMENT'S OWN SEVEN TAKE THE OTHER BRANCH. A class registering under a name that is
800
     * already in the built-in format table is the element registering one of its own readers; its
801
     * description is the frozen table `./catalog` publishes, and there is nothing to publish. A
802
     * SECOND registration under such a name is refused, so no plugin can change what `json` means
803
     * for a document that was saved yesterday.
804
     * @param cls - The reader class, carrying `static type` and -- unless the element ships this
805
     *   format -- `static descriptor` and an optional `static detect`.
806
     * @param options - Pass `{ strict: true }` to refuse a second registration under a name this
807
     *   build already gave to a different format, instead of replacing it with a warning.
808
     * @returns The registered class, so a declaration can be wrapped in the call.
5,111 ✔
809
     * @throws A `GraphtyError` with `E_BAD_COMMAND` naming the static or the descriptor member
810
     * that is missing or wrong, or with `E_DUPLICATE_PLUGIN` for a format name the element ships.
5,111 ✔
811
     */
1 ✔
812
    static register<T extends DataSourceClass>(cls: T, options?: RegisterOptions): T {
813
        const { type } = cls;
814

815
        if (typeof type !== "string" || type === "") {
816
            refuseRegistration(
817
                "type",
33,325 ✔
818
                "a data source registers under the name in its `static type`, and this class declares none",
5,089 ✔
819
            );
1 ✔
820
        }
821

822
        if (FORMAT_DESCRIPTORS.some((descriptor) => descriptor.id === type)) {
823
            if (dataSourceRegistry.hasOwn(type)) {
824
                throw new GraphtyError({
825
                    code: "E_DUPLICATE_PLUGIN",
826
                    message:
827
                        `"${type}" is a format the element ships and already reads, and a built-in name may ` +
828
                        "not be taken: a document that named it yesterday has to mean the same thing today",
829
                    source: "registry",
5,088 ✔
830
                    details: { kind: "format", name: type, builtIn: true },
5,088 ✔
831
                });
832
            }
833

21 ✔
834
            dataSourceRegistry.set(type, cls);
21 ✔
835
            return cls;
836
        }
837

838
        const detect = readFormatDetector(type, cls.detect);
839
        const listGraphs = readGraphLister(type, cls.listGraphs);
21 ✔
840

21 ✔
841
        // Published BEFORE the class is filed, so a refusal leaves neither half registered: a
21 ✔
842
        // reader loadable by name that no catalogue lists is the state this whole seam exists to
843
        // end, and half-succeeding here would recreate it.
844
        const descriptor = readFormatDescriptor(type, cls.descriptor);
845
        assertReaderAgreesWithWriter(descriptor);
15 ✔
846
        publishFormatDescriptor(
15 ✔
847
            {
848
                descriptor,
849
                type,
850
                ...(detect === undefined ? {} : { detect }),
851
                ...(listGraphs === undefined ? {} : { listGraphs }),
5,111 ✔
852
            },
5,111 ✔
853
            options,
854
        );
855

856
        dataSourceRegistry.set(type, cls);
857
        return cls;
858
    }
859

860
    /**
861
     * Turn a graph-io importer into a reader class, ready for {@link DataSource.register}.
862
     *
863
     * For an author who already has a `GraphImporter` (an object whose `import(input, sink,
864
     * options)` pushes nodes and edges into a builder). The class reads its input the way every
865
     * reader does -- inline `data`, a `File` or a `url` with retries -- and hands the importer
866
     * inline text as it is and anything else as bytes, which the importer decodes. When the
867
     * importer has `listGraphs`, the class has it too, so `listGraphs` from `./catalog` lists a
868
     * file's graphs and the `graphIndex` / `graphName` load options reach the importer. Each node and edge attribute the importer set becomes a key of the record under
869
     * its column name; an edge's weight becomes `weight`. A repeated node keeps its first
870
     * declaration, as the element keeps a repeated record. The importer's errors are aggregated
871
     * like any reader's, and a file the importer gives up on (it throws graph-io's `ImportError`)
872
     * fails the load with `E_PARSE_FAILED` naming the format and the line, leaving the graph on
873
     * screen as it was. The options the descriptor declares are checked against what a host
874
     * passes, filled with their defaults, and handed to the importer.
875
     *
876
     * Throw the `ImportError` re-exported by `@graphty/graphty-element/extend`, not one from your
877
     * own copy of graph-io, or the element cannot tell a refusal from a crash.
878
     * @param importer - the graph-io importer
879
     * @param descriptor - the format's catalogue entry; its `id` is the name the class registers under
880
     * @param options - fixed importer options and how the file states its direction
881
     * @returns a `DataSource` subclass whose `type` is `descriptor.id`
882
     * @throws A `GraphtyError` with `E_BAD_COMMAND`, `details.field: "importer"`, when `importer` has
883
     * no `import` method.
2,127 ✔
884
     */
885
    static fromImporter<Opts>(
2,127 ✔
886
        importer: GraphImporter<Opts>,
1 ✔
887
        descriptor: FormatDescriptor,
888
        options: ImporterSourceOptions<Opts> = {},
889
    ): ImporterDataSourceClass {
890
        if (typeof importer !== "object" || typeof (importer as Partial<GraphImporter> | null)?.import !== "function") {
891
            refuseRegistration(
892
                "importer",
2,126 ✔
893
                `the reader for "${descriptor.id}" needs a graph-io importer: an object with import(input, sink, options)`,
2,126 ✔
894
            );
2,127 ✔
895
        }
896

2,127 ✔
897
        const { importOptions = {}, statedBy } = options;
2,171 ✔
898
        const sniff = importer.sniff?.bind(importer);
2,171 ✔
899
        const list = importer.listGraphs?.bind(importer);
900

2,171 ✔
901
        return class ImporterDataSource extends DataSource {
57 ✔
902
            static override readonly type: string = descriptor.id;
903
            static override readonly descriptor: FormatDescriptor = descriptor;
11 ✔
904
            // The importer's sniffer, held to the confidence the built-in sniffers are held to.
905
            static override readonly detect =
2,171 ✔
906
                sniff === undefined
903 ✔
907
                    ? undefined
908
                    : (sample: string): boolean => sniff(new TextEncoder().encode(sample)) >= MIN_CONTENT_CONFIDENCE;
909
            // The importer's lister, handed the same fixed options its import is.
6 ✔
910
            static override readonly listGraphs =
911
                list === undefined
912
                    ? undefined
913
                    : (input: SourceInput): Promise<readonly GraphListing[]> =>
914
                          list(input, importOptions as Opts & CommonImportOptions);
915

50 ✔
916
            readonly #config: BaseDataSourceConfig;
50 ✔
917
            readonly #options: Record<string, unknown>;
50 ✔
918

919
            constructor(config: BaseDataSourceConfig) {
920
                super(config.errorLimit ?? 100, config.chunkSize);
921
                this.#config = config;
125 ✔
922
                this.#options = this.resolveOptions(config);
923
            }
924

925
            protected getConfig(): BaseDataSourceConfig {
926
                return this.#config;
927
            }
928

929
            /**
930
             * The fixed options, with the descriptor options the host passed laid over them and
40 ✔
931
             * the defaults of the ones it left out filling only what the fixed options leave open.
40 ✔
932
             * @returns the options handed to the importer
40 ✔
933
             */
31 ✔
934
            #importerOptions(): Opts & CommonImportOptions {
4 ✔
935
                const merged: Record<string, unknown> = { ...this.#options, ...importOptions };
936
                const passed = this.#config as unknown as Record<string, unknown>;
937
                for (const [name, value] of Object.entries(this.#options)) {
938
                    if (passed[name] !== undefined) {
40 ✔
939
                        merged[name] = value;
940
                    }
941
                }
942

40 ✔
943
                return { ...merged, ...this.graphChoice() } as Opts & CommonImportOptions;
944
            }
945

946
            async *sourceFetchData(): AsyncGenerator<DataSourceChunk, void, unknown> {
947
                const imported = await importWhole(
948
                    importer,
949
                    await this.getInput(),
38 ✔
950
                    this.#importerOptions(),
38 ✔
951
                    this.errorAggregator,
40 ✔
952
                    { firstDeclarationWins: true },
29 ✔
953
                );
954
                const { snapshot, report } = imported;
955
                const words = statedBy?.(snapshot, report) ?? null;
38 ✔
956
                if (words !== null) {
38 ✔
957
                    this.declareDirection(snapshot.directed, words, report.counts.expandedMixed);
958
                }
959

960
                const { nodes, edges } = toRecords(imported, columnsMapping(snapshot));
961
                yield* this.chunkData(nodes, edges);
962
            }
963
        };
964
    }
965

966
    /**
967
     * Turn a plain object into a record the element ingests.
968
     *
969
     * A record is typed `AdHocData`, which is a branded map, and no object literal carries the
970
     * brand -- so a format author writing typed records used to be forced into a double cast, and
971
     * the element's own readers write one too. The cast belongs here, once, in the element, rather
972
     * than in every reader that was ever written.
973
     *
974
     * Two keys on a node record mean something to the element beyond being data: `position`
975
     * (`{x, y, z}`, `[x, y]` or `[x, y, z]`) seeds the node's coordinates in FILE units, so a
976
     * graph that arrives with positions arrives placed; and on an edge record the configured
977
     * weight key -- `weight` unless `data.knownFields.edgeWeightPath` says otherwise -- is the
978
     * weight algorithms and styles read.
307 ✔
979
     * @param fields - The record's keys and values.
980
     * @returns The same object, typed as a record the element accepts.
981
     */
982
    static toRecord(fields: Readonly<Record<string, unknown>>): AdHocData {
983
        return fields as AdHocData;
984
    }
985

986
    /**
987
     * Turn plain objects into records the element ingests. See {@link DataSource.toRecord}.
4 ✔
988
     * @param records - The records' keys and values.
989
     * @returns The same objects, typed as records the element accepts.
990
     */
991
    static toRecords(records: readonly Readonly<Record<string, unknown>>[]): AdHocData[] {
992
        return records as AdHocData[];
993
    }
994

995
    /**
996
     * Creates a data source instance by type name.
1,372 ✔
997
     * @param type - The registered type identifier
1,372 ✔
998
     * @param opts - Configuration options for the data source
1,372 !
999
     * @returns A new data source instance or null if type not found
1,372 ✔
1000
     */
1001
    static get(type: string, opts: object = {}): DataSource | null {
UNCOV
1002
        const SourceClass = dataSourceRegistry.get(type);
×
1003
        if (SourceClass) {
1004
            return new SourceClass(opts);
1005
        }
1006

1007
        return null;
1008
    }
1009

1010
    /**
1011
     * Get all registered data source types.
1012
     * @returns Array of registered data source type names
1013
     * @since 1.5.0
1014
     * @example
1015
     * ```typescript
1016
     * const types = DataSource.getRegisteredTypes();
1017
     * console.log('Available data sources:', types);
5 ✔
1018
     * // ['csv', 'gexf', 'gml', 'graphml', 'json', 'pajek']
1019
     * ```
1020
     */
1021
    static getRegisteredTypes(): string[] {
1022
        return Array.from(dataSourceRegistry.keys()).sort();
1023
    }
1024
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc