diff --git a/packages/core-ts/package.json b/packages/core-ts/package.json index 05a4fddd..6b6c28a5 100644 --- a/packages/core-ts/package.json +++ b/packages/core-ts/package.json @@ -12,7 +12,7 @@ "dist" ], "scripts": { - "build": "tsup src/index.ts src/cli.ts --format cjs,esm --dts", + "build": "tsup src/index.ts src/cli.ts src/cli/ndjson.ts --format cjs,esm --dts", "test": "vitest run", "test:watch": "vitest", "lint": "eslint src/**/*.ts" @@ -23,6 +23,7 @@ "devDependencies": { "@stellar-address-kit/spec": "workspace:*", "@stellar/stellar-sdk": "^12.3.0", + "@types/node": "^20.11.0", "eslint": "^8.56.0", "tsup": "^8.0.1", "typescript": "^5.3.3", @@ -31,4 +32,4 @@ "dependencies": { "commander": "^15.0.0" } -} \ No newline at end of file +} diff --git a/packages/core-ts/src/cli.ts b/packages/core-ts/src/cli.ts index c423d4cd..6e1c8318 100644 --- a/packages/core-ts/src/cli.ts +++ b/packages/core-ts/src/cli.ts @@ -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
", "Destination Stellar address (G... or M...)") + .option("--dest
", "Destination Stellar address (G... or M...)") .option("--memo ", "Memo value (e.g. a MEMO_ID or MEMO_TEXT value)") .option("--type ", "Memo type: none, id, text, hash, return", "none") + .option("--file ", "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 { + 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 { + 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); + } + + if (hasErrors) { + process.exitCode = 1; + } +} + +async function main(): Promise { + 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; -} \ No newline at end of file +}); diff --git a/packages/core-ts/src/cli/batch.ts b/packages/core-ts/src/cli/batch.ts new file mode 100644 index 00000000..ef9653ea --- /dev/null +++ b/packages/core-ts/src/cli/batch.ts @@ -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): BatchRowInput | null { + const dest = row.destination ?? row.dest ?? row.address; + if (typeof dest !== "string" || dest.trim() === "") { + return null; + } + + 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), + }; +} + +/** + * 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, + }; + } 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 { + const rii = createInterface({ input: stream, crlfDelay: Infinity }); + let headers: string[] | null = null; + + for await (const line of rli) { + const trimmed = line.trim(); + if (trimmed === "") continue; + + const fields = parseCsvLine(trimmed); + + if (!headers) { + headers = fields.map((h) => h.trim()); + continue; + } + + const row: Record = {}; + 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 { + 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(); + 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); + 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); + if (batchRow) { + yield batchRow; + } + } + } catch { + // Skip malformed lines. + } + } +} + +/** + * 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"; +} + +/** + * 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; +} diff --git a/packages/core-ts/src/cli/ndjson.ts b/packages/core-ts/src/cli/ndjson.ts new file mode 100644 index 00000000..c3a55504 --- /dev/null +++ b/packages/core-ts/src/cli/ndjson.ts @@ -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"); +}