gangtise-openapi-cli 0.37.1 → 0.39.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.
@@ -0,0 +1,87 @@
1
+ import { ApiError, markStructural } from "./errors.js";
2
+ import { columnarSchemaValid } from "./normalize.js";
3
+ import { attachRowSink } from "./rowSink.js";
4
+ import { PAGE_CONCURRENCY, runInOrder } from "./transport.js";
5
+ export function estimateTradingDays(startDate, endDate) {
6
+ if (!startDate)
7
+ return 262;
8
+ const start = Date.parse(`${startDate}T00:00:00Z`);
9
+ const end = endDate ? Date.parse(`${endDate}T00:00:00Z`) : Date.now() + 86_400_000;
10
+ if (Number.isNaN(start) || Number.isNaN(end) || end < start)
11
+ return 262;
12
+ let days = 0;
13
+ for (let t = start; t <= end; t += 86_400_000) {
14
+ const weekday = new Date(t).getUTCDay();
15
+ if (weekday !== 0 && weekday !== 6)
16
+ days++;
17
+ }
18
+ return days;
19
+ }
20
+ export async function callPerSecurity(client, endpointKey, securities, makeBody, cap, label) {
21
+ const sink = client.claimRowSink?.();
22
+ let fieldList;
23
+ let headerSecurity;
24
+ let emptyFields;
25
+ const merged = [];
26
+ let count = 0;
27
+ const truncated = [];
28
+ let partial = false;
29
+ const mergePart = async (part, i) => {
30
+ const rec = part;
31
+ if (!(rec && typeof rec === "object" && Array.isArray(rec.list))) {
32
+ throw markStructural(new ApiError(`${label}: ${securities[i]} returned no list payload — the response layout may have changed`, undefined, undefined, rec));
33
+ }
34
+ if (rec.partial === true)
35
+ partial = true;
36
+ if (rec.list.length === 0) {
37
+ if ((typeof rec.total === "number" && rec.total > 0) || rec.partial === true) {
38
+ const claim = typeof rec.total === "number" && rec.total > 0 ? `reported total=${rec.total}` : "carried a partial marker";
39
+ throw markStructural(new ApiError(`${label}: ${securities[i]} ${claim} but delivered no rows — the response is inconsistent`, undefined, undefined, rec));
40
+ }
41
+ if (!emptyFields && Array.isArray(rec.fieldList))
42
+ emptyFields = rec.fieldList;
43
+ return;
44
+ }
45
+ const fields = Array.isArray(rec.fieldList) && rec.fieldList.length > 0 ? rec.fieldList : undefined;
46
+ if (rec.list.some(Array.isArray) && !columnarSchemaValid(fields, rec.list)) {
47
+ throw markStructural(new ApiError(`${label}: ${securities[i]} returned columnar rows without a usable fieldList (missing, duplicated or mis-sized) — they cannot be read by position`, undefined, undefined, rec));
48
+ }
49
+ if (fields && !fieldList) {
50
+ fieldList = fields;
51
+ headerSecurity = securities[i];
52
+ sink?.setFieldList(fieldList);
53
+ }
54
+ if ((fields ?? fieldList) && !sameColumns(fieldList, fields)) {
55
+ throw markStructural(new ApiError(`${label}: ${securities[i]} answered with columns ${JSON.stringify(fields)} while ${headerSecurity} answered ${JSON.stringify(fieldList)} — the parts cannot be merged`, undefined, undefined, rec));
56
+ }
57
+ if (rec.list.length >= cap)
58
+ truncated.push(securities[i]);
59
+ count += rec.list.length;
60
+ if (sink)
61
+ await sink.push(rec.list);
62
+ else
63
+ for (const row of rec.list)
64
+ merged.push(row);
65
+ };
66
+ await runInOrder(securities, PAGE_CONCURRENCY, (code) => client.call(endpointKey, makeBody(code)), mergePart);
67
+ const out = { total: count, list: merged };
68
+ if (sink)
69
+ attachRowSink(out, sink);
70
+ if (fieldList)
71
+ out.fieldList = fieldList;
72
+ else if (count === 0 && emptyFields)
73
+ out.fieldList = emptyFields;
74
+ if (partial)
75
+ out.partial = true;
76
+ if (truncated.length > 0) {
77
+ out.partial = true;
78
+ out.truncatedSecurities = truncated;
79
+ process.stderr.write(`[gangtise] warning: ${label}: ${truncated.join(", ")} returned ${cap} rows = the per-request limit; those securities are likely truncated (see truncatedSecurities). Narrow the date range for them or raise --limit (max 10000).\n`);
80
+ }
81
+ return out;
82
+ }
83
+ function sameColumns(a, b) {
84
+ if (!a || !b)
85
+ return false;
86
+ return a.length === b.length && a.every((field, i) => field === b[i]);
87
+ }
@@ -1,17 +1,117 @@
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, digestFile, pickList, renderOutput, saveOutputIfNeeded, stagingPath, 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;
9
+ export const SUPERSEDED_EXIT = 4;
10
+ export async function warnIfSuperseded(output, digest) {
11
+ const actual = await digestFile(output).catch(() => undefined);
12
+ if (!actual || actual.sha256 === digest.sha256)
13
+ return;
14
+ process.stderr.write(`[gangtise] warning: ${output} was replaced by another export to the same path while this one was finishing — the file there is not this command's output `
15
+ + `(wrote sha256 ${digest.sha256.slice(0, 12)}…, found ${actual.sha256.slice(0, 12)}…). Give concurrent exports distinct --output paths.\n`);
16
+ if (process.exitCode !== 3)
17
+ process.exitCode = SUPERSEDED_EXIT;
18
+ }
5
19
  function warnIfLargeInMemory(items, format) {
6
20
  if (items && items.length >= LARGE_RESULT_ROWS) {
7
21
  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
22
  }
9
23
  }
24
+ function localIso(d) {
25
+ const pad = (n) => String(n).padStart(2, "0");
26
+ const offset = -d.getTimezoneOffset();
27
+ const sign = offset >= 0 ? "+" : "-";
28
+ const abs = Math.abs(offset);
29
+ 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)}`;
30
+ }
31
+ const SECRET_KEY = /^(access|secret|api|private)?key$|secret|token|password|credential/i;
32
+ function redactSecrets(value) {
33
+ if (Array.isArray(value))
34
+ return value.map(redactSecrets);
35
+ if (value && typeof value === "object") {
36
+ const out = {};
37
+ for (const [key, inner] of Object.entries(value))
38
+ out[key] = SECRET_KEY.test(key) ? "[redacted]" : redactSecrets(inner);
39
+ return out;
40
+ }
41
+ if (typeof value === "string") {
42
+ const eq = value.startsWith("--") ? value.indexOf("=") : -1;
43
+ const head = eq > 0 ? value.slice(0, eq + 1) : "";
44
+ const raw = eq > 0 ? value.slice(eq + 1) : value;
45
+ const trimmed = raw.trimStart();
46
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
47
+ try {
48
+ const parsed = JSON.parse(trimmed);
49
+ if (parsed && typeof parsed === "object")
50
+ return head + JSON.stringify(redactSecrets(parsed));
51
+ }
52
+ catch {
53
+ }
54
+ }
55
+ }
56
+ return value;
57
+ }
58
+ async function stageExportMeta(output, format, rows, columns, normalized, complete) {
59
+ const result = normalized && typeof normalized === "object" && !Array.isArray(normalized) ? { ...normalized } : {};
60
+ delete result.list;
61
+ const doc = {
62
+ file: path.basename(output),
63
+ format,
64
+ rows,
65
+ complete,
66
+ exitCode: complete ? 0 : 3,
67
+ command: redactSecrets(process.argv.slice(2)),
68
+ cliVersion: CLI_VERSION,
69
+ fetchedAt: localIso(new Date()),
70
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
71
+ columns,
72
+ result: redactSecrets(result),
73
+ };
74
+ const metaPath = `${output}.meta.json`;
75
+ const partPath = stagingPath(metaPath);
76
+ await fs.mkdir(path.dirname(output), { recursive: true });
77
+ try {
78
+ await fs.writeFile(partPath, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
79
+ }
80
+ catch (error) {
81
+ await fs.unlink(partPath).catch(() => { });
82
+ throw error;
83
+ }
84
+ return {
85
+ async commit(digest) {
86
+ try {
87
+ if (digest)
88
+ await fs.writeFile(partPath, `${JSON.stringify({ ...doc, ...digest }, null, 2)}\n`, "utf8");
89
+ await fs.rename(partPath, metaPath);
90
+ }
91
+ catch (error) {
92
+ await fs.unlink(partPath).catch(() => { });
93
+ await fs.unlink(metaPath).catch(() => { });
94
+ throw error;
95
+ }
96
+ },
97
+ async discard() {
98
+ await fs.unlink(partPath).catch(() => { });
99
+ },
100
+ };
101
+ }
10
102
  export async function printData(data, format, output, cache) {
103
+ const sink = getRowSink(data);
104
+ if (sink && !sink.opened)
105
+ data.list = sink.takeBuffer();
106
+ const streamed = sink?.opened ? sink : undefined;
107
+ const columns = data && typeof data === "object" && Array.isArray(data.fieldList)
108
+ ? data.fieldList
109
+ : undefined;
11
110
  const normalized = normalizeRows(data);
12
111
  const items = pickList(normalized);
13
- if (cache && items) {
14
- const titles = extractTitles(items, cache);
112
+ const showing = streamed ? streamed.rows : (items?.length ?? 0);
113
+ if (cache) {
114
+ const titles = { ...(streamed?.titles ?? {}), ...(items ? extractTitles(items, cache) : {}) };
15
115
  if (Object.keys(titles).length > 0) {
16
116
  writeTitleCache(cache.endpointKey, titles).catch(() => { });
17
117
  }
@@ -19,21 +119,36 @@ export async function printData(data, format, output, cache) {
19
119
  if (normalized && typeof normalized === "object" && !Array.isArray(normalized)) {
20
120
  const meta = normalized;
21
121
  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`);
122
+ process.stderr.write(`Total: ${meta.total}, showing: ${showing}\n`);
24
123
  }
25
124
  if (meta.partial === true) {
26
125
  process.exitCode = 3;
27
126
  }
28
127
  }
128
+ const complete = process.exitCode !== 3;
29
129
  if (output) {
30
- if (await streamOutputToFile(normalized, format, output)) {
31
- process.stdout.write(`${output}\n`);
32
- return;
130
+ const staged = format === "csv" || format === "jsonl"
131
+ ? await stageExportMeta(output, format, streamed ? streamed.dataRows : countOutputRows(normalized, format), columns, normalized, complete)
132
+ : null;
133
+ let digest;
134
+ try {
135
+ if (streamed) {
136
+ await streamed.finish();
137
+ digest = streamed.digest;
138
+ }
139
+ else if (!(digest = (await streamOutputToFile(normalized, format, output)) ?? undefined)) {
140
+ warnIfLargeInMemory(items, format);
141
+ const content = renderOutput(normalized, format);
142
+ digest = await saveOutputIfNeeded(format === "csv" ? `\ufeff${content}` : content, output);
143
+ }
144
+ }
145
+ catch (error) {
146
+ await staged?.discard();
147
+ throw error;
33
148
  }
34
- warnIfLargeInMemory(items, format);
35
- const content = renderOutput(normalized, format);
36
- await saveOutputIfNeeded(format === "csv" ? `\ufeff${content}` : content, output);
149
+ await staged?.commit(digest);
150
+ if (digest)
151
+ await warnIfSuperseded(output, digest);
37
152
  process.stdout.write(`${output}\n`);
38
153
  return;
39
154
  }
@@ -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,21 @@ function parseDate(value) {
12
15
  function formatDate(d) {
13
16
  return d.toISOString().slice(0, 10);
14
17
  }
18
+ function columnRemap(header, shard) {
19
+ const headerNames = new Set(header.map(String));
20
+ const extra = shard.map(String).filter((field) => !headerNames.has(field));
21
+ if (header.length === shard.length && header.every((field, i) => field === shard[i]))
22
+ return { map: null, extra };
23
+ const index = new Map(shard.map((field, i) => [String(field), i]));
24
+ const map = [];
25
+ for (const field of header) {
26
+ const i = index.get(String(field));
27
+ if (i === undefined)
28
+ return undefined;
29
+ map.push(i);
30
+ }
31
+ return { map, extra };
32
+ }
15
33
  export function isFullMarket(body, fullMarketValue) {
16
34
  const list = body.securityList;
17
35
  if (!Array.isArray(list) || list.length !== 1)
@@ -79,7 +97,7 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
79
97
  const failedShards = [];
80
98
  let firstError = null;
81
99
  let aborted = false;
82
- const results = await runWithConcurrency(shards, config.concurrency ?? PAGE_CONCURRENCY, async (shard) => {
100
+ const fetchShard = async (shard) => {
83
101
  if (aborted) {
84
102
  failedShards.push(shard);
85
103
  return null;
@@ -95,43 +113,115 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
95
113
  catch (error) {
96
114
  if (!firstError)
97
115
  firstError = error;
98
- aborted = true;
116
+ if (!isStructuralError(error))
117
+ aborted = true;
99
118
  failedShards.push(shard);
100
119
  return null;
101
120
  }
102
- });
121
+ };
122
+ const sink = client.claimRowSink?.();
103
123
  let fieldList;
104
124
  let header = null;
125
+ let fallback = null;
105
126
  const merged = [];
127
+ let count = 0;
128
+ const objectKeys = new Set();
106
129
  const truncatedShards = [];
107
- for (let i = 0; i < results.length; i++) {
108
- const r = results[i];
130
+ const partialShards = [];
131
+ const droppedColumns = new Set();
132
+ const keep = async (rows) => {
133
+ count += rows.length;
134
+ if (!fieldList)
135
+ for (const row of rows)
136
+ if (row && typeof row === "object" && !Array.isArray(row))
137
+ for (const key of Object.keys(row))
138
+ objectKeys.add(key);
139
+ if (sink)
140
+ await sink.push(rows);
141
+ else
142
+ for (const row of rows)
143
+ merged.push(row);
144
+ };
145
+ const mergeShard = async (r, i) => {
109
146
  if (!(r && typeof r === "object"))
110
- continue;
147
+ return;
111
148
  const rec = r;
112
- if (!header)
113
- header = rec;
114
- if (!fieldList && Array.isArray(rec.fieldList))
115
- fieldList = rec.fieldList;
116
149
  if (isTruncated(rec))
117
150
  truncatedShards.push(shards[i]);
118
- if (Array.isArray(rec.list))
119
- for (const item of rec.list)
120
- merged.push(item);
121
- }
151
+ if (!Array.isArray(rec.list))
152
+ return;
153
+ if (rec.list.length === 0) {
154
+ if ((typeof rec.total === "number" && rec.total > 0) || rec.partial === true) {
155
+ failedShards.push(shards[i]);
156
+ const claim = typeof rec.total === "number" && rec.total > 0 ? `reported total=${rec.total}` : "carried a partial marker";
157
+ process.stderr.write(`[gangtise] warning: shard ${shards[i].startDate}..${shards[i].endDate} ${claim} but delivered no rows; treated as failed (see failedShards)\n`);
158
+ return;
159
+ }
160
+ if (!fallback)
161
+ fallback = rec;
162
+ return;
163
+ }
164
+ const shardFields = Array.isArray(rec.fieldList) && rec.fieldList.length > 0 ? rec.fieldList : undefined;
165
+ const columnar = rec.list.some(Array.isArray);
166
+ if (columnar && !columnarSchemaValid(shardFields, rec.list)) {
167
+ failedShards.push(shards[i]);
168
+ 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`);
169
+ return;
170
+ }
171
+ if (!fieldList && columnar && shardFields) {
172
+ fieldList = shardFields;
173
+ sink?.setFieldList(fieldList);
174
+ }
175
+ if (!header)
176
+ header = rec;
177
+ if (rec.partial === true)
178
+ partialShards.push(shards[i]);
179
+ const remap = columnar && fieldList && shardFields ? columnRemap(fieldList, shardFields) : null;
180
+ if (remap === undefined) {
181
+ failedShards.push(shards[i]);
182
+ 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`);
183
+ return;
184
+ }
185
+ for (const field of remap?.extra ?? [])
186
+ droppedColumns.add(field);
187
+ const rows = rec.list;
188
+ const map = remap?.map;
189
+ await keep(map ? rows.map((item) => (Array.isArray(item) ? map.map((k) => item[k]) : item)) : rows);
190
+ };
191
+ await runInOrder(shards, config.concurrency ?? PAGE_CONCURRENCY, fetchShard, mergeShard);
122
192
  if (failedShards.length === shards.length) {
123
193
  throw firstError ?? new Error(`All ${shards.length} kline shards failed`);
124
194
  }
125
- if (!header)
126
- return { list: [] };
127
- const out = { ...header, total: merged.length, list: merged };
128
- if (fieldList)
195
+ const base = header ?? fallback ?? {};
196
+ const out = { ...base, total: count, list: merged };
197
+ if (sink)
198
+ attachRowSink(out, sink);
199
+ if (fieldList) {
129
200
  out.fieldList = fieldList;
201
+ }
202
+ else if (count > 0) {
203
+ out.fieldList = [...objectKeys];
204
+ }
205
+ else if (Array.isArray(base.fieldList)) {
206
+ out.fieldList = base.fieldList;
207
+ }
208
+ else {
209
+ delete out.fieldList;
210
+ }
130
211
  if (failedShards.length > 0) {
131
212
  out.partial = true;
132
213
  out.failedShards = failedShards;
133
214
  process.stderr.write(`[gangtise] warning: ${failedShards.length}/${shards.length} shards failed; results are partial (see failedShards)\n`);
134
215
  }
216
+ if (partialShards.length > 0) {
217
+ out.partial = true;
218
+ process.stderr.write(`[gangtise] warning: ${partialShards.length}/${shards.length} shard(s) reported themselves partial; the merged result is marked partial\n`);
219
+ }
220
+ if (droppedColumns.size > 0) {
221
+ out.partial = true;
222
+ out.droppedColumns = [...droppedColumns];
223
+ process.stderr.write(`[gangtise] warning: ${droppedColumns.size} column(s) came back only in later shards and could not be carried (see droppedColumns); the merged fieldList is the first data shard's — re-pull a narrower range to get them.\n`);
224
+ }
135
225
  if (truncatedShards.length > 0) {
136
226
  out.partial = true;
137
227
  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, digestFile, formatScalar, stagingPath, 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
+ digest;
15
+ titles = {};
16
+ titleCount = 0;
17
+ buffer = [];
18
+ stream = null;
19
+ fields;
20
+ headerChecked = false;
21
+ finished = false;
22
+ columns = [];
23
+ columnSet = new Set();
24
+ objectRows = 0;
25
+ partPath;
26
+ rowsPath;
27
+ constructor(outputPath, format = "jsonl", cache) {
28
+ this.outputPath = outputPath;
29
+ this.format = format;
30
+ this.cache = cache;
31
+ this.partPath = stagingPath(outputPath);
32
+ this.rowsPath = stagingPath(outputPath, "rows.part");
33
+ }
34
+ get opened() {
35
+ return this.stream !== null;
36
+ }
37
+ get count() {
38
+ return this.rows + this.buffer.length;
39
+ }
40
+ get dataRows() {
41
+ if (this.format === "jsonl")
42
+ return this.rows;
43
+ return this.objectRows > 0 ? this.objectRows : this.rows;
44
+ }
45
+ setFieldList(fields) {
46
+ this.fields = fields;
47
+ }
48
+ async push(rows) {
49
+ if (this.stream) {
50
+ await this.write(rows);
51
+ return;
52
+ }
53
+ for (const row of rows)
54
+ this.buffer.push(row);
55
+ if (this.buffer.length < STREAM_THRESHOLD)
56
+ return;
57
+ await this.open();
58
+ const buffered = this.buffer;
59
+ this.buffer = [];
60
+ await this.write(buffered);
61
+ }
62
+ takeBuffer() {
63
+ const rows = this.buffer;
64
+ this.buffer = [];
65
+ return rows;
66
+ }
67
+ async finish() {
68
+ const stream = this.stream;
69
+ if (!stream || this.finished)
70
+ return;
71
+ try {
72
+ await endStream(stream);
73
+ if (this.format === "csv")
74
+ await this.assembleCsv();
75
+ this.digest = await digestFile(this.partPath);
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.1";
1
+ export const CLI_VERSION = "0.39.0";