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.
@@ -4,18 +4,28 @@ import path from "node:path";
4
4
  import { pipeline } from "node:stream/promises";
5
5
  import { FormData, request } from "undici";
6
6
  import { isTokenCacheValid, normalizeToken, readTokenCache, requireAccessCredentials, writeTokenCache } from "./auth.js";
7
- import { ApiError, attachEnvelopeTraceId, ValidationError } from "./errors.js";
7
+ import { ApiError, attachEnvelopeTraceId, markStructural, ValidationError } from "./errors.js";
8
8
  import { ENDPOINTS, resolveTimeoutMs } from "./endpoints.js";
9
9
  import { getLookupData } from "./lookupData/index.js";
10
- import { decodeResponseBody, getDispatcher, isVerbose, logTiming, markRetryable, PAGE_CONCURRENCY, parseRetryAfterMs, quoteBigIntFields, runWithConcurrency, withRetry } from "./transport.js";
10
+ import { decodeResponseBody, getDispatcher, isVerbose, logTiming, markRetryable, PAGE_CONCURRENCY, parseRetryAfterMs, quoteBigIntFields, runInOrder, withRetry } from "./transport.js";
11
+ import { attachRowSink } from "./rowSink.js";
11
12
  const AUTH_RETRY_CODES = new Set(["8000014", "8000015", "0000001008", "999002"]);
12
13
  export class GangtiseClient {
13
14
  config;
15
+ rowSink;
14
16
  refreshPromise = null;
15
17
  memoCache = null;
16
18
  envTokenInvalidated = false;
17
- constructor(config) {
19
+ rowSinkClaimed = false;
20
+ constructor(config, rowSink) {
18
21
  this.config = config;
22
+ this.rowSink = rowSink;
23
+ }
24
+ claimRowSink() {
25
+ if (!this.rowSink || this.rowSinkClaimed)
26
+ return undefined;
27
+ this.rowSinkClaimed = true;
28
+ return this.rowSink;
19
29
  }
20
30
  async getAuthorizationHeader(forceRefresh = false) {
21
31
  if (this.config.token && !this.envTokenInvalidated && !forceRefresh) {
@@ -151,27 +161,42 @@ export class GangtiseClient {
151
161
  return firstPage;
152
162
  }
153
163
  const total = firstPage.total;
154
- const collected = [...firstPage.list];
155
164
  const available = Math.max(total - startFrom, 0);
156
165
  const target = requestedSize === undefined ? available : Math.min(requestedSize, available);
166
+ const sink = this.claimRowSink();
167
+ if (sink && Array.isArray(firstPage.fieldList))
168
+ sink.setFieldList(firstPage.fieldList);
169
+ const collected = [];
170
+ let count = 0;
171
+ const keep = async (rows) => {
172
+ const kept = requestedSize === undefined ? rows : rows.slice(0, Math.max(0, requestedSize - count));
173
+ if (kept.length === 0)
174
+ return;
175
+ count += kept.length;
176
+ if (sink)
177
+ await sink.push(kept);
178
+ else
179
+ for (const row of kept)
180
+ collected.push(row);
181
+ };
182
+ const result = () => {
183
+ const out = { ...firstPage, total, list: collected };
184
+ return sink ? attachRowSink(out, sink) : out;
185
+ };
186
+ await keep(firstPage.list);
157
187
  if (firstPage.list.length < firstPageSize) {
158
- const out = {
159
- ...firstPage,
160
- total,
161
- list: requestedSize === undefined ? collected : collected.slice(0, requestedSize),
162
- };
163
- if (collected.length < target) {
164
- process.stderr.write(`[gangtise] warning: server returned a short page (${collected.length} rows) but reported total=${total}; treating it as the end of data — results may be incomplete\n`);
188
+ const out = result();
189
+ if (count < target) {
190
+ process.stderr.write(`[gangtise] warning: server returned a short page (${count} rows) but reported total=${total}; treating it as the end of data — results may be incomplete\n`);
165
191
  out.partial = true;
166
192
  }
167
193
  return out;
168
194
  }
169
- if (collected.length >= target) {
170
- return {
171
- ...firstPage,
172
- total,
173
- list: requestedSize === undefined ? collected : collected.slice(0, requestedSize),
174
- };
195
+ if (count >= target) {
196
+ const out = result();
197
+ if (requestedSize === undefined && total > firstPageSize)
198
+ await this.flagIfTotalCapped(endpoint, initialBody, total, out, count);
199
+ return out;
175
200
  }
176
201
  const MAX_PAGES = 1000;
177
202
  let truncatedByPageCap = false;
@@ -190,10 +215,11 @@ export class GangtiseClient {
190
215
  }
191
216
  let unexpectedShape = false;
192
217
  let totalDrift = false;
218
+ let laterPartial = false;
193
219
  const failedPages = [];
194
220
  let firstError = null;
195
221
  let aborted = false;
196
- const pages = await runWithConcurrency(pageRequests, PAGE_CONCURRENCY, async (req) => {
222
+ await runInOrder(pageRequests, PAGE_CONCURRENCY, async (req) => {
197
223
  if (aborted) {
198
224
  failedPages.push(req);
199
225
  return [];
@@ -211,6 +237,8 @@ export class GangtiseClient {
211
237
  }
212
238
  if (page.total !== total)
213
239
  totalDrift = true;
240
+ if (page.partial === true)
241
+ laterPartial = true;
214
242
  return page.list;
215
243
  }
216
244
  catch (error) {
@@ -220,12 +248,7 @@ export class GangtiseClient {
220
248
  failedPages.push(req);
221
249
  return [];
222
250
  }
223
- });
224
- for (const list of pages) {
225
- if (list.length === 0)
226
- continue;
227
- collected.push(...list);
228
- }
251
+ }, (list) => keep(list));
229
252
  if (unexpectedShape) {
230
253
  process.stderr.write(`[gangtise] warning: a page response had unexpected shape; its rows are missing (counted in failedPages)\n`);
231
254
  }
@@ -233,44 +256,45 @@ export class GangtiseClient {
233
256
  process.stderr.write(`[gangtise] warning: 'total' changed across pages (data shifted during fetch); rows may be duplicated or missing\n`);
234
257
  }
235
258
  if (truncatedByPageCap) {
236
- process.stderr.write(`[gangtise] warning: hit the ${MAX_PAGES}-page safety cap; fetched ${collected.length} of ${total} rows. Narrow the query (e.g. a shorter date range) or pass --size to fetch a bounded subset.\n`);
259
+ process.stderr.write(`[gangtise] warning: hit the ${MAX_PAGES}-page safety cap; fetched ${count} of ${total} rows. Narrow the query (e.g. a shorter date range) or pass --size to fetch a bounded subset.\n`);
237
260
  }
238
- const short = collected.length < target;
239
- let totalCapped = false;
261
+ const short = count < target;
262
+ const out = result();
240
263
  if (requestedSize === undefined && total > 0 && !short && !totalDrift && !truncatedByPageCap && failedPages.length === 0) {
241
- try {
242
- const probe = await this.requestJson(endpoint, { ...initialBody, from: total, size: 1 });
243
- if (this.isPaginatedListResponse(probe) && probe.list.length > 0)
244
- totalCapped = true;
245
- }
246
- catch {
247
- }
248
- }
249
- if (totalCapped) {
250
- process.stderr.write(`[gangtise] warning: ${endpoint.key} reported total=${total} but rows exist past that offset — 'total' is a server-side cap, not the real count. This export is TRUNCATED at ${collected.length} rows. Narrow the query (date range / filters) and fetch in slices.\n`);
251
- }
252
- const out = {
253
- ...firstPage,
254
- total,
255
- list: requestedSize === undefined ? collected : collected.slice(0, requestedSize),
256
- };
257
- if (totalCapped) {
258
- out.partial = true;
259
- out.totalCapped = true;
264
+ await this.flagIfTotalCapped(endpoint, initialBody, total, out, count);
260
265
  }
261
266
  if (short || totalDrift || failedPages.length > 0)
262
267
  out.partial = true;
268
+ if (laterPartial) {
269
+ out.partial = true;
270
+ process.stderr.write(`[gangtise] warning: a later page reported itself partial; the merged result is marked partial\n`);
271
+ }
263
272
  if (failedPages.length > 0) {
264
273
  out.failedPages = failedPages.map((p) => ({ from: p.from, size: p.size }));
265
274
  const detail = firstError instanceof Error ? `: ${firstError.message}` : "";
266
275
  const skippedHint = aborted ? " A page hit a non-retryable error (e.g. rate limit); remaining pages were skipped." : "";
267
- process.stderr.write(`[gangtise] warning: ${failedPages.length}/${pageRequests.length} pages not fetched${detail}; results are partial — got ${collected.length}/${total} rows (see failedPages).${skippedHint}\n`);
276
+ process.stderr.write(`[gangtise] warning: ${failedPages.length}/${pageRequests.length} pages not fetched${detail}; results are partial — got ${count}/${total} rows (see failedPages).${skippedHint}\n`);
268
277
  }
269
278
  else if (short && !truncatedByPageCap && !totalDrift) {
270
- process.stderr.write(`[gangtise] warning: server returned ${collected.length} of ${total} rows (a later page came back short); results may be incomplete\n`);
279
+ process.stderr.write(`[gangtise] warning: server returned ${count} of ${total} rows (a later page came back short); results may be incomplete\n`);
271
280
  }
272
281
  return out;
273
282
  }
283
+ async flagIfTotalCapped(endpoint, initialBody, total, out, collectedLength) {
284
+ let totalCapped = false;
285
+ try {
286
+ const probe = await this.requestJson(endpoint, { ...initialBody, from: total, size: 1 });
287
+ if (this.isPaginatedListResponse(probe) && probe.list.length > 0)
288
+ totalCapped = true;
289
+ }
290
+ catch {
291
+ }
292
+ if (!totalCapped)
293
+ return;
294
+ process.stderr.write(`[gangtise] warning: ${endpoint.key} reported total=${total} but rows exist past that offset — 'total' is a server-side cap, not the real count. This export is TRUNCATED at ${collectedLength} rows. Narrow the query (date range / filters) and fetch in slices.\n`);
295
+ out.partial = true;
296
+ out.totalCapped = true;
297
+ }
274
298
  async login() {
275
299
  const authorization = await this.getAuthorizationHeader();
276
300
  const cache = await readTokenCache(this.config.tokenCachePath);
@@ -340,7 +364,11 @@ export class GangtiseClient {
340
364
  if (response.statusCode >= 400) {
341
365
  this.throwHttpError(parsed, response.statusCode, retryAfterMs);
342
366
  }
343
- return this.unwrapEnvelope(parsed, response.statusCode, retryAfterMs);
367
+ const payload = this.unwrapEnvelope(parsed, response.statusCode, retryAfterMs);
368
+ if (endpoint.expects === "list" && !(payload && typeof payload === "object" && Array.isArray(payload.list))) {
369
+ throw markStructural(new ApiError(`${endpoint.key} returned no list payload (got ${payload === null ? "null" : Array.isArray(payload) ? "an array" : typeof payload}) — the response layout may have changed`, undefined, response.statusCode, parsed));
370
+ }
371
+ return payload;
344
372
  }
345
373
  catch (error) {
346
374
  await this.refreshAuthIfRecoverable(error, useAuth, authState, usedAuthorization);
@@ -519,6 +547,6 @@ export class GangtiseClient {
519
547
  if (endpoint.kind === 'json' && endpoint.pagination?.enabled) {
520
548
  return this.requestPaginated(endpoint, body);
521
549
  }
522
- return this.requestJson(endpoint, body);
550
+ return this.requestJson(endpoint, body, endpoint.key !== 'auth.login');
523
551
  }
524
552
  }
@@ -1,5 +1,5 @@
1
1
  import fs from "node:fs/promises";
2
- import { extname } from "node:path";
2
+ import { dirname, extname } from "node:path";
3
3
  import { DownloadError } from "./errors.js";
4
4
  import { saveOutputIfNeeded } from "./output.js";
5
5
  import { extractTitles, lookupTitleCache, readTitleCache, TITLE_LOOKUP_SIZE, writeTitleCache } from "./titleCache.js";
@@ -16,18 +16,38 @@ function truncateFilename(name, maxBytes = 200) {
16
16
  return stem.join("") + ext;
17
17
  }
18
18
  export async function uniquePath(p) {
19
- const exists = (f) => fs.access(f).then(() => true, () => false);
20
- if (!(await exists(p)))
19
+ const claim = async (f) => {
20
+ try {
21
+ const handle = await fs.open(f, "wx");
22
+ await handle.close();
23
+ return true;
24
+ }
25
+ catch (error) {
26
+ if (error.code === "EEXIST")
27
+ return false;
28
+ throw error;
29
+ }
30
+ };
31
+ await fs.mkdir(dirname(p) || ".", { recursive: true });
32
+ if (await claim(p))
21
33
  return p;
22
34
  const ext = extname(p);
23
35
  const stem = p.slice(0, p.length - ext.length);
24
36
  for (let i = 1; i <= 99; i++) {
25
37
  const candidate = `${stem}-${i}${ext}`;
26
- if (!(await exists(candidate)))
38
+ if (await claim(candidate))
27
39
  return candidate;
28
40
  }
29
41
  throw new DownloadError(`Refusing to overwrite: 100 files already share the name "${p}" — pass --output or clean up the directory`);
30
42
  }
43
+ export async function releaseClaim(p) {
44
+ try {
45
+ if ((await fs.stat(p)).size === 0)
46
+ await fs.unlink(p);
47
+ }
48
+ catch {
49
+ }
50
+ }
31
51
  const MIME_EXT = {
32
52
  "application/pdf": ".pdf",
33
53
  "application/msword": ".doc",
@@ -178,13 +198,27 @@ export async function saveDownloadResult(result, fallbackName, output) {
178
198
  if (file.data instanceof Uint8Array) {
179
199
  const autoName = (file.filename ? sanitizeFilename(file.filename) : undefined) ?? (safeFallback + extFromContentType(file.contentType));
180
200
  const outputPath = output ?? await uniquePath(truncateFilename(autoName));
181
- await saveOutputIfNeeded(file.data, outputPath);
201
+ try {
202
+ await saveOutputIfNeeded(file.data, outputPath);
203
+ }
204
+ catch (error) {
205
+ if (!output)
206
+ await releaseClaim(outputPath);
207
+ throw error;
208
+ }
182
209
  process.stdout.write(`${outputPath}\n`);
183
210
  return;
184
211
  }
185
212
  if (typeof file.text === "string") {
186
213
  const outputPath = output ?? await uniquePath(truncateFilename(`${safeFallback}.txt`));
187
- await saveOutputIfNeeded(file.text, outputPath);
214
+ try {
215
+ await saveOutputIfNeeded(file.text, outputPath);
216
+ }
217
+ catch (error) {
218
+ if (!output)
219
+ await releaseClaim(outputPath);
220
+ throw error;
221
+ }
188
222
  process.stdout.write(`${outputPath}\n`);
189
223
  return;
190
224
  }
@@ -273,42 +273,49 @@ const ENDPOINT_DEFS = {
273
273
  path: "/application/open-quote/kline/daily",
274
274
  kind: "json",
275
275
  description: "Query A-share historical daily kline (SH/SZ/BJ)",
276
+ expects: "list",
276
277
  },
277
278
  "quote.day-kline-hk": {
278
279
  method: "POST",
279
280
  path: "/application/open-quote/kline-hk/daily",
280
281
  kind: "json",
281
282
  description: "Query HK stock historical daily kline (HK)",
283
+ expects: "list",
282
284
  },
283
285
  "quote.day-kline-us": {
284
286
  method: "POST",
285
287
  path: "/application/open-quote/kline-us/daily",
286
288
  kind: "json",
287
289
  description: "Query US stock historical daily kline (NYSE/NASDAQ/AMEX)",
290
+ expects: "list",
288
291
  },
289
292
  "quote.index-day-kline": {
290
293
  method: "POST",
291
294
  path: "/application/open-quote/index/kline/daily",
292
295
  kind: "json",
293
296
  description: "Query SH/SZ/BJ index daily kline",
297
+ expects: "list",
294
298
  },
295
299
  "quote.minute-kline": {
296
300
  method: "POST",
297
301
  path: "/application/open-quote/kline/minute",
298
302
  kind: "json",
299
303
  description: "Query A-share minute kline (SH/SZ/BJ)",
304
+ expects: "list",
300
305
  },
301
306
  "quote.realtime": {
302
307
  method: "POST",
303
308
  path: "/application/open-quote/quote/realtime",
304
309
  kind: "json",
305
310
  description: "Query realtime quote snapshot (A-share / HK / US)",
311
+ expects: "list",
306
312
  },
307
313
  "quote.fund-flow": {
308
314
  method: "POST",
309
315
  path: "/application/open-quote/fund-flow/daily",
310
316
  kind: "json",
311
317
  description: "Query A-share daily fund flow (SH/SZ/BJ; small/medium/large/xlarge orders + main net inflow)",
318
+ expects: "list",
312
319
  },
313
320
  "fundamental.income-statement": {
314
321
  method: "POST",
@@ -17,6 +17,14 @@ export function attachEnvelopeTraceId(payload, traceId) {
17
17
  }
18
18
  return payload;
19
19
  }
20
+ const STRUCTURAL = Symbol("gangtise.structural");
21
+ export function markStructural(error) {
22
+ Object.defineProperty(error, STRUCTURAL, { value: true, enumerable: false, configurable: true });
23
+ return error;
24
+ }
25
+ export function isStructuralError(error) {
26
+ return Boolean(error && typeof error === "object" && error[STRUCTURAL] === true);
27
+ }
20
28
  const ERROR_HINTS = {
21
29
  "999001": "检查 GANGTISE_TOKEN 或 AK/SK 是否已 export。",
22
30
  "999002": "有 AK/SK 时 CLI 会自动重新登录重试一次,否则请重新登录。",
@@ -14,6 +14,37 @@ export function zipFieldRow(fields, row, source) {
14
14
  return acc;
15
15
  }, {});
16
16
  }
17
+ export function columnarSchemaValid(fields, list) {
18
+ if (!fields)
19
+ return false;
20
+ if (new Set(fields.map(String)).size !== fields.length)
21
+ return false;
22
+ return list.every((row) => !Array.isArray(row) || row.length === fields.length);
23
+ }
24
+ export function flagMissingFields(data, requested, label) {
25
+ if (!requested?.length || !data || typeof data !== "object" || Array.isArray(data))
26
+ return;
27
+ const rec = data;
28
+ if (!Array.isArray(rec.fieldList))
29
+ return;
30
+ const returned = new Set(rec.fieldList.map(String));
31
+ const missing = requested.filter((field) => !returned.has(field));
32
+ if (missing.length === 0)
33
+ return;
34
+ rec.partial = true;
35
+ rec.missingFields = missing;
36
+ process.stderr.write(`[gangtise] warning: ${label} returned no column for ${missing.join(", ")} — the server drops a field name it does not recognise (or no longer serves) without an error. Check the name against references/fields.md; result marked partial (exit 3).\n`);
37
+ }
38
+ export function assertColumnarHeader(fields, source) {
39
+ if (!fields) {
40
+ throw new ValidationError(`响应包含数组形式的行但没有 fieldList,无法确定各列的含义,已拒绝输出。这是上游响应结构异常,请报障${traceSuffix(source)}。`);
41
+ }
42
+ const names = fields.map(String);
43
+ const dupes = [...new Set(names.filter((name, i) => names.indexOf(name) !== i))];
44
+ if (dupes.length > 0) {
45
+ throw new ValidationError(`响应 fieldList 有重复列名(${dupes.join(", ")})——按位置拍平时后一列会覆盖前一列,已拒绝输出。这是上游响应结构异常,请报障${traceSuffix(source)}。`);
46
+ }
47
+ }
17
48
  export function normalizeRows(value) {
18
49
  if (!value || typeof value !== "object") {
19
50
  return value;
@@ -24,12 +55,16 @@ export function normalizeRows(value) {
24
55
  const record = value;
25
56
  if (Array.isArray(record.fieldList) && Array.isArray(record.list)) {
26
57
  const fields = record.fieldList;
58
+ if (record.list.some(Array.isArray))
59
+ assertColumnarHeader(fields, record);
27
60
  const normalizedList = record.list.map((row) => (Array.isArray(row) ? zipFieldRow(fields, row, record) : row));
28
61
  const { fieldList, list, ...meta } = record;
29
62
  const hasMeta = Object.keys(meta).length > 0;
30
63
  return hasMeta ? { ...meta, list: normalizedList } : normalizedList;
31
64
  }
32
65
  if (Array.isArray(record.list)) {
66
+ if (record.list.some(Array.isArray))
67
+ assertColumnarHeader(undefined, record);
33
68
  const { list, ...meta } = record;
34
69
  const hasMeta = Object.keys(meta).length > 0;
35
70
  return hasMeta ? { ...meta, list } : list;
@@ -24,7 +24,7 @@ function displayWidth(value) {
24
24
  }
25
25
  return width;
26
26
  }
27
- function formatScalar(value) {
27
+ export function formatScalar(value) {
28
28
  if (value === null || value === undefined) {
29
29
  return "";
30
30
  }
@@ -104,16 +104,19 @@ function renderCsv(rows) {
104
104
  const body = rows.map((row) => columns.map((column) => csvEscape(formatScalar(row[column]))).join(","));
105
105
  return [header, ...body].join("\n");
106
106
  }
107
+ export function jsonlItems(value) {
108
+ const list = value && typeof value === "object" && !Array.isArray(value) ? value.list : undefined;
109
+ return Array.isArray(list) ? list : toRows(value);
110
+ }
111
+ export function countOutputRows(value, format) {
112
+ return format === "jsonl" ? jsonlItems(value).length : toRows(value).length;
113
+ }
107
114
  export function renderOutput(value, format) {
108
115
  switch (format) {
109
116
  case "json":
110
117
  return JSON.stringify(value, null, 2);
111
- case "jsonl": {
112
- const items = value && typeof value === "object" && !Array.isArray(value) && Array.isArray(value.list)
113
- ? value.list
114
- : null;
115
- return (items ?? toRows(value)).map((item) => JSON.stringify(item)).join("\n");
116
- }
118
+ case "jsonl":
119
+ return jsonlItems(value).map((item) => JSON.stringify(item)).join("\n");
117
120
  case "csv":
118
121
  return renderCsv(toRows(value));
119
122
  case "markdown":
@@ -147,7 +150,7 @@ export async function streamOutputToFile(value, format, outputPath) {
147
150
  stream.on("error", () => { });
148
151
  try {
149
152
  if (format === "jsonl") {
150
- for (const item of list) {
153
+ for (const item of jsonlItems(value)) {
151
154
  await writeLine(stream, JSON.stringify(item));
152
155
  }
153
156
  }
@@ -183,7 +186,7 @@ export function pickList(value) {
183
186
  }
184
187
  return null;
185
188
  }
186
- function csvEscape(value) {
189
+ export function csvEscape(value) {
187
190
  let out = value;
188
191
  if (/^[=@\t\r]/.test(out) || (/^[+\-]/.test(out) && !Number.isFinite(Number(out))))
189
192
  out = "'" + out;
@@ -191,7 +194,7 @@ function csvEscape(value) {
191
194
  return `"${out.replaceAll("\"", "\"\"")}"`;
192
195
  return out;
193
196
  }
194
- function writeLine(stream, line) {
197
+ export function writeLine(stream, line) {
195
198
  return new Promise((resolve, reject) => {
196
199
  const ok = stream.write(line + "\n", (err) => err ? reject(err) : undefined);
197
200
  if (ok) {
@@ -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
+ }