@tailglow/cli 0.7.1 → 0.9.4

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.
package/src/help.js CHANGED
@@ -229,6 +229,11 @@ export function describeForMachines({ resource, command } = {}) {
229
229
  description:
230
230
  "Every guide and API reference page as markdown, shipped with this CLI. Start with `tglow docs guides/agents`."
231
231
  },
232
+ import: {
233
+ command: "tglow import scan|validate FILE; tglow import FILE --collection NAME",
234
+ description:
235
+ "Preview XML, JSONL, JSON, CSV and TSV, then validate records while importing. Full checking is optional. Run tglow import --help."
236
+ },
232
237
  account: describeLoginForMachines(),
233
238
  global_flags: globals,
234
239
  usage_notes: USAGE_NOTES
@@ -250,6 +255,7 @@ export function renderRootHelp() {
250
255
  ...resources.map((resource) => ` ${resource}`),
251
256
  " config (stored defaults and credentials)",
252
257
  " docs (every guide and API reference page, as markdown)",
258
+ " import (scan, validate and upload XML, JSONL, JSON, CSV or TSV files)",
253
259
  "",
254
260
  "Account:",
255
261
  " tglow login Sign in through the browser. `tglow login --help` for agents and scripts.",
@@ -0,0 +1,636 @@
1
+ import { parseArgs } from "node:util";
2
+ import { loadConfig, resolveApiUrl, resolveCredential, resolveSetting } from "./config.js";
3
+ import { openImportFile, snapshotImportFile } from "./import-file.js";
4
+ import { IMPORT_PARSER_OPTIONS } from "./import-options.js";
5
+ import {
6
+ createImportBatches,
7
+ createNodeImportWorker,
8
+ detectImport,
9
+ IMPORT_MAX_WORKERS,
10
+ IMPORT_REJECTED_STATUSES,
11
+ readImportRows,
12
+ scanImport
13
+ } from "./import.generated.js";
14
+ import { sanitizeText, shouldEmitJson } from "./output.js";
15
+ import { buildRequestHeaders } from "./request.js";
16
+
17
+ const FORMATS = ["auto", "xml", "jsonl", "json", "csv", "tsv"];
18
+ // Bundled from packages/import/src/worker.node.js by scripts/build/import.build.js.
19
+ const IMPORT_WORKER_URL = new URL("./import-worker.generated.js", import.meta.url);
20
+ const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
21
+ const HELP_NOTES = [
22
+ "Scan and validate run offline. Scan samples unless --full is given; a sample is not validation.",
23
+ "Upload previews a bounded sample, then validates records while sending bounded batches.",
24
+ "--validate-first copies a private snapshot and checks it fully before upload; it needs temporary disk space approximately equal to the input size.",
25
+ "Default upload reads one open file and stops if it detects changes. Do not edit it during import.",
26
+ "Only HTTP 202 acknowledges a batch. Delivery uncertainty stops upload without retrying.",
27
+ "Ctrl-C reports accepted rows and any uncertain batch. Retrying a partial import may duplicate rows."
28
+ ];
29
+
30
+ const HELP = [
31
+ "tglow import scan FILE [--full] [options]",
32
+ "tglow import validate FILE [options]",
33
+ "tglow import FILE --collection NAME [destination] [options]",
34
+ "",
35
+ "--format auto|xml|jsonl|json|csv|tsv",
36
+ "--record-path /root/item Optional XML filter; imports all top-level entries by default.",
37
+ "--workers auto|0|N XML worker threads for full reads; auto uses 2 from 32 MiB.",
38
+ "--validate-first Check a private snapshot completely before uploading it.",
39
+ "--ingest-url URL Explicit ingest URL, including its key. Keep this credential private.",
40
+ "--project ID Expected project, also accepts --project-id; defaults from config.",
41
+ "--source-id ID --ingest-key-id ID Resolve this exact key through the authenticated API.",
42
+ "--collection NAME Required upload destination collection.",
43
+ "--json Machine JSON; implied when stdout is piped.",
44
+ "",
45
+ ...HELP_NOTES
46
+ ].join("\n");
47
+
48
+ /** @returns {Record<string, unknown>} */
49
+ export function describeImportForMachines() {
50
+ return {
51
+ usage: ["tglow import scan FILE [--full]", "tglow import validate FILE", "tglow import FILE"],
52
+ formats: FORMATS,
53
+ flags: Object.entries(IMPORT_PARSER_OPTIONS).map(([name, option]) => ({
54
+ flag: "--" + name,
55
+ type: option.type
56
+ })),
57
+ notes: HELP_NOTES
58
+ };
59
+ }
60
+
61
+ /** @param {unknown} error @param {Record<string, unknown>} flags */
62
+ function safeError(error, flags) {
63
+ let message = error instanceof Error ? error.message : "Import failed.";
64
+ for (const name of ["api-key", "ingest-url"]) {
65
+ const value = flags[name];
66
+ if (typeof value === "string" && value) message = message.split(value).join("[redacted]");
67
+ }
68
+ message = message.replace(/https?:\/\/[^\s"'<>]+/gi, "[redacted URL]");
69
+ return sanitizeText(message).slice(0, 1200);
70
+ }
71
+
72
+ /** @param {unknown} value @returns {"auto" | number} */
73
+ function parseWorkers(value) {
74
+ if (value === undefined || value === "auto") return "auto";
75
+ if (typeof value === "string" && /^\d{1,2}$/.test(value) && Number(value) <= IMPORT_MAX_WORKERS)
76
+ return Number(value);
77
+ throw new Error(`--workers takes auto or a whole number from 0 to ${IMPORT_MAX_WORKERS}.`);
78
+ }
79
+
80
+ /** @param {AbortSignal} signal */
81
+ function checkAbort(signal) {
82
+ if (signal.aborted) throw new Error("Import cancelled.");
83
+ }
84
+
85
+ /** @param {string} address @param {boolean} [is_ingest] */
86
+ function safeUrl(address, is_ingest = false) {
87
+ let url;
88
+ try {
89
+ url = new URL(address);
90
+ } catch {
91
+ throw new Error("Invalid destination address.");
92
+ }
93
+ if (
94
+ url.username ||
95
+ url.password ||
96
+ url.hash ||
97
+ (url.protocol !== "https:" && !(url.protocol === "http:" && LOCAL_HOSTS.has(url.hostname)))
98
+ ) {
99
+ throw new Error(
100
+ "Use an HTTPS address, or HTTP on localhost, without URL credentials or fragments."
101
+ );
102
+ }
103
+ if (is_ingest && !url.searchParams.get("key"))
104
+ throw new Error("The ingest address must contain its key.");
105
+ return url;
106
+ }
107
+
108
+ /**
109
+ * A manually linked signal works on the oldest supported Node release.
110
+ *
111
+ * @param {AbortSignal} parent @param {number} milliseconds
112
+ */
113
+ function requestSignal(parent, milliseconds) {
114
+ const controller = new AbortController();
115
+ const abort = () => controller.abort();
116
+ if (parent.aborted) abort();
117
+ else parent.addEventListener("abort", abort, { once: true });
118
+ const timer = setTimeout(abort, milliseconds);
119
+ return {
120
+ signal: controller.signal,
121
+ dispose() {
122
+ clearTimeout(timer);
123
+ parent.removeEventListener("abort", abort);
124
+ }
125
+ };
126
+ }
127
+
128
+ /** @param {Response} response */
129
+ async function smallJson(response) {
130
+ if (!response.body) throw new Error("Missing response.");
131
+ const reader = response.body.getReader();
132
+ const decoder = new TextDecoder("utf-8", { fatal: true });
133
+ let text = "";
134
+ let bytes = 0;
135
+ try {
136
+ for (;;) {
137
+ const { value, done } = await reader.read();
138
+ if (done) break;
139
+ bytes += value.byteLength;
140
+ if (bytes > 64 * 1024) throw new Error("Oversized response.");
141
+ text += decoder.decode(value, { stream: true });
142
+ }
143
+ return JSON.parse(text + decoder.decode());
144
+ } finally {
145
+ // Stream cleanup must not hold up the result or cancellation of the command.
146
+ void reader.cancel().catch(() => {});
147
+ reader.releaseLock();
148
+ }
149
+ }
150
+
151
+ /**
152
+ * @param {URL} url @param {RequestInit} options @param {AbortSignal} signal @param {typeof fetch}
153
+ * request
154
+ */
155
+ async function getJson(url, options, signal, request) {
156
+ const linked = requestSignal(signal, 8000);
157
+ try {
158
+ const response = await request(url, {
159
+ ...options,
160
+ method: "GET",
161
+ signal: linked.signal,
162
+ redirect: "error",
163
+ cache: "no-store",
164
+ credentials: "omit"
165
+ });
166
+ if (!response.ok) {
167
+ void response.body?.cancel().catch(() => {});
168
+ throw new Error("Refused response.");
169
+ }
170
+ return await smallJson(response);
171
+ } catch {
172
+ checkAbort(signal);
173
+ throw new Error(
174
+ "Could not verify the destination. Check credentials, IDs, and endpoint availability."
175
+ );
176
+ } finally {
177
+ linked.dispose();
178
+ }
179
+ }
180
+
181
+ /**
182
+ * @param {Record<string, unknown>} flags
183
+ * @param {AbortSignal} signal
184
+ * @param {typeof fetch} request
185
+ * @param {typeof loadConfig} read_config
186
+ */
187
+ async function resolveDestination(flags, signal, request, read_config) {
188
+ const config = await read_config();
189
+ const project_id =
190
+ typeof flags["project-id"] === "string"
191
+ ? flags["project-id"]
192
+ : resolveSetting("project", { flags, config });
193
+ if (flags.project && flags["project-id"] && flags.project !== flags["project-id"]) {
194
+ throw new Error("--project and --project-id must name the same project.");
195
+ }
196
+ if (!project_id || project_id === "." || project_id === "..")
197
+ throw new Error("Upload requires --project (or a configured project).");
198
+ let url;
199
+ if (typeof flags["ingest-url"] === "string") {
200
+ if (flags["source-id"] || flags["ingest-key-id"])
201
+ throw new Error("Choose --ingest-url or explicit --source-id/--ingest-key-id, not both.");
202
+ url = safeUrl(flags["ingest-url"], true);
203
+ } else {
204
+ const source_id = flags["source-id"];
205
+ const ingest_key_id = flags["ingest-key-id"];
206
+ if (typeof source_id !== "string" || typeof ingest_key_id !== "string") {
207
+ throw new Error("Upload requires --ingest-url, or both --source-id and --ingest-key-id.");
208
+ }
209
+ if ([source_id, ingest_key_id].some((id) => id === "." || id === ".."))
210
+ throw new Error("Destination IDs cannot be dot segments.");
211
+ const api_url = safeUrl(resolveApiUrl({ flags, config }));
212
+ let credential;
213
+ try {
214
+ credential = resolveCredential({ flags, config });
215
+ } catch {
216
+ throw new Error(
217
+ "Sign in to the configured API or supply an API key to resolve this ingest key."
218
+ );
219
+ }
220
+ const team_id = resolveSetting("team", { flags, config }) ?? credential.login?.team_id ?? null;
221
+ const path =
222
+ "/v1/projects/" +
223
+ encodeURIComponent(project_id) +
224
+ "/sources/" +
225
+ encodeURIComponent(source_id) +
226
+ "/ingest_keys/" +
227
+ encodeURIComponent(ingest_key_id);
228
+
229
+ const payload = await getJson(
230
+ new URL(path, api_url),
231
+ {
232
+ headers: await buildRequestHeaders({ secret: credential.secret, team_id })
233
+ },
234
+ signal,
235
+ request
236
+ );
237
+
238
+ const key = payload?.data;
239
+ if (
240
+ key?.id !== ingest_key_id ||
241
+ key.source_id !== source_id ||
242
+ key.status !== "active" ||
243
+ typeof key.url !== "string"
244
+ ) {
245
+ throw new Error(
246
+ "The selected ingest key is unavailable, inactive, or belongs to another source."
247
+ );
248
+ }
249
+ url = safeUrl(key.url, true);
250
+ if (LOCAL_HOSTS.has(api_url.hostname) && !LOCAL_HOSTS.has(url.hostname)) {
251
+ throw new Error(
252
+ "A local API returned a remote ingest address. Pass an explicit --ingest-url for the intended destination."
253
+ );
254
+ }
255
+ }
256
+ url.searchParams.set("collection", String(flags.collection));
257
+ return { url, project_id };
258
+ }
259
+
260
+ /** @param {Record<string, unknown>} report */
261
+ function renderHumanReport(report) {
262
+ if (!report.error && (report.action === "scan" || report.action === "validate")) {
263
+ const lines = [
264
+ String(report.format).toUpperCase() + ": " + report.row_count + " selected rows.",
265
+ report.is_complete
266
+ ? "Full validation complete."
267
+ : "Sample or discovery only; upload validation is still required."
268
+ ];
269
+ lines.push(
270
+ "File size: " + report.total_bytes + " bytes; scanned " + report.bytes_read + " bytes."
271
+ );
272
+ const xml = /** @type {any} */ (report.xml);
273
+ if (xml?.record_paths?.length)
274
+ lines.push(
275
+ "XML record paths: " +
276
+ xml.record_paths
277
+ .map((/** @type {{ path: string }} */ entry) => sanitizeText(entry.path))
278
+ .join(", ")
279
+ );
280
+ const fields = /** @type {import("./import.generated.js").FieldProfile[]} */ (
281
+ report.fields ?? []
282
+ );
283
+ if (fields.length) {
284
+ lines.push("Fields:");
285
+ for (const field of fields.slice(0, 20))
286
+ lines.push(
287
+ " " +
288
+ sanitizeText(field.path) +
289
+ ": " +
290
+ field.types.join(" | ") +
291
+ "; present " +
292
+ field.present +
293
+ ", missing " +
294
+ field.missing
295
+ );
296
+ if (fields.length > 20) lines.push(" More fields are available with --json.");
297
+ }
298
+ const candidates = /** @type {import("./import.generated.js").TimestampCandidate[]} */ (
299
+ report.timestamp_candidates ?? []
300
+ );
301
+ if (candidates.length)
302
+ lines.push(
303
+ "Timestamp candidates: " + candidates.map((entry) => sanitizeText(entry.path)).join(", ")
304
+ );
305
+ for (const warning of /** @type {string[]} */ (report.warnings ?? []))
306
+ lines.push(sanitizeText(warning));
307
+ return lines.join("\n");
308
+ }
309
+ const affected = /** @type {any} */ (report.affected_batch);
310
+ return [
311
+ report.status === "complete"
312
+ ? "Import complete."
313
+ : sanitizeText(String(report.error ?? "Import stopped.")),
314
+ "Accepted rows: " + report.accepted_count + ".",
315
+ ...(report.status !== "complete" && Number(report.accepted_count) > 0
316
+ ? ["Accepted rows remain in the collection. Retrying this file may duplicate them."]
317
+ : []),
318
+ ...(affected
319
+ ? [
320
+ "Batch rows " +
321
+ affected.first_row +
322
+ "-" +
323
+ affected.last_row +
324
+ ": " +
325
+ affected.status +
326
+ ". No retry was attempted."
327
+ ]
328
+ : [])
329
+ ].join("\n");
330
+ }
331
+
332
+ /**
333
+ * @typedef {Object} ImportCommandContext
334
+ * @property {typeof fetch} [request]
335
+ * @property {typeof loadConfig} [read_config]
336
+ * @property {AbortSignal} [signal]
337
+ * @property {{ write(text: string): unknown }} [stderr]
338
+ * @property {string} [temporary_root]
339
+ */
340
+
341
+ /**
342
+ * Dedicated import parsing keeps file formats and local flags separate from the API registry.
343
+ *
344
+ * @param {string[]} argv Complete argv, including the import command.
345
+ * @param {ImportCommandContext} [context]
346
+ * @returns {Promise<{ output: string; code: number }>}
347
+ */
348
+ export async function runImportCommand(argv, context = {}) {
349
+ /** @type {Record<string, unknown>} */
350
+ let flags = {};
351
+ let action = "import";
352
+ let accepted_count = 0;
353
+ /** @type {number | null} */
354
+ let total_rows = null;
355
+ let outcome = "invalid_input";
356
+ /**
357
+ * @type {{
358
+ * index: number;
359
+ * first_row: number;
360
+ * last_row: number;
361
+ * status: string;
362
+ * http_status?: number;
363
+ * } | null}
364
+ */
365
+ let affected_batch = null;
366
+ /** @type {Awaited<ReturnType<typeof openImportFile>> | undefined} */
367
+ let input;
368
+ const controller = new AbortController();
369
+ const signal = controller.signal;
370
+ const abort = () => controller.abort();
371
+ const external_signal = context.signal;
372
+ if (external_signal?.aborted) abort();
373
+ else external_signal?.addEventListener("abort", abort, { once: true });
374
+ process.on("SIGINT", abort);
375
+ const stderr = context.stderr ?? process.stderr;
376
+ const request = context.request ?? fetch;
377
+ let result;
378
+ try {
379
+ let positionals;
380
+ try {
381
+ ({ values: flags, positionals } = parseArgs({
382
+ args: argv,
383
+ options: IMPORT_PARSER_OPTIONS,
384
+ allowPositionals: true,
385
+ strict: false
386
+ }));
387
+ } catch {
388
+ throw new Error("Invalid import options. Run tglow import --help.");
389
+ }
390
+ for (const [name, value] of Object.entries(flags)) {
391
+ const option = IMPORT_PARSER_OPTIONS[name];
392
+ if (!option) throw new Error("Unknown import option. Run tglow import --help.");
393
+ if (option.type === "boolean") {
394
+ if (value === "true" || value === "false") flags[name] = value === "true";
395
+ else if (typeof value !== "boolean")
396
+ throw new Error("--" + name + " takes no value, or =true / =false.");
397
+ } else if (typeof value !== "string" || !value.length) {
398
+ throw new Error("--" + name + " requires a value.");
399
+ }
400
+ }
401
+ if (flags.help || positionals.length === 1) {
402
+ return {
403
+ output: shouldEmitJson(flags) ? JSON.stringify(describeImportForMachines(), null, 2) : HELP,
404
+ code: 0
405
+ };
406
+ }
407
+ if (positionals[0] !== "import") throw new Error("Expected the import command.");
408
+ const words = positionals.slice(1);
409
+ if (words[0] === "scan" || words[0] === "validate") {
410
+ action = words[0];
411
+ words.shift();
412
+ }
413
+ if (words.length !== 1)
414
+ throw new Error("Specify exactly one input file. Run tglow import --help.");
415
+ if (action !== "scan" && flags.full !== undefined)
416
+ throw new Error("--full only applies to import scan.");
417
+ if (action !== "import" && flags["validate-first"] !== undefined)
418
+ throw new Error("--validate-first only applies to uploading.");
419
+ if (!FORMATS.includes(String(flags.format ?? "auto")))
420
+ throw new Error("Unsupported --format. Use auto, xml, jsonl, json, csv, or tsv.");
421
+ const workers = parseWorkers(flags.workers);
422
+ if (
423
+ action !== "import" &&
424
+ ["ingest-url", "collection", "source-id", "ingest-key-id"].some(
425
+ (name) => flags[name] !== undefined
426
+ )
427
+ ) {
428
+ throw new Error("Destination options apply only to uploading, not scan or validate.");
429
+ }
430
+ if (action === "import" && (typeof flags.collection !== "string" || !flags.collection.trim()))
431
+ throw new Error("Upload requires an explicit --collection.");
432
+ checkAbort(signal);
433
+ let last_progress = 0;
434
+ /** @param {import("./import.generated.js").ImportProgress} progress */
435
+ const onprogress = ({ bytes_read, total_bytes, rows }) => {
436
+ if (Date.now() - last_progress < 1000) return;
437
+ last_progress = Date.now();
438
+ stderr.write("Reading: " + bytes_read + "/" + total_bytes + " bytes, " + rows + " rows.\n");
439
+ };
440
+
441
+ // Each scan and upload pass starts its own workers and reports what actually ran. Automatic
442
+ // sequential reads stay quiet; explicit counts always explain the outcome.
443
+ /** @param {import("./import.generated.js").ImportWorkerStatus} status */
444
+ const onworkers = (status) => {
445
+ let line;
446
+ if (status.fallback)
447
+ line = `XML workers stopped (${status.fallback}); continuing sequentially.`;
448
+ else if (status.actual)
449
+ line = `XML workers: ${status.actual}${status.reason ? ` (${status.reason})` : ""}.`;
450
+ else if (status.requested || (workers !== "auto" && workers > 0))
451
+ line = `XML workers: none, reading sequentially (${status.reason}).`;
452
+ else return;
453
+ stderr.write(sanitizeText(line) + "\n");
454
+ };
455
+
456
+ outcome = "file_error";
457
+ if (action === "import" && flags["validate-first"] === true) {
458
+ stderr.write("Making a private snapshot for a full check before upload.\n");
459
+ input = await snapshotImportFile(words[0], {
460
+ signal,
461
+ temporary_root: context.temporary_root
462
+ });
463
+ } else input = await openImportFile(words[0], { signal });
464
+ const source = input.source;
465
+ outcome = "validation_failed";
466
+ const detection = await detectImport(source, {
467
+ format: /** @type {any} */ (flags.format ?? "auto"),
468
+ signal
469
+ });
470
+
471
+ const options = Object.freeze({
472
+ format: detection.format,
473
+ ...(typeof flags["record-path"] === "string" ? { record_path: flags["record-path"] } : {}),
474
+ workers,
475
+ create_worker: () => createNodeImportWorker(IMPORT_WORKER_URL),
476
+ onworkers,
477
+ signal,
478
+ onprogress
479
+ });
480
+
481
+ const report = await scanImport(source, {
482
+ ...options,
483
+ is_full: action === "validate" || flags.full === true || flags["validate-first"] === true
484
+ });
485
+ if (action !== "import") {
486
+ if (action === "validate" && (!report.is_complete || report.is_sampled))
487
+ throw new Error("Validation did not cover the complete selected input.");
488
+ return {
489
+ output: shouldEmitJson(flags)
490
+ ? JSON.stringify({ action, ...report }, null, 2)
491
+ : renderHumanReport({ action, ...report }),
492
+ code: 0
493
+ };
494
+ }
495
+ if (flags["validate-first"] === true && (!report.is_complete || report.is_sampled))
496
+ throw new Error("The full file check did not finish.");
497
+ total_rows = report.is_complete && !report.is_sampled ? report.row_count : null;
498
+ outcome = "destination_failed";
499
+ const destination = await resolveDestination(
500
+ flags,
501
+ signal,
502
+ request,
503
+ context.read_config ?? loadConfig
504
+ );
505
+
506
+ const readiness = await getJson(destination.url, {}, signal, request);
507
+ if (readiness?.status !== "ready" || readiness.project_id !== destination.project_id)
508
+ throw new Error("The ingest endpoint is not ready for the expected project.");
509
+ outcome = "validation_failed";
510
+ for await (const batch of createImportBatches(readImportRows(source, options))) {
511
+ checkAbort(signal);
512
+ await input.assertUnchanged();
513
+ affected_batch = {
514
+ index: batch.index,
515
+ first_row: batch.rows[0].index,
516
+ last_row: batch.rows[batch.rows.length - 1].index,
517
+ status: "uncertain"
518
+ };
519
+ outcome = "uncertain_delivery";
520
+ const linked = requestSignal(signal, 30_000);
521
+ try {
522
+ const response = await request(destination.url, {
523
+ method: "POST",
524
+ headers: { "Content-Type": "application/x-ndjson" },
525
+ body: batch.body,
526
+ signal: linked.signal,
527
+ redirect: "error",
528
+ credentials: "omit"
529
+ });
530
+ if (response.status !== 202) {
531
+ affected_batch.status = IMPORT_REJECTED_STATUSES.includes(response.status)
532
+ ? "rejected"
533
+ : "uncertain";
534
+ outcome = affected_batch.status === "rejected" ? "rejected" : "uncertain_delivery";
535
+ affected_batch.http_status = response.status;
536
+ void response.body?.cancel().catch(() => {});
537
+ throw new Error(
538
+ "The server did not acknowledge the batch. Upload stopped without retrying."
539
+ );
540
+ }
541
+ accepted_count += batch.rows.length;
542
+ affected_batch = null;
543
+ void response.body?.cancel().catch(() => {});
544
+ if (Date.now() - last_progress >= 1000 || accepted_count === total_rows) {
545
+ last_progress = Date.now();
546
+ stderr.write(
547
+ "Accepted " +
548
+ accepted_count +
549
+ (total_rows === null ? "" : "/" + total_rows) +
550
+ " rows.\n"
551
+ );
552
+ }
553
+ } catch {
554
+ if (affected_batch?.status === "rejected")
555
+ throw new Error("The server rejected the batch. Upload stopped without retrying.");
556
+ throw new Error(
557
+ "No acknowledgement from the server. The affected batch may have arrived; upload stopped without retrying."
558
+ );
559
+ } finally {
560
+ linked.dispose();
561
+ }
562
+ outcome = "validation_failed";
563
+ }
564
+ checkAbort(signal);
565
+ await input.assertUnchanged();
566
+ if (!accepted_count)
567
+ throw new Error(
568
+ "This selection has no data rows. Check the XML record path or file contents."
569
+ );
570
+ if (total_rows !== null && accepted_count !== total_rows)
571
+ throw new Error("The imported row count differed from the full file check.");
572
+ total_rows = accepted_count;
573
+ result = {
574
+ action,
575
+ status: "complete",
576
+ outcome: "complete",
577
+ accepted_count,
578
+ total_rows,
579
+ affected_batch: null
580
+ };
581
+ } catch (error) {
582
+ if (input && !signal.aborted && !affected_batch) {
583
+ try {
584
+ await input.assertUnchanged();
585
+ } catch (source_error) {
586
+ if (
587
+ source_error &&
588
+ typeof source_error === "object" &&
589
+ "code" in source_error &&
590
+ source_error.code === "IMPORT_SOURCE_CHANGED"
591
+ )
592
+ error = source_error;
593
+ }
594
+ }
595
+ result = {
596
+ action,
597
+ status: signal.aborted ? "cancelled" : "failed",
598
+ outcome:
599
+ error &&
600
+ typeof error === "object" &&
601
+ "code" in error &&
602
+ error.code === "IMPORT_SOURCE_CHANGED"
603
+ ? "file_changed"
604
+ : signal.aborted && !affected_batch
605
+ ? "cancelled"
606
+ : outcome,
607
+ ...(error &&
608
+ typeof error === "object" &&
609
+ "index" in error &&
610
+ Number.isSafeInteger(error.index)
611
+ ? { failed_row: error.index }
612
+ : {}),
613
+ error: safeError(error, flags),
614
+ accepted_count,
615
+ total_rows,
616
+ affected_batch
617
+ };
618
+ } finally {
619
+ if (input) {
620
+ try {
621
+ await input.cleanup();
622
+ } catch {
623
+ const cleanup_error =
624
+ "Could not close the input or remove its private temporary snapshot. Check the tailglow-import directory in your temporary folder.";
625
+ result = { ...result, cleanup_error };
626
+ stderr.write("Warning: " + cleanup_error + "\n");
627
+ }
628
+ }
629
+ external_signal?.removeEventListener("abort", abort);
630
+ process.removeListener("SIGINT", abort);
631
+ }
632
+ return {
633
+ output: shouldEmitJson(flags) ? JSON.stringify(result, null, 2) : renderHumanReport(result),
634
+ code: result.status === "complete" ? 0 : signal.aborted ? 130 : 1
635
+ };
636
+ }