gangtise-openapi-cli 0.37.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,105 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
1
3
  import { normalizeRows } from "./normalize.js";
2
- import { pickList, renderOutput, saveOutputIfNeeded, streamOutputToFile } from "./output.js";
4
+ import { countOutputRows, pickList, renderOutput, saveOutputIfNeeded, streamOutputToFile } from "./output.js";
5
+ import { getRowSink } from "./rowSink.js";
3
6
  import { extractTitles, writeTitleCache } from "./titleCache.js";
7
+ import { CLI_VERSION } from "../version.js";
4
8
  const LARGE_RESULT_ROWS = 50_000;
5
9
  function warnIfLargeInMemory(items, format) {
6
10
  if (items && items.length >= LARGE_RESULT_ROWS) {
7
11
  process.stderr.write(`[gangtise] note: ${items.length} rows in '${format}' is built entirely in memory; stream large exports to a file with --format jsonl --output <path> (or csv).\n`);
8
12
  }
9
13
  }
14
+ function localIso(d) {
15
+ const pad = (n) => String(n).padStart(2, "0");
16
+ const offset = -d.getTimezoneOffset();
17
+ const sign = offset >= 0 ? "+" : "-";
18
+ const abs = Math.abs(offset);
19
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
20
+ }
21
+ const SECRET_KEY = /^(access|secret|api|private)?key$|secret|token|password|credential/i;
22
+ function redactSecrets(value) {
23
+ if (Array.isArray(value))
24
+ return value.map(redactSecrets);
25
+ if (value && typeof value === "object") {
26
+ const out = {};
27
+ for (const [key, inner] of Object.entries(value))
28
+ out[key] = SECRET_KEY.test(key) ? "[redacted]" : redactSecrets(inner);
29
+ return out;
30
+ }
31
+ if (typeof value === "string") {
32
+ const eq = value.startsWith("--") ? value.indexOf("=") : -1;
33
+ const head = eq > 0 ? value.slice(0, eq + 1) : "";
34
+ const raw = eq > 0 ? value.slice(eq + 1) : value;
35
+ const trimmed = raw.trimStart();
36
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
37
+ try {
38
+ const parsed = JSON.parse(trimmed);
39
+ if (parsed && typeof parsed === "object")
40
+ return head + JSON.stringify(redactSecrets(parsed));
41
+ }
42
+ catch {
43
+ }
44
+ }
45
+ }
46
+ return value;
47
+ }
48
+ async function stageExportMeta(output, format, rows, columns, normalized, complete) {
49
+ const result = normalized && typeof normalized === "object" && !Array.isArray(normalized) ? { ...normalized } : {};
50
+ delete result.list;
51
+ const doc = {
52
+ file: path.basename(output),
53
+ format,
54
+ rows,
55
+ complete,
56
+ exitCode: complete ? 0 : 3,
57
+ command: redactSecrets(process.argv.slice(2)),
58
+ cliVersion: CLI_VERSION,
59
+ fetchedAt: localIso(new Date()),
60
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
61
+ columns,
62
+ result: redactSecrets(result),
63
+ };
64
+ const metaPath = `${output}.meta.json`;
65
+ const partPath = `${metaPath}.part`;
66
+ await fs.mkdir(path.dirname(output), { recursive: true });
67
+ try {
68
+ await fs.writeFile(partPath, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
69
+ }
70
+ catch (error) {
71
+ await fs.unlink(partPath).catch(() => { });
72
+ throw error;
73
+ }
74
+ return {
75
+ async commit() {
76
+ try {
77
+ await fs.rename(partPath, metaPath);
78
+ }
79
+ catch (error) {
80
+ await fs.unlink(partPath).catch(() => { });
81
+ await fs.unlink(metaPath).catch(() => { });
82
+ throw error;
83
+ }
84
+ },
85
+ async discard() {
86
+ await fs.unlink(partPath).catch(() => { });
87
+ },
88
+ };
89
+ }
10
90
  export async function printData(data, format, output, cache) {
91
+ const sink = getRowSink(data);
92
+ if (sink && !sink.opened)
93
+ data.list = sink.takeBuffer();
94
+ const streamed = sink?.opened ? sink : undefined;
95
+ const columns = data && typeof data === "object" && Array.isArray(data.fieldList)
96
+ ? data.fieldList
97
+ : undefined;
11
98
  const normalized = normalizeRows(data);
12
99
  const items = pickList(normalized);
13
- if (cache && items) {
14
- const titles = extractTitles(items, cache);
100
+ const showing = streamed ? streamed.rows : (items?.length ?? 0);
101
+ if (cache) {
102
+ const titles = { ...(streamed?.titles ?? {}), ...(items ? extractTitles(items, cache) : {}) };
15
103
  if (Object.keys(titles).length > 0) {
16
104
  writeTitleCache(cache.endpointKey, titles).catch(() => { });
17
105
  }
@@ -19,21 +107,32 @@ export async function printData(data, format, output, cache) {
19
107
  if (normalized && typeof normalized === "object" && !Array.isArray(normalized)) {
20
108
  const meta = normalized;
21
109
  if (typeof meta.total === "number" && format !== "json") {
22
- const listLen = Array.isArray(meta.list) ? meta.list.length : 0;
23
- process.stderr.write(`Total: ${meta.total}, showing: ${listLen}\n`);
110
+ process.stderr.write(`Total: ${meta.total}, showing: ${showing}\n`);
24
111
  }
25
112
  if (meta.partial === true) {
26
113
  process.exitCode = 3;
27
114
  }
28
115
  }
116
+ const complete = process.exitCode !== 3;
29
117
  if (output) {
30
- if (await streamOutputToFile(normalized, format, output)) {
31
- process.stdout.write(`${output}\n`);
32
- return;
118
+ const staged = format === "csv" || format === "jsonl"
119
+ ? await stageExportMeta(output, format, streamed ? streamed.dataRows : countOutputRows(normalized, format), columns, normalized, complete)
120
+ : null;
121
+ try {
122
+ if (streamed) {
123
+ await streamed.finish();
124
+ }
125
+ else if (!(await streamOutputToFile(normalized, format, output))) {
126
+ warnIfLargeInMemory(items, format);
127
+ const content = renderOutput(normalized, format);
128
+ await saveOutputIfNeeded(format === "csv" ? `\ufeff${content}` : content, output);
129
+ }
130
+ }
131
+ catch (error) {
132
+ await staged?.discard();
133
+ throw error;
33
134
  }
34
- warnIfLargeInMemory(items, format);
35
- const content = renderOutput(normalized, format);
36
- await saveOutputIfNeeded(format === "csv" ? `\ufeff${content}` : content, output);
135
+ await staged?.commit();
37
136
  process.stdout.write(`${output}\n`);
38
137
  return;
39
138
  }
@@ -1,4 +1,7 @@
1
- import { isVerbose, PAGE_CONCURRENCY, runWithConcurrency } from "./transport.js";
1
+ import { isStructuralError } from "./errors.js";
2
+ import { columnarSchemaValid } from "./normalize.js";
3
+ import { attachRowSink } from "./rowSink.js";
4
+ import { isVerbose, PAGE_CONCURRENCY, runInOrder } from "./transport.js";
2
5
  const DAY_MS = 86_400_000;
3
6
  const ALL_MARKET_LIMIT = 10_000;
4
7
  function parseDate(value) {
@@ -12,6 +15,19 @@ function parseDate(value) {
12
15
  function formatDate(d) {
13
16
  return d.toISOString().slice(0, 10);
14
17
  }
18
+ function columnRemap(header, shard) {
19
+ if (header.length === shard.length && header.every((field, i) => field === shard[i]))
20
+ return null;
21
+ const index = new Map(shard.map((field, i) => [String(field), i]));
22
+ const map = [];
23
+ for (const field of header) {
24
+ const i = index.get(String(field));
25
+ if (i === undefined)
26
+ return undefined;
27
+ map.push(i);
28
+ }
29
+ return map;
30
+ }
15
31
  export function isFullMarket(body, fullMarketValue) {
16
32
  const list = body.securityList;
17
33
  if (!Array.isArray(list) || list.length !== 1)
@@ -79,7 +95,7 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
79
95
  const failedShards = [];
80
96
  let firstError = null;
81
97
  let aborted = false;
82
- const results = await runWithConcurrency(shards, config.concurrency ?? PAGE_CONCURRENCY, async (shard) => {
98
+ const fetchShard = async (shard) => {
83
99
  if (aborted) {
84
100
  failedShards.push(shard);
85
101
  return null;
@@ -95,43 +111,106 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
95
111
  catch (error) {
96
112
  if (!firstError)
97
113
  firstError = error;
98
- aborted = true;
114
+ if (!isStructuralError(error))
115
+ aborted = true;
99
116
  failedShards.push(shard);
100
117
  return null;
101
118
  }
102
- });
119
+ };
120
+ const sink = client.claimRowSink?.();
103
121
  let fieldList;
104
122
  let header = null;
123
+ let fallback = null;
105
124
  const merged = [];
125
+ let count = 0;
126
+ const objectKeys = new Set();
106
127
  const truncatedShards = [];
107
- for (let i = 0; i < results.length; i++) {
108
- const r = results[i];
128
+ const partialShards = [];
129
+ const keep = async (rows) => {
130
+ count += rows.length;
131
+ if (!fieldList)
132
+ for (const row of rows)
133
+ if (row && typeof row === "object" && !Array.isArray(row))
134
+ for (const key of Object.keys(row))
135
+ objectKeys.add(key);
136
+ if (sink)
137
+ await sink.push(rows);
138
+ else
139
+ for (const row of rows)
140
+ merged.push(row);
141
+ };
142
+ const mergeShard = async (r, i) => {
109
143
  if (!(r && typeof r === "object"))
110
- continue;
144
+ return;
111
145
  const rec = r;
112
- if (!header)
113
- header = rec;
114
- if (!fieldList && Array.isArray(rec.fieldList))
115
- fieldList = rec.fieldList;
116
146
  if (isTruncated(rec))
117
147
  truncatedShards.push(shards[i]);
118
- if (Array.isArray(rec.list))
119
- for (const item of rec.list)
120
- merged.push(item);
121
- }
148
+ if (!Array.isArray(rec.list))
149
+ return;
150
+ if (rec.list.length === 0) {
151
+ if ((typeof rec.total === "number" && rec.total > 0) || rec.partial === true) {
152
+ failedShards.push(shards[i]);
153
+ const claim = typeof rec.total === "number" && rec.total > 0 ? `reported total=${rec.total}` : "carried a partial marker";
154
+ process.stderr.write(`[gangtise] warning: shard ${shards[i].startDate}..${shards[i].endDate} ${claim} but delivered no rows; treated as failed (see failedShards)\n`);
155
+ return;
156
+ }
157
+ if (!fallback)
158
+ fallback = rec;
159
+ return;
160
+ }
161
+ const shardFields = Array.isArray(rec.fieldList) && rec.fieldList.length > 0 ? rec.fieldList : undefined;
162
+ const columnar = rec.list.some(Array.isArray);
163
+ if (columnar && !columnarSchemaValid(shardFields, rec.list)) {
164
+ failedShards.push(shards[i]);
165
+ process.stderr.write(`[gangtise] warning: shard ${shards[i].startDate}..${shards[i].endDate} returned columnar rows that do not match its own fieldList (missing, duplicated or mis-sized); its rows were dropped (see failedShards)\n`);
166
+ return;
167
+ }
168
+ if (!fieldList && columnar && shardFields) {
169
+ fieldList = shardFields;
170
+ sink?.setFieldList(fieldList);
171
+ }
172
+ if (!header)
173
+ header = rec;
174
+ if (rec.partial === true)
175
+ partialShards.push(shards[i]);
176
+ const remap = columnar && fieldList && shardFields ? columnRemap(fieldList, shardFields) : null;
177
+ if (remap === undefined) {
178
+ failedShards.push(shards[i]);
179
+ process.stderr.write(`[gangtise] warning: shard ${shards[i].startDate}..${shards[i].endDate} returned columns that cannot be aligned with the first shard's fieldList; its rows were dropped (see failedShards)\n`);
180
+ return;
181
+ }
182
+ const rows = rec.list;
183
+ await keep(remap ? rows.map((item) => (Array.isArray(item) ? remap.map((k) => item[k]) : item)) : rows);
184
+ };
185
+ await runInOrder(shards, config.concurrency ?? PAGE_CONCURRENCY, fetchShard, mergeShard);
122
186
  if (failedShards.length === shards.length) {
123
187
  throw firstError ?? new Error(`All ${shards.length} kline shards failed`);
124
188
  }
125
- if (!header)
126
- return { list: [] };
127
- const out = { ...header, total: merged.length, list: merged };
128
- if (fieldList)
189
+ const base = header ?? fallback ?? {};
190
+ const out = { ...base, total: count, list: merged };
191
+ if (sink)
192
+ attachRowSink(out, sink);
193
+ if (fieldList) {
129
194
  out.fieldList = fieldList;
195
+ }
196
+ else if (count > 0) {
197
+ out.fieldList = [...objectKeys];
198
+ }
199
+ else if (Array.isArray(base.fieldList)) {
200
+ out.fieldList = base.fieldList;
201
+ }
202
+ else {
203
+ delete out.fieldList;
204
+ }
130
205
  if (failedShards.length > 0) {
131
206
  out.partial = true;
132
207
  out.failedShards = failedShards;
133
208
  process.stderr.write(`[gangtise] warning: ${failedShards.length}/${shards.length} shards failed; results are partial (see failedShards)\n`);
134
209
  }
210
+ if (partialShards.length > 0) {
211
+ out.partial = true;
212
+ process.stderr.write(`[gangtise] warning: ${partialShards.length}/${shards.length} shard(s) reported themselves partial; the merged result is marked partial\n`);
213
+ }
135
214
  if (truncatedShards.length > 0) {
136
215
  out.partial = true;
137
216
  out.truncatedShards = truncatedShards;
@@ -0,0 +1,196 @@
1
+ import { createReadStream, createWriteStream } from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { assertColumnarHeader, zipFieldRow } from "./normalize.js";
5
+ import { csvEscape, formatScalar, writeLine } from "./output.js";
6
+ import { extractTitles, MAX_TITLES_PER_ENDPOINT } from "./titleCache.js";
7
+ export const ROW_SINK = Symbol("gangtise.rowSink");
8
+ const STREAM_THRESHOLD = 1000;
9
+ export class ExportSink {
10
+ outputPath;
11
+ format;
12
+ cache;
13
+ rows = 0;
14
+ titles = {};
15
+ titleCount = 0;
16
+ buffer = [];
17
+ stream = null;
18
+ fields;
19
+ headerChecked = false;
20
+ finished = false;
21
+ columns = [];
22
+ columnSet = new Set();
23
+ objectRows = 0;
24
+ constructor(outputPath, format = "jsonl", cache) {
25
+ this.outputPath = outputPath;
26
+ this.format = format;
27
+ this.cache = cache;
28
+ }
29
+ get opened() {
30
+ return this.stream !== null;
31
+ }
32
+ get count() {
33
+ return this.rows + this.buffer.length;
34
+ }
35
+ get dataRows() {
36
+ if (this.format === "jsonl")
37
+ return this.rows;
38
+ return this.objectRows > 0 ? this.objectRows : this.rows;
39
+ }
40
+ get partPath() {
41
+ return `${this.outputPath}.part`;
42
+ }
43
+ get rowsPath() {
44
+ return `${this.outputPath}.rows.part`;
45
+ }
46
+ setFieldList(fields) {
47
+ this.fields = fields;
48
+ }
49
+ async push(rows) {
50
+ if (this.stream) {
51
+ await this.write(rows);
52
+ return;
53
+ }
54
+ for (const row of rows)
55
+ this.buffer.push(row);
56
+ if (this.buffer.length < STREAM_THRESHOLD)
57
+ return;
58
+ await this.open();
59
+ const buffered = this.buffer;
60
+ this.buffer = [];
61
+ await this.write(buffered);
62
+ }
63
+ takeBuffer() {
64
+ const rows = this.buffer;
65
+ this.buffer = [];
66
+ return rows;
67
+ }
68
+ async finish() {
69
+ const stream = this.stream;
70
+ if (!stream || this.finished)
71
+ return;
72
+ try {
73
+ await endStream(stream);
74
+ if (this.format === "csv")
75
+ await this.assembleCsv();
76
+ await fs.rename(this.partPath, this.outputPath);
77
+ this.finished = true;
78
+ }
79
+ catch (error) {
80
+ await this.abort();
81
+ throw error;
82
+ }
83
+ }
84
+ async abort() {
85
+ const stream = this.stream;
86
+ if (!stream || this.finished)
87
+ return;
88
+ this.finished = true;
89
+ stream.destroy();
90
+ if (!stream.closed)
91
+ await new Promise((resolve) => stream.once("close", resolve));
92
+ await fs.unlink(this.partPath).catch(() => { });
93
+ await fs.unlink(this.rowsPath).catch(() => { });
94
+ }
95
+ async open() {
96
+ await fs.mkdir(path.dirname(this.outputPath), { recursive: true });
97
+ const stream = createWriteStream(this.format === "csv" ? this.rowsPath : this.partPath, { encoding: "utf8" });
98
+ stream.on("error", () => { });
99
+ this.stream = stream;
100
+ }
101
+ async write(rows) {
102
+ const stream = this.stream;
103
+ for (const row of rows) {
104
+ let item = row;
105
+ if (Array.isArray(row)) {
106
+ if (!this.headerChecked) {
107
+ assertColumnarHeader(this.fields);
108
+ this.headerChecked = true;
109
+ }
110
+ item = zipFieldRow(this.fields, row);
111
+ }
112
+ if (this.format === "csv" && isObjectRow(item)) {
113
+ this.objectRows++;
114
+ for (const key of Object.keys(item)) {
115
+ if (!this.columnSet.has(key)) {
116
+ this.columnSet.add(key);
117
+ this.columns.push(key);
118
+ }
119
+ }
120
+ }
121
+ await writeLine(stream, JSON.stringify(item));
122
+ this.rows++;
123
+ if (this.cache && this.titleCount < MAX_TITLES_PER_ENDPOINT) {
124
+ for (const [id, title] of Object.entries(extractTitles([item], this.cache))) {
125
+ if (!(id in this.titles))
126
+ this.titleCount++;
127
+ this.titles[id] = title;
128
+ }
129
+ }
130
+ }
131
+ }
132
+ async assembleCsv() {
133
+ const out = createWriteStream(this.partPath, { encoding: "utf8" });
134
+ out.on("error", () => { });
135
+ this.stream = out;
136
+ const objectMode = this.objectRows > 0;
137
+ const columns = objectMode ? this.columns : ["index", "value"];
138
+ await writeLine(out, "\ufeff" + columns.map(csvEscape).join(","));
139
+ const input = createReadStream(this.rowsPath, { encoding: "utf8" });
140
+ try {
141
+ let index = 0;
142
+ const emit = async (line) => {
143
+ if (!line)
144
+ return;
145
+ const item = JSON.parse(line);
146
+ if (objectMode) {
147
+ if (!isObjectRow(item))
148
+ return;
149
+ await writeLine(out, columns.map((column) => csvEscape(formatScalar(item[column]))).join(","));
150
+ }
151
+ else {
152
+ await writeLine(out, `${csvEscape(String(index++))},${csvEscape(formatScalar(item))}`);
153
+ }
154
+ };
155
+ let rest = "";
156
+ for await (const chunk of input) {
157
+ const lines = (rest + String(chunk)).split("\n");
158
+ rest = lines.pop() ?? "";
159
+ for (const line of lines)
160
+ await emit(line);
161
+ }
162
+ await emit(rest);
163
+ await endStream(out);
164
+ }
165
+ catch (error) {
166
+ input.destroy();
167
+ throw error;
168
+ }
169
+ await fs.unlink(this.rowsPath).catch(() => { });
170
+ }
171
+ }
172
+ function isObjectRow(value) {
173
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
174
+ }
175
+ function endStream(stream) {
176
+ return new Promise((resolve, reject) => {
177
+ stream.end((err) => err ? reject(err) : resolve());
178
+ });
179
+ }
180
+ export function attachRowSink(result, sink) {
181
+ Object.defineProperty(result, ROW_SINK, { value: sink, enumerable: false, configurable: true });
182
+ return result;
183
+ }
184
+ export function getRowSink(value) {
185
+ if (!value || typeof value !== "object")
186
+ return undefined;
187
+ const sink = value[ROW_SINK];
188
+ return sink instanceof ExportSink ? sink : undefined;
189
+ }
190
+ export function rowCount(value) {
191
+ const sink = getRowSink(value);
192
+ if (sink)
193
+ return sink.count;
194
+ const list = value && typeof value === "object" ? value.list : undefined;
195
+ return Array.isArray(list) ? list.length : 0;
196
+ }
@@ -62,6 +62,57 @@ export async function runWithConcurrency(items, concurrency, fn) {
62
62
  await Promise.all(Array.from({ length: limit }, () => worker()));
63
63
  return results;
64
64
  }
65
+ export async function runInOrder(items, concurrency, fn, consume) {
66
+ const width = Math.max(1, concurrency);
67
+ const pending = new Map();
68
+ let next = 0;
69
+ let failure = null;
70
+ let chain = Promise.resolve();
71
+ let waiters = [];
72
+ const wake = () => {
73
+ const ws = waiters;
74
+ waiters = [];
75
+ for (const w of ws)
76
+ w();
77
+ };
78
+ const drain = () => {
79
+ chain = chain.then(async () => {
80
+ while (!failure && pending.has(next)) {
81
+ const result = pending.get(next);
82
+ pending.delete(next);
83
+ await consume(result, next++);
84
+ wake();
85
+ }
86
+ }).catch((error) => {
87
+ failure ??= { error };
88
+ wake();
89
+ });
90
+ };
91
+ await runWithConcurrency(items, width, async (item, index) => {
92
+ if (failure)
93
+ return;
94
+ let result;
95
+ try {
96
+ result = await fn(item, index);
97
+ }
98
+ catch (error) {
99
+ failure ??= { error };
100
+ wake();
101
+ return;
102
+ }
103
+ if (failure)
104
+ return;
105
+ pending.set(index, result);
106
+ drain();
107
+ while (!failure && pending.size >= width) {
108
+ await new Promise((resolve) => waiters.push(resolve));
109
+ }
110
+ });
111
+ await chain;
112
+ const failed = failure;
113
+ if (failed)
114
+ throw failed.error;
115
+ }
65
116
  export function resolvePageConcurrency(raw, fallback = 5, max = 32) {
66
117
  const parsed = Math.floor(Number(raw));
67
118
  if (!Number.isFinite(parsed) || parsed < 1)
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.37.0";
1
+ export const CLI_VERSION = "0.38.0";