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

graphty-org / graphty-element / 20514590651

26 Dec 2025 02:37AM UTC coverage: 70.559% (-0.3%) from 70.836%
20514590651

push

github

apowers313
ci: fix npm ci

9591 of 13363 branches covered (71.77%)

Branch coverage included in aggregate %.

25136 of 35854 relevant lines covered (70.11%)

6233.71 hits per line

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

77.49
/src/data/CSVDataSource.ts
1
import Papa from "papaparse";
15!
2

3
import {AdHocData} from "../config";
4
import {type CSVVariant, type CSVVariantInfo, detectCSVVariant} from "./csv-variant-detection.js";
15✔
5
import {BaseDataSourceConfig, DataSource, DataSourceChunk} from "./DataSource.js";
15✔
6

7
export interface CSVDataSourceConfig extends BaseDataSourceConfig {
8
    delimiter?: string;
9
    variant?: CSVVariant; // Allow explicit variant override
10
    sourceColumn?: string;
11
    targetColumn?: string;
12
    idColumn?: string;
13
    // For paired files
14
    nodeFile?: File;
15
    edgeFile?: File;
16
    nodeURL?: string;
17
    edgeURL?: string;
18
}
19

20
/**
21
 * Data source for loading graph data from CSV files.
22
 * Supports edge lists, adjacency lists, and paired node/edge files.
23
 */
24
export class CSVDataSource extends DataSource {
15✔
25
    static readonly type = "csv";
21✔
26

27
    private config: CSVDataSourceConfig;
28

29
    /**
30
     * Creates a new CSVDataSource instance.
31
     * @param config - Configuration options for CSV parsing and data loading
32
     */
33
    constructor(config: CSVDataSourceConfig) {
21✔
34
        super(config.errorLimit ?? 100, config.chunkSize);
49✔
35
        this.config = {
49✔
36
            delimiter: ",",
49✔
37
            chunkSize: 1000,
49✔
38
            errorLimit: 100,
49✔
39
            ... config,
49✔
40
        };
49✔
41
    }
49✔
42

43
    protected getConfig(): BaseDataSourceConfig {
21✔
44
        return this.config;
42✔
45
    }
42✔
46

47
    /**
48
     * Fetches and parses CSV data into graph chunks.
49
     * Automatically detects CSV variant (edge list, adjacency list, or paired files).
50
     * @yields DataSourceChunk objects containing parsed nodes and edges
51
     */
52
    async *sourceFetchData(): AsyncGenerator<DataSourceChunk, void, unknown> {
21✔
53
        // Handle paired files first
54
        if (
49✔
55
            this.config.nodeFile ||
49✔
56
            this.config.edgeFile ||
46✔
57
            this.config.nodeURL ||
45✔
58
            this.config.edgeURL
43✔
59
        ) {
49!
60
            yield* this.parsePairedFiles();
7✔
61
            return;
4✔
62
        }
4✔
63

64
        // Get CSV content
65
        const csvContent = await this.getContent();
42✔
66

67
        // Parse headers to detect variant
68
        const previewResult = Papa.parse(csvContent, {
41✔
69
            header: true,
41✔
70
            preview: 1,
41✔
71
            delimiter: this.config.delimiter,
41✔
72
            dynamicTyping: true,
41✔
73
            transformHeader: (header) => header.trim(),
41✔
74
        });
41✔
75

76
        const headers = previewResult.meta.fields ?? [];
42!
77

78
        // Detect or use explicit variant
79
        let variantInfo: CSVVariantInfo;
49✔
80
        if (this.config.variant) {
49!
81
            // User specified a variant - get defaults for that variant type
82
            const variantDefaults: Record<string, Partial<CSVVariantInfo>> = {
11✔
83
                "neo4j": {
11✔
84
                    hasHeaders: false,
11✔
85
                    labelColumn: ":LABEL",
11✔
86
                    typeColumn: ":TYPE",
11✔
87
                },
11✔
88
                "adjacency-list": {hasHeaders: false},
11✔
89
                "node-list": {hasHeaders: true},
11✔
90
                "edge-list": {
11✔
91
                    hasHeaders: true,
11✔
92
                    sourceColumn: "source",
11✔
93
                    targetColumn: "target",
11✔
94
                },
11✔
95
                "gephi": {
11✔
96
                    hasHeaders: true,
11✔
97
                    sourceColumn: "Source",
11✔
98
                    targetColumn: "Target",
11✔
99
                    typeColumn: "Type",
11✔
100
                    labelColumn: "Label",
11✔
101
                },
11✔
102
                "cytoscape": {
11✔
103
                    hasHeaders: true,
11✔
104
                    sourceColumn: "source",
11✔
105
                    targetColumn: "target",
11✔
106
                    interactionColumn: "interaction",
11✔
107
                },
11✔
108
                "generic": {hasHeaders: true},
11✔
109
            };
11✔
110

111
            const defaults = variantDefaults[this.config.variant] ?? {hasHeaders: true};
11!
112
            variantInfo = {
11✔
113
                variant: this.config.variant,
11✔
114
                hasHeaders: defaults.hasHeaders ?? true,
11!
115
                delimiter: this.config.delimiter ?? ",",
11!
116
                // Use user config or variant defaults
117
                sourceColumn: this.config.sourceColumn ?? defaults.sourceColumn,
11✔
118
                targetColumn: this.config.targetColumn ?? defaults.targetColumn,
11✔
119
                idColumn: this.config.idColumn ?? defaults.idColumn,
11✔
120
                labelColumn: defaults.labelColumn,
11✔
121
                typeColumn: defaults.typeColumn,
11✔
122
                interactionColumn: defaults.interactionColumn,
11✔
123
            };
11✔
124
        } else {
42✔
125
            // Auto-detect variant
126
            variantInfo = {
30✔
127
                ... detectCSVVariant(headers),
30✔
128
                // Preserve user-specified delimiter if provided
129
                delimiter: this.config.delimiter ?? detectCSVVariant(headers).delimiter,
30!
130
            };
30✔
131
        }
30✔
132

133
        // Parse full file
134
        // Neo4j format has multiple header rows, so we parse without headers
135
        const useHeaders = variantInfo.variant === "neo4j" ? false : variantInfo.hasHeaders;
49!
136
        const fullParse = Papa.parse(csvContent, {
49✔
137
            header: useHeaders,
49✔
138
            delimiter: variantInfo.delimiter,
49✔
139
            dynamicTyping: true,
49✔
140
            skipEmptyLines: true,
49✔
141
            transformHeader: (header) => header.trim(),
49✔
142
        });
49✔
143

144
        if (fullParse.errors.length > 0) {
49!
145
            // Collect parsing errors but continue if possible
146
            for (const error of fullParse.errors) {
2✔
147
                const canContinue = this.errorAggregator.addError({
5✔
148
                    message: `CSV parsing error: ${error.message}`,
5✔
149
                    line: error.row,
5✔
150
                    category: "parse-error",
5✔
151
                });
5✔
152

153
                if (!canContinue) {
5!
154
                    throw new Error(
×
155
                        `Too many CSV parsing errors (${this.errorAggregator.getErrorCount()}), aborting`,
×
156
                    );
×
157
                }
×
158
            }
5✔
159
        }
2✔
160

161
        // Route to appropriate parser based on variant
162
        switch (variantInfo.variant) {
41✔
163
            case "neo4j":
42!
164
                yield* this.parseNeo4jFormat(
3✔
165
                    fullParse.data as string[][],
3✔
166
                );
3✔
167
                break;
3✔
168
            case "gephi":
49!
169
                yield* this.parseGephiFormat(
11✔
170
                    fullParse.data as Record<string, unknown>[],
11✔
171
                    variantInfo,
11✔
172
                );
11✔
173
                break;
11✔
174
            case "cytoscape":
49!
175
                yield* this.parseCytoscapeFormat(
3✔
176
                    fullParse.data as Record<string, unknown>[],
3✔
177
                    variantInfo,
3✔
178
                );
3✔
179
                break;
3✔
180
            case "adjacency-list":
49!
181
                yield* this.parseAdjacencyList(
6✔
182
                    fullParse.data as string[][],
6✔
183
                );
6✔
184
                break;
6✔
185
            case "node-list":
49!
186
                yield* this.parseNodeList(fullParse.data as Record<string, unknown>[]);
2✔
187
                break;
2✔
188
            case "edge-list":
49✔
189
            case "generic":
49✔
190
            default:
49✔
191
                yield* this.parseEdgeList(fullParse.data as Record<string, unknown>[]);
16✔
192
                break;
16✔
193
        }
49✔
194
    }
49✔
195

196
    /**
197
     * Create an edge from CSV row data
198
     * Returns null if source or target is missing (and logs error)
199
     * @param src - Source node ID (will be converted to string)
200
     * @param dst - Target node ID (will be converted to string)
201
     * @param row - Full row data for additional properties
202
     * @param sourceColName - Name of source column (for error messages)
203
     * @param targetColName - Name of target column (for error messages)
204
     * @param rowIndex - Row index (for error messages)
205
     * @returns Edge data object or null if invalid
206
     */
207
    private createEdge(
21✔
208
        src: unknown,
5,494✔
209
        dst: unknown,
5,494✔
210
        row: Record<string, unknown>,
5,494✔
211
        sourceColName: string,
5,494✔
212
        targetColName: string,
5,494✔
213
        rowIndex: number,
5,494✔
214
    ): AdHocData | null {
5,494✔
215
        // Validate source and target exist
216
        if (src === null || src === undefined || src === "") {
5,494!
217
            this.errorAggregator.addError({
9✔
218
                message: `Missing source in row ${rowIndex} (column: ${sourceColName})`,
9✔
219
                line: rowIndex,
9✔
220
                category: "missing-data",
9✔
221
                field: "source",
9✔
222
            });
9✔
223
            return null;
9✔
224
        }
9✔
225

226
        if (dst === null || dst === undefined || dst === "") {
5,494!
227
            this.errorAggregator.addError({
4✔
228
                message: `Missing target in row ${rowIndex} (column: ${targetColName})`,
4✔
229
                line: rowIndex,
4✔
230
                category: "missing-data",
4✔
231
                field: "target",
4✔
232
            });
4✔
233
            return null;
4✔
234
        }
4✔
235

236
        // Convert to strings (CSV parsers may return numbers/booleans)
237
        const srcStr = typeof src === "string" || typeof src === "number" ? String(src) : JSON.stringify(src);
5,494!
238
        const dstStr = typeof dst === "string" || typeof dst === "number" ? String(dst) : JSON.stringify(dst);
5,494!
239

240
        // Create edge with all row properties except source/target columns
241
        // (they're now in src/dst)
242
        const edge: Record<string, unknown> = {
5,494✔
243
            src: srcStr,
5,494✔
244
            dst: dstStr,
5,494✔
245
        };
5,494✔
246

247
        // Copy all other properties from row except source/target columns
248
        for (const key in row) {
5,494✔
249
            if (key !== sourceColName && key !== targetColName) {
11,532!
250
                edge[key] = row[key];
604✔
251
            }
604✔
252
        }
11,532✔
253

254
        return edge as AdHocData;
5,481✔
255
    }
5,494✔
256

257
    private *parseEdgeList(rows: Record<string, unknown>[]): Generator<DataSourceChunk, void, unknown> {
21✔
258
        const edges: unknown[] = [];
16✔
259
        const nodeIds = new Set<string>();
16✔
260
        // Default to lowercase column names for generic edge lists
261
        const sourceCol = this.config.sourceColumn ?? "source";
16✔
262
        const targetCol = this.config.targetColumn ?? "target";
16✔
263

264
        for (let i = 0; i < rows.length; i++) {
16✔
265
            try {
5,043✔
266
                const row = rows[i];
5,043✔
267
                const edge = this.createEdge(
5,043✔
268
                    row[sourceCol],
5,043✔
269
                    row[targetCol],
5,043✔
270
                    row,
5,043✔
271
                    sourceCol,
5,043✔
272
                    targetCol,
5,043✔
273
                    i + 1,
5,043✔
274
                );
5,043✔
275

276
                if (edge) {
5,043✔
277
                    // Track unique node IDs
278
                    nodeIds.add(edge.src as string);
5,035✔
279
                    nodeIds.add(edge.dst as string);
5,035✔
280

281
                    edges.push(edge);
5,035✔
282

283
                    // Yield chunk when full
284
                    if (edges.length >= this.chunkSize) {
5,035!
285
                        yield {nodes: [] as AdHocData[], edges: edges.splice(0, this.chunkSize) as AdHocData[]};
5✔
286
                    }
5✔
287
                }
5,035✔
288
            } catch (error) {
5,043!
289
                const canContinue = this.errorAggregator.addError({
×
290
                    message: `Failed to parse row ${i + 1}: ${error instanceof Error ? error.message : String(error)}`,
×
291
                    line: i,
×
292
                    category: "parse-error",
×
293
                });
×
294

295
                if (!canContinue) {
×
296
                    throw new Error(`Too many errors (${this.errorAggregator.getErrorCount()}), aborting parse`);
×
297
                }
×
298
            }
×
299
        }
5,043✔
300

301
        // Create nodes from unique IDs and yield final chunk
302
        const nodes: unknown[] = Array.from(nodeIds).map((id) => ({id}));
16✔
303

304
        // Always yield final chunk to ensure nodes are included
305
        // (edges may have been yielded in earlier chunks, but nodes are collected at the end)
306
        yield {nodes: nodes as AdHocData[], edges: edges as AdHocData[]};
16✔
307
    }
16✔
308

309
    private *parseNodeList(
21✔
310
        rows: Record<string, unknown>[],
2✔
311
    ): Generator<DataSourceChunk, void, unknown> {
2✔
312
        const nodes: unknown[] = [];
2✔
313

314
        for (let i = 0; i < rows.length; i++) {
2✔
315
            try {
11✔
316
                const row = rows[i];
11✔
317

318
                // Try to find ID
319
                const id = row.id ?? row.Id ?? row.ID ?? String(i);
11!
320

321
                const node: Record<string, unknown> = {
11✔
322
                    id,
11✔
323
                    ... row,
11✔
324
                };
11✔
325

326
                nodes.push(node);
11✔
327

328
                // Yield chunk when full
329
                if (nodes.length >= this.chunkSize) {
11!
330
                    yield {
×
331
                        nodes: nodes.splice(0, this.chunkSize) as AdHocData[],
×
332
                        edges: [] as AdHocData[],
×
333
                    };
×
334
                }
×
335
            } catch (error) {
11!
336
                const canContinue = this.errorAggregator.addError({
×
337
                    message: `Failed to parse row ${i + 1}: ${error instanceof Error ? error.message : String(error)}`,
×
338
                    line: i,
×
339
                    category: "parse-error",
×
340
                });
×
341

342
                if (!canContinue) {
×
343
                    throw new Error(
×
344
                        `Too many errors (${this.errorAggregator.getErrorCount()}), aborting parse`,
×
345
                    );
×
346
                }
×
347
            }
×
348
        }
11✔
349

350
        // Yield remaining nodes
351
        if (nodes.length > 0) {
2✔
352
            yield {nodes: nodes as AdHocData[], edges: [] as AdHocData[]};
2✔
353
        }
2✔
354
    }
2✔
355

356
    private *parseNeo4jFormat(
21✔
357
        rows: string[][],
3✔
358
    ): Generator<DataSourceChunk, void, unknown> {
3✔
359
        // Neo4j format has multiple sections with headers
360
        // Format: header row, data rows, header row, data rows, etc.
361
        const nodes: unknown[] = [];
3✔
362
        const edges: unknown[] = [];
3✔
363

364
        let currentHeaders: string[] = [];
3✔
365
        let isNodeSection = false;
3✔
366
        let isEdgeSection = false;
3✔
367

368
        for (const row of rows) {
3✔
369
            if (row.length === 0) {
18!
370
                continue;
×
371
            }
×
372

373
            // Check if this is a header row by looking for Neo4j special columns
374
            const hasIdColumn = row.some((col) => typeof col === "string" && col.endsWith(":ID"));
18✔
375
            const hasStartEnd = row.some((col) => col === ":START_ID" || col === ":END_ID");
18✔
376

377
            if (hasIdColumn || hasStartEnd) {
18✔
378
                // This is a header row
379
                currentHeaders = row.map((h) => h.trim());
6✔
380
                isNodeSection = hasIdColumn && !hasStartEnd;
6✔
381
                isEdgeSection = hasStartEnd;
6✔
382
                continue;
6✔
383
            }
6✔
384

385
            // Process data row based on current section
386
            if (isNodeSection) {
18✔
387
                const node: Record<string, unknown> = {};
7✔
388
                for (let i = 0; i < Math.min(row.length, currentHeaders.length); i++) {
7✔
389
                    const header = currentHeaders[i];
24✔
390
                    const value = row[i];
24✔
391

392
                    if (header.endsWith(":ID")) {
24✔
393
                        node.id = value;
7✔
394
                    } else if (header === ":LABEL") {
24✔
395
                        node.label = value;
7✔
396
                    } else if (!header.startsWith(":")) {
13✔
397
                        node[header] = value;
10✔
398
                    }
10✔
399
                }
24✔
400

401
                if (node.id) {
7✔
402
                    nodes.push(node);
7✔
403
                }
7✔
404
            } else if (isEdgeSection) {
13✔
405
                const edge: Record<string, unknown> = {};
5✔
406
                for (let i = 0; i < Math.min(row.length, currentHeaders.length); i++) {
5✔
407
                    const header = currentHeaders[i];
22✔
408
                    const value = row[i];
22✔
409

410
                    if (header === ":START_ID") {
22✔
411
                        edge.src = value;
5✔
412
                    } else if (header === ":END_ID") {
22✔
413
                        edge.dst = value;
5✔
414
                    } else if (header === ":TYPE") {
17✔
415
                        edge.type = value;
5✔
416
                    } else if (!header.startsWith(":")) {
12✔
417
                        edge[header] = value;
7✔
418
                    }
7✔
419
                }
22✔
420

421
                if (edge.src && edge.dst) {
5✔
422
                    edges.push(edge);
5✔
423
                }
5✔
424
            }
5✔
425

426
            // Yield in chunks
427
            if (nodes.length >= this.chunkSize) {
18!
428
                yield {
×
429
                    nodes: nodes.splice(0, this.chunkSize) as AdHocData[],
×
430
                    edges: [],
×
431
                };
×
432
            }
✔
433

434
            if (edges.length >= this.chunkSize) {
18!
435
                yield {
×
436
                    nodes: [],
×
437
                    edges: edges.splice(0, this.chunkSize) as AdHocData[],
×
438
                };
×
439
            }
×
440
        }
18✔
441

442
        // Yield remaining
443
        if (nodes.length > 0 || edges.length > 0) {
3!
444
            yield {nodes: nodes as AdHocData[], edges: edges as AdHocData[]};
3✔
445
        }
3✔
446
    }
3✔
447

448
    private *parseGephiFormat(
21✔
449
        rows: Record<string, unknown>[],
11✔
450
        info: CSVVariantInfo,
11✔
451
    ): Generator<DataSourceChunk, void, unknown> {
11✔
452
        // Gephi uses capitalized column names: Source, Target, Type, Id, Label, Weight
453
        const edges: unknown[] = [];
11✔
454
        const nodeIds = new Set<string>();
11✔
455
        const sourceCol = info.sourceColumn ?? "Source";
11!
456
        const targetCol = info.targetColumn ?? "Target";
11!
457

458
        for (let i = 0; i < rows.length; i++) {
11✔
459
            try {
425✔
460
                const row = rows[i];
425✔
461
                const edge = this.createEdge(
425✔
462
                    row[sourceCol],
425✔
463
                    row[targetCol],
425✔
464
                    row,
425✔
465
                    sourceCol,
425✔
466
                    targetCol,
425✔
467
                    i + 1,
425✔
468
                );
425✔
469

470
                if (edge) {
425✔
471
                    // Track unique node IDs
472
                    nodeIds.add(edge.src as string);
420✔
473
                    nodeIds.add(edge.dst as string);
420✔
474

475
                    edges.push(edge);
420✔
476

477
                    // Yield chunk when full
478
                    if (edges.length >= this.chunkSize) {
420!
479
                        yield {
×
480
                            nodes: [] as AdHocData[],
×
481
                            edges: edges.splice(0, this.chunkSize) as AdHocData[],
×
482
                        };
×
483
                    }
×
484
                }
420✔
485
            } catch (error) {
425!
486
                const canContinue = this.errorAggregator.addError({
×
487
                    message: `Failed to parse row ${i + 1}: ${error instanceof Error ? error.message : String(error)}`,
×
488
                    line: i,
×
489
                    category: "parse-error",
×
490
                });
×
491

492
                if (!canContinue) {
×
493
                    throw new Error(
×
494
                        `Too many errors (${this.errorAggregator.getErrorCount()}), aborting parse`,
×
495
                    );
×
496
                }
×
497
            }
×
498
        }
425✔
499

500
        // Create nodes from unique IDs and yield final chunk
501
        const nodes: unknown[] = Array.from(nodeIds).map((id) => ({id}));
11✔
502
        yield {nodes: nodes as AdHocData[], edges: edges as AdHocData[]};
11✔
503
    }
11✔
504

505
    private *parseCytoscapeFormat(
21✔
506
        rows: Record<string, unknown>[],
3✔
507
        info: CSVVariantInfo,
3✔
508
    ): Generator<DataSourceChunk, void, unknown> {
3✔
509
        // Cytoscape has an 'interaction' column for edge type
510
        const edges: unknown[] = [];
3✔
511
        const nodeIds = new Set<string>();
3✔
512
        const sourceCol = info.sourceColumn ?? "source";
3!
513
        const targetCol = info.targetColumn ?? "target";
3!
514

515
        for (let i = 0; i < rows.length; i++) {
3✔
516
            try {
9✔
517
                const row = rows[i];
9✔
518
                const edge = this.createEdge(
9✔
519
                    row[sourceCol],
9✔
520
                    row[targetCol],
9✔
521
                    row,
9✔
522
                    sourceCol,
9✔
523
                    targetCol,
9✔
524
                    i + 1,
9✔
525
                );
9✔
526

527
                if (edge) {
9✔
528
                    // Track unique node IDs
529
                    nodeIds.add(edge.src as string);
9✔
530
                    nodeIds.add(edge.dst as string);
9✔
531

532
                    edges.push(edge);
9✔
533

534
                    if (edges.length >= this.chunkSize) {
9!
535
                        yield {
×
536
                            nodes: [] as AdHocData[],
×
537
                            edges: edges.splice(0, this.chunkSize) as AdHocData[],
×
538
                        };
×
539
                    }
×
540
                }
9✔
541
            } catch (error) {
9!
542
                const canContinue = this.errorAggregator.addError({
×
543
                    message: `Failed to parse row ${i + 1}: ${error instanceof Error ? error.message : String(error)}`,
×
544
                    line: i,
×
545
                    category: "parse-error",
×
546
                });
×
547

548
                if (!canContinue) {
×
549
                    throw new Error(
×
550
                        `Too many errors (${this.errorAggregator.getErrorCount()}), aborting parse`,
×
551
                    );
×
552
                }
×
553
            }
×
554
        }
9✔
555

556
        const nodes: unknown[] = Array.from(nodeIds).map((id) => ({id}));
3✔
557
        yield {nodes: nodes as AdHocData[], edges: edges as AdHocData[]};
3✔
558
    }
3✔
559

560
    private *parseAdjacencyList(
21✔
561
        rows: string[][],
6✔
562
    ): Generator<DataSourceChunk, void, unknown> {
6✔
563
        // Format: each row is [node, neighbor1, neighbor2, ...]
564
        // Can optionally have weights: node neighbor1:weight1 neighbor2:weight2
565
        const edges: unknown[] = [];
6✔
566
        const nodeIds = new Set<string>();
6✔
567

568
        for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
6✔
569
            const row = rows[rowIndex];
17✔
570
            if (row.length < 2) {
17!
571
                continue;
7✔
572
            }
7✔
573

574
            const sourceNode = row[0];
10✔
575
            nodeIds.add(sourceNode);
10✔
576

577
            // Process neighbors
578
            for (let j = 1; j < row.length; j++) {
10✔
579
                const neighbor = row[j];
17✔
580
                if (!neighbor) {
17!
581
                    continue;
×
582
                }
×
583

584
                // Check for weight notation: neighbor:weight
585
                let targetNode = neighbor;
17✔
586
                const rowData: Record<string, unknown> = {};
17✔
587

588
                if (neighbor.includes(":")) {
17✔
589
                    const parts = neighbor.split(":");
12✔
590
                    targetNode = parts[0];
12✔
591
                    const weight = parseFloat(parts[1]);
12✔
592
                    if (!isNaN(weight)) {
12✔
593
                        rowData.weight = weight;
12✔
594
                    }
12✔
595
                }
12✔
596

597
                const edge = this.createEdge(
17✔
598
                    sourceNode,
17✔
599
                    targetNode,
17✔
600
                    rowData,
17✔
601
                    "source",
17✔
602
                    "target",
17✔
603
                    rowIndex + 1,
17✔
604
                );
17✔
605

606
                if (edge) {
17✔
607
                    nodeIds.add(edge.src as string);
17✔
608
                    nodeIds.add(edge.dst as string);
17✔
609

610
                    edges.push(edge);
17✔
611

612
                    if (edges.length >= this.chunkSize) {
17!
613
                        yield {
×
614
                            nodes: [] as AdHocData[],
×
615
                            edges: edges.splice(0, this.chunkSize) as AdHocData[],
×
616
                        };
×
617
                    }
×
618
                }
17✔
619
            }
17✔
620
        }
10✔
621

622
        // Create nodes for all unique node IDs
623
        const nodes = Array.from(nodeIds).map((id) => ({id}));
6✔
624
        yield {nodes: nodes as unknown as AdHocData[], edges: edges as AdHocData[]};
6✔
625
    }
6✔
626

627
    private async *parsePairedFiles(): AsyncGenerator<
21✔
628
        DataSourceChunk,
629
        void,
630
        unknown
631
    > {
7✔
632
        // Validate that both URLs or both files are provided
633
        const hasNodeSource = !!(this.config.nodeURL ?? this.config.nodeFile);
7!
634
        const hasEdgeSource = !!(this.config.edgeURL ?? this.config.edgeFile);
7!
635

636
        if (!hasNodeSource || !hasEdgeSource) {
7!
637
            throw new Error(
3✔
638
                "parsePairedFiles requires both node and edge sources. " +
3✔
639
                "Provide either (nodeURL + edgeURL) or (nodeFile + edgeFile).",
640
            );
3✔
641
        }
3✔
642

643
        // Load and parse node file
644
        const nodes: unknown[] = [];
4✔
645
        if (this.config.nodeFile ?? this.config.nodeURL) {
7!
646
            const nodeContent = this.config.nodeFile ?
4!
647
                await this.config.nodeFile.text() :
3!
648
                await (await this.fetchWithRetry(this.config.nodeURL ?? "")).text();
1!
649

650
            const nodeParse = Papa.parse(nodeContent, {
1✔
651
                header: true,
1✔
652
                dynamicTyping: true,
1✔
653
                skipEmptyLines: true,
1✔
654
                transformHeader: (header) => header.trim(),
1✔
655
            });
1✔
656

657
            for (const row of nodeParse.data as Record<string, unknown>[]) {
4✔
658
                nodes.push({
2,510✔
659
                    id: row.id ?? row.Id ?? row.ID,
2,510!
660
                    ... row,
2,510✔
661
                });
2,510✔
662
            }
2,510✔
663
        }
4✔
664

665
        // Load and parse edge file
666
        const edges: unknown[] = [];
4✔
667
        if (this.config.edgeFile ?? this.config.edgeURL) {
7!
668
            const edgeContent = this.config.edgeFile ?
4!
669
                await this.config.edgeFile.text() :
3!
670
                await (await this.fetchWithRetry(this.config.edgeURL ?? "")).text();
1!
671

672
            const edgeParse = Papa.parse(edgeContent, {
1✔
673
                header: true,
1✔
674
                dynamicTyping: true,
1✔
675
                skipEmptyLines: true,
1✔
676
                transformHeader: (header) => header.trim(),
1✔
677
            });
1✔
678

679
            for (const row of edgeParse.data as Record<string, unknown>[]) {
4✔
680
                edges.push({
110✔
681
                    src: row.source ?? row.src ?? row.Source,
110!
682
                    dst: row.target ?? row.dst ?? row.Target,
110!
683
                    ... row,
110✔
684
                });
110✔
685
            }
110✔
686
        }
4✔
687

688
        // Yield data in chunks using inherited helper
689
        yield* this.chunkData(nodes as AdHocData[], edges as AdHocData[]);
4✔
690
    }
7✔
691
}
21✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc