Repository navigation
feat(cli): add batch file/stdin processing and NDJSON output to stellar-route #347
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,14 @@ | |
| import { Command } from "commander"; | ||
| import { extractRouting } from "./routing/extract"; | ||
| import type { RoutingInput } from "./routing/types"; | ||
| import { | ||
| detectFormat, | ||
| openInputStream, | ||
| parseCsvStream, | ||
| parseJsonStream, | ||
| processRows, | ||
| } from "./cli/batch"; | ||
| import { writeNdjson } from "./cli/ndjson"; | ||
|
|
||
| const program = new Command(); | ||
|
|
||
|
|
@@ -10,34 +18,82 @@ program | |
| .description( | ||
| "Debug Stellar deposit routing: test a destination address + memo combination and see the resulting RoutingResult." | ||
| ) | ||
| .requiredOption("--dest <address>", "Destination Stellar address (G... or M...)") | ||
| .option("--dest <address>", "Destination Stellar address (G... or M...)") | ||
| .option("--memo <value>", "Memo value (e.g. a MEMO_ID or MEMO_TEXT value)") | ||
| .option("--type <type>", "Memo type: none, id, text, hash, return", "none") | ||
| .option("--file <path>", "Read batch inputs from a CSV or JSON file") | ||
| .option("--stdin", "Read batch inputs from stdin") | ||
| .parse(process.argv); | ||
|
|
||
| const opts = program.opts(); | ||
|
|
||
| const input: RoutingInput = { | ||
| destination: opts.dest, | ||
| memoType: opts.type, | ||
| memoValue: opts.memo ?? null, | ||
| sourceAccount: null, | ||
| }; | ||
|
|
||
| try { | ||
| const result = extractRouting(input); | ||
| console.log( | ||
| JSON.stringify( | ||
| result, | ||
| (_key, value) => (typeof value === "bigint" ? value.toString() : value), | ||
| 2 | ||
| ) | ||
| ); | ||
| } catch (error) { | ||
| async function runSingle(): Promise<void> { | ||
| const input: RoutingInput = { | ||
| destination: opts.dest, | ||
| memoType: opts.type, | ||
| memoValue: opts.memo ?? null, | ||
| sourceAccount: null, | ||
| }; | ||
|
|
||
| try { | ||
| const result = extractRouting(input); | ||
| console.log( | ||
| JSON.stringify( | ||
| result, | ||
| (_key, value) => (typeof value === "bigint" ? value.toString() : value), | ||
| 2 | ||
| ) | ||
| ); | ||
| } catch (error) { | ||
| if (error instanceof Error) { | ||
| console.error(`Error: ${error.message}`); | ||
| } else { | ||
| console.error("An unknown error occurred."); | ||
| } | ||
| process.exitCode = 1; | ||
| } | ||
| } | ||
|
|
||
| async function runBatch(filePath: string | null): Promise<void> { | ||
| const format = detectFormat(filePath); | ||
| const stream = openInputStream(filePath); | ||
| const parser = format === "csv" ? parseCsvStream(stream) : parseJsonStream(stream); | ||
|
|
||
| const rows: unknown[] = []; | ||
| for await (const row of parser) { | ||
| rows.push(row); | ||
| } | ||
|
|
||
| const { records, hasErrors } = processRows(rows); | ||
| for (const record of records) { | ||
| writeNdjson(process.stdout, record); | ||
|
Comment on lines
+62
to
+69
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift Route and write each row as it arrives. This loop collects all rows before 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| if (hasErrors) { | ||
| process.exitCode = 1; | ||
| } | ||
| } | ||
|
|
||
| async function main(): Promise<void> { | ||
| if (opts.file || opts.stdin) { | ||
| await runBatch(opts.file ?? null); | ||
| return; | ||
| } | ||
|
|
||
| if (!opts.dest) { | ||
| console.error("Error: either --dest or --file/--stdin is required."); | ||
| process.exitCode = 1; | ||
| return; | ||
| } | ||
|
|
||
| await runSingle(); | ||
| } | ||
|
|
||
| main().catch((error) => { | ||
| if (error instanceof Error) { | ||
| console.error(`Error: ${error.message}`); | ||
| } else { | ||
| console.error("An unknown error occurred."); | ||
| } | ||
| process.exitCode = 1; | ||
| } | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,222 @@ | ||||||
| import { createReadStream } from "node:fs"; | ||||||
| import { createInterface } from "node:readline"; | ||||||
| import type { Readable } from "node:stream"; | ||||||
| import { extractRouting } from "../routing/extract"; | ||||||
| import type { RoutingInput } from "../routing/types"; | ||||||
| import type { NdjsonRecord } from "./ndjson"; | ||||||
|
|
||||||
| export interface BatchRowInput { | ||||||
| destination: string; | ||||||
| memoType?: string; | ||||||
| memoValue?: string | null; | ||||||
| } | ||||||
|
|
||||||
| export interface BatchProcessResult { | ||||||
| records: NdjsonRecord[]; | ||||||
| hasErrors: boolean; | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Parse a CSV line into fields, handling quoted values and escaped quotes. | ||||||
| */ | ||||||
| function parseCsvLine(line: string): string[] { | ||||||
| const fields: string[] = []; | ||||||
| let current = ""; | ||||||
| let inQuotes = false; | ||||||
|
|
||||||
| for (let i = 0; i < line.length; i++) { | ||||||
| const char = line[i]; | ||||||
|
|
||||||
| if (inQuotes) { | ||||||
| if (char === '"') { | ||||||
| if (line[i + 1] === '"') { | ||||||
| current += '"'; | ||||||
| i++; | ||||||
| } else { | ||||||
| inQuotes = false; | ||||||
| } | ||||||
| } else { | ||||||
| current += char; | ||||||
| } | ||||||
| } else { | ||||||
| if (char === '"') { | ||||||
| inQuotes = true; | ||||||
| } else if (char === ",") { | ||||||
| fields.push(current); | ||||||
| current = ""; | ||||||
| } else { | ||||||
| current += char; | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| fields.push(current); | ||||||
| return fields; | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Convert a raw row object into a BatchRowInput. | ||||||
| */ | ||||||
| function toBatchRow(row: Record<string, unknown>): BatchRowInput | null { | ||||||
| const dest = row.destination ?? row.dest ?? row.address; | ||||||
| if (typeof dest !== "string" || dest.trim() === "") { | ||||||
| return null; | ||||||
|
Comment on lines
+61
to
+63
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Report rows with a missing destination. If a CSV or JSON row has an empty or missing destination, 🤖 Prompt for AI Agents |
||||||
| } | ||||||
|
|
||||||
| const memoTypeRaw = row.memoType ?? row.type ?? "none"; | ||||||
| const memoValueRaw = row.memoValue ?? row.memo ?? null; | ||||||
|
|
||||||
| return { | ||||||
| destination: dest.trim(), | ||||||
| memoType: typeof memoTypeRaw === "string" ? memoTypeRaw : "none", | ||||||
| memoValue: memoValueRaw === null || memoValueRaw === undefined ? null : String(memoValueRaw), | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Do not convert JSON numeric memo IDs after parsing. If JSON contains 🤖 Prompt for AI Agents |
||||||
| }; | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Process a single row and produce an NDJSON record. | ||||||
| */ | ||||||
| function processRow(row: BatchRowInput, index: number): NdjsonRecord { | ||||||
| const input: RoutingInput = { | ||||||
| destination: row.destination, | ||||||
| memoType: row.memoType ?? "none", | ||||||
| memoValue: row.memoValue ?? null, | ||||||
| sourceAccount: null, | ||||||
| }; | ||||||
|
|
||||||
| try { | ||||||
| const result = extractRouting(input); | ||||||
| return { | ||||||
| index, | ||||||
| destination: row.destination, | ||||||
| memoType: input.memoType, | ||||||
| memoValue: input.memoValue, | ||||||
| ok: true, | ||||||
| result, | ||||||
|
Comment on lines
+94
to
+95
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Mark unroutable routing results as errors.
🤖 Prompt for AI Agents |
||||||
| }; | ||||||
| } catch (error) { | ||||||
| const message = error instanceof Error ? error.message : "Unknown error"; | ||||||
| return { | ||||||
| index, | ||||||
| destination: row.destination, | ||||||
| memoType: input.memoType, | ||||||
| memoValue: input.memoValue, | ||||||
| ok: false, | ||||||
| error: message, | ||||||
| }; | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Process an array of rows and return NDJSON records along with an error flag. | ||||||
| */ | ||||||
| export function processRows(rows: BatchRowInput[]): BatchProcessResult { | ||||||
| const records = rows.map((row, index) => processRow(row, index)); | ||||||
| const hasErrors = records.some((r) => !r.ok); | ||||||
| return { records, hasErrors }; | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Parse a CSV stream into BatchRowInput rows. | ||||||
| */ | ||||||
| export async function* parseCsvStream( | ||||||
| stream: Readable | ||||||
| ): AsyncGenerator<BatchRowInput> { | ||||||
| const rii = createInterface({ input: stream, crlfDelay: Infinity }); | ||||||
| let headers: string[] | null = null; | ||||||
|
|
||||||
| for await (const line of rli) { | ||||||
|
Comment on lines
+125
to
+128
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Keep quoted CSV fields together across line breaks. After the identifier is corrected, 🤖 Prompt for AI AgentsThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win Use the declared readline interface. Line 125 declares Proposed fix- for await (const line of rli) {
+ for await (const line of rii) {📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| const trimmed = line.trim(); | ||||||
| if (trimmed === "") continue; | ||||||
|
|
||||||
| const fields = parseCsvLine(trimmed); | ||||||
|
|
||||||
| if (!headers) { | ||||||
| headers = fields.map((h) => h.trim()); | ||||||
| continue; | ||||||
| } | ||||||
|
|
||||||
| const row: Record<string, unknown> = {}; | ||||||
| for (let i = 0; i < headers.length; i++) { | ||||||
| row[headers[i]] = fields[i] ?? ""; | ||||||
| } | ||||||
|
|
||||||
| const batchRow = toBatchRow(row); | ||||||
| if (batchRow) { | ||||||
| yield batchRow; | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Parse a JSON stream (either an array or NDJSON) into BatchRowInput rows. | ||||||
| */ | ||||||
| export async function* parseJsonStream( | ||||||
| stream: Readable | ||||||
| ): AsyncGenerator<BatchRowInput> { | ||||||
| const rli = createInterface({ input: stream, crlfDelay: Infinity }); | ||||||
| const chunks: string[] = []; | ||||||
|
|
||||||
| for await (const line of rli) { | ||||||
| chunks.push(line); | ||||||
| } | ||||||
|
|
||||||
| const raw = chunks.join("\n").trim(); | ||||||
|
Comment on lines
+158
to
+164
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift Process JSON input without buffering the entire stream.
🤖 Prompt for AI Agents |
||||||
| if (raw === "") return; | ||||||
|
|
||||||
| // Try parsing as a JSON array or object first. | ||||||
| try { | ||||||
| const parsed = JSON.parse(raw); | ||||||
| const array = Array.isArray(parsed) ? parsed : [parsed]; | ||||||
| for (const item of array) { | ||||||
| if (item && typeof item === "object") { | ||||||
| const batchRow = toBatchRow(item as Record<string, unknown>); | ||||||
| if (batchRow) { | ||||||
| yield batchRow; | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
| return; | ||||||
| } catch { | ||||||
| // Fall through to NDJSON parsing. | ||||||
| } | ||||||
|
|
||||||
| // Fall back to NDJSON (one JSON object per line). | ||||||
| for (const line of chunks) { | ||||||
| const trimmed = line.trim(); | ||||||
| if (trimmed === "") continue; | ||||||
| try { | ||||||
| const item = JSON.parse(trimmed); | ||||||
| if (item && typeof item === "object") { | ||||||
| const batchRow = toBatchRow(item as Record<string, unknown>); | ||||||
| if (batchRow) { | ||||||
| yield batchRow; | ||||||
| } | ||||||
| } | ||||||
| } catch { | ||||||
| // Skip malformed lines. | ||||||
|
Comment on lines
+196
to
+197
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Report malformed NDJSON lines. If one NDJSON line contains invalid JSON, this catch discards it and processing continues. The CLI emits no record for that input row and can report success. Emit an indexed error record and set the batch error flag instead of silently skipping the line. 🤖 Prompt for AI Agents |
||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Detect the input format based on file extension or content sniffer. | ||||||
| */ | ||||||
| export function detectFormat(filePath: string | null): "csv" | "json" { | ||||||
| if (filePath) { | ||||||
| const lower = filePath.toLowerCase(); | ||||||
| if (lower.endsWith(".csv")) return "csv"; | ||||||
| if (lower.endsWith(".json") || lower.endsWith(".ndjson")) return "json"; | ||||||
| } | ||||||
| return "csv"; | ||||||
|
Comment on lines
+205
to
+211
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift Detect JSON when input comes from stdin.
🤖 Prompt for AI Agents |
||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Open a readable stream from a file path or stdin. | ||||||
| */ | ||||||
| export function openInputStream(filePath: string | null): Readable { | ||||||
| if (filePath) { | ||||||
| return createReadStream(filePath, { encoding: "utf8" }); | ||||||
| } | ||||||
| return process.stdin; | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| import type { RoutingResult } from "../routing/types"; | ||
|
|
||
| export interface NdjsonRecord { | ||
| index: number; | ||
| destination: string; | ||
| memoType: string; | ||
| memoValue: string | null; | ||
| ok: boolean; | ||
| result?: RoutingResult; | ||
| error?: string; | ||
| } | ||
|
|
||
| /** | ||
| * Serialize a value to a JSON string, converting bigints to strings. | ||
| */ | ||
| export function stringifyWithBigint(value: unknown): string { | ||
| return JSON.stringify(value, (_key, val) => | ||
| typeof val === "bigint" ? val.toString() : val | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Write a single NDJSON record as a line to the provided writable stream. | ||
| */ | ||
| export function writeNdjson( | ||
| stream: NodeJS.WritableStream, | ||
| record: NdjsonRecord | ||
| ): void { | ||
| stream.write(stringifyWithBigint(record) + "\n"); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift Wait when the output stream applies backpressure. If stdout is a slow pipe, 🤖 Prompt for AI Agents |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Type collected rows as
BatchRowInput[].The parser yields
BatchRowInput, butrowsis declared asunknown[]. Line 67 passes that array toprocessRows(rows: BatchRowInput[]). TypeScript rejects the call, which blocks declaration generation in the configured build. Preserve the parser’s row type when declaring the collection. (typescriptlang.org)🤖 Prompt for AI Agents