@oxygen-agent/cli 1.1014.136 → 1.1014.619
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/README.md +1 -1
- package/dist/admin-primary-providers-render.js +27 -19
- package/dist/cli-output.d.ts +12 -0
- package/dist/cli-output.js +395 -0
- package/dist/cli-paths.d.ts +1 -0
- package/dist/cli-paths.js +3 -0
- package/dist/cli-profile-context.d.ts +3 -0
- package/dist/cli-profile-context.js +13 -0
- package/dist/command-manifest.d.ts +15 -0
- package/dist/command-manifest.js +23 -1
- package/dist/command-support.d.ts +438 -0
- package/dist/command-support.js +1610 -0
- package/dist/commands/action-column.d.ts +3 -0
- package/dist/commands/action-column.js +43 -0
- package/dist/commands/activation.d.ts +3 -0
- package/dist/commands/activation.js +24 -0
- package/dist/commands/admin.d.ts +3 -0
- package/dist/commands/admin.js +347 -0
- package/dist/commands/ads.d.ts +3 -0
- package/dist/commands/ads.js +62 -0
- package/dist/commands/agent.d.ts +3 -0
- package/dist/commands/agent.js +274 -0
- package/dist/commands/api-keys.d.ts +3 -0
- package/dist/commands/api-keys.js +77 -0
- package/dist/commands/approvals.d.ts +3 -0
- package/dist/commands/approvals.js +518 -0
- package/dist/commands/auth.d.ts +3 -0
- package/dist/commands/auth.js +230 -0
- package/dist/commands/billing.d.ts +3 -0
- package/dist/commands/billing.js +357 -0
- package/dist/commands/blueprints.d.ts +3 -0
- package/dist/commands/blueprints.js +312 -0
- package/dist/commands/budget.d.ts +3 -0
- package/dist/commands/budget.js +55 -0
- package/dist/commands/callables.d.ts +3 -0
- package/dist/commands/callables.js +111 -0
- package/dist/commands/capabilities.d.ts +3 -0
- package/dist/commands/capabilities.js +44 -0
- package/dist/commands/cells.d.ts +3 -0
- package/dist/commands/cells.js +94 -0
- package/dist/commands/columns.d.ts +3 -0
- package/dist/commands/columns.js +1628 -0
- package/dist/commands/commands.d.ts +3 -0
- package/dist/commands/commands.js +45 -0
- package/dist/commands/comments.d.ts +3 -0
- package/dist/commands/comments.js +188 -0
- package/dist/commands/companies.d.ts +3 -0
- package/dist/commands/companies.js +312 -0
- package/dist/commands/connections.d.ts +3 -0
- package/dist/commands/connections.js +39 -0
- package/dist/commands/context.d.ts +3 -0
- package/dist/commands/context.js +170 -0
- package/dist/commands/copilot.d.ts +3 -0
- package/dist/commands/copilot.js +565 -0
- package/dist/commands/crm.d.ts +3 -0
- package/dist/commands/crm.js +1672 -0
- package/dist/commands/custom-integrations.d.ts +3 -0
- package/dist/commands/custom-integrations.js +140 -0
- package/dist/commands/dashboards.d.ts +3 -0
- package/dist/commands/dashboards.js +240 -0
- package/dist/commands/db.d.ts +3 -0
- package/dist/commands/db.js +190 -0
- package/dist/commands/deliverability.d.ts +3 -0
- package/dist/commands/deliverability.js +90 -0
- package/dist/commands/directory.d.ts +3 -0
- package/dist/commands/directory.js +101 -0
- package/dist/commands/domains.d.ts +3 -0
- package/dist/commands/domains.js +604 -0
- package/dist/commands/egress.d.ts +3 -0
- package/dist/commands/egress.js +130 -0
- package/dist/commands/email.d.ts +3 -0
- package/dist/commands/email.js +49 -0
- package/dist/commands/engagement.d.ts +3 -0
- package/dist/commands/engagement.js +189 -0
- package/dist/commands/enrich-column.d.ts +3 -0
- package/dist/commands/enrich-column.js +149 -0
- package/dist/commands/enrichment.d.ts +3 -0
- package/dist/commands/enrichment.js +49 -0
- package/dist/commands/feedback.d.ts +3 -0
- package/dist/commands/feedback.js +96 -0
- package/dist/commands/feeds.d.ts +3 -0
- package/dist/commands/feeds.js +486 -0
- package/dist/commands/find.d.ts +3 -0
- package/dist/commands/find.js +159 -0
- package/dist/commands/followers.d.ts +3 -0
- package/dist/commands/followers.js +39 -0
- package/dist/commands/formulas.d.ts +3 -0
- package/dist/commands/formulas.js +43 -0
- package/dist/commands/home.d.ts +3 -0
- package/dist/commands/home.js +22 -0
- package/dist/commands/inbox.d.ts +3 -0
- package/dist/commands/inbox.js +779 -0
- package/dist/commands/integrations.d.ts +3 -0
- package/dist/commands/integrations.js +290 -0
- package/dist/commands/knowledge.d.ts +3 -0
- package/dist/commands/knowledge.js +874 -0
- package/dist/commands/lead-sourcing.d.ts +3 -0
- package/dist/commands/lead-sourcing.js +52 -0
- package/dist/commands/limits.d.ts +3 -0
- package/dist/commands/limits.js +54 -0
- package/dist/commands/linkedin.d.ts +3 -0
- package/dist/commands/linkedin.js +275 -0
- package/dist/commands/login.d.ts +3 -0
- package/dist/commands/login.js +347 -0
- package/dist/commands/logout.d.ts +3 -0
- package/dist/commands/logout.js +12 -0
- package/dist/commands/mailboxes.d.ts +3 -0
- package/dist/commands/mailboxes.js +1290 -0
- package/dist/commands/managed-inboxes.d.ts +3 -0
- package/dist/commands/managed-inboxes.js +361 -0
- package/dist/commands/messages.d.ts +3 -0
- package/dist/commands/messages.js +78 -0
- package/dist/commands/notetaker.d.ts +3 -0
- package/dist/commands/notetaker.js +79 -0
- package/dist/commands/observability.d.ts +3 -0
- package/dist/commands/observability.js +125 -0
- package/dist/commands/orgs.d.ts +3 -0
- package/dist/commands/orgs.js +237 -0
- package/dist/commands/people.d.ts +3 -0
- package/dist/commands/people.js +353 -0
- package/dist/commands/posts.d.ts +3 -0
- package/dist/commands/posts.js +139 -0
- package/dist/commands/profiles.d.ts +3 -0
- package/dist/commands/profiles.js +250 -0
- package/dist/commands/projects.d.ts +3 -0
- package/dist/commands/projects.js +94 -0
- package/dist/commands/prompts.d.ts +3 -0
- package/dist/commands/prompts.js +84 -0
- package/dist/commands/publishing.d.ts +3 -0
- package/dist/commands/publishing.js +1419 -0
- package/dist/commands/recipes.d.ts +3 -0
- package/dist/commands/recipes.js +120 -0
- package/dist/commands/reviews.d.ts +3 -0
- package/dist/commands/reviews.js +75 -0
- package/dist/commands/rows.d.ts +3 -0
- package/dist/commands/rows.js +27 -0
- package/dist/commands/runs.d.ts +3 -0
- package/dist/commands/runs.js +37 -0
- package/dist/commands/search.d.ts +3 -0
- package/dist/commands/search.js +182 -0
- package/dist/commands/senders.d.ts +3 -0
- package/dist/commands/senders.js +393 -0
- package/dist/commands/sequences.d.ts +3 -0
- package/dist/commands/sequences.js +2322 -0
- package/dist/commands/session.d.ts +3 -0
- package/dist/commands/session.js +109 -0
- package/dist/commands/signals.d.ts +3 -0
- package/dist/commands/signals.js +289 -0
- package/dist/commands/signup-leads.d.ts +3 -0
- package/dist/commands/signup-leads.js +46 -0
- package/dist/commands/skills.d.ts +3 -0
- package/dist/commands/skills.js +50 -0
- package/dist/commands/sourcing.d.ts +3 -0
- package/dist/commands/sourcing.js +27 -0
- package/dist/commands/status.d.ts +3 -0
- package/dist/commands/status.js +51 -0
- package/dist/commands/supabase.d.ts +3 -0
- package/dist/commands/supabase.js +128 -0
- package/dist/commands/support.d.ts +3 -0
- package/dist/commands/support.js +651 -0
- package/dist/commands/suppressions.d.ts +3 -0
- package/dist/commands/suppressions.js +577 -0
- package/dist/commands/table-ingestions.d.ts +3 -0
- package/dist/commands/table-ingestions.js +145 -0
- package/dist/commands/table-runs.d.ts +3 -0
- package/dist/commands/table-runs.js +277 -0
- package/dist/commands/tables.d.ts +3 -0
- package/dist/commands/tables.js +3505 -0
- package/dist/commands/tags.d.ts +3 -0
- package/dist/commands/tags.js +147 -0
- package/dist/commands/tools.d.ts +3 -0
- package/dist/commands/tools.js +246 -0
- package/dist/commands/update.d.ts +3 -0
- package/dist/commands/update.js +106 -0
- package/dist/commands/verify.d.ts +3 -0
- package/dist/commands/verify.js +27 -0
- package/dist/commands/viewers.d.ts +3 -0
- package/dist/commands/viewers.js +72 -0
- package/dist/commands/voice.d.ts +3 -0
- package/dist/commands/voice.js +351 -0
- package/dist/commands/whatsapp.d.ts +3 -0
- package/dist/commands/whatsapp.js +226 -0
- package/dist/commands/whoami.d.ts +3 -0
- package/dist/commands/whoami.js +110 -0
- package/dist/commands/worker.d.ts +3 -0
- package/dist/commands/worker.js +60 -0
- package/dist/commands/workflows.d.ts +3 -0
- package/dist/commands/workflows.js +1752 -0
- package/dist/commands/workspace.d.ts +3 -0
- package/dist/commands/workspace.js +94 -0
- package/dist/compact-list-flags.d.ts +7 -0
- package/dist/compact-list-flags.js +12 -0
- package/dist/crm-support-flags.js +14 -5
- package/dist/customer-operation-commands.js +8 -6
- package/dist/diagnostic-uploader.d.ts +3 -1
- package/dist/diagnostic-uploader.js +29 -8
- package/dist/help.d.ts +5 -0
- package/dist/help.js +22 -0
- package/dist/index.d.ts +98 -1
- package/dist/index.js +331 -30719
- package/dist/local-module-loader.d.ts +4 -0
- package/dist/local-module-loader.js +156 -0
- package/dist/visual-commands.js +9 -5
- package/node_modules/@oxygen/cli-ugc/dist/commands.js +2 -2
- package/node_modules/@oxygen/shared/dist/agent-session-schedule.d.ts +36 -0
- package/node_modules/@oxygen/shared/dist/agent-session-schedule.js +69 -0
- package/node_modules/@oxygen/shared/dist/agent-sessions.d.ts +14 -5
- package/node_modules/@oxygen/shared/dist/agent-sessions.js +14 -3
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +175 -14
- package/node_modules/@oxygen/shared/dist/compact-list-result.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/compact-list-result.js +27 -0
- package/node_modules/@oxygen/shared/dist/contracts/agent-sessions.d.ts +12 -1
- package/node_modules/@oxygen/shared/dist/contracts/agent-sessions.js +5 -2
- package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +4 -2
- package/node_modules/@oxygen/shared/dist/contracts/compact-list-reads.d.ts +57 -0
- package/node_modules/@oxygen/shared/dist/contracts/compact-list-reads.js +23 -0
- package/node_modules/@oxygen/shared/dist/contracts/crm-support.d.ts +52 -0
- package/node_modules/@oxygen/shared/dist/contracts/crm-support.js +43 -2
- package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +20 -16
- package/node_modules/@oxygen/shared/dist/contracts/knowledge-content-reads.js +1 -1
- package/node_modules/@oxygen/shared/dist/contracts/knowledge-proposal-reads.d.ts +49 -0
- package/node_modules/@oxygen/shared/dist/contracts/knowledge-proposal-reads.js +14 -0
- package/node_modules/@oxygen/shared/dist/contracts/knowledge-sync.js +1 -1
- package/node_modules/@oxygen/shared/dist/contracts/limits-read.js +60 -7
- package/node_modules/@oxygen/shared/dist/contracts/operations.d.ts +9 -1
- package/node_modules/@oxygen/shared/dist/contracts/operations.js +13 -5
- package/node_modules/@oxygen/shared/dist/contracts/publishing-reads.js +1 -1
- package/node_modules/@oxygen/shared/dist/contracts/teammates.d.ts +16 -0
- package/node_modules/@oxygen/shared/dist/contracts/teammates.js +115 -0
- package/node_modules/@oxygen/shared/dist/cost-estimate-view.js +21 -3
- package/node_modules/@oxygen/shared/dist/cost-estimate.d.ts +31 -1
- package/node_modules/@oxygen/shared/dist/cost-estimate.js +56 -15
- package/node_modules/@oxygen/shared/dist/diagnostic-credentials.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-credentials.js +2 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-lock.js +71 -44
- package/node_modules/@oxygen/shared/dist/diagnostic-spool.d.ts +18 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-spool.js +348 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-storage-failure.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-storage-failure.js +42 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-storage.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/diagnostic-storage.js +59 -213
- package/node_modules/@oxygen/shared/dist/diagnostics.js +3 -2
- package/node_modules/@oxygen/shared/dist/email-dsn.d.ts +16 -5
- package/node_modules/@oxygen/shared/dist/email-dsn.js +97 -1
- package/node_modules/@oxygen/shared/dist/emailguard-placement-probe.js +5 -0
- package/node_modules/@oxygen/shared/dist/enrichment-intents.js +4 -5
- package/node_modules/@oxygen/shared/dist/file-import.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/file-import.js +21 -2
- package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +30 -0
- package/node_modules/@oxygen/shared/dist/hosted-ai.js +20 -0
- package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +6 -2
- package/node_modules/@oxygen/shared/dist/knowledge-seed-content.d.ts +17 -7
- package/node_modules/@oxygen/shared/dist/knowledge-seed-content.js +81 -17
- package/node_modules/@oxygen/shared/dist/langfuse.js +8 -4
- package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/mailbox-import.d.ts +6 -3
- package/node_modules/@oxygen/shared/dist/mailbox-import.js +9 -9
- package/node_modules/@oxygen/shared/dist/mcp-server-info.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/mcp-server-info.js +10 -0
- package/node_modules/@oxygen/shared/dist/object-storage.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/object-storage.js +14 -0
- package/node_modules/@oxygen/shared/dist/plan-limits-view.d.ts +5 -0
- package/node_modules/@oxygen/shared/dist/plan-limits-view.js +17 -1
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +1 -1
- package/node_modules/@oxygen/shared/dist/provider-selection.d.ts +15 -0
- package/node_modules/@oxygen/shared/dist/provider-selection.js +3 -0
- package/node_modules/@oxygen/shared/dist/repricing.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/repricing.js +2 -2
- package/node_modules/@oxygen/shared/dist/schedule-label.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/schedule-label.js +12 -0
- package/node_modules/@oxygen/shared/dist/sequence-failures.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/sequence-failures.js +20 -6
- package/node_modules/@oxygen/shared/dist/teammate-groups.d.ts +55 -0
- package/node_modules/@oxygen/shared/dist/teammate-groups.js +74 -0
- package/node_modules/@oxygen/shared/dist/teammate-messages.d.ts +52 -0
- package/node_modules/@oxygen/shared/dist/teammate-messages.js +88 -0
- package/node_modules/@oxygen/shared/dist/teammate-notes.d.ts +58 -0
- package/node_modules/@oxygen/shared/dist/teammate-notes.js +73 -0
- package/node_modules/@oxygen/shared/dist/teammate-routines.d.ts +95 -0
- package/node_modules/@oxygen/shared/dist/teammate-routines.js +266 -0
- package/node_modules/@oxygen/shared/dist/teammate-tasks.d.ts +56 -0
- package/node_modules/@oxygen/shared/dist/teammate-tasks.js +114 -0
- package/node_modules/@oxygen/shared/dist/teammates.d.ts +78 -0
- package/node_modules/@oxygen/shared/dist/teammates.js +140 -0
- package/node_modules/@oxygen/shared/dist/user-capability-routing.js +120 -0
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- package/node_modules/@oxygen/shared/dist/visual-render.d.ts +9 -1
- package/node_modules/@oxygen/shared/dist/visual-render.js +12 -5
- package/node_modules/@oxygen/shared/dist/warmup-metrics.d.ts +5 -0
- package/node_modules/@oxygen/shared/dist/warmup-metrics.js +44 -0
- package/package.json +1 -1
|
@@ -0,0 +1,3505 @@
|
|
|
1
|
+
import { asPayloadRecord, emitCliFailure, emitSuccess, writeJson } from "../cli-output.js";
|
|
2
|
+
import { parseJsonObject, readJsonObjectOption, readNonNegativeInt, readPositiveInt, readRecordString } from "../cli-values.js";
|
|
3
|
+
import { CADENCE_FLAG_DESCRIPTION, ansi, collectRepeatable, formatRows, normalizeExportRowsFormat, parseJsonArray, parseJsonValue, readCount, readCsvOption, readFileIfPresent, readFilterJsonOption, readFormulaValuesOption, readPositiveNumber, readRecord, splitCommaList, tableWebhookDeliveriesPath, writeListCapNotice } from "../command-support.js";
|
|
4
|
+
import { addContractFlags, readContractFlags } from "../contract-flags.js";
|
|
5
|
+
import { defaultApiUrl } from "../credentials.js";
|
|
6
|
+
import { oxygenAppUrl } from "../help.js";
|
|
7
|
+
import { requestOxygen } from "../http-client.js";
|
|
8
|
+
import { writeExclusiveJson } from "../json-output.js";
|
|
9
|
+
import { STREAMED_FILE_IMPORT_MIN_BYTES, putImportFileStream, rowsForStreamedImportTarget, scanStreamedImportFile, shouldStreamFileImport } from "../streamed-file-import.js";
|
|
10
|
+
import { isRecord, readOption } from "../util.js";
|
|
11
|
+
import { chunk } from "@oxygen/shared/array-utils";
|
|
12
|
+
import { OxygenError, success } from "@oxygen/shared/cli-result";
|
|
13
|
+
import { PIPEDRIVE_CATALOG_QUERY_CONTRACT, pipedriveCatalogQueryParams } from "@oxygen/shared/contracts/pipedrive-catalog";
|
|
14
|
+
import { TABLE_LIST_QUERY_CONTRACT, tableListQueryParams } from "@oxygen/shared/contracts/table-list";
|
|
15
|
+
import { TABLE_QUERY_GRID_CONTRACT } from "@oxygen/shared/contracts/table-query-grid";
|
|
16
|
+
import { TABLE_QUERY_CLI_DEFAULT_LIMIT, TABLE_QUERY_CLI_DEFAULT_MAX_CHARS, tableQueryRawResultFits, tableQueryRawRecovery } from "@oxygen/shared/contracts/table-query-raw";
|
|
17
|
+
import { MAX_BUFFERED_IMPORT_PARSE_BYTES, assertImportFormatWithinBufferedLimit, dropBlankImportCells, inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer } from "@oxygen/shared/file-import";
|
|
18
|
+
import { PUBLIC_WEBHOOK_INGRESS_PER_TARGET_PER_MINUTE } from "@oxygen/shared/plan-band";
|
|
19
|
+
import { MAX_CLI_JSON_BODY_BYTES, PLAN_LIMITS } from "@oxygen/shared/plan-limits";
|
|
20
|
+
import { FILTER_TREE_OPERATORS } from "@oxygen/shared/table-filter-operators";
|
|
21
|
+
import { Command, Option } from "commander";
|
|
22
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
23
|
+
import { closeSync, createReadStream, createWriteStream, existsSync, openSync, readFileSync, readSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { basename, resolve } from "node:path";
|
|
25
|
+
import { stdout as output } from "node:process";
|
|
26
|
+
import { createInterface } from "node:readline/promises";
|
|
27
|
+
const LARGE_IMPORT_BACKGROUND_ROW_THRESHOLD = 500;
|
|
28
|
+
const SAFE_IMPORT_WRITE_BATCH_SIZE = 500;
|
|
29
|
+
// The only numbers on `tables import --help` used to be --batch-size (a
|
|
30
|
+
// per-request chunk) and the background threshold, so users read 500 as a
|
|
31
|
+
// per-file cap. Echo the shared row ceiling and the tiered byte ceilings instead
|
|
32
|
+
// of restating either as a chunk-size constraint.
|
|
33
|
+
// Webhook ingress scales with plan size (repricing 2026-09, decision L4.4).
|
|
34
|
+
const TABLE_WEBHOOK_RATE_HELP = `${PUBLIC_WEBHOOK_INGRESS_PER_TARGET_PER_MINUTE.free.toLocaleString("en-US")}–${PUBLIC_WEBHOOK_INGRESS_PER_TARGET_PER_MINUTE["1999"].toLocaleString("en-US")} requests a minute by plan size, per endpoint and sender IP; 429 returns Retry-After; \`oxygen limits show\` lists yours`;
|
|
35
|
+
const IMPORT_FILE_LIMIT_HELP = `Per-file row limit: your plan's rows-per-Table limit, since one file can fill an empty Table (\`oxygen limits show\` lists it for every plan size). File-size limit: ${formatImportFileSizeLimit("free")} on free, ${formatImportFileSizeLimit("starter")} on $49-$99, ${formatImportFileSizeLimit("pro")} on $199-$499, ${formatImportFileSizeLimit("scale")} on $999 and up. JSON and XLSX are read whole, so they stop at ${formatImportByteSize(MAX_BUFFERED_IMPORT_PARSE_BYTES)}; save a larger file as CSV or JSONL. Files over ${formatImportByteSize(STREAMED_FILE_IMPORT_MIN_BYTES)} upload straight from disk and always load in the background.`;
|
|
36
|
+
function linkBar(coverage, width = 36) {
|
|
37
|
+
const filled = Math.max(0, Math.min(width, Math.round(coverage * width)));
|
|
38
|
+
return `[${"#".repeat(filled)}${"-".repeat(width - filled)}]`;
|
|
39
|
+
}
|
|
40
|
+
function writeTableLinkPreview(table, data, options) {
|
|
41
|
+
const out = (line = "") => process.stdout.write(`${line}\n`);
|
|
42
|
+
// Undo has no plan to render — just the count and what it touched.
|
|
43
|
+
if (typeof data.edges_to_archive === "number" || typeof data.archived_edge_count === "number") {
|
|
44
|
+
if (typeof data.archived_edge_count === "number") {
|
|
45
|
+
out(`Undone — ${data.archived_edge_count.toLocaleString()} links archived.`);
|
|
46
|
+
out("Links are archived, not deleted, and only the ones that run created were touched.");
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
out(`This will archive ${(data.edges_to_archive ?? 0).toLocaleString()} links created by that run.`);
|
|
50
|
+
out(`Apply: oxygen tables link ${table} --undo ${readOption(options.undo) ?? "<run_id>"} --approved`);
|
|
51
|
+
}
|
|
52
|
+
if (data.web_url)
|
|
53
|
+
out(data.web_url);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const blockers = data.blockers ?? [];
|
|
57
|
+
if (blockers.length > 0) {
|
|
58
|
+
for (const blocker of blockers)
|
|
59
|
+
out(blocker.message ?? blocker.code ?? "This link cannot run yet.");
|
|
60
|
+
if (data.web_url)
|
|
61
|
+
out(data.web_url);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// Approved run: it is queued, so print how to watch it, not the plan again.
|
|
65
|
+
// The two follow-ups take DIFFERENT ids and that is not cosmetic — undo
|
|
66
|
+
// archives by edge run_id, which is the parent workspace run.
|
|
67
|
+
const watchId = data.action_run_id ?? data.workspace_run_id;
|
|
68
|
+
const undoId = data.workspace_run_id;
|
|
69
|
+
if (!data.dry_run && watchId) {
|
|
70
|
+
out(`Linking rows in the background.`);
|
|
71
|
+
if (data.web_url)
|
|
72
|
+
out(`Run: ${data.web_url}`);
|
|
73
|
+
out(`Watch: oxygen table-runs wait ${watchId}`);
|
|
74
|
+
if (undoId)
|
|
75
|
+
out(`Undo: oxygen tables link ${table} --undo ${undoId}`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const chosen = data.chosen ?? null;
|
|
79
|
+
const sourceLabel = data.source?.tableLabel ?? table;
|
|
80
|
+
const targetLabel = data.target?.tableLabel ?? readOption(options.to) ?? "the other table";
|
|
81
|
+
out(`Linking "${sourceLabel}" -> "${targetLabel}"`);
|
|
82
|
+
out();
|
|
83
|
+
if (!chosen) {
|
|
84
|
+
out(" No column in these two tables matches well enough to link them.");
|
|
85
|
+
out(" Add a shared value to both — a website, a domain, a LinkedIn URL, or an email.");
|
|
86
|
+
if (data.web_url)
|
|
87
|
+
out(data.web_url);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const pad = (label) => label.padEnd(14);
|
|
91
|
+
out(` ${pad("Matching on")}${chosen.source?.columnLabel ?? "?"} -> ${chosen.target?.columnLabel ?? "?"}`);
|
|
92
|
+
if (chosen.matchExplanation)
|
|
93
|
+
out(` ${pad("")}(${chosen.matchExplanation})`);
|
|
94
|
+
if (data.cardinalitySentence)
|
|
95
|
+
out(` ${pad("Direction")}${data.cardinalitySentence}`);
|
|
96
|
+
const scanned = data.source?.scannedRows ?? 0;
|
|
97
|
+
const total = data.source?.rowCount ?? scanned;
|
|
98
|
+
// Always name the sample. `sampled` means the scan hit its bound, so these are
|
|
99
|
+
// estimates over a prefix of the table, not the whole thing.
|
|
100
|
+
if (data.sampled && scanned < total) {
|
|
101
|
+
out(` ${pad("Measured on")}${scanned.toLocaleString()} of ${total.toLocaleString()} rows`);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
out(` ${pad("Measured on")}all ${total.toLocaleString()} rows`);
|
|
105
|
+
}
|
|
106
|
+
out();
|
|
107
|
+
const coverage = chosen.coverage ?? 0;
|
|
108
|
+
out(` ${linkBar(coverage)} ${(chosen.matchedRows ?? 0).toLocaleString()} will link ${Math.round(coverage * 100)}%`);
|
|
109
|
+
if ((chosen.ambiguousRows ?? 0) > 0) {
|
|
110
|
+
out(` ${" ".repeat(38)}${(chosen.ambiguousRows ?? 0).toLocaleString()} ambiguous (skipped)`);
|
|
111
|
+
}
|
|
112
|
+
if ((chosen.unmatchedRows ?? 0) > 0) {
|
|
113
|
+
out(` ${" ".repeat(38)}${(chosen.unmatchedRows ?? 0).toLocaleString()} with no match yet`);
|
|
114
|
+
}
|
|
115
|
+
out();
|
|
116
|
+
const samples = chosen.samplePairs ?? [];
|
|
117
|
+
if (samples.length > 0) {
|
|
118
|
+
for (const sample of samples.slice(0, 3))
|
|
119
|
+
out(` matched on "${sample.key ?? ""}"`);
|
|
120
|
+
if (samples.length > 3)
|
|
121
|
+
out(` ... ${samples.length - 3} more`);
|
|
122
|
+
out();
|
|
123
|
+
}
|
|
124
|
+
if ((chosen.ambiguousRows ?? 0) > 0) {
|
|
125
|
+
out(` Ambiguous rows match more than one row on the other side, so they are left`);
|
|
126
|
+
out(` unlinked rather than guessed. Fix the duplicates first:`);
|
|
127
|
+
out(` oxygen tables dedupe ${targetLabel} --on <column>`);
|
|
128
|
+
out();
|
|
129
|
+
}
|
|
130
|
+
if ((chosen.unmatchedRows ?? 0) > 0 && !options.createMissing) {
|
|
131
|
+
out(` Add --create-missing to create the missing target rows too (free).`);
|
|
132
|
+
out();
|
|
133
|
+
}
|
|
134
|
+
const alternatives = data.alternatives ?? [];
|
|
135
|
+
if (alternatives.length > 0) {
|
|
136
|
+
const best = alternatives[0];
|
|
137
|
+
if (best?.source?.columnLabel) {
|
|
138
|
+
out(` Also measured: ${best.source.columnLabel} -> ${best.target?.columnLabel ?? "?"} ${Math.round((best.coverage ?? 0) * 100)}%`);
|
|
139
|
+
out(` retry with --on "${best.source.columnLabel}"`);
|
|
140
|
+
out();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (data.relationStorage === "tables") {
|
|
144
|
+
out(` Stored in Tables — neither table is registered as a CRM object.`);
|
|
145
|
+
out();
|
|
146
|
+
}
|
|
147
|
+
out(" Free — 0 credits. Nothing has been written.");
|
|
148
|
+
const applyFlags = [`--to ${readOption(options.to) ?? "<table>"}`];
|
|
149
|
+
if (readOption(options.on))
|
|
150
|
+
applyFlags.push(`--on "${readOption(options.on)}"`);
|
|
151
|
+
if (options.createMissing)
|
|
152
|
+
applyFlags.push("--create-missing");
|
|
153
|
+
out(` Apply: oxygen tables link ${table} ${applyFlags.join(" ")} --approved`);
|
|
154
|
+
if (data.web_url)
|
|
155
|
+
out(` ${data.web_url}`);
|
|
156
|
+
}
|
|
157
|
+
async function readDeleteRowIdsOption(options) {
|
|
158
|
+
const rowIdsJson = readOption(options.rowIdsJson);
|
|
159
|
+
const rowIdsFile = readOption(options.rowIdsFile);
|
|
160
|
+
if (rowIdsJson && rowIdsFile) {
|
|
161
|
+
throw new OxygenError("invalid_request", "Pass either --row-ids-json or --row-ids-file, not both.", {
|
|
162
|
+
exitCode: 1,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (!rowIdsJson && !rowIdsFile) {
|
|
166
|
+
throw new OxygenError("invalid_request", "Pass --row-ids-json or --row-ids-file.", {
|
|
167
|
+
exitCode: 1,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (rowIdsJson)
|
|
171
|
+
return normalizeDeleteRowIds(parseJsonArray(rowIdsJson));
|
|
172
|
+
const filePath = resolve(rowIdsFile ?? "");
|
|
173
|
+
const buffer = readFileSync(filePath);
|
|
174
|
+
const format = normalizeRowsFormat(options.format, inferRowsFileFormat(filePath));
|
|
175
|
+
if (format === "json") {
|
|
176
|
+
const text = buffer.toString("utf8");
|
|
177
|
+
const inlineIds = tryParseJsonStringArray(text);
|
|
178
|
+
if (inlineIds)
|
|
179
|
+
return normalizeDeleteRowIds(inlineIds);
|
|
180
|
+
if (!options.format && !text.trimStart().startsWith("[")) {
|
|
181
|
+
return normalizeDeleteRowIds(parsePlainRowIdList(text));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const sheet = readOption(options.sheet);
|
|
185
|
+
const rows = await parseRowsFileBuffer(buffer, format, sheet ? { sheet } : {});
|
|
186
|
+
const rowIdColumn = readOption(options.rowIdColumn) ?? "_row_id";
|
|
187
|
+
return normalizeDeleteRowIds(rows.map((row, index) => {
|
|
188
|
+
const value = row[rowIdColumn];
|
|
189
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
190
|
+
throw new OxygenError("invalid_request", `Row id column "${rowIdColumn}" must contain row UUID strings.`, {
|
|
191
|
+
details: { row_number: index + 1, column: rowIdColumn },
|
|
192
|
+
exitCode: 1,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return value.trim();
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
function tryParseJsonStringArray(value) {
|
|
199
|
+
let parsed;
|
|
200
|
+
try {
|
|
201
|
+
parsed = JSON.parse(value);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
if (!Array.isArray(parsed) || !parsed.every((entry) => typeof entry === "string"))
|
|
207
|
+
return null;
|
|
208
|
+
return parsed;
|
|
209
|
+
}
|
|
210
|
+
function parsePlainRowIdList(value) {
|
|
211
|
+
return value
|
|
212
|
+
.split(/[\n,]/)
|
|
213
|
+
.map((entry) => entry.trim())
|
|
214
|
+
.filter(Boolean);
|
|
215
|
+
}
|
|
216
|
+
function normalizeDeleteRowIds(values) {
|
|
217
|
+
const rowIds = values.map((value, index) => {
|
|
218
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
219
|
+
throw new OxygenError("invalid_request", "Row IDs must be non-empty strings.", {
|
|
220
|
+
details: { index },
|
|
221
|
+
exitCode: 1,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
return value.trim();
|
|
225
|
+
});
|
|
226
|
+
if (rowIds.length === 0) {
|
|
227
|
+
throw new OxygenError("invalid_request", "Row ID list cannot be empty.", {
|
|
228
|
+
exitCode: 1,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return rowIds;
|
|
232
|
+
}
|
|
233
|
+
function tableWebhookListPath(options) {
|
|
234
|
+
const params = new URLSearchParams();
|
|
235
|
+
const table = readOption(options.table);
|
|
236
|
+
const status = readOption(options.status);
|
|
237
|
+
const limit = readPositiveInt(options.limit);
|
|
238
|
+
if (table)
|
|
239
|
+
params.set("table", table);
|
|
240
|
+
if (status)
|
|
241
|
+
params.set("status", status);
|
|
242
|
+
if (limit)
|
|
243
|
+
params.set("limit", String(limit));
|
|
244
|
+
const query = params.toString();
|
|
245
|
+
return `/api/cli/tables/webhooks${query ? `?${query}` : ""}`;
|
|
246
|
+
}
|
|
247
|
+
function readTableWebhookAutoRunOptions(options) {
|
|
248
|
+
const columns = readCsvOption(options.autoRunColumns);
|
|
249
|
+
const requested = columns.length > 0
|
|
250
|
+
|| options.autoRunMaxCredits !== undefined
|
|
251
|
+
|| options.autoRunMaxConcurrency !== undefined
|
|
252
|
+
|| Boolean(options.autoRunForce)
|
|
253
|
+
|| options.autoRunConnectionId !== undefined
|
|
254
|
+
|| Boolean(options.autoRunApproved);
|
|
255
|
+
if (!requested)
|
|
256
|
+
return null;
|
|
257
|
+
if (columns.length === 0) {
|
|
258
|
+
throw new OxygenError("invalid_table_webhook", "Pass --auto-run-columns when configuring webhook auto-runs.", {
|
|
259
|
+
exitCode: 1,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
const maxCredits = readPositiveNumber(options.autoRunMaxCredits);
|
|
263
|
+
const maxConcurrency = readPositiveInt(options.autoRunMaxConcurrency);
|
|
264
|
+
return {
|
|
265
|
+
columns,
|
|
266
|
+
...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
|
|
267
|
+
...(maxConcurrency ? { max_concurrency: maxConcurrency } : {}),
|
|
268
|
+
...(options.autoRunForce ? { force: true } : {}),
|
|
269
|
+
...(readOption(options.autoRunConnectionId) ? { connection_id: readOption(options.autoRunConnectionId) } : {}),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
// Parse a `tables promote --map` value into the wire mappings [{column, attribute}].
|
|
273
|
+
// Accepts the CSV pair form `column=attribute,column2=attribute2` (LHS is the working
|
|
274
|
+
// column, RHS the CRM attribute — the reverse of --bind-map's identity=column), a
|
|
275
|
+
// JSON array of {column, attribute}, or a JSON object { column: attribute }.
|
|
276
|
+
function parsePromoteMapOption(value) {
|
|
277
|
+
const trimmed = readOption(value);
|
|
278
|
+
if (!trimmed) {
|
|
279
|
+
throw new OxygenError("invalid_promote_map", "--map is required: column=attribute pairs or a JSON array/object.", { exitCode: 1 });
|
|
280
|
+
}
|
|
281
|
+
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
|
282
|
+
let parsed;
|
|
283
|
+
try {
|
|
284
|
+
parsed = JSON.parse(trimmed);
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
throw new OxygenError("invalid_promote_map", "--map JSON is not valid JSON.", { details: { value: trimmed }, exitCode: 1 });
|
|
288
|
+
}
|
|
289
|
+
const entries = Array.isArray(parsed)
|
|
290
|
+
? parsed.map((entry) => {
|
|
291
|
+
const record = isRecord(entry) ? entry : {};
|
|
292
|
+
return {
|
|
293
|
+
column: typeof record.column === "string" ? record.column.trim() : "",
|
|
294
|
+
attribute: typeof record.attribute === "string" ? record.attribute.trim() : "",
|
|
295
|
+
};
|
|
296
|
+
})
|
|
297
|
+
: isRecord(parsed)
|
|
298
|
+
? Object.entries(parsed).map(([column, attribute]) => ({ column: column.trim(), attribute: String(attribute).trim() }))
|
|
299
|
+
: [];
|
|
300
|
+
const mappings = entries.filter((mapping) => mapping.column && mapping.attribute);
|
|
301
|
+
if (mappings.length === 0) {
|
|
302
|
+
throw new OxygenError("invalid_promote_map", "--map must contain at least one column-to-attribute mapping.", { exitCode: 1 });
|
|
303
|
+
}
|
|
304
|
+
return mappings;
|
|
305
|
+
}
|
|
306
|
+
const mappings = [];
|
|
307
|
+
for (const entry of readCsvOption(value)) {
|
|
308
|
+
const separator = entry.indexOf("=");
|
|
309
|
+
if (separator <= 0 || separator === entry.length - 1) {
|
|
310
|
+
throw new OxygenError("invalid_promote_map", "--map entries must be column=attribute pairs, e.g. job_title=job_title,seniority=seniority.", { details: { entry }, exitCode: 1 });
|
|
311
|
+
}
|
|
312
|
+
mappings.push({ column: entry.slice(0, separator).trim(), attribute: entry.slice(separator + 1).trim() });
|
|
313
|
+
}
|
|
314
|
+
if (mappings.length === 0) {
|
|
315
|
+
throw new OxygenError("invalid_promote_map", "--map must contain at least one column-to-attribute mapping.", { exitCode: 1 });
|
|
316
|
+
}
|
|
317
|
+
return mappings;
|
|
318
|
+
}
|
|
319
|
+
// Map the hyphenated CLI policy flag to the wire enum.
|
|
320
|
+
function normalizePromotePolicy(value) {
|
|
321
|
+
const normalized = (readOption(value) ?? "fill-empty-only").replace(/-/g, "_");
|
|
322
|
+
if (normalized === "fill_empty_only" || normalized === "overwrite" || normalized === "skip_conflicts") {
|
|
323
|
+
return normalized;
|
|
324
|
+
}
|
|
325
|
+
throw new OxygenError("invalid_promote_policy", "--policy must be fill-empty-only, overwrite, or skip-conflicts.", {
|
|
326
|
+
details: { policy: value },
|
|
327
|
+
exitCode: 1,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
// Assemble the `tables send` request: --map pairs (headline sugar) become
|
|
331
|
+
// column-copy mapping entries; --mapping JSON is the literal/path escape hatch;
|
|
332
|
+
// --automap asks the server to resolve the mapping (echoed back). Exactly one
|
|
333
|
+
// mapping source must be chosen, locally, so the error names CLI flags instead
|
|
334
|
+
// of API fields.
|
|
335
|
+
function buildTablesSendRequest(source, target, options) {
|
|
336
|
+
const mapping = parseTablesSendMapping(options);
|
|
337
|
+
if (mapping && options.automap) {
|
|
338
|
+
throw new OxygenError("invalid_send", "Pass either a mapping (--map/--mapping) or --automap, not both.", { exitCode: 1 });
|
|
339
|
+
}
|
|
340
|
+
if (!mapping && !options.automap) {
|
|
341
|
+
throw new OxygenError("invalid_send", "Pass --map target=source pairs, --mapping <json>, or --automap.", { exitCode: 1 });
|
|
342
|
+
}
|
|
343
|
+
const mode = normalizeTablesSendMode(options.mode);
|
|
344
|
+
const upsertKey = readOption(options.upsertKey);
|
|
345
|
+
if (mode === "upsert" && !upsertKey) {
|
|
346
|
+
throw new OxygenError("invalid_send", "--mode upsert requires --upsert-key <target column>.", { exitCode: 1 });
|
|
347
|
+
}
|
|
348
|
+
if (mode === "insert" && upsertKey) {
|
|
349
|
+
throw new OxygenError("invalid_send", "--upsert-key is only valid with --mode upsert.", { exitCode: 1 });
|
|
350
|
+
}
|
|
351
|
+
const flattenColumn = readOption(options.flatten);
|
|
352
|
+
const flattenPath = readOption(options.flattenPath);
|
|
353
|
+
if (flattenPath && !flattenColumn) {
|
|
354
|
+
throw new OxygenError("invalid_send", "--flatten-path requires --flatten <column>.", { exitCode: 1 });
|
|
355
|
+
}
|
|
356
|
+
const filters = parseTablesSendFilters(options.filter);
|
|
357
|
+
const pageSize = readPositiveInt(options.pageSize);
|
|
358
|
+
return {
|
|
359
|
+
source,
|
|
360
|
+
target,
|
|
361
|
+
...(mapping ? { mapping } : {}),
|
|
362
|
+
...(options.automap ? { automap: true } : {}),
|
|
363
|
+
...(flattenColumn ? { flatten: { column: flattenColumn, ...(flattenPath ? { path: flattenPath } : {}) } } : {}),
|
|
364
|
+
...(filters ? { filters } : {}),
|
|
365
|
+
mode,
|
|
366
|
+
...(upsertKey ? { upsert_key: upsertKey } : {}),
|
|
367
|
+
...(pageSize ? { page_size: pageSize } : {}),
|
|
368
|
+
...(options.dryRun ? { dry_run: true } : {}),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
function parseTablesSendMapping(options) {
|
|
372
|
+
const pairs = (options.map ?? []).flatMap((entry) => readCsvOption(entry));
|
|
373
|
+
const mappingJson = readOption(options.mapping);
|
|
374
|
+
if (pairs.length > 0 && mappingJson) {
|
|
375
|
+
throw new OxygenError("invalid_send", "Pass either --map pairs or --mapping <json>, not both.", { exitCode: 1 });
|
|
376
|
+
}
|
|
377
|
+
if (mappingJson) {
|
|
378
|
+
let parsed;
|
|
379
|
+
try {
|
|
380
|
+
parsed = JSON.parse(mappingJson);
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
throw new OxygenError("invalid_send", "--mapping is not valid JSON.", { details: { mapping: mappingJson }, exitCode: 1 });
|
|
384
|
+
}
|
|
385
|
+
if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every(isRecord)) {
|
|
386
|
+
throw new OxygenError("invalid_send", "--mapping must be a non-empty JSON array of { target, source, path? } entries.", { exitCode: 1 });
|
|
387
|
+
}
|
|
388
|
+
return parsed;
|
|
389
|
+
}
|
|
390
|
+
if (pairs.length === 0)
|
|
391
|
+
return null;
|
|
392
|
+
return pairs.map((entry) => {
|
|
393
|
+
const separator = entry.indexOf("=");
|
|
394
|
+
if (separator <= 0 || separator === entry.length - 1) {
|
|
395
|
+
throw new OxygenError("invalid_send", "--map entries must be target=source column pairs, e.g. company=company_name,website=domain.", { details: { entry }, exitCode: 1 });
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
target: entry.slice(0, separator).trim(),
|
|
399
|
+
source: { type: "column", key: entry.slice(separator + 1).trim() },
|
|
400
|
+
};
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
function parseTablesSendFilters(value) {
|
|
404
|
+
const raw = readOption(value);
|
|
405
|
+
if (!raw)
|
|
406
|
+
return null;
|
|
407
|
+
let parsed;
|
|
408
|
+
try {
|
|
409
|
+
parsed = JSON.parse(raw);
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
throw new OxygenError("invalid_send", "--filter is not valid JSON.", { details: { filter: raw }, exitCode: 1 });
|
|
413
|
+
}
|
|
414
|
+
const entries = Array.isArray(parsed) ? parsed : [parsed];
|
|
415
|
+
if (entries.length === 0 || !entries.every(isRecord)) {
|
|
416
|
+
throw new OxygenError("invalid_send", "--filter must be a JSON array of { column, op, value? } objects.", { exitCode: 1 });
|
|
417
|
+
}
|
|
418
|
+
return entries;
|
|
419
|
+
}
|
|
420
|
+
function normalizeTablesSendMode(value) {
|
|
421
|
+
const normalized = (readOption(value) ?? "insert").toLowerCase();
|
|
422
|
+
if (normalized === "insert" || normalized === "upsert")
|
|
423
|
+
return normalized;
|
|
424
|
+
throw new OxygenError("invalid_send", "--mode must be insert or upsert.", {
|
|
425
|
+
details: { mode: value },
|
|
426
|
+
exitCode: 1,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
// After a live send starts, point the terminal at the durable run's inspection
|
|
430
|
+
// surfaces (stderr, so the machine-read stdout envelope stays clean).
|
|
431
|
+
function writeTablesSendHint(data) {
|
|
432
|
+
if (!isRecord(data) || typeof data.ingestion_run_id !== "string")
|
|
433
|
+
return;
|
|
434
|
+
const runUrl = typeof data.run_web_url === "string" ? ` (or open ${data.run_web_url})` : "";
|
|
435
|
+
process.stderr.write(`hint: watch the copy with oxygen table-ingestions wait ${data.ingestion_run_id}${runUrl}\n`);
|
|
436
|
+
}
|
|
437
|
+
// Route `tables dedupe` to the preview / apply / cross-check API by flag: --against
|
|
438
|
+
// switches to the read-only cross-table check (rejecting --apply); --apply targets
|
|
439
|
+
// the destructive delete (approved only when --approved is set); otherwise preview.
|
|
440
|
+
function buildDedupeRequest(table, options) {
|
|
441
|
+
const onColumns = readCsvOption(options.on);
|
|
442
|
+
if (onColumns.length === 0) {
|
|
443
|
+
throw new OxygenError("invalid_dedupe", "--on requires at least one column.", { exitCode: 1 });
|
|
444
|
+
}
|
|
445
|
+
const normalizer = normalizeDedupeNormalizer(options.normalize);
|
|
446
|
+
const scanLimit = readPositiveInt(options.scanLimit);
|
|
447
|
+
const against = readOption(options.against);
|
|
448
|
+
if (against) {
|
|
449
|
+
if (options.apply) {
|
|
450
|
+
throw new OxygenError("invalid_dedupe", "--apply cannot be combined with --against; the cross-table check is read-only.", { exitCode: 1 });
|
|
451
|
+
}
|
|
452
|
+
const againstColumn = readOption(options.againstColumn);
|
|
453
|
+
if (!againstColumn) {
|
|
454
|
+
throw new OxygenError("invalid_dedupe", "--against-column is required with --against.", { exitCode: 1 });
|
|
455
|
+
}
|
|
456
|
+
if (onColumns.length > 1) {
|
|
457
|
+
throw new OxygenError("invalid_dedupe", "The cross-table check matches on a single --on column.", { exitCode: 1 });
|
|
458
|
+
}
|
|
459
|
+
return {
|
|
460
|
+
endpoint: "/api/cli/tables/dedupe/cross-check",
|
|
461
|
+
body: {
|
|
462
|
+
table,
|
|
463
|
+
column: onColumns[0],
|
|
464
|
+
against_table: against,
|
|
465
|
+
against_column: againstColumn,
|
|
466
|
+
normalizer,
|
|
467
|
+
...(scanLimit ? { scan_limit: scanLimit } : {}),
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
const keys = onColumns.map((column) => ({ column, normalizer }));
|
|
472
|
+
const keep = normalizeDedupeKeep(options.keep);
|
|
473
|
+
if (options.apply) {
|
|
474
|
+
const mergeValues = normalizeDedupeMergeValues(options.mergeValues);
|
|
475
|
+
return {
|
|
476
|
+
endpoint: "/api/cli/tables/dedupe/apply",
|
|
477
|
+
body: {
|
|
478
|
+
table,
|
|
479
|
+
keys,
|
|
480
|
+
keep,
|
|
481
|
+
...(mergeValues ? { merge_values: mergeValues } : {}),
|
|
482
|
+
...(scanLimit ? { scan_limit: scanLimit } : {}),
|
|
483
|
+
// --approved is the only path that deletes; without it the API 409s with the preview.
|
|
484
|
+
...(options.approved ? { approved: true } : {}),
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
return {
|
|
489
|
+
endpoint: "/api/cli/tables/dedupe/preview",
|
|
490
|
+
body: { table, keys, keep, ...(scanLimit ? { scan_limit: scanLimit } : {}) },
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
// Build the standing auto-dedupe `set` body: --on columns paired positionally
|
|
494
|
+
// with --normalize modes (unpaired columns default to exact). fuzzy-label maps
|
|
495
|
+
// through so the API rejects it with the on-demand pointer — one source of
|
|
496
|
+
// truth for the hot-path restriction.
|
|
497
|
+
function buildTablesAutoDedupeSetBody(table, options) {
|
|
498
|
+
const onColumns = readCsvOption(options.on);
|
|
499
|
+
if (onColumns.length === 0) {
|
|
500
|
+
throw new OxygenError("invalid_auto_dedupe", "--on requires at least one column.", { exitCode: 1 });
|
|
501
|
+
}
|
|
502
|
+
const normalizers = readCsvOption(options.normalize);
|
|
503
|
+
const keys = onColumns.map((column, index) => ({
|
|
504
|
+
column,
|
|
505
|
+
normalizer: normalizeDedupeNormalizer(normalizers[index]),
|
|
506
|
+
}));
|
|
507
|
+
const keep = normalizeDedupeKeep(options.keep);
|
|
508
|
+
const mergeValues = normalizeDedupeMergeValues(options.mergeValues);
|
|
509
|
+
return {
|
|
510
|
+
table,
|
|
511
|
+
keys,
|
|
512
|
+
keep,
|
|
513
|
+
...(mergeValues ? { merge_values: mergeValues } : {}),
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
// Map the CLI --normalize flag to the wire normalizer vocabulary.
|
|
517
|
+
function normalizeDedupeNormalizer(value) {
|
|
518
|
+
const normalized = (readOption(value) ?? "exact").toLowerCase();
|
|
519
|
+
switch (normalized) {
|
|
520
|
+
case "exact":
|
|
521
|
+
return "exact";
|
|
522
|
+
case "email":
|
|
523
|
+
return "email";
|
|
524
|
+
case "domain":
|
|
525
|
+
return "domain";
|
|
526
|
+
case "linkedin":
|
|
527
|
+
case "linkedin-url":
|
|
528
|
+
case "linkedin_url":
|
|
529
|
+
return "linkedin_url";
|
|
530
|
+
case "fuzzy":
|
|
531
|
+
case "fuzzy-label":
|
|
532
|
+
case "fuzzy_label":
|
|
533
|
+
return "fuzzy_label";
|
|
534
|
+
default:
|
|
535
|
+
throw new OxygenError("invalid_dedupe_normalize", "--normalize must be exact, email, domain, linkedin, or fuzzy-label.", {
|
|
536
|
+
details: { normalize: value },
|
|
537
|
+
exitCode: 1,
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
function normalizeDedupeKeep(value) {
|
|
542
|
+
const normalized = (readOption(value) ?? "oldest").replace(/-/g, "_");
|
|
543
|
+
if (normalized === "oldest" || normalized === "newest" || normalized === "most_complete") {
|
|
544
|
+
return normalized;
|
|
545
|
+
}
|
|
546
|
+
throw new OxygenError("invalid_dedupe_keep", "--keep must be oldest, newest, or most-complete.", {
|
|
547
|
+
details: { keep: value },
|
|
548
|
+
exitCode: 1,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
function normalizeDedupeMergeValues(value) {
|
|
552
|
+
const raw = readOption(value);
|
|
553
|
+
if (!raw)
|
|
554
|
+
return null;
|
|
555
|
+
const normalized = raw.replace(/-/g, "_");
|
|
556
|
+
if (normalized === "fill_empty")
|
|
557
|
+
return "fill_empty";
|
|
558
|
+
if (normalized === "none")
|
|
559
|
+
return null;
|
|
560
|
+
throw new OxygenError("invalid_dedupe_merge", "--merge-values must be fill-empty.", {
|
|
561
|
+
details: { merge_values: value },
|
|
562
|
+
exitCode: 1,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
function readSortJsonOption(value) {
|
|
566
|
+
const raw = readOption(value);
|
|
567
|
+
if (!raw)
|
|
568
|
+
return undefined;
|
|
569
|
+
const parsed = parseJsonArray(raw);
|
|
570
|
+
if (parsed.length === 0)
|
|
571
|
+
return undefined;
|
|
572
|
+
if (parsed.some((entry) => !entry || typeof entry !== "object" || Array.isArray(entry))) {
|
|
573
|
+
throw new OxygenError("invalid_sort", "--sort-json must be an array of sort-rule objects.", {
|
|
574
|
+
exitCode: 1,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
return parsed;
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* `--url` hands the fetch to the server: the same durable import the file path
|
|
581
|
+
* enqueues, with the bytes read by OXYGEN behind SSRF, size and content guards,
|
|
582
|
+
* so the CLI never downloads anything and a 0-credit preview is one flag away.
|
|
583
|
+
*/
|
|
584
|
+
async function importRowsFromUrl(table, options) {
|
|
585
|
+
if (options.create && table) {
|
|
586
|
+
throw new OxygenError("invalid_import_target", "Pass either a table argument or --create, not both.", { exitCode: 1 });
|
|
587
|
+
}
|
|
588
|
+
if (!options.preview && !options.create && !table) {
|
|
589
|
+
throw new OxygenError("invalid_import_target", "Pass a table argument or --create <name>, or add --preview to look first.", { exitCode: 1 });
|
|
590
|
+
}
|
|
591
|
+
const result = await requestOxygen("/api/cli/tables/import-from-url", {
|
|
592
|
+
method: "POST",
|
|
593
|
+
timeoutMs: 120_000,
|
|
594
|
+
body: {
|
|
595
|
+
url: options.url,
|
|
596
|
+
mode: options.preview ? "preview" : "import",
|
|
597
|
+
...(table ? { table } : {}),
|
|
598
|
+
...(options.create ? { create_name: options.create } : {}),
|
|
599
|
+
...(readOption(options.project) ? { project: readOption(options.project) } : {}),
|
|
600
|
+
...(readOption(options.upsertKey) ? { upsert_key: readOption(options.upsertKey) } : {}),
|
|
601
|
+
...(readOption(options.format) ? { format: readOption(options.format) } : {}),
|
|
602
|
+
...(readOption(options.sheet) ? { sheet: readOption(options.sheet) } : {}),
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
if (options.preview)
|
|
606
|
+
return result;
|
|
607
|
+
emitQueueWaitStderrNote(readRecord(result, "queue_wait"));
|
|
608
|
+
return withImportWaitNextStep({ ...result, background: true, autoBackground: true });
|
|
609
|
+
}
|
|
610
|
+
async function importRows(table, options) {
|
|
611
|
+
if (options.url && options.skipBlankCells) {
|
|
612
|
+
throw new OxygenError("invalid_request", "--skip-blank-cells works on --file imports. Download the link and import the file.", { exitCode: 1 });
|
|
613
|
+
}
|
|
614
|
+
if (options.url)
|
|
615
|
+
return importRowsFromUrl(table, { ...options, url: options.url });
|
|
616
|
+
if (!options.file) {
|
|
617
|
+
throw new OxygenError("invalid_request", "Pass --file <path> or --url <https-link>.", { exitCode: 1 });
|
|
618
|
+
}
|
|
619
|
+
if (options.preview) {
|
|
620
|
+
throw new OxygenError("invalid_request", "--preview only applies to --url imports.", { exitCode: 1 });
|
|
621
|
+
}
|
|
622
|
+
return importRowsFromFile(table, { ...options, file: options.file });
|
|
623
|
+
}
|
|
624
|
+
async function importRowsFromFile(table, options) {
|
|
625
|
+
const format = normalizeRowsFormat(options.format, inferRowsFileFormat(options.file));
|
|
626
|
+
// Refuse a JSON or XLSX file too large to hold in memory before reading it,
|
|
627
|
+
// and send a large CSV or JSONL file down the streamed path, which never does.
|
|
628
|
+
const fileBytes = statSync(options.file).size;
|
|
629
|
+
assertImportFormatWithinBufferedLimit(format, fileBytes);
|
|
630
|
+
if (shouldStreamFileImport(format, fileBytes)) {
|
|
631
|
+
return withImportWaitNextStep(await importLargeFileStreamed(table, options, format, fileBytes));
|
|
632
|
+
}
|
|
633
|
+
const parsedRows = await readRowsFile(options.file, format, options.sheet);
|
|
634
|
+
if (parsedRows.length === 0) {
|
|
635
|
+
throw new OxygenError("invalid_rows", "Import file did not contain any rows.", {
|
|
636
|
+
exitCode: 1,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
const batchSize = normalizeImportBatchSize(options.batchSize);
|
|
640
|
+
const effectiveBatchSize = Math.min(batchSize, SAFE_IMPORT_WRITE_BATCH_SIZE);
|
|
641
|
+
const sourceHash = hashImportFile(options.file);
|
|
642
|
+
const shouldUseBackground = options.background
|
|
643
|
+
|| (!options.sync && parsedRows.length > LARGE_IMPORT_BACKGROUND_ROW_THRESHOLD);
|
|
644
|
+
const preparedBackgroundTarget = shouldUseBackground && table && !options.create
|
|
645
|
+
? await prepareImportTarget(table, options, parsedRows)
|
|
646
|
+
: null;
|
|
647
|
+
if (shouldUseBackground) {
|
|
648
|
+
// Prefer the object-storage fast path: upload the raw file once via a
|
|
649
|
+
// presigned URL (no request-body limit) so the worker COPY-loads it.
|
|
650
|
+
// Falls back to the inline multipart import when storage is unconfigured.
|
|
651
|
+
const fileBuffer = readFileSync(options.file);
|
|
652
|
+
const staged = await tryEnqueueStagedFileImport(table, options, format, {
|
|
653
|
+
byteLength: fileBuffer.byteLength,
|
|
654
|
+
read: () => Promise.resolve({ targetRows: parsedRows, rowCount: parsedRows.length, sha256: sourceHash }),
|
|
655
|
+
put: (uploadUrl, contentType) => putImportFile(uploadUrl, fileBuffer, contentType),
|
|
656
|
+
}, {
|
|
657
|
+
autoBackground: !options.background,
|
|
658
|
+
batchSize,
|
|
659
|
+
}, preparedBackgroundTarget);
|
|
660
|
+
if (staged)
|
|
661
|
+
return withImportWaitNextStep(staged);
|
|
662
|
+
return withImportWaitNextStep(await enqueueImportFile(table, options, format, batchSize, {
|
|
663
|
+
autoBackground: !options.background,
|
|
664
|
+
sourceHash,
|
|
665
|
+
}, preparedBackgroundTarget));
|
|
666
|
+
}
|
|
667
|
+
const prepared = await prepareImportTarget(table, options, parsedRows);
|
|
668
|
+
// The foreground writes rows itself: a row without a key leaves that value alone.
|
|
669
|
+
const target = options.skipBlankCells
|
|
670
|
+
? { ...prepared, rows: prepared.rows.map((row) => dropBlankImportCells(row, prepared.upsertKey)) }
|
|
671
|
+
: prepared;
|
|
672
|
+
let rowCount = 0;
|
|
673
|
+
let insertedCount = 0;
|
|
674
|
+
let updatedCount = 0;
|
|
675
|
+
let warningCount = 0;
|
|
676
|
+
const warnings = [];
|
|
677
|
+
let warningsTruncated = false;
|
|
678
|
+
let lastResult = null;
|
|
679
|
+
let writeRequestCount = 0;
|
|
680
|
+
let requestTooLargeRetries = 0;
|
|
681
|
+
let byteSplits = 0;
|
|
682
|
+
let minimumBatchSizeUsed = null;
|
|
683
|
+
for (const [batchIndex, batch] of chunk(target.rows, effectiveBatchSize).entries()) {
|
|
684
|
+
const result = await writeImportBatchWithAutoSplit({
|
|
685
|
+
tableRef: target.tableRef,
|
|
686
|
+
upsertKey: target.upsertKey,
|
|
687
|
+
rows: batch,
|
|
688
|
+
...(!target.upsertKey
|
|
689
|
+
? { requestId: `tables-import:${sourceHash}:${batchIndex}` }
|
|
690
|
+
: {}),
|
|
691
|
+
});
|
|
692
|
+
rowCount += result.rowCount;
|
|
693
|
+
insertedCount += result.insertedCount;
|
|
694
|
+
updatedCount += result.updatedCount;
|
|
695
|
+
warningCount += result.warningCount;
|
|
696
|
+
for (const warning of result.warnings) {
|
|
697
|
+
if (warnings.length < 20)
|
|
698
|
+
warnings.push(warning);
|
|
699
|
+
}
|
|
700
|
+
warningsTruncated = warningsTruncated || result.warningsTruncated || warningCount > warnings.length;
|
|
701
|
+
writeRequestCount += result.writeRequestCount;
|
|
702
|
+
requestTooLargeRetries += result.requestTooLargeRetries;
|
|
703
|
+
byteSplits += result.byteSplits;
|
|
704
|
+
minimumBatchSizeUsed = minimumBatchSizeUsed === null
|
|
705
|
+
? result.minimumBatchSizeUsed
|
|
706
|
+
: Math.min(minimumBatchSizeUsed, result.minimumBatchSizeUsed);
|
|
707
|
+
lastResult = result.lastResult;
|
|
708
|
+
}
|
|
709
|
+
return {
|
|
710
|
+
table: lastResult?.table ?? null,
|
|
711
|
+
...(target.createdTable ? { createdTable: target.createdTable } : {}),
|
|
712
|
+
rowCount,
|
|
713
|
+
insertedCount,
|
|
714
|
+
updatedCount,
|
|
715
|
+
...(warningCount > 0 ? {
|
|
716
|
+
warnings,
|
|
717
|
+
warningCount,
|
|
718
|
+
...(warningsTruncated ? { warningsTruncated: true } : {}),
|
|
719
|
+
} : {}),
|
|
720
|
+
batchCount: writeRequestCount,
|
|
721
|
+
batchSize: effectiveBatchSize,
|
|
722
|
+
...(effectiveBatchSize !== batchSize ? { requestedBatchSize: batchSize } : {}),
|
|
723
|
+
// Two separate counters: a byte pre-split cost nothing, a server 413 cost a
|
|
724
|
+
// wasted upload — reporting them as one number would hide which happened.
|
|
725
|
+
...(requestTooLargeRetries > 0 ? { requestTooLargeRetries } : {}),
|
|
726
|
+
...(byteSplits > 0 ? { byteSplits } : {}),
|
|
727
|
+
...(requestTooLargeRetries > 0 || byteSplits > 0 ? { minimumBatchSizeUsed } : {}),
|
|
728
|
+
...(target.tableWebUrl ? { table_web_url: target.tableWebUrl } : {}),
|
|
729
|
+
...(readRecordString(lastResult, "web_url") ? { web_url: readRecordString(lastResult, "web_url") } : {}),
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
async function prepareImportTarget(table, options, parsedRows) {
|
|
733
|
+
if (options.create && table) {
|
|
734
|
+
throw new OxygenError("invalid_import_target", "Pass either a table argument or --create, not both.", {
|
|
735
|
+
exitCode: 1,
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
if (!options.create && !table) {
|
|
739
|
+
throw new OxygenError("invalid_import_target", "Pass a table argument or --create <name>.", {
|
|
740
|
+
exitCode: 1,
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
if (!options.create) {
|
|
744
|
+
const keyMapping = await resolveExistingTableImportKeyMapping(table, parsedRows, readOption(options.upsertKey) ?? undefined);
|
|
745
|
+
return {
|
|
746
|
+
tableRef: table,
|
|
747
|
+
rows: remapImportRows(parsedRows, keyMapping.sourceKeyMap),
|
|
748
|
+
createdTable: null,
|
|
749
|
+
tableWebUrl: null,
|
|
750
|
+
upsertKey: keyMapping.upsertKey,
|
|
751
|
+
sourceKeyMap: keyMapping.sourceKeyMap,
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
const normalized = normalizeRowsForNewTable(parsedRows);
|
|
755
|
+
const created = await requestOxygen("/api/cli/tables", {
|
|
756
|
+
method: "POST",
|
|
757
|
+
body: {
|
|
758
|
+
name: options.create,
|
|
759
|
+
columns: normalized.columns,
|
|
760
|
+
...(readOption(options.project) ? { project: readOption(options.project) } : {}),
|
|
761
|
+
},
|
|
762
|
+
});
|
|
763
|
+
const tableRecord = readRecord(created, "table");
|
|
764
|
+
const createdSlug = readRecordString(tableRecord, "slug") ?? readRecordString(tableRecord, "id");
|
|
765
|
+
if (!createdSlug) {
|
|
766
|
+
throw new OxygenError("invalid_response", "Oxygen API response is missing the created table slug.", {
|
|
767
|
+
exitCode: 1,
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
const createdWebUrl = readRecordString(created, "web_url")
|
|
771
|
+
?? readRecordString(created, "deepLink");
|
|
772
|
+
return {
|
|
773
|
+
tableRef: createdSlug,
|
|
774
|
+
rows: normalized.rows,
|
|
775
|
+
createdTable: created,
|
|
776
|
+
tableWebUrl: createdWebUrl ?? tableWebUrl(createdSlug),
|
|
777
|
+
upsertKey: normalizeCreatedTableUpsertKey(options.upsertKey, normalized.keyBySource),
|
|
778
|
+
sourceKeyMap: normalized.keyBySource,
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
async function resolveExistingTableImportKeyMapping(table, rows, upsertKey) {
|
|
782
|
+
const describe = await requestOxygen("/api/cli/tables/describe", { method: "POST", body: { table } });
|
|
783
|
+
const resolver = buildExistingTableColumnResolver(describe.columns ?? []);
|
|
784
|
+
const resolvedByHeader = new Map();
|
|
785
|
+
const targetCounts = new Map();
|
|
786
|
+
for (const sourceKey of inferImportColumnLabels(rows)) {
|
|
787
|
+
const resolved = resolveImportSourceKey(sourceKey, resolver);
|
|
788
|
+
if (!resolved)
|
|
789
|
+
continue;
|
|
790
|
+
resolvedByHeader.set(sourceKey, resolved);
|
|
791
|
+
targetCounts.set(resolved, (targetCounts.get(resolved) ?? 0) + 1);
|
|
792
|
+
}
|
|
793
|
+
const sourceKeyMap = {};
|
|
794
|
+
for (const [sourceKey, resolved] of resolvedByHeader) {
|
|
795
|
+
if ((targetCounts.get(resolved) ?? 0) > 1)
|
|
796
|
+
continue;
|
|
797
|
+
if (resolved !== sourceKey)
|
|
798
|
+
sourceKeyMap[sourceKey] = resolved;
|
|
799
|
+
}
|
|
800
|
+
return {
|
|
801
|
+
sourceKeyMap: Object.keys(sourceKeyMap).length > 0 ? sourceKeyMap : null,
|
|
802
|
+
upsertKey: upsertKey
|
|
803
|
+
? resolveImportSourceKey(upsertKey, resolver) ?? upsertKey
|
|
804
|
+
: undefined,
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
function buildExistingTableColumnResolver(columns) {
|
|
808
|
+
const exact = new Map();
|
|
809
|
+
const aliases = new Map();
|
|
810
|
+
for (const column of columns) {
|
|
811
|
+
if (!column.key)
|
|
812
|
+
continue;
|
|
813
|
+
exact.set(column.key, column.key);
|
|
814
|
+
addImportColumnAlias(aliases, column.label, column.key);
|
|
815
|
+
addImportColumnAlias(aliases, normalizeImportColumnKey(column.key), column.key);
|
|
816
|
+
if (column.label)
|
|
817
|
+
addImportColumnAlias(aliases, normalizeImportColumnKey(column.label), column.key);
|
|
818
|
+
}
|
|
819
|
+
return { exact, aliases };
|
|
820
|
+
}
|
|
821
|
+
function addImportColumnAlias(aliases, alias, key) {
|
|
822
|
+
const normalizedAlias = alias?.trim();
|
|
823
|
+
if (!normalizedAlias)
|
|
824
|
+
return;
|
|
825
|
+
const existing = aliases.get(normalizedAlias);
|
|
826
|
+
if (existing === undefined) {
|
|
827
|
+
aliases.set(normalizedAlias, key);
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
if (existing !== key)
|
|
831
|
+
aliases.set(normalizedAlias, null);
|
|
832
|
+
}
|
|
833
|
+
function resolveImportSourceKey(sourceKey, resolver) {
|
|
834
|
+
const trimmed = sourceKey.trim();
|
|
835
|
+
return resolver.exact.get(sourceKey)
|
|
836
|
+
?? resolver.exact.get(trimmed)
|
|
837
|
+
?? readImportColumnAlias(resolver.aliases, sourceKey)
|
|
838
|
+
?? readImportColumnAlias(resolver.aliases, trimmed)
|
|
839
|
+
?? readImportColumnAlias(resolver.aliases, normalizeImportColumnKey(sourceKey));
|
|
840
|
+
}
|
|
841
|
+
function readImportColumnAlias(aliases, alias) {
|
|
842
|
+
return aliases.get(alias) ?? null;
|
|
843
|
+
}
|
|
844
|
+
function remapImportRows(rows, sourceKeyMap) {
|
|
845
|
+
if (!sourceKeyMap)
|
|
846
|
+
return rows;
|
|
847
|
+
return rows.map((row) => {
|
|
848
|
+
const remapped = {};
|
|
849
|
+
for (const [key, value] of Object.entries(row)) {
|
|
850
|
+
remapped[sourceKeyMap[key] ?? key] = value;
|
|
851
|
+
}
|
|
852
|
+
return remapped;
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
async function writeImportBatchWithAutoSplit(input) {
|
|
856
|
+
const body = {
|
|
857
|
+
table: input.tableRef,
|
|
858
|
+
rows: input.rows,
|
|
859
|
+
...(input.upsertKey ? { key: input.upsertKey } : {}),
|
|
860
|
+
...(!input.upsertKey && input.requestId ? { request_id: input.requestId } : {}),
|
|
861
|
+
};
|
|
862
|
+
// The server checks content-length against MAX_CLI_JSON_BODY_BYTES and the
|
|
863
|
+
// client sends exactly JSON.stringify(body), so measuring it here is the same
|
|
864
|
+
// test — a wide batch (large text cells) splits before it costs an upload and a
|
|
865
|
+
// 413. Same halving and request_id suffixes as the reactive path below, so a
|
|
866
|
+
// re-run replays the identical batch ids either way.
|
|
867
|
+
const bodyBytes = Buffer.byteLength(JSON.stringify(body));
|
|
868
|
+
if (bodyBytes > MAX_CLI_JSON_BODY_BYTES && input.rows.length > 1) {
|
|
869
|
+
const midpoint = Math.ceil(input.rows.length / 2);
|
|
870
|
+
process.stderr.write(`note: import batch of ${input.rows.length} rows serializes to ${bodyBytes} bytes, `
|
|
871
|
+
+ `over the ${MAX_CLI_JSON_BODY_BYTES} byte request limit; `
|
|
872
|
+
+ `sending as ${midpoint} and ${input.rows.length - midpoint} row batches.\n`);
|
|
873
|
+
const [first, second] = await writeImportBatchHalves(input, midpoint);
|
|
874
|
+
return combineImportBatchWriteSummaries(first, second, { byteSplits: 1 });
|
|
875
|
+
}
|
|
876
|
+
try {
|
|
877
|
+
const result = await requestOxygen(input.upsertKey ? "/api/cli/tables/rows/upsert" : "/api/cli/tables/rows", {
|
|
878
|
+
method: "POST",
|
|
879
|
+
timeoutMs: 300_000,
|
|
880
|
+
body,
|
|
881
|
+
});
|
|
882
|
+
return summarizeImportBatchWrite(result, input.rows.length);
|
|
883
|
+
}
|
|
884
|
+
catch (error) {
|
|
885
|
+
if (!isRequestTooLargeError(error) || input.rows.length <= 1)
|
|
886
|
+
throw error;
|
|
887
|
+
// Fallback for a server whose ceiling is tighter than the shared constant.
|
|
888
|
+
const midpoint = Math.ceil(input.rows.length / 2);
|
|
889
|
+
process.stderr.write(`note: import batch of ${input.rows.length} rows exceeded the request limit; `
|
|
890
|
+
+ `retrying as ${midpoint} and ${input.rows.length - midpoint} row batches.\n`);
|
|
891
|
+
const [first, second] = await writeImportBatchHalves(input, midpoint);
|
|
892
|
+
return combineImportBatchWriteSummaries(first, second, { requestTooLargeRetries: 1 });
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
async function writeImportBatchHalves(input, midpoint) {
|
|
896
|
+
const first = await writeImportBatchWithAutoSplit({
|
|
897
|
+
...input,
|
|
898
|
+
rows: input.rows.slice(0, midpoint),
|
|
899
|
+
...(input.requestId ? { requestId: `${input.requestId}:0` } : {}),
|
|
900
|
+
});
|
|
901
|
+
const second = await writeImportBatchWithAutoSplit({
|
|
902
|
+
...input,
|
|
903
|
+
rows: input.rows.slice(midpoint),
|
|
904
|
+
...(input.requestId ? { requestId: `${input.requestId}:1` } : {}),
|
|
905
|
+
});
|
|
906
|
+
return [first, second];
|
|
907
|
+
}
|
|
908
|
+
function summarizeImportBatchWrite(result, batchSize) {
|
|
909
|
+
const warningCount = readCount(result.warningCount);
|
|
910
|
+
const batchWarnings = Array.isArray(result.warnings) ? result.warnings : [];
|
|
911
|
+
return {
|
|
912
|
+
rowCount: readCount(result.rowCount),
|
|
913
|
+
insertedCount: readCount(result.insertedCount),
|
|
914
|
+
updatedCount: readCount(result.updatedCount),
|
|
915
|
+
warningCount,
|
|
916
|
+
warnings: batchWarnings.slice(0, 20),
|
|
917
|
+
warningsTruncated: result.warningsTruncated === true || warningCount > batchWarnings.length,
|
|
918
|
+
writeRequestCount: 1,
|
|
919
|
+
requestTooLargeRetries: 0,
|
|
920
|
+
byteSplits: 0,
|
|
921
|
+
minimumBatchSizeUsed: batchSize,
|
|
922
|
+
lastResult: result,
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
function combineImportBatchWriteSummaries(first, second, extra) {
|
|
926
|
+
const warnings = [...first.warnings, ...second.warnings].slice(0, 20);
|
|
927
|
+
const warningCount = first.warningCount + second.warningCount;
|
|
928
|
+
return {
|
|
929
|
+
rowCount: first.rowCount + second.rowCount,
|
|
930
|
+
insertedCount: first.insertedCount + second.insertedCount,
|
|
931
|
+
updatedCount: first.updatedCount + second.updatedCount,
|
|
932
|
+
warningCount,
|
|
933
|
+
warnings,
|
|
934
|
+
warningsTruncated: first.warningsTruncated || second.warningsTruncated || warningCount > warnings.length,
|
|
935
|
+
writeRequestCount: first.writeRequestCount + second.writeRequestCount,
|
|
936
|
+
requestTooLargeRetries: first.requestTooLargeRetries + second.requestTooLargeRetries + (extra.requestTooLargeRetries ?? 0),
|
|
937
|
+
byteSplits: first.byteSplits + second.byteSplits + (extra.byteSplits ?? 0),
|
|
938
|
+
minimumBatchSizeUsed: Math.min(first.minimumBatchSizeUsed, second.minimumBatchSizeUsed),
|
|
939
|
+
lastResult: second.lastResult ?? first.lastResult,
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
function isRequestTooLargeError(error) {
|
|
943
|
+
return error instanceof OxygenError && error.code === "request_too_large";
|
|
944
|
+
}
|
|
945
|
+
async function tryEnqueueStagedFileImport(// skipcq: JS-R1005
|
|
946
|
+
table, options, format, file, context, preparedTarget = null) {
|
|
947
|
+
if (options.create && table) {
|
|
948
|
+
throw new OxygenError("invalid_import_target", "Pass either a table argument or --create, not both.", {
|
|
949
|
+
exitCode: 1,
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
if (!options.create && !table) {
|
|
953
|
+
throw new OxygenError("invalid_import_target", "Pass a table argument or --create <name>.", {
|
|
954
|
+
exitCode: 1,
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
const filename = basename(options.file);
|
|
958
|
+
const contentType = importFileContentType(format);
|
|
959
|
+
const traceId = randomUUID();
|
|
960
|
+
// Probe for a presigned upload URL. If object storage isn't configured the
|
|
961
|
+
// server returns object_storage_not_configured; signal the caller to fall
|
|
962
|
+
// back to the inline multipart import by returning null.
|
|
963
|
+
const requestPresign = async () => {
|
|
964
|
+
try {
|
|
965
|
+
return await requestOxygen("/api/cli/tables/import-url", {
|
|
966
|
+
method: "POST",
|
|
967
|
+
body: {
|
|
968
|
+
file_name: filename,
|
|
969
|
+
content_type: contentType,
|
|
970
|
+
byte_length: file.byteLength,
|
|
971
|
+
format,
|
|
972
|
+
trace_id: traceId,
|
|
973
|
+
},
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
catch (error) {
|
|
977
|
+
if (error instanceof OxygenError && error.code === "object_storage_not_configured") {
|
|
978
|
+
return null;
|
|
979
|
+
}
|
|
980
|
+
throw error;
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
let presigned = await requestPresign();
|
|
984
|
+
if (!presigned)
|
|
985
|
+
return null;
|
|
986
|
+
const contents = await file.read();
|
|
987
|
+
// Create the table for --create (or resolve the existing ref) before the
|
|
988
|
+
// upload so a presign success always pairs with a real target.
|
|
989
|
+
const target = preparedTarget ?? await prepareImportTarget(table, options, contents.targetRows);
|
|
990
|
+
const put = (current) => {
|
|
991
|
+
const uploadUrl = readRecordString(current, "upload_url");
|
|
992
|
+
return uploadUrl ? file.put(uploadUrl, contentType) : Promise.resolve(new Response(null, { status: 500 }));
|
|
993
|
+
};
|
|
994
|
+
let putResponse = await put(presigned);
|
|
995
|
+
// A direct-upload 403 most commonly means a stale/mismatched signature.
|
|
996
|
+
// Refresh once under the same client trace so a transient presign never
|
|
997
|
+
// strands a durable import, while persistent policy failures stay explicit.
|
|
998
|
+
if (putResponse.status === 403) {
|
|
999
|
+
const refreshed = await requestPresign();
|
|
1000
|
+
if (refreshed) {
|
|
1001
|
+
presigned = refreshed;
|
|
1002
|
+
putResponse = await put(presigned);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
if (!putResponse.ok) {
|
|
1006
|
+
throw new OxygenError("import_upload_failed", `Uploading the import file to object storage failed (HTTP ${putResponse.status}).`, { details: { status: putResponse.status, trace_id: traceId }, exitCode: 1 });
|
|
1007
|
+
}
|
|
1008
|
+
const storageKey = readRecordString(presigned, "storage_key");
|
|
1009
|
+
const storageBucket = readRecordString(presigned, "storage_bucket");
|
|
1010
|
+
const storageProvider = readRecordString(presigned, "storage_provider") ?? "s3";
|
|
1011
|
+
if (!storageKey)
|
|
1012
|
+
return null;
|
|
1013
|
+
const result = await requestOxygen("/api/cli/tables/import-staged", {
|
|
1014
|
+
method: "POST",
|
|
1015
|
+
timeoutMs: 120_000,
|
|
1016
|
+
body: {
|
|
1017
|
+
table: target.tableRef,
|
|
1018
|
+
storage_provider: storageProvider,
|
|
1019
|
+
storage_bucket: storageBucket,
|
|
1020
|
+
storage_key: storageKey,
|
|
1021
|
+
format,
|
|
1022
|
+
file_name: filename,
|
|
1023
|
+
byte_length: file.byteLength,
|
|
1024
|
+
sha256: contents.sha256,
|
|
1025
|
+
row_count: contents.rowCount,
|
|
1026
|
+
batch_size: context.batchSize,
|
|
1027
|
+
...(readPositiveInt(options.maxConcurrency)
|
|
1028
|
+
? { max_concurrency: readPositiveInt(options.maxConcurrency) }
|
|
1029
|
+
: {}),
|
|
1030
|
+
...(target.upsertKey ? { upsert_key: target.upsertKey } : {}),
|
|
1031
|
+
...(target.sourceKeyMap ? { source_key_map: target.sourceKeyMap } : {}),
|
|
1032
|
+
...(options.skipBlankCells ? { skip_blank_cells: true } : {}),
|
|
1033
|
+
},
|
|
1034
|
+
});
|
|
1035
|
+
emitQueueWaitStderrNote(readRecord(result, "queue_wait"));
|
|
1036
|
+
return {
|
|
1037
|
+
...result,
|
|
1038
|
+
...(target.createdTable ? { createdTable: target.createdTable } : {}),
|
|
1039
|
+
...(target.tableWebUrl ? { table_web_url: target.tableWebUrl } : {}),
|
|
1040
|
+
background: true,
|
|
1041
|
+
autoBackground: context.autoBackground,
|
|
1042
|
+
import_engine: "chunked_file_v2",
|
|
1043
|
+
storage_provider: storageProvider,
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* A CSV or JSONL file over STREAMED_FILE_IMPORT_MIN_BYTES: never read whole.
|
|
1048
|
+
* One streaming pass counts, hashes and collects its columns, the upload streams
|
|
1049
|
+
* from disk, and the import always runs in the background. There is no inline
|
|
1050
|
+
* fallback: a file this large cannot travel in one API request.
|
|
1051
|
+
*/
|
|
1052
|
+
async function importLargeFileStreamed(table, options, format, fileBytes) {
|
|
1053
|
+
if (options.sync) {
|
|
1054
|
+
process.stderr.write(`note: files over ${Math.round(STREAMED_FILE_IMPORT_MIN_BYTES / (1024 * 1024))} MiB always import in the background; --sync is ignored\n`);
|
|
1055
|
+
}
|
|
1056
|
+
const staged = await tryEnqueueStagedFileImport(table, options, format, {
|
|
1057
|
+
byteLength: fileBytes,
|
|
1058
|
+
read: async () => {
|
|
1059
|
+
process.stderr.write(`note: reading ${basename(options.file)} (${formatMebibytes(fileBytes)}) to count its rows before uploading\n`);
|
|
1060
|
+
const scan = await scanStreamedImportFile(options.file, format);
|
|
1061
|
+
if (scan.rowCount === 0) {
|
|
1062
|
+
throw new OxygenError("invalid_rows", "Import file did not contain any rows.", { exitCode: 1 });
|
|
1063
|
+
}
|
|
1064
|
+
return { targetRows: rowsForStreamedImportTarget(scan), rowCount: scan.rowCount, sha256: scan.sha256 };
|
|
1065
|
+
},
|
|
1066
|
+
put: (uploadUrl, contentType) => putImportFileStream({ uploadUrl, path: options.file, byteLength: fileBytes, contentType }),
|
|
1067
|
+
}, {
|
|
1068
|
+
autoBackground: !options.background,
|
|
1069
|
+
batchSize: normalizeImportBatchSize(options.batchSize),
|
|
1070
|
+
});
|
|
1071
|
+
if (staged)
|
|
1072
|
+
return { ...staged, streamed_upload: true };
|
|
1073
|
+
throw new OxygenError("object_storage_not_configured", `Files over ${Math.round(STREAMED_FILE_IMPORT_MIN_BYTES / (1024 * 1024))} MiB upload straight to file storage, which this OXYGEN server has not configured.`, {
|
|
1074
|
+
details: { file_bytes: fileBytes, streamed_import_min_bytes: STREAMED_FILE_IMPORT_MIN_BYTES },
|
|
1075
|
+
exitCode: 1,
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
function formatMebibytes(bytes) {
|
|
1079
|
+
return `${Math.round(bytes / (1024 * 1024)).toLocaleString("en-US")} MiB`;
|
|
1080
|
+
}
|
|
1081
|
+
async function putImportFile(uploadUrl, fileBuffer, contentType) {
|
|
1082
|
+
const controller = new AbortController();
|
|
1083
|
+
const timer = setTimeout(() => controller.abort(), 300_000);
|
|
1084
|
+
try {
|
|
1085
|
+
return await fetch(uploadUrl, {
|
|
1086
|
+
method: "PUT",
|
|
1087
|
+
headers: {
|
|
1088
|
+
"content-type": contentType,
|
|
1089
|
+
"content-length": String(fileBuffer.byteLength),
|
|
1090
|
+
},
|
|
1091
|
+
body: new Uint8Array(fileBuffer),
|
|
1092
|
+
signal: controller.signal,
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
finally {
|
|
1096
|
+
clearTimeout(timer);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
function importFileContentType(format) {
|
|
1100
|
+
if (format === "csv")
|
|
1101
|
+
return "text/csv";
|
|
1102
|
+
if (format === "xlsx")
|
|
1103
|
+
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
1104
|
+
if (format === "jsonl")
|
|
1105
|
+
return "application/x-ndjson";
|
|
1106
|
+
return "application/json";
|
|
1107
|
+
}
|
|
1108
|
+
async function enqueueImportFile(table, options, format, batchSize, context, preparedTarget = null) {
|
|
1109
|
+
if (options.create && table) {
|
|
1110
|
+
throw new OxygenError("invalid_import_target", "Pass either a table argument or --create, not both.", {
|
|
1111
|
+
exitCode: 1,
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
if (!options.create && !table) {
|
|
1115
|
+
throw new OxygenError("invalid_import_target", "Pass a table argument or --create <name>.", {
|
|
1116
|
+
exitCode: 1,
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
if (options.skipBlankCells) {
|
|
1120
|
+
// The multipart import cannot leave blank cells out; failing beats clearing them.
|
|
1121
|
+
throw new OxygenError("skip_blank_cells_unavailable", "--skip-blank-cells needs the staged file upload, which this OXYGEN server has not configured. Add --sync to write the rows from this machine instead.", { exitCode: 1 });
|
|
1122
|
+
}
|
|
1123
|
+
const fileBuffer = readFileSync(options.file);
|
|
1124
|
+
const filename = basename(options.file);
|
|
1125
|
+
const form = new FormData();
|
|
1126
|
+
const file = new File([fileBuffer], filename);
|
|
1127
|
+
form.append("file", file, filename);
|
|
1128
|
+
form.append("filename", filename);
|
|
1129
|
+
form.append("format", format);
|
|
1130
|
+
form.append("batch_size", String(batchSize));
|
|
1131
|
+
form.append("auto_background", String(context.autoBackground));
|
|
1132
|
+
form.append("source_hash", context.sourceHash);
|
|
1133
|
+
const targetTable = preparedTarget?.tableRef ?? table;
|
|
1134
|
+
if (targetTable)
|
|
1135
|
+
form.append("table", targetTable);
|
|
1136
|
+
if (!preparedTarget && options.create)
|
|
1137
|
+
form.append("table_name", options.create);
|
|
1138
|
+
const project = readOption(options.project);
|
|
1139
|
+
if (project)
|
|
1140
|
+
form.append("project", project);
|
|
1141
|
+
const upsertKey = preparedTarget?.upsertKey ?? readOption(options.upsertKey);
|
|
1142
|
+
if (upsertKey)
|
|
1143
|
+
form.append("upsert_key", upsertKey);
|
|
1144
|
+
if (preparedTarget?.sourceKeyMap) {
|
|
1145
|
+
form.append("source_key_map", JSON.stringify(preparedTarget.sourceKeyMap));
|
|
1146
|
+
}
|
|
1147
|
+
if (options.sheet)
|
|
1148
|
+
form.append("sheet", options.sheet);
|
|
1149
|
+
const maxConcurrency = readPositiveInt(options.maxConcurrency);
|
|
1150
|
+
if (maxConcurrency)
|
|
1151
|
+
form.append("max_concurrency", String(maxConcurrency));
|
|
1152
|
+
const result = await requestOxygen("/api/cli/tables/import-file", {
|
|
1153
|
+
method: "POST",
|
|
1154
|
+
timeoutMs: 300_000,
|
|
1155
|
+
formData: form,
|
|
1156
|
+
});
|
|
1157
|
+
emitQueueWaitStderrNote(readRecord(result, "queue_wait"));
|
|
1158
|
+
return {
|
|
1159
|
+
...result,
|
|
1160
|
+
background: true,
|
|
1161
|
+
autoBackground: context.autoBackground,
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
function emitQueueWaitStderrNote(queueWait) {
|
|
1165
|
+
if (!queueWait)
|
|
1166
|
+
return;
|
|
1167
|
+
const note = readRecordString(queueWait, "note");
|
|
1168
|
+
if (note)
|
|
1169
|
+
process.stderr.write(`note: ${note}\n`);
|
|
1170
|
+
}
|
|
1171
|
+
async function exportRows(table, options) {
|
|
1172
|
+
const format = normalizeExportRowsFormat(options.format);
|
|
1173
|
+
const limit = readPositiveInt(options.limit);
|
|
1174
|
+
const result = await requestOxygen("/api/cli/tables/query", {
|
|
1175
|
+
method: "POST",
|
|
1176
|
+
body: {
|
|
1177
|
+
table,
|
|
1178
|
+
...(limit ? { limit } : {}),
|
|
1179
|
+
},
|
|
1180
|
+
});
|
|
1181
|
+
const formatted = formatRows(result.rows, format, result.columns);
|
|
1182
|
+
if (options.output) {
|
|
1183
|
+
writeFileSync(options.output, formatted.content);
|
|
1184
|
+
}
|
|
1185
|
+
return {
|
|
1186
|
+
table: result.table ?? null,
|
|
1187
|
+
format,
|
|
1188
|
+
rowCount: result.rows.length,
|
|
1189
|
+
output: options.output ?? null,
|
|
1190
|
+
...(options.output ? {} : { content: formatted.content }),
|
|
1191
|
+
...(formatted.rescuedCount > 0 ? { rescuedNumericCells: formatted.rescuedCount } : {}),
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
// Bundle formats. Version 2 is line-delimited: a header line carrying the
|
|
1195
|
+
// schema, then one `{"row":…}` line per row, then a `{"totals":…}` trailer.
|
|
1196
|
+
// It exists because the version-1 single-document bundle is assembled in memory
|
|
1197
|
+
// and serialized once, and a 52k-row table carrying LinkedIn profile text pushed
|
|
1198
|
+
// that one string past V8's limit ("Invalid string length") on 2026-09-14: the
|
|
1199
|
+
// export died after twelve minutes with nothing on disk, and a 96-column table
|
|
1200
|
+
// on the other side of the same copy could never have been read back into
|
|
1201
|
+
// memory by the importer either. `--output` now streams version 2; import reads
|
|
1202
|
+
// both versions, streaming version 2 so file size no longer matters.
|
|
1203
|
+
const TABLE_BUNDLE_SCHEMA_VERSION = 2;
|
|
1204
|
+
const TABLE_BUNDLE_LEGACY_SCHEMA_VERSION = 1;
|
|
1205
|
+
const TABLE_BUNDLE_SUPPORTED_SCHEMA_VERSIONS = [TABLE_BUNDLE_LEGACY_SCHEMA_VERSION, TABLE_BUNDLE_SCHEMA_VERSION];
|
|
1206
|
+
const TABLE_BUNDLE_MAX_PAGE_SIZE = 1000;
|
|
1207
|
+
const TABLE_BUNDLE_DEFAULT_PAGE_SIZE = 500;
|
|
1208
|
+
// How much of a bundle file is read to find its header line and totals trailer
|
|
1209
|
+
// without loading the whole file: 8 MiB comfortably holds a header for a
|
|
1210
|
+
// 100-column table with long AI prompts, and the trailer is one short line.
|
|
1211
|
+
const TABLE_BUNDLE_HEADER_SCAN_BYTES = 8 * 1024 * 1024;
|
|
1212
|
+
const TABLE_BUNDLE_TRAILER_SCAN_BYTES = 1024 * 1024;
|
|
1213
|
+
// skipcq: JS-R1005 — intentional branching across describe/query pagination, column-definition normalization, and output sink (stdout/file)
|
|
1214
|
+
async function exportTableBundle(table, options) {
|
|
1215
|
+
const pageSize = Math.min(readPositiveInt(options.pageSize) ?? TABLE_BUNDLE_DEFAULT_PAGE_SIZE, TABLE_BUNDLE_MAX_PAGE_SIZE);
|
|
1216
|
+
// Pull the canonical schema (incl. enrichment/tool definitions and
|
|
1217
|
+
// semantic types) from `describe`; the paginated query response only
|
|
1218
|
+
// surfaces the user-visible projection.
|
|
1219
|
+
const describe = await requestOxygen("/api/cli/tables/describe", { method: "POST", body: { table } });
|
|
1220
|
+
const tableMeta = describe.table ?? null;
|
|
1221
|
+
const columns = (describe.columns ?? []).map(toBundleColumn);
|
|
1222
|
+
const tableId = readRecordString(tableMeta, "id");
|
|
1223
|
+
const tableSlug = readRecordString(tableMeta, "slug");
|
|
1224
|
+
const projectSlug = readRecordString(tableMeta, "projectSlug");
|
|
1225
|
+
const tableSummary = {
|
|
1226
|
+
id: tableId ?? null,
|
|
1227
|
+
slug: tableSlug ?? null,
|
|
1228
|
+
name: readRecordString(tableMeta, "displayName") ?? readRecordString(tableMeta, "name") ?? null,
|
|
1229
|
+
projectSlug: projectSlug ?? null,
|
|
1230
|
+
};
|
|
1231
|
+
const exportedAt = new Date().toISOString();
|
|
1232
|
+
// With --output the bundle streams to disk one row per line, so memory stays
|
|
1233
|
+
// flat however many rows the table has. The file is written under a .partial
|
|
1234
|
+
// name and renamed only after the totals trailer lands, so an interrupted
|
|
1235
|
+
// export can never be mistaken for a complete bundle.
|
|
1236
|
+
const outputPath = options.output ? resolve(options.output) : null;
|
|
1237
|
+
const partialPath = outputPath ? `${outputPath}.partial` : null;
|
|
1238
|
+
const stream = partialPath ? createWriteStream(partialPath, { encoding: "utf8" }) : null;
|
|
1239
|
+
const rows = [];
|
|
1240
|
+
let cursor = null;
|
|
1241
|
+
let expectedTotal = null;
|
|
1242
|
+
let pageCount = 0;
|
|
1243
|
+
let rowCount = 0;
|
|
1244
|
+
let hasMoreFlag = false;
|
|
1245
|
+
try {
|
|
1246
|
+
if (stream) {
|
|
1247
|
+
await writeBundleLine(stream, JSON.stringify({
|
|
1248
|
+
schemaVersion: TABLE_BUNDLE_SCHEMA_VERSION,
|
|
1249
|
+
exportedAt,
|
|
1250
|
+
table: tableSummary,
|
|
1251
|
+
columns,
|
|
1252
|
+
}));
|
|
1253
|
+
}
|
|
1254
|
+
do {
|
|
1255
|
+
const requestBody = { table, limit: pageSize };
|
|
1256
|
+
if (cursor)
|
|
1257
|
+
requestBody.cursor = cursor;
|
|
1258
|
+
const page = await requestOxygen("/api/cli/tables/query", { method: "POST", body: requestBody });
|
|
1259
|
+
pageCount += 1;
|
|
1260
|
+
if (expectedTotal === null) {
|
|
1261
|
+
expectedTotal = readBundleNumber(page.totalCount) ?? readBundleNumber(page.total_count) ?? null;
|
|
1262
|
+
}
|
|
1263
|
+
for (const row of page.rows ?? []) {
|
|
1264
|
+
rowCount += 1;
|
|
1265
|
+
if (stream)
|
|
1266
|
+
await writeBundleLine(stream, JSON.stringify({ row }));
|
|
1267
|
+
else
|
|
1268
|
+
rows.push(row);
|
|
1269
|
+
}
|
|
1270
|
+
cursor = typeof page.nextCursor === "string" && page.nextCursor.length > 0 ? page.nextCursor : null;
|
|
1271
|
+
hasMoreFlag = Boolean(page.hasMore);
|
|
1272
|
+
// Defensive: hasMore=false should always mean cursor=null. If a future
|
|
1273
|
+
// server change ever sets one without the other, stop iterating on
|
|
1274
|
+
// hasMore=false so we don't loop forever.
|
|
1275
|
+
if (!hasMoreFlag)
|
|
1276
|
+
cursor = null;
|
|
1277
|
+
} while (cursor);
|
|
1278
|
+
if (expectedTotal !== null && rowCount !== expectedTotal) {
|
|
1279
|
+
throw new OxygenError("bundle_export_incomplete", "Cursor pagination ended before every row was written. The bundle would be a silent under-export.", {
|
|
1280
|
+
details: {
|
|
1281
|
+
table,
|
|
1282
|
+
totalCount: expectedTotal,
|
|
1283
|
+
exportedCount: rowCount,
|
|
1284
|
+
missing: expectedTotal - rowCount,
|
|
1285
|
+
pages: pageCount,
|
|
1286
|
+
},
|
|
1287
|
+
exitCode: 1,
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
const totals = {
|
|
1291
|
+
rowCount,
|
|
1292
|
+
...(expectedTotal !== null ? { sourceTotalCount: expectedTotal } : {}),
|
|
1293
|
+
pages: pageCount,
|
|
1294
|
+
pageSize,
|
|
1295
|
+
};
|
|
1296
|
+
if (stream && partialPath && outputPath) {
|
|
1297
|
+
await writeBundleLine(stream, JSON.stringify({ totals }));
|
|
1298
|
+
await finishBundleStream(stream);
|
|
1299
|
+
renameSync(partialPath, outputPath);
|
|
1300
|
+
}
|
|
1301
|
+
const summary = {
|
|
1302
|
+
table_id: tableId ?? null,
|
|
1303
|
+
table_slug: tableSlug ?? null,
|
|
1304
|
+
schema_version: stream ? TABLE_BUNDLE_SCHEMA_VERSION : TABLE_BUNDLE_LEGACY_SCHEMA_VERSION,
|
|
1305
|
+
format: stream ? "jsonl" : "json",
|
|
1306
|
+
column_count: columns.length,
|
|
1307
|
+
row_count: rowCount,
|
|
1308
|
+
pages: pageCount,
|
|
1309
|
+
page_size: pageSize,
|
|
1310
|
+
output: options.output ?? null,
|
|
1311
|
+
...(tableId ? { web_url: tableWebUrl(tableId) } : tableSlug ? { web_url: tableWebUrl(tableSlug) } : {}),
|
|
1312
|
+
};
|
|
1313
|
+
// With --output, the file is the source of truth; keep the stdout response
|
|
1314
|
+
// a small summary so it's readable.
|
|
1315
|
+
if (stream)
|
|
1316
|
+
return summary;
|
|
1317
|
+
// When piping to stdout, emit the full single-document bundle so it can be
|
|
1318
|
+
// redirected into a file. That document has to fit in one string, so say
|
|
1319
|
+
// so — with the fix — instead of dying on a bare RangeError.
|
|
1320
|
+
const bundle = {
|
|
1321
|
+
schemaVersion: TABLE_BUNDLE_LEGACY_SCHEMA_VERSION,
|
|
1322
|
+
exportedAt,
|
|
1323
|
+
table: tableSummary,
|
|
1324
|
+
columns,
|
|
1325
|
+
rows,
|
|
1326
|
+
totals,
|
|
1327
|
+
};
|
|
1328
|
+
assertBundleSerializable(bundle, rowCount);
|
|
1329
|
+
return { ...summary, bundle };
|
|
1330
|
+
}
|
|
1331
|
+
catch (error) {
|
|
1332
|
+
if (stream) {
|
|
1333
|
+
stream.destroy();
|
|
1334
|
+
if (partialPath) {
|
|
1335
|
+
try {
|
|
1336
|
+
unlinkSync(partialPath);
|
|
1337
|
+
}
|
|
1338
|
+
catch {
|
|
1339
|
+
// The partial file may never have been created; nothing to clean.
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
throw error;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
// Writes one bundle line and honors the stream's backpressure, so a 50k-row
|
|
1347
|
+
// export never accumulates the file in memory while the disk catches up.
|
|
1348
|
+
function writeBundleLine(stream, line) {
|
|
1349
|
+
return new Promise((resolveLine, rejectLine) => {
|
|
1350
|
+
const onError = (error) => rejectLine(error);
|
|
1351
|
+
if (stream.write(`${line}\n`, "utf8")) {
|
|
1352
|
+
resolveLine();
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
stream.once("error", onError);
|
|
1356
|
+
stream.once("drain", () => {
|
|
1357
|
+
stream.off("error", onError);
|
|
1358
|
+
resolveLine();
|
|
1359
|
+
});
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
function finishBundleStream(stream) {
|
|
1363
|
+
return new Promise((resolveStream, rejectStream) => {
|
|
1364
|
+
stream.once("error", rejectStream);
|
|
1365
|
+
stream.end(() => resolveStream());
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
function assertBundleSerializable(bundle, rowCount) {
|
|
1369
|
+
try {
|
|
1370
|
+
JSON.stringify(bundle);
|
|
1371
|
+
}
|
|
1372
|
+
catch (error) {
|
|
1373
|
+
if (error instanceof RangeError) {
|
|
1374
|
+
throw new OxygenError("bundle_too_large", `This table's ${rowCount} rows do not fit in one JSON document. Re-run with --output <path>: the bundle then streams to the file one row per line, with no size limit.`, { details: { row_count: rowCount }, exitCode: 1 });
|
|
1375
|
+
}
|
|
1376
|
+
throw error;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
async function importTableBundle(// skipcq: JS-R1005
|
|
1380
|
+
options, binaryName) {
|
|
1381
|
+
const path = options.file;
|
|
1382
|
+
if (!path || !path.trim()) {
|
|
1383
|
+
throw new OxygenError("invalid_input", "--file is required.", { exitCode: 1 });
|
|
1384
|
+
}
|
|
1385
|
+
const batchSize = normalizeImportBatchSize(options.batchSize);
|
|
1386
|
+
const effectiveBatchSize = Math.min(batchSize, SAFE_IMPORT_WRITE_BATCH_SIZE);
|
|
1387
|
+
const bundle = await openTableBundle(resolve(path));
|
|
1388
|
+
// A native `relation` column is only half of a relation: the other half is a
|
|
1389
|
+
// row in ox_tables.relation_definitions that ONLY `tables relate` writes. A
|
|
1390
|
+
// bundle carries the column but not the definition, and its `target_table` is
|
|
1391
|
+
// the SOURCE workspace's table id, which does not exist here. Importing it
|
|
1392
|
+
// produced a column that pointed at a foreign workspace and that no surface
|
|
1393
|
+
// could repair, rename, archive or delete — every column lifecycle command
|
|
1394
|
+
// refuses a relation-shaped column, and the relation commands refuse a
|
|
1395
|
+
// definition that was never written (OXY-4360). Skip them and say so;
|
|
1396
|
+
// `tables relate` rebuilds a real relation on this side once both tables
|
|
1397
|
+
// exist. Keyed on the NATIVE shape, not `kind === "relation"`: CRM
|
|
1398
|
+
// relationship columns (`target_object`, no `storage`) are a different
|
|
1399
|
+
// mechanism with their own lifecycle and must still import.
|
|
1400
|
+
const isNativeRelationColumn = (column) => column.kind === "relation"
|
|
1401
|
+
&& (column.definition?.storage === "tables"
|
|
1402
|
+
|| typeof column.definition?.archived_relation_definition_id === "string");
|
|
1403
|
+
const bundleColumns = bundle.columns.map(bundleColumnToCreateInput);
|
|
1404
|
+
const skippedRelationColumns = bundleColumns
|
|
1405
|
+
.filter(isNativeRelationColumn)
|
|
1406
|
+
.map((column) => column.key ?? column.label);
|
|
1407
|
+
const columns = bundleColumns.filter((column) => !isNativeRelationColumn(column));
|
|
1408
|
+
if (bundleColumns.length === 0) {
|
|
1409
|
+
throw new OxygenError("invalid_bundle", "Bundle has no columns; nothing to import.", { exitCode: 1 });
|
|
1410
|
+
}
|
|
1411
|
+
if (columns.length === 0) {
|
|
1412
|
+
throw new OxygenError("invalid_bundle", "Bundle contains only native relation columns, which cannot be imported directly. Import the tables they point at, then run `tables relate`.", { details: { skipped_relation_columns: skippedRelationColumns }, exitCode: 1 });
|
|
1413
|
+
}
|
|
1414
|
+
const validColumnKeys = new Set(columns.map((column) => column.key).filter((key) => Boolean(key)));
|
|
1415
|
+
const into = readOption(options.into);
|
|
1416
|
+
const upsertKey = readOption(options.key);
|
|
1417
|
+
// Resuming/appending into an existing table without an upsert key would
|
|
1418
|
+
// duplicate every already-imported row. Require --key so the resume matches
|
|
1419
|
+
// rows instead of re-inserting them.
|
|
1420
|
+
if (into && !upsertKey) {
|
|
1421
|
+
throw new OxygenError("invalid_input", "--into requires --key so rows already imported are matched instead of duplicated. Pass --key <uniqueColumn>.", { details: { into }, exitCode: 1 });
|
|
1422
|
+
}
|
|
1423
|
+
if (upsertKey) {
|
|
1424
|
+
if (upsertKey.startsWith("_")) {
|
|
1425
|
+
throw new OxygenError("invalid_input", "--key must be a workspace column key, not an internal field.", { details: { key: upsertKey }, exitCode: 1 });
|
|
1426
|
+
}
|
|
1427
|
+
if (validColumnKeys.size > 0 && !validColumnKeys.has(upsertKey)) {
|
|
1428
|
+
throw new OxygenError("invalid_input", `--key ${upsertKey} is not a column in this bundle.`, { details: { key: upsertKey, available_keys: [...validColumnKeys] }, exitCode: 1 });
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
// Resolve the target table: resume into an existing one (--into), or create a
|
|
1432
|
+
// fresh one from the bundle schema.
|
|
1433
|
+
let newTableId;
|
|
1434
|
+
let newTableSlug;
|
|
1435
|
+
let createdTable;
|
|
1436
|
+
if (into) {
|
|
1437
|
+
const describe = await requestOxygen("/api/cli/tables/describe", { method: "POST", body: { table: into } });
|
|
1438
|
+
const tableMeta = describe.table ?? null;
|
|
1439
|
+
const resolvedId = tableMeta ? readRecordString(tableMeta, "id") : null;
|
|
1440
|
+
if (!resolvedId) {
|
|
1441
|
+
throw new OxygenError("table_not_found", `--into table ${into} was not found in this org.`, { details: { into }, exitCode: 1 });
|
|
1442
|
+
}
|
|
1443
|
+
newTableId = resolvedId;
|
|
1444
|
+
newTableSlug = tableMeta ? readRecordString(tableMeta, "slug") ?? null : null;
|
|
1445
|
+
createdTable = false;
|
|
1446
|
+
}
|
|
1447
|
+
else {
|
|
1448
|
+
const tableName = readOption(options.name) ?? bundle.tableName;
|
|
1449
|
+
if (!tableName) {
|
|
1450
|
+
throw new OxygenError("invalid_bundle", "Bundle has no table.name and no --name override was provided.", { exitCode: 1 });
|
|
1451
|
+
}
|
|
1452
|
+
const createResponse = await requestOxygen("/api/cli/tables", {
|
|
1453
|
+
method: "POST",
|
|
1454
|
+
body: {
|
|
1455
|
+
name: tableName,
|
|
1456
|
+
columns,
|
|
1457
|
+
...(readOption(options.project) ? { project: readOption(options.project) } : {}),
|
|
1458
|
+
},
|
|
1459
|
+
});
|
|
1460
|
+
const created = readRecord(createResponse, "table");
|
|
1461
|
+
const createdId = readRecordString(createResponse, "table_id")
|
|
1462
|
+
?? (created ? readRecordString(created, "id") : null);
|
|
1463
|
+
if (!createdId) {
|
|
1464
|
+
throw new OxygenError("invalid_response", "Table create response did not include an id; bundle import cannot proceed.", { exitCode: 1 });
|
|
1465
|
+
}
|
|
1466
|
+
newTableId = createdId;
|
|
1467
|
+
newTableSlug = readRecordString(createResponse, "table_slug")
|
|
1468
|
+
?? (created ? readRecordString(created, "slug") : null);
|
|
1469
|
+
createdTable = true;
|
|
1470
|
+
}
|
|
1471
|
+
// In upsert mode every row must carry the key value; otherwise the upsert
|
|
1472
|
+
// route rejects the whole batch midway. A single-document bundle has every
|
|
1473
|
+
// row in memory, so it is checked before anything is written and never
|
|
1474
|
+
// leaves a half-imported table behind for a predictable data problem. A
|
|
1475
|
+
// streamed bundle is checked row by row as it is read; a miss stops the
|
|
1476
|
+
// import with the row number, and --key makes the rerun idempotent.
|
|
1477
|
+
if (upsertKey && bundle.preloadedRows) {
|
|
1478
|
+
const missingIndex = bundle.preloadedRows.findIndex((row) => row[upsertKey] === undefined || row[upsertKey] === null);
|
|
1479
|
+
if (missingIndex >= 0) {
|
|
1480
|
+
throw new OxygenError("invalid_bundle", `Row ${missingIndex + 1} is missing a value for the upsert key "${upsertKey}".`, { details: { key: upsertKey, row_number: missingIndex + 1 }, exitCode: 1 });
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
const resumeCommand = buildBundleResumeCommand({
|
|
1484
|
+
file: path,
|
|
1485
|
+
tableId: newTableId,
|
|
1486
|
+
key: upsertKey ?? null,
|
|
1487
|
+
batchSize: effectiveBatchSize,
|
|
1488
|
+
binaryName,
|
|
1489
|
+
});
|
|
1490
|
+
let processed = 0;
|
|
1491
|
+
let insertedCount = 0;
|
|
1492
|
+
let updatedCount = 0;
|
|
1493
|
+
const batches = readBundleBatches({
|
|
1494
|
+
bundle,
|
|
1495
|
+
validColumnKeys,
|
|
1496
|
+
upsertKey: upsertKey ?? null,
|
|
1497
|
+
batchSize: effectiveBatchSize,
|
|
1498
|
+
tableId: newTableId,
|
|
1499
|
+
resumeCommand,
|
|
1500
|
+
});
|
|
1501
|
+
for await (const batch of batches) {
|
|
1502
|
+
try {
|
|
1503
|
+
// Same byte-aware writer as `tables import`: a wide batch pre-splits (and a
|
|
1504
|
+
// server 413 halves) instead of dead-ending the bundle at an unchanged
|
|
1505
|
+
// resume batch size. No request_id — bundle resumes match on --key.
|
|
1506
|
+
const result = await writeImportBatchWithAutoSplit({
|
|
1507
|
+
tableRef: newTableId,
|
|
1508
|
+
upsertKey: upsertKey ?? undefined,
|
|
1509
|
+
rows: batch,
|
|
1510
|
+
});
|
|
1511
|
+
if (upsertKey) {
|
|
1512
|
+
insertedCount += result.insertedCount;
|
|
1513
|
+
updatedCount += result.updatedCount;
|
|
1514
|
+
}
|
|
1515
|
+
else {
|
|
1516
|
+
insertedCount += batch.length;
|
|
1517
|
+
}
|
|
1518
|
+
processed += batch.length;
|
|
1519
|
+
}
|
|
1520
|
+
catch (error) {
|
|
1521
|
+
// A mid-loop failure used to leave a partial table with no way forward.
|
|
1522
|
+
// Surface exactly where it stopped and how to resume so the import is
|
|
1523
|
+
// recoverable instead of a silent half-write.
|
|
1524
|
+
const cause = error instanceof OxygenError
|
|
1525
|
+
? error.code
|
|
1526
|
+
: error instanceof Error
|
|
1527
|
+
? error.message
|
|
1528
|
+
: "unknown";
|
|
1529
|
+
const recovery = upsertKey
|
|
1530
|
+
? `Re-run with --into ${newTableId} --key ${upsertKey} to resume — already-imported rows are matched by "${upsertKey}", not duplicated.`
|
|
1531
|
+
: `Resume with: ${resumeCommand}`;
|
|
1532
|
+
throw new OxygenError("bundle_import_incomplete", `Bundle import stopped after ${processed}/${bundle.rowCount} rows. ${recovery}`, {
|
|
1533
|
+
details: {
|
|
1534
|
+
table_id: newTableId,
|
|
1535
|
+
table_slug: newTableSlug,
|
|
1536
|
+
rows_total: bundle.rowCount,
|
|
1537
|
+
rows_processed: processed,
|
|
1538
|
+
failed_batch_offset: processed,
|
|
1539
|
+
failed_batch_size: batch.length,
|
|
1540
|
+
mode: upsertKey ? "upsert" : "insert",
|
|
1541
|
+
...(upsertKey ? { key: upsertKey } : {}),
|
|
1542
|
+
resume_command: resumeCommand,
|
|
1543
|
+
web_url: tableWebUrl(newTableSlug ?? newTableId),
|
|
1544
|
+
cause,
|
|
1545
|
+
},
|
|
1546
|
+
exitCode: 1,
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
return {
|
|
1551
|
+
table_id: newTableId,
|
|
1552
|
+
table_slug: newTableSlug ?? null,
|
|
1553
|
+
created_table: createdTable,
|
|
1554
|
+
column_count: columns.length,
|
|
1555
|
+
row_count: processed,
|
|
1556
|
+
mode: upsertKey ? "upsert" : "insert",
|
|
1557
|
+
...(skippedRelationColumns.length > 0
|
|
1558
|
+
? {
|
|
1559
|
+
skipped_relation_columns: skippedRelationColumns,
|
|
1560
|
+
note: `${skippedRelationColumns.length} native relation column(s) were not imported: a relation must be created on this side with \`tables relate\` once both tables exist.`,
|
|
1561
|
+
}
|
|
1562
|
+
: {}),
|
|
1563
|
+
...(upsertKey
|
|
1564
|
+
? { upsert_key: upsertKey, inserted_count: insertedCount, updated_count: updatedCount }
|
|
1565
|
+
: {}),
|
|
1566
|
+
source_table: bundle.tableSummary,
|
|
1567
|
+
schema_version: bundle.schemaVersion,
|
|
1568
|
+
web_url: tableWebUrl(newTableSlug ?? newTableId),
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
// Builds the exact CLI command that resumes a failed `import-bundle` into the
|
|
1572
|
+
// table it already created. Always targets that table with --into + --key so the
|
|
1573
|
+
// resume is idempotent; falls back to a <uniqueColumn> placeholder when the
|
|
1574
|
+
// original run had no key (insert mode can't be safely auto-resumed).
|
|
1575
|
+
function buildBundleResumeCommand(input) {
|
|
1576
|
+
const fileArg = /\s/.test(input.file) ? `"${input.file}"` : input.file;
|
|
1577
|
+
const parts = [
|
|
1578
|
+
// Must be the binary the user actually invoked. Hardcoding "oxygen" meant an
|
|
1579
|
+
// `oxygen-dev` import printed a resume command that, pasted verbatim, re-ran
|
|
1580
|
+
// a DEV bundle against PRODUCTION (OXY-4360).
|
|
1581
|
+
`${input.binaryName} tables import-bundle`,
|
|
1582
|
+
`--file ${fileArg}`,
|
|
1583
|
+
`--into ${input.tableId}`,
|
|
1584
|
+
`--key ${input.key ?? "<uniqueColumn>"}`,
|
|
1585
|
+
];
|
|
1586
|
+
// 500 is the default in normalizeImportBatchSize; only echo --batch-size when
|
|
1587
|
+
// the user picked something else, to keep the resume command minimal.
|
|
1588
|
+
if (input.batchSize !== 500) {
|
|
1589
|
+
parts.push(`--batch-size ${input.batchSize}`);
|
|
1590
|
+
}
|
|
1591
|
+
return parts.join(" ");
|
|
1592
|
+
}
|
|
1593
|
+
function toBundleColumn(column) {
|
|
1594
|
+
// Persist only the fields `tables create` knows how to ingest. Drop ids,
|
|
1595
|
+
// physical column names, archivedAt — those are tenant-local and would
|
|
1596
|
+
// collide on import.
|
|
1597
|
+
return {
|
|
1598
|
+
key: column.key,
|
|
1599
|
+
...(column.label !== undefined ? { label: column.label } : {}),
|
|
1600
|
+
...(column.dataType !== undefined ? { dataType: column.dataType } : {}),
|
|
1601
|
+
...(column.kind !== undefined ? { kind: column.kind } : {}),
|
|
1602
|
+
...(column.semanticType !== undefined ? { semanticType: column.semanticType } : {}),
|
|
1603
|
+
...(column.definition !== undefined ? { definition: column.definition } : {}),
|
|
1604
|
+
...(column.position !== undefined ? { position: column.position } : {}),
|
|
1605
|
+
};
|
|
1606
|
+
}
|
|
1607
|
+
function bundleColumnToCreateInput(column) {
|
|
1608
|
+
const label = typeof column.label === "string" && column.label.trim()
|
|
1609
|
+
? column.label.trim()
|
|
1610
|
+
: typeof column.key === "string"
|
|
1611
|
+
? column.key
|
|
1612
|
+
: null;
|
|
1613
|
+
if (!label) {
|
|
1614
|
+
throw new OxygenError("invalid_bundle", "Bundle column is missing label/key.", {
|
|
1615
|
+
details: { column },
|
|
1616
|
+
exitCode: 1,
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1619
|
+
return {
|
|
1620
|
+
label,
|
|
1621
|
+
...(typeof column.key === "string" ? { key: column.key } : {}),
|
|
1622
|
+
...(typeof column.dataType === "string" ? { dataType: column.dataType } : {}),
|
|
1623
|
+
...(typeof column.kind === "string" ? { kind: column.kind } : {}),
|
|
1624
|
+
...(typeof column.semanticType === "string" ? { semanticType: column.semanticType } : {}),
|
|
1625
|
+
...(column.definition && typeof column.definition === "object" && !Array.isArray(column.definition)
|
|
1626
|
+
? { definition: column.definition }
|
|
1627
|
+
: {}),
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
// Opens either bundle format. A streamed (version 2) bundle is recognised by
|
|
1631
|
+
// its header line and read line by line, so a multi-GB file never has to fit
|
|
1632
|
+
// in memory; a single-document (version 1) bundle is parsed whole, as before.
|
|
1633
|
+
async function openTableBundle(filePath) {
|
|
1634
|
+
if (!existsSync(filePath)) {
|
|
1635
|
+
throw new OxygenError("invalid_input", `Bundle file not found: ${filePath}`, {
|
|
1636
|
+
details: { file: filePath },
|
|
1637
|
+
exitCode: 1,
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
const firstLine = readFirstBundleLine(filePath);
|
|
1641
|
+
const header = firstLine === null ? null : parseJsonObjectOrNull(firstLine);
|
|
1642
|
+
if (header && readBundleNumber(header.schemaVersion) === TABLE_BUNDLE_SCHEMA_VERSION) {
|
|
1643
|
+
return openStreamedTableBundle(filePath, header);
|
|
1644
|
+
}
|
|
1645
|
+
let raw;
|
|
1646
|
+
try {
|
|
1647
|
+
raw = readFileSync(filePath, "utf8");
|
|
1648
|
+
}
|
|
1649
|
+
catch (error) {
|
|
1650
|
+
if (error instanceof RangeError || error.code === "ERR_STRING_TOO_LONG") {
|
|
1651
|
+
throw new OxygenError("bundle_too_large", "This bundle is one JSON document too large to load into memory. Re-export it with the current CLI (`oxygen tables export-bundle <table> --output <path>`), which writes one row per line and imports at any size.", { details: { file: filePath }, exitCode: 1 });
|
|
1652
|
+
}
|
|
1653
|
+
throw error;
|
|
1654
|
+
}
|
|
1655
|
+
const parsed = parseBundleFile(raw);
|
|
1656
|
+
return {
|
|
1657
|
+
schemaVersion: parsed.schemaVersion,
|
|
1658
|
+
tableName: parsed.tableName,
|
|
1659
|
+
tableSummary: parsed.tableSummary,
|
|
1660
|
+
columns: parsed.columns,
|
|
1661
|
+
rowCount: parsed.rows.length,
|
|
1662
|
+
preloadedRows: parsed.rows,
|
|
1663
|
+
rows: () => iterateArrayRows(parsed.rows),
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
async function* iterateArrayRows(rows) {
|
|
1667
|
+
for (const row of rows)
|
|
1668
|
+
yield row;
|
|
1669
|
+
}
|
|
1670
|
+
function openStreamedTableBundle(filePath, header) {
|
|
1671
|
+
const rawColumns = header.columns;
|
|
1672
|
+
if (!Array.isArray(rawColumns)) {
|
|
1673
|
+
throw new OxygenError("invalid_bundle", "Streamed bundle header is missing a columns array.", {
|
|
1674
|
+
details: { file: filePath },
|
|
1675
|
+
exitCode: 1,
|
|
1676
|
+
});
|
|
1677
|
+
}
|
|
1678
|
+
const tableSummary = readRecord(header, "table");
|
|
1679
|
+
// The totals trailer is the last thing the exporter writes, so its absence
|
|
1680
|
+
// means the export was interrupted: refuse before creating anything rather
|
|
1681
|
+
// than importing a silently truncated table.
|
|
1682
|
+
const trailer = parseJsonObjectOrNull(readLastBundleLine(filePath));
|
|
1683
|
+
const totals = trailer ? readRecord(trailer, "totals") : null;
|
|
1684
|
+
const rowCount = totals ? readBundleNumber(totals.rowCount) : null;
|
|
1685
|
+
if (rowCount === null) {
|
|
1686
|
+
throw new OxygenError("invalid_bundle", "This streamed bundle has no totals trailer, so the export that produced it was interrupted before it finished. Re-run `oxygen tables export-bundle` and import the complete file.", { details: { file: filePath }, exitCode: 1 });
|
|
1687
|
+
}
|
|
1688
|
+
return {
|
|
1689
|
+
schemaVersion: TABLE_BUNDLE_SCHEMA_VERSION,
|
|
1690
|
+
tableName: tableSummary
|
|
1691
|
+
? (readRecordString(tableSummary, "name") ?? readRecordString(tableSummary, "displayName"))
|
|
1692
|
+
: null,
|
|
1693
|
+
tableSummary,
|
|
1694
|
+
columns: rawColumns.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)),
|
|
1695
|
+
rowCount,
|
|
1696
|
+
preloadedRows: null,
|
|
1697
|
+
rows: () => iterateStreamedBundleRows(filePath),
|
|
1698
|
+
};
|
|
1699
|
+
}
|
|
1700
|
+
async function* iterateStreamedBundleRows(filePath) {
|
|
1701
|
+
const fileStream = createReadStream(filePath, { encoding: "utf8" });
|
|
1702
|
+
const reader = createInterface({ input: fileStream, crlfDelay: Infinity });
|
|
1703
|
+
let lineNumber = 0;
|
|
1704
|
+
try {
|
|
1705
|
+
for await (const line of reader) {
|
|
1706
|
+
lineNumber += 1;
|
|
1707
|
+
if (lineNumber === 1 || !line.trim())
|
|
1708
|
+
continue;
|
|
1709
|
+
const entry = parseJsonObjectOrNull(line);
|
|
1710
|
+
if (!entry) {
|
|
1711
|
+
throw new OxygenError("invalid_bundle", `Line ${lineNumber} of the bundle is not a JSON object.`, {
|
|
1712
|
+
details: { file: filePath, line: lineNumber },
|
|
1713
|
+
exitCode: 1,
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1716
|
+
if ("totals" in entry)
|
|
1717
|
+
return;
|
|
1718
|
+
const row = readRecord(entry, "row");
|
|
1719
|
+
if (!row) {
|
|
1720
|
+
throw new OxygenError("invalid_bundle", `Line ${lineNumber} of the bundle is neither a row nor the totals trailer.`, { details: { file: filePath, line: lineNumber }, exitCode: 1 });
|
|
1721
|
+
}
|
|
1722
|
+
yield row;
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
finally {
|
|
1726
|
+
reader.close();
|
|
1727
|
+
fileStream.destroy();
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
// Batches the bundle's rows for the shared import writer, stripping tenant-local
|
|
1731
|
+
// fields and enforcing the upsert key on the way through.
|
|
1732
|
+
async function* readBundleBatches(input) {
|
|
1733
|
+
let batch = [];
|
|
1734
|
+
let rowNumber = 0;
|
|
1735
|
+
for await (const rawRow of input.bundle.rows()) {
|
|
1736
|
+
rowNumber += 1;
|
|
1737
|
+
const row = stripRowForImport(rawRow, input.validColumnKeys);
|
|
1738
|
+
if (input.upsertKey && (row[input.upsertKey] === undefined || row[input.upsertKey] === null)) {
|
|
1739
|
+
throw new OxygenError("invalid_bundle", `Row ${rowNumber} is missing a value for the upsert key "${input.upsertKey}".`, {
|
|
1740
|
+
details: {
|
|
1741
|
+
key: input.upsertKey,
|
|
1742
|
+
row_number: rowNumber,
|
|
1743
|
+
table_id: input.tableId,
|
|
1744
|
+
resume_command: input.resumeCommand,
|
|
1745
|
+
},
|
|
1746
|
+
exitCode: 1,
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
batch.push(row);
|
|
1750
|
+
if (batch.length >= input.batchSize) {
|
|
1751
|
+
yield batch;
|
|
1752
|
+
batch = [];
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
if (batch.length > 0)
|
|
1756
|
+
yield batch;
|
|
1757
|
+
}
|
|
1758
|
+
// Reads up to the first newline without loading the file. Returns null when no
|
|
1759
|
+
// newline appears inside the scan window and the file is larger than it, which
|
|
1760
|
+
// is what a huge single-document bundle looks like.
|
|
1761
|
+
function readFirstBundleLine(filePath) {
|
|
1762
|
+
const size = statSync(filePath).size;
|
|
1763
|
+
const span = Math.min(size, TABLE_BUNDLE_HEADER_SCAN_BYTES);
|
|
1764
|
+
if (span === 0)
|
|
1765
|
+
return "";
|
|
1766
|
+
const fd = openSync(filePath, "r");
|
|
1767
|
+
try {
|
|
1768
|
+
const buffer = Buffer.alloc(span);
|
|
1769
|
+
const read = readSync(fd, buffer, 0, span, 0);
|
|
1770
|
+
const text = buffer.subarray(0, read).toString("utf8");
|
|
1771
|
+
const newline = text.indexOf("\n");
|
|
1772
|
+
if (newline >= 0)
|
|
1773
|
+
return text.slice(0, newline);
|
|
1774
|
+
return read < size ? null : text;
|
|
1775
|
+
}
|
|
1776
|
+
finally {
|
|
1777
|
+
closeSync(fd);
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
function readLastBundleLine(filePath) {
|
|
1781
|
+
const size = statSync(filePath).size;
|
|
1782
|
+
const span = Math.min(size, TABLE_BUNDLE_TRAILER_SCAN_BYTES);
|
|
1783
|
+
if (span === 0)
|
|
1784
|
+
return "";
|
|
1785
|
+
const fd = openSync(filePath, "r");
|
|
1786
|
+
try {
|
|
1787
|
+
const buffer = Buffer.alloc(span);
|
|
1788
|
+
const read = readSync(fd, buffer, 0, span, size - span);
|
|
1789
|
+
const lines = buffer.subarray(0, read).toString("utf8").split("\n").map((line) => line.trim()).filter(Boolean);
|
|
1790
|
+
return lines.at(-1) ?? "";
|
|
1791
|
+
}
|
|
1792
|
+
finally {
|
|
1793
|
+
closeSync(fd);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
function parseJsonObjectOrNull(text) {
|
|
1797
|
+
try {
|
|
1798
|
+
const parsed = JSON.parse(text);
|
|
1799
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
1800
|
+
? parsed
|
|
1801
|
+
: null;
|
|
1802
|
+
}
|
|
1803
|
+
catch {
|
|
1804
|
+
return null;
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
function parseBundleFile(text) {
|
|
1808
|
+
let parsed;
|
|
1809
|
+
try {
|
|
1810
|
+
parsed = JSON.parse(text);
|
|
1811
|
+
}
|
|
1812
|
+
catch (error) {
|
|
1813
|
+
throw new OxygenError("invalid_bundle", "Bundle file is not valid JSON.", {
|
|
1814
|
+
details: { reason: error instanceof Error ? error.message : "unknown" },
|
|
1815
|
+
exitCode: 1,
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1819
|
+
throw new OxygenError("invalid_bundle", "Bundle file must be a JSON object.", { exitCode: 1 });
|
|
1820
|
+
}
|
|
1821
|
+
const record = parsed;
|
|
1822
|
+
const schemaVersion = typeof record.schemaVersion === "number" ? record.schemaVersion : 1;
|
|
1823
|
+
// A version-2 bundle never reaches this parser (openTableBundle streams it),
|
|
1824
|
+
// so a single document claiming any other version is from a CLI this one
|
|
1825
|
+
// does not know.
|
|
1826
|
+
if (schemaVersion !== TABLE_BUNDLE_LEGACY_SCHEMA_VERSION) {
|
|
1827
|
+
throw new OxygenError("unsupported_bundle_version", `Bundle schema version ${schemaVersion} is not supported by this CLI.`, {
|
|
1828
|
+
details: { supported: TABLE_BUNDLE_SUPPORTED_SCHEMA_VERSIONS, got: schemaVersion },
|
|
1829
|
+
exitCode: 1,
|
|
1830
|
+
});
|
|
1831
|
+
}
|
|
1832
|
+
const rawColumns = record.columns;
|
|
1833
|
+
if (!Array.isArray(rawColumns)) {
|
|
1834
|
+
throw new OxygenError("invalid_bundle", "Bundle is missing a columns array.", { exitCode: 1 });
|
|
1835
|
+
}
|
|
1836
|
+
const rawRows = record.rows;
|
|
1837
|
+
if (!Array.isArray(rawRows)) {
|
|
1838
|
+
throw new OxygenError("invalid_bundle", "Bundle is missing a rows array.", { exitCode: 1 });
|
|
1839
|
+
}
|
|
1840
|
+
const tableSummary = record.table && typeof record.table === "object" && !Array.isArray(record.table)
|
|
1841
|
+
? record.table
|
|
1842
|
+
: null;
|
|
1843
|
+
return {
|
|
1844
|
+
schemaVersion,
|
|
1845
|
+
tableName: tableSummary
|
|
1846
|
+
? (readRecordString(tableSummary, "name") ?? readRecordString(tableSummary, "displayName"))
|
|
1847
|
+
: null,
|
|
1848
|
+
tableSummary,
|
|
1849
|
+
columns: rawColumns.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)),
|
|
1850
|
+
rows: rawRows.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)),
|
|
1851
|
+
};
|
|
1852
|
+
}
|
|
1853
|
+
function stripRowForImport(row, validColumnKeys) {
|
|
1854
|
+
const out = {};
|
|
1855
|
+
for (const [key, value] of Object.entries(row)) {
|
|
1856
|
+
if (key === "_row_id" || key === "_created_at" || key === "_updated_at")
|
|
1857
|
+
continue;
|
|
1858
|
+
if (validColumnKeys.size > 0 && !validColumnKeys.has(key))
|
|
1859
|
+
continue;
|
|
1860
|
+
out[key] = value;
|
|
1861
|
+
}
|
|
1862
|
+
return out;
|
|
1863
|
+
}
|
|
1864
|
+
function readBundleNumber(value) {
|
|
1865
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
1866
|
+
return value;
|
|
1867
|
+
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value)))
|
|
1868
|
+
return Number(value);
|
|
1869
|
+
return null;
|
|
1870
|
+
}
|
|
1871
|
+
async function readRowsFile(path, format, sheet) {
|
|
1872
|
+
return await parseRowsFileBuffer(readFileSync(path), format, sheet ? { sheet } : {});
|
|
1873
|
+
}
|
|
1874
|
+
function normalizeCreatedTableUpsertKey(value, keyBySource) {
|
|
1875
|
+
if (!value)
|
|
1876
|
+
return undefined;
|
|
1877
|
+
return keyBySource[value] ?? value;
|
|
1878
|
+
}
|
|
1879
|
+
// Deep-links must name the host the command actually talked to. This hardcoded
|
|
1880
|
+
// https://oxygen-agent.com, so every `oxygen-dev` bundle export/import handed
|
|
1881
|
+
// back a PRODUCTION url for a table that only exists on dev (OXY-4360).
|
|
1882
|
+
// `defaultApiUrl()` is the same resolver the request layer uses, so the link and
|
|
1883
|
+
// the call can no longer disagree.
|
|
1884
|
+
function tableWebUrl(tableIdOrSlug) {
|
|
1885
|
+
return oxygenAppUrl(defaultApiUrl(), `/tables/${encodeURIComponent(tableIdOrSlug)}`);
|
|
1886
|
+
}
|
|
1887
|
+
function formatImportFileSizeLimit(tier) {
|
|
1888
|
+
return formatImportByteSize(PLAN_LIMITS[tier].import.maxFileBytes);
|
|
1889
|
+
}
|
|
1890
|
+
function formatImportByteSize(bytes) {
|
|
1891
|
+
const mebibytes = bytes / (1024 * 1024);
|
|
1892
|
+
return mebibytes >= 1024 && mebibytes % 1024 === 0 ? `${mebibytes / 1024} GiB` : `${Math.round(mebibytes)} MiB`;
|
|
1893
|
+
}
|
|
1894
|
+
// Help copy reads the shared ceiling so the number can never drift from the
|
|
1895
|
+
// one the server enforces and the import writer splits against.
|
|
1896
|
+
function formatJsonBodyLimit() {
|
|
1897
|
+
return `${Math.round(MAX_CLI_JSON_BODY_BYTES / 1_000_000)} MB`;
|
|
1898
|
+
}
|
|
1899
|
+
// A backgrounded import returns as soon as the file is staged, with counts.rows
|
|
1900
|
+
// still 0 while the worker loads. With no follow-up command in the envelope that
|
|
1901
|
+
// reads as a truncated import - which is how a 500-row `--batch-size` default
|
|
1902
|
+
// gets mistaken for a 500-row cap. `table-ingestions wait` takes the ingestion
|
|
1903
|
+
// run's own id (`ingestionRunId`), not the linked `runId`, which 404s.
|
|
1904
|
+
function withImportWaitNextStep(envelope) {
|
|
1905
|
+
const ingestionRunId = readRecordString(envelope, "ingestionRunId")
|
|
1906
|
+
?? readRecordString(readRecord(envelope, "ingestionRun"), "id");
|
|
1907
|
+
if (!ingestionRunId)
|
|
1908
|
+
return envelope;
|
|
1909
|
+
const nextStep = `oxygen table-ingestions wait ${ingestionRunId}`;
|
|
1910
|
+
process.stderr.write(`note: rows load in the background - watch with ${nextStep}\n`);
|
|
1911
|
+
return { ...envelope, next_step: nextStep };
|
|
1912
|
+
}
|
|
1913
|
+
function normalizeImportBatchSize(value) {
|
|
1914
|
+
const batchSize = readPositiveInt(value) ?? 500;
|
|
1915
|
+
if (batchSize > 5000) {
|
|
1916
|
+
throw new OxygenError("invalid_number", "Import batch size cannot exceed 5000.", {
|
|
1917
|
+
details: { value },
|
|
1918
|
+
exitCode: 1,
|
|
1919
|
+
});
|
|
1920
|
+
}
|
|
1921
|
+
return batchSize;
|
|
1922
|
+
}
|
|
1923
|
+
function hashImportFile(path) {
|
|
1924
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
1925
|
+
}
|
|
1926
|
+
// `tables tidy-suggest` is a read-only analysis (no credits, no writes): it
|
|
1927
|
+
// surfaces duplicate columns, formatting inconsistencies, hide candidates, and
|
|
1928
|
+
// value-sanity findings so the caller can pick which reversible cleanup to run
|
|
1929
|
+
// next. --json returns the raw report envelope; the default rendering compacts
|
|
1930
|
+
// the four sections for a terminal reader and echoes the deep-link.
|
|
1931
|
+
async function handleTablesTidySuggestAction(table, options) {
|
|
1932
|
+
try {
|
|
1933
|
+
const limit = readPositiveInt(options.limit);
|
|
1934
|
+
const categories = readCsvOption(options.categories);
|
|
1935
|
+
const data = await requestOxygen("/api/cli/tables/tidy/suggest", {
|
|
1936
|
+
method: "POST",
|
|
1937
|
+
body: {
|
|
1938
|
+
table,
|
|
1939
|
+
...(limit ? { limit } : {}),
|
|
1940
|
+
...(categories.length ? { categories } : {}),
|
|
1941
|
+
},
|
|
1942
|
+
});
|
|
1943
|
+
if (options.json) {
|
|
1944
|
+
writeJson(success("tables tidy-suggest", data));
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1947
|
+
process.stdout.write(formatTidySuggestions(data));
|
|
1948
|
+
}
|
|
1949
|
+
catch (error) {
|
|
1950
|
+
emitCliFailure("tables tidy-suggest", error);
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
function formatTidyCount(value) {
|
|
1954
|
+
return typeof value === "number" && Number.isFinite(value) ? String(value) : "0";
|
|
1955
|
+
}
|
|
1956
|
+
function formatTidyPercent(value) {
|
|
1957
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
1958
|
+
return "—";
|
|
1959
|
+
return `${(value * 100).toFixed(0)}%`;
|
|
1960
|
+
}
|
|
1961
|
+
// Render one tidy-suggest section (header + count, then either a dim "None" or
|
|
1962
|
+
// one formatted line per item) into the shared `lines` buffer. Extracted so the
|
|
1963
|
+
// four sections share a single control-flow shape instead of repeating it.
|
|
1964
|
+
function pushTidySection(lines, styles, title, items, renderItem) {
|
|
1965
|
+
lines.push("", styles.bold(`${title} (${items.length})`));
|
|
1966
|
+
if (items.length === 0) {
|
|
1967
|
+
lines.push(` ${styles.dim("None")}`);
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
for (const item of items) {
|
|
1971
|
+
lines.push(renderItem(item));
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
function formatTidySuggestions(data) {
|
|
1975
|
+
const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
|
|
1976
|
+
const duplicates = Array.isArray(data.duplicateColumns) ? data.duplicateColumns : [];
|
|
1977
|
+
const formatting = Array.isArray(data.formattingIssues) ? data.formattingIssues : [];
|
|
1978
|
+
const hide = Array.isArray(data.hideSuggestions) ? data.hideSuggestions : [];
|
|
1979
|
+
const valueSanity = Array.isArray(data.valueSanity) ? data.valueSanity : [];
|
|
1980
|
+
const notComputed = Array.isArray(data.notComputed) ? data.notComputed : [];
|
|
1981
|
+
const lines = [
|
|
1982
|
+
"",
|
|
1983
|
+
`${styles.bold("Table tidy suggestions")} ${styles.dim(`(sampled ${formatTidyCount(data.sampledRows)} of ${formatTidyCount(data.rowCount)} rows)`)}`,
|
|
1984
|
+
];
|
|
1985
|
+
pushTidySection(lines, styles, "Duplicate columns", duplicates, (dup) => {
|
|
1986
|
+
const loser = dup.loserKey ?? dup.loserColumnId ?? "?";
|
|
1987
|
+
const survivor = dup.survivorKey ?? dup.survivorColumnId ?? "?";
|
|
1988
|
+
return ` ${loser} ${styles.dim("→ duplicate of")} ${survivor} ${styles.dim(`(${formatTidyPercent(dup.similarity)} match, ${dup.recommendation ?? "review"})`)}`;
|
|
1989
|
+
});
|
|
1990
|
+
pushTidySection(lines, styles, "Formatting issues", formatting, (issue) => {
|
|
1991
|
+
const sampleCount = Array.isArray(issue.sampleRowIds) ? issue.sampleRowIds.length : 0;
|
|
1992
|
+
const column = issue.columnKey ?? issue.columnId ?? "?";
|
|
1993
|
+
return ` ${column} ${styles.dim(`— ${issue.issue ?? "issue"}`)}${issue.detail ? `: ${issue.detail}` : ""} ${styles.dim(`(${sampleCount} sample row(s))`)}`;
|
|
1994
|
+
});
|
|
1995
|
+
pushTidySection(lines, styles, "Hide suggestions", hide, (suggestion) => {
|
|
1996
|
+
const column = suggestion.columnKey ?? suggestion.columnId ?? "?";
|
|
1997
|
+
return ` ${column} ${styles.dim(`— ${suggestion.reason ?? "hide"} (${formatTidyPercent(suggestion.fillRate)} filled)`)}`;
|
|
1998
|
+
});
|
|
1999
|
+
pushTidySection(lines, styles, "Value-sanity findings", valueSanity, (finding) => {
|
|
2000
|
+
const column = finding.columnKey ?? finding.columnId ?? "?";
|
|
2001
|
+
return ` ${column} ${styles.dim(`row ${finding.rowId ?? "?"} — ${finding.reason ?? "check value"}`)}`;
|
|
2002
|
+
});
|
|
2003
|
+
// Printed even when empty, like the four finding sections: a reader who never
|
|
2004
|
+
// sees this heading cannot tell a measured column from an unmeasured one.
|
|
2005
|
+
pushTidySection(lines, styles, "Not measured", notComputed, (entry) => {
|
|
2006
|
+
const column = entry.columnKey ?? entry.columnId ?? "?";
|
|
2007
|
+
return ` ${column} ${styles.dim(`— ${entry.message ?? entry.reason ?? "could not be measured"}`)}`;
|
|
2008
|
+
});
|
|
2009
|
+
const link = data.deepLink ?? data.web_url ?? data.url;
|
|
2010
|
+
if (link) {
|
|
2011
|
+
lines.push("", styles.dim(`View table: ${link}`));
|
|
2012
|
+
}
|
|
2013
|
+
lines.push("");
|
|
2014
|
+
return lines.join("\n");
|
|
2015
|
+
}
|
|
2016
|
+
// `tables delete-row|delete-rows` for a human reader: a deleted row's lead stays
|
|
2017
|
+
// enrolled in its campaigns, so name them where the delete was typed.
|
|
2018
|
+
function writeRowDeleteWarnings(data) {
|
|
2019
|
+
const payload = asPayloadRecord(data);
|
|
2020
|
+
const warnings = Array.isArray(payload?.warnings) ? payload.warnings : [];
|
|
2021
|
+
for (const entry of warnings) {
|
|
2022
|
+
const warning = asPayloadRecord(entry);
|
|
2023
|
+
if (warning && typeof warning.message === "string")
|
|
2024
|
+
process.stderr.write(`Warning: ${warning.message}\n`);
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
export function registerTablesFamily(program, handleAsyncAction, binaryName) {
|
|
2028
|
+
const tablesCommand = program
|
|
2029
|
+
.command("tables")
|
|
2030
|
+
.description("Tenant workspace tables: create, import rows (CSV, a link, a HubSpot saved list, a Pipedrive saved filter, Supabase), enrich with columns, and run them. `tables sources` lists every way a table can start.")
|
|
2031
|
+
.addCommand(new Command("sources")
|
|
2032
|
+
.description("List every way a table can start — imports, company/people/signal sources, webhooks, functions — with the provider count and per-row credit estimate behind each. Free; nothing is called or written. The same catalog the web New table picker shows.")
|
|
2033
|
+
.option("--group <group>", "Only one group: Companies, People, \"Professional Network\" (or linkedin), Imports, CRM, Signals, or Tables.")
|
|
2034
|
+
.option("--json", "Print a JSON envelope.")
|
|
2035
|
+
.action(async (options) => {
|
|
2036
|
+
await handleAsyncAction("tables sources", options, () => {
|
|
2037
|
+
const params = new URLSearchParams();
|
|
2038
|
+
const group = readOption(options.group);
|
|
2039
|
+
if (group)
|
|
2040
|
+
params.set("group", group);
|
|
2041
|
+
const qs = params.toString() ? `?${params.toString()}` : "";
|
|
2042
|
+
return requestOxygen(`/api/cli/tables/sources${qs}`);
|
|
2043
|
+
});
|
|
2044
|
+
}))
|
|
2045
|
+
.addCommand(new Command("records")
|
|
2046
|
+
.description("Copy a Records object or saved view into a working Table, once or kept in sync. One-way: Records stay unchanged, the Table is keyed by record_id, cells edited in the Table are kept, and records that leave the source stay as rows with in_records=false.")
|
|
2047
|
+
.addCommand(new Command("import")
|
|
2048
|
+
.description("Create a Table from a Records object or saved view. --dry-run counts the records and lists the columns without creating anything. --refresh sync also arms a schedule (a Workflow you can pause) that repeats the copy. The copy costs 0 credits and calls no provider; each scheduled sync bills one workflow step (credits_per_scheduled_sync).")
|
|
2049
|
+
.requiredOption("--object <slug>", "Records object slug, such as companies, people or deals (`crm objects`).")
|
|
2050
|
+
.option("--view <id|name>", "Saved view on that object, by id or exact name (`tables views list <object table>`). Omit for every record.")
|
|
2051
|
+
.option("--name <table>", "Name for the new Table. Defaults to the object and view name.")
|
|
2052
|
+
.option("--project <project>", "Project id or slug. Defaults to General.")
|
|
2053
|
+
.option("--refresh <once|sync>", "once (default) copies now; sync also keeps it in sync (needs --approved).")
|
|
2054
|
+
.option("--every <cadence>", "With --refresh sync: hourly, every_6h, daily@<hour> or weekly@<day><hour> (UTC). Default daily@6.")
|
|
2055
|
+
.option("--dry-run", "Preview without creating the Table.")
|
|
2056
|
+
.option("--approved", "Approve the standing per-sync charge that --refresh sync arms.")
|
|
2057
|
+
.option("--json", "Print a JSON envelope.")
|
|
2058
|
+
.action(async (options) => {
|
|
2059
|
+
await handleAsyncAction("tables records import", options, () => requestOxygen("/api/cli/tables/records-import", {
|
|
2060
|
+
method: "POST",
|
|
2061
|
+
body: {
|
|
2062
|
+
object: options.object,
|
|
2063
|
+
...(readOption(options.view) ? { view: readOption(options.view) } : {}),
|
|
2064
|
+
...(readOption(options.name) ? { name: readOption(options.name) } : {}),
|
|
2065
|
+
...(readOption(options.project) ? { project: readOption(options.project) } : {}),
|
|
2066
|
+
...(readOption(options.refresh) ? { refresh: readOption(options.refresh) } : {}),
|
|
2067
|
+
...(readOption(options.every) ? { every: readOption(options.every) } : {}),
|
|
2068
|
+
...(options.dryRun ? { dry_run: true } : {}),
|
|
2069
|
+
...(options.approved ? { approved: true } : {}),
|
|
2070
|
+
},
|
|
2071
|
+
}));
|
|
2072
|
+
}))
|
|
2073
|
+
.addCommand(new Command("sync")
|
|
2074
|
+
.description("Copy a Records-imported Table's source again now: new records are added, changed cells updated, edited cells kept, and departed records marked in_records=false (they flip back if they return). Without --refresh the schedule stays as it is; --refresh sync --approved arms or retimes it, --refresh once removes it. preserved_cells counts copied-field cells left as someone edited them in the Table; columns you added are never touched and not counted. Running it now costs 0 credits; each scheduled sync bills one workflow step (credits_per_scheduled_sync in an import dry run).")
|
|
2075
|
+
.argument("<table>", "Table id, slug or URL.")
|
|
2076
|
+
.option("--refresh <once|sync>", "Omit to keep the schedule; sync (with --approved) arms or retimes it; once removes it.")
|
|
2077
|
+
.option("--every <cadence>", "With --refresh sync: hourly, every_6h, daily@<hour> or weekly@<day><hour> (UTC). Default daily@6.")
|
|
2078
|
+
.option("--dry-run", "Count what would change without writing.")
|
|
2079
|
+
.option("--approved", "Approve the standing per-sync charge that --refresh sync arms.")
|
|
2080
|
+
.option("--json", "Print a JSON envelope.")
|
|
2081
|
+
.action(async (table, options) => {
|
|
2082
|
+
await handleAsyncAction("tables records sync", options, () => requestOxygen("/api/cli/tables/records-sync", {
|
|
2083
|
+
method: "POST",
|
|
2084
|
+
body: {
|
|
2085
|
+
table,
|
|
2086
|
+
...(readOption(options.refresh) ? { refresh: readOption(options.refresh) } : {}),
|
|
2087
|
+
...(readOption(options.every) ? { every: readOption(options.every) } : {}),
|
|
2088
|
+
...(options.dryRun ? { dry_run: true } : {}),
|
|
2089
|
+
...(options.approved ? { approved: true } : {}),
|
|
2090
|
+
},
|
|
2091
|
+
}));
|
|
2092
|
+
}))
|
|
2093
|
+
.addCommand(new Command("status")
|
|
2094
|
+
.description("Show a Records-imported Table's source object and view, the last sync's counts and its schedule. Read-only, 0 credits.")
|
|
2095
|
+
.argument("<table>", "Table id, slug or URL.")
|
|
2096
|
+
.option("--json", "Print a JSON envelope.")
|
|
2097
|
+
.action(async (table, options) => {
|
|
2098
|
+
await handleAsyncAction("tables records status", options, () => requestOxygen(`/api/cli/tables/records-sync?table=${encodeURIComponent(table)}`));
|
|
2099
|
+
})))
|
|
2100
|
+
.addCommand(new Command("hubspot")
|
|
2101
|
+
.description("Import HubSpot contacts or companies into a new table or Records, optionally from a saved list and with do-not-contact suppression.")
|
|
2102
|
+
.addCommand(new Command("lists")
|
|
2103
|
+
.description("List the connected portal's saved contact/company segments and readable identity properties. Reads definitions only; no memberships, rows, or writes. Free — 0 Oxygen credits, uses your HubSpot API quota.")
|
|
2104
|
+
.option("--query <text>", "Optional list-name search.")
|
|
2105
|
+
.option("--connection <id>", "HubSpot connection id; defaults to the first connected account.")
|
|
2106
|
+
.option("--json", "Print a JSON envelope.")
|
|
2107
|
+
.action(async (options) => {
|
|
2108
|
+
await handleAsyncAction("tables hubspot lists", options, () => {
|
|
2109
|
+
const params = new URLSearchParams();
|
|
2110
|
+
const query = readOption(options.query);
|
|
2111
|
+
if (query)
|
|
2112
|
+
params.set("query", query);
|
|
2113
|
+
const connection = readOption(options.connection);
|
|
2114
|
+
if (connection)
|
|
2115
|
+
params.set("connection_id", connection);
|
|
2116
|
+
const suffix = params.toString();
|
|
2117
|
+
return requestOxygen(`/api/cli/tables/hubspot-import${suffix ? `?${suffix}` : ""}`);
|
|
2118
|
+
});
|
|
2119
|
+
}))
|
|
2120
|
+
.addCommand(new Command("import")
|
|
2121
|
+
.description("Preview or import HubSpot contacts/companies into a new table or Records. Named do-not-contact lists must synchronize before any import. Nothing is enrolled or sent. Live requires the exact dry-run fingerprint and approval.")
|
|
2122
|
+
.option("--list <id>", "HubSpot saved-list id. Omit with --object to import all records; discover ids with `oxygen tables hubspot lists`.")
|
|
2123
|
+
.option("--object <object>", "HubSpot object: contacts or companies. Required without --list; defaults to contacts for a saved list.")
|
|
2124
|
+
.option("--destination <destination>", "Import into table (default) or records.")
|
|
2125
|
+
.option("--connection <id>", "HubSpot connection id; defaults to the first connected account.")
|
|
2126
|
+
.option("--sample", "Read up to 50 real records during the dry-run preview; uses HubSpot API quota and makes no writes.")
|
|
2127
|
+
.option("--dnc-list <id>", "HubSpot contact-list id to suppress into do-not-contact before importing.")
|
|
2128
|
+
.option("--company-dnc-list <id>", "Optional HubSpot company-list id to suppress organization-wide.")
|
|
2129
|
+
.option("--no-dnc", "Import without suppressing anything. Required if you pass no --dnc-list, so an unsuppressed import is never accidental.")
|
|
2130
|
+
.option("--name <table>", "Display name for the new table.")
|
|
2131
|
+
.option("--project <project>", "Project id or slug. Defaults to General.")
|
|
2132
|
+
.option("--contact-linkedin-property <name>", "Optional contact property holding a public LinkedIn /in/ URL.")
|
|
2133
|
+
.option("--contact-company-domain-property <name>", "Optional contact property copied to company_domain.")
|
|
2134
|
+
.option("--contact-company-linkedin-property <name>", "Optional contact property copied to company_linkedin_url.")
|
|
2135
|
+
.option("--company-domain-property <name>", "Company domain property (default domain).", "domain")
|
|
2136
|
+
.option("--company-linkedin-property <name>", "Optional company LinkedIn URL property.")
|
|
2137
|
+
.option("--contact-properties <list>", "Comma-separated extra HubSpot contact properties to hydrate (max 50).")
|
|
2138
|
+
.option("--max-records-per-list <n>", "Optional record limit; omit to import all selected records.")
|
|
2139
|
+
.option("--max-credits <n>", "Hard credits cap per delivery.", "10")
|
|
2140
|
+
.option("--once", "Import once instead of refreshing on a schedule.")
|
|
2141
|
+
.option("--cron <expression>", "Refresh schedule; default every 15 minutes.")
|
|
2142
|
+
.option("--timezone <iana>", "Schedule timezone; default UTC.")
|
|
2143
|
+
.option("--reviewed-fingerprint <hash>", "Exact dry-run fingerprint, required for live.")
|
|
2144
|
+
.option("--idempotency-key <key>", "Optional first-run idempotency key.")
|
|
2145
|
+
.option("--live", "Create the import and queue the first durable run.")
|
|
2146
|
+
.option("--approved", "Approve the reviewed reads, DNC writes, and destination upserts.")
|
|
2147
|
+
.option("--json", "Print a JSON envelope.")
|
|
2148
|
+
.action(async (options) => {
|
|
2149
|
+
await handleAsyncAction("tables hubspot import", options, () => {
|
|
2150
|
+
const list = readOption(options.list);
|
|
2151
|
+
const object = readOption(options.object);
|
|
2152
|
+
const destination = readOption(options.destination);
|
|
2153
|
+
if (!list && !object)
|
|
2154
|
+
throw new OxygenError("invalid_request", "Pass --list or --object contacts|companies to select what to import.", { exitCode: 1 });
|
|
2155
|
+
if (object && object !== "contacts" && object !== "companies")
|
|
2156
|
+
throw new OxygenError("invalid_request", "--object must be contacts or companies.", { exitCode: 1 });
|
|
2157
|
+
if (destination && destination !== "table" && destination !== "records")
|
|
2158
|
+
throw new OxygenError("invalid_request", "--destination must be table or records.", { exitCode: 1 });
|
|
2159
|
+
const dncList = readOption(options.dncList);
|
|
2160
|
+
const companyDncList = readOption(options.companyDncList);
|
|
2161
|
+
// Commander maps `--no-dnc` to `dnc: false`; the default is
|
|
2162
|
+
// `true`, so an unset flag is indistinguishable from an opt-in.
|
|
2163
|
+
// Requiring one of the two means an import that suppresses
|
|
2164
|
+
// nothing is always something the operator typed, which is the
|
|
2165
|
+
// same guarantee the web lane gets from its preview fingerprint.
|
|
2166
|
+
const optedOut = options.dnc === false;
|
|
2167
|
+
if (!dncList && !companyDncList && !optedOut) {
|
|
2168
|
+
// invalid_request, not a bare Error: handleAsyncAction reports
|
|
2169
|
+
// an uncoded throw as `unexpected_error`, which tells a calling
|
|
2170
|
+
// agent that OXYGEN broke when the caller simply omitted a flag.
|
|
2171
|
+
throw new OxygenError("invalid_request", "Pass --dnc-list (and/or --company-dnc-list) to suppress a HubSpot list, or --no-dnc to import without suppressing anything. Run `oxygen tables hubspot lists` to see the ids your portal exposes.", { exitCode: 1 });
|
|
2172
|
+
}
|
|
2173
|
+
if (optedOut && (dncList || companyDncList)) {
|
|
2174
|
+
throw new OxygenError("invalid_request", "--no-dnc cannot be combined with --dnc-list or --company-dnc-list.", { exitCode: 1 });
|
|
2175
|
+
}
|
|
2176
|
+
const maxRecords = readPositiveInt(options.maxRecordsPerList);
|
|
2177
|
+
const maxCredits = readPositiveNumber(options.maxCredits);
|
|
2178
|
+
if (options.maxRecordsPerList !== undefined && (maxRecords === undefined || !Number.isSafeInteger(maxRecords)))
|
|
2179
|
+
throw new OxygenError("invalid_request", "--max-records-per-list must be a positive integer.", { exitCode: 1 });
|
|
2180
|
+
if (maxCredits === undefined)
|
|
2181
|
+
throw new OxygenError("spend_cap_required", "--max-credits must be positive.", { exitCode: 1 });
|
|
2182
|
+
const fingerprint = readOption(options.reviewedFingerprint);
|
|
2183
|
+
if (options.live && !fingerprint)
|
|
2184
|
+
throw new OxygenError("approval_required", "Live import requires --reviewed-fingerprint from the exact dry-run. Run the same command without --live first.", { exitCode: 1 });
|
|
2185
|
+
const contactProperties = readOption(options.contactProperties)
|
|
2186
|
+
?.split(",")
|
|
2187
|
+
.map((entry) => entry.trim())
|
|
2188
|
+
.filter(Boolean) ?? [];
|
|
2189
|
+
return requestOxygen("/api/cli/tables/hubspot-import", {
|
|
2190
|
+
method: "POST",
|
|
2191
|
+
body: {
|
|
2192
|
+
lead_list_id: list ?? null,
|
|
2193
|
+
...(object ? { object } : {}),
|
|
2194
|
+
...(destination ? { destination } : {}),
|
|
2195
|
+
...(readOption(options.connection) ? { connection_id: readOption(options.connection) } : {}),
|
|
2196
|
+
...(options.sample ? { sample: true } : {}),
|
|
2197
|
+
dnc_list_id: dncList ?? null,
|
|
2198
|
+
company_dnc_list_id: companyDncList ?? null,
|
|
2199
|
+
table_name: readOption(options.name) ?? null,
|
|
2200
|
+
project: readOption(options.project) ?? null,
|
|
2201
|
+
contact_linkedin_property: readOption(options.contactLinkedinProperty) ?? null,
|
|
2202
|
+
contact_company_domain_property: readOption(options.contactCompanyDomainProperty) ?? null,
|
|
2203
|
+
contact_company_linkedin_property: readOption(options.contactCompanyLinkedinProperty) ?? null,
|
|
2204
|
+
company_domain_property: readOption(options.companyDomainProperty) ?? "domain",
|
|
2205
|
+
company_linkedin_property: readOption(options.companyLinkedinProperty) ?? null,
|
|
2206
|
+
contact_properties: contactProperties,
|
|
2207
|
+
max_records_per_list: maxRecords ?? null,
|
|
2208
|
+
refresh: options.once ? "once" : "scheduled",
|
|
2209
|
+
cron: readOption(options.cron) ?? undefined,
|
|
2210
|
+
timezone: readOption(options.timezone) ?? undefined,
|
|
2211
|
+
max_credits: maxCredits,
|
|
2212
|
+
mode: options.live ? "live" : "dry_run",
|
|
2213
|
+
...(options.approved ? { approved: true } : {}),
|
|
2214
|
+
...(fingerprint ? { reviewed_fingerprint: fingerprint } : {}),
|
|
2215
|
+
...(readOption(options.idempotencyKey) ? { idempotency_key: readOption(options.idempotencyKey) } : {}),
|
|
2216
|
+
},
|
|
2217
|
+
});
|
|
2218
|
+
});
|
|
2219
|
+
})))
|
|
2220
|
+
.addCommand(new Command("pipedrive")
|
|
2221
|
+
.description("Import a Pipedrive saved people filter into a new Oxygen table, optionally suppressing a Pipedrive do-not-contact filter first. Connect Pipedrive with an API token first: `oxygen integrations connect pipedrive --api-key <token>`.")
|
|
2222
|
+
.addCommand(addContractFlags(new Command("filters")
|
|
2223
|
+
.description("Read connected Pipedrive saved filters and custom-field definitions; no people imported. Four BYOK fees may apply. Use --approved with --max-credits to authorize this read; refusal returns the required cap without provider calls."), PIPEDRIVE_CATALOG_QUERY_CONTRACT, {
|
|
2224
|
+
query: { valueName: "<text>" },
|
|
2225
|
+
approved: { noNegate: true },
|
|
2226
|
+
max_credits: { valueName: "<n>" },
|
|
2227
|
+
})
|
|
2228
|
+
.option("--json", "Print a JSON envelope.")
|
|
2229
|
+
.action(async (options) => {
|
|
2230
|
+
await handleAsyncAction("tables pipedrive filters", options, () => {
|
|
2231
|
+
const params = pipedriveCatalogQueryParams(readContractFlags(options, PIPEDRIVE_CATALOG_QUERY_CONTRACT, { approved: { noNegate: true } }));
|
|
2232
|
+
const suffix = params.toString();
|
|
2233
|
+
return requestOxygen(`/api/cli/tables/pipedrive-import${suffix ? `?${suffix}` : ""}`);
|
|
2234
|
+
});
|
|
2235
|
+
}))
|
|
2236
|
+
.addCommand(new Command("import")
|
|
2237
|
+
.description("Preview or run a Pipedrive saved people filter into a NEW table. Any do-not-contact filter you name is synchronized first and no row lands unless it fully succeeds. Nothing is ever enrolled or sent. Live requires the exact dry-run fingerprint and approval.")
|
|
2238
|
+
.requiredOption("--filter <id>", "Pipedrive people-filter id whose persons become rows. Run `oxygen tables pipedrive filters` to see the ids your account exposes.")
|
|
2239
|
+
.option("--dnc-filter <id>", "Pipedrive people-filter id to suppress into do-not-contact before importing (a filter on Marketing status = Unsubscribed is the usual choice).")
|
|
2240
|
+
.option("--org-dnc-filter <id>", "Optional Pipedrive organization-filter id to suppress organization-wide.")
|
|
2241
|
+
.option("--no-dnc", "Import without suppressing anything. Required if you pass no --dnc-filter, so an unsuppressed import is never accidental.")
|
|
2242
|
+
.option("--name <table>", "Display name for the new table.")
|
|
2243
|
+
.option("--project <project>", "Project id or slug. Defaults to General.")
|
|
2244
|
+
.option("--person-linkedin-field <key>", "Optional person field key holding a public LinkedIn /in/ URL (custom keys are 40-character hex; see `tables pipedrive filters`).")
|
|
2245
|
+
.option("--person-title-field <key>", "Optional person field key copied to title. Pipedrive has no native job-title field.")
|
|
2246
|
+
.option("--org-domain-field <key>", "Optional organization field key holding the website or domain. Pipedrive has no native domain field; never inferred from email.")
|
|
2247
|
+
.option("--org-linkedin-field <key>", "Optional organization field key holding its LinkedIn company page.")
|
|
2248
|
+
.option("--person-fields <list>", "Comma-separated extra Pipedrive person field keys to hydrate into pipedrive_fields (max 15).")
|
|
2249
|
+
.option("--no-hydrate-organizations", "Skip reading each person's organization; company columns stay empty.")
|
|
2250
|
+
.option("--max-records-per-filter <n>", "Complete-snapshot cap (1-5000), enforced while the filter is read.", "5000")
|
|
2251
|
+
.option("--max-credits <n>", "Hard credits cap per delivery.", "10")
|
|
2252
|
+
.option("--once", "Import once instead of refreshing on a schedule.")
|
|
2253
|
+
.option("--cron <expression>", "Refresh schedule; default every 15 minutes.")
|
|
2254
|
+
.option("--timezone <iana>", "Schedule timezone; default UTC.")
|
|
2255
|
+
.option("--reviewed-fingerprint <hash>", "Exact dry-run fingerprint, required for live.")
|
|
2256
|
+
.option("--idempotency-key <key>", "Optional first-run idempotency key.")
|
|
2257
|
+
.option("--live", "Create the table and queue the first durable run.")
|
|
2258
|
+
.option("--approved", "Approve the reviewed bounded reads, DNC writes, and table upserts.")
|
|
2259
|
+
.option("--json", "Print a JSON envelope.")
|
|
2260
|
+
.action(async (options) => {
|
|
2261
|
+
await handleAsyncAction("tables pipedrive import", options, () => {
|
|
2262
|
+
const dncFilter = readOption(options.dncFilter);
|
|
2263
|
+
const orgDncFilter = readOption(options.orgDncFilter);
|
|
2264
|
+
// Same affirmative contract as `tables hubspot import`: an import
|
|
2265
|
+
// that suppresses nothing is always something the operator typed.
|
|
2266
|
+
const optedOut = options.dnc === false;
|
|
2267
|
+
if (!dncFilter && !orgDncFilter && !optedOut) {
|
|
2268
|
+
throw new OxygenError("invalid_request", "Pass --dnc-filter (and/or --org-dnc-filter) to suppress a Pipedrive filter, or --no-dnc to import without suppressing anything. Run `oxygen tables pipedrive filters` to see the ids your account exposes.", { exitCode: 1 });
|
|
2269
|
+
}
|
|
2270
|
+
if (optedOut && (dncFilter || orgDncFilter)) {
|
|
2271
|
+
throw new OxygenError("invalid_request", "--no-dnc cannot be combined with --dnc-filter or --org-dnc-filter.", { exitCode: 1 });
|
|
2272
|
+
}
|
|
2273
|
+
const maxRecords = readPositiveInt(options.maxRecordsPerFilter);
|
|
2274
|
+
const maxCredits = readPositiveNumber(options.maxCredits);
|
|
2275
|
+
if (maxRecords === undefined || maxRecords > 5000)
|
|
2276
|
+
throw new OxygenError("invalid_request", "--max-records-per-filter must be between 1 and 5000.", { exitCode: 1 });
|
|
2277
|
+
if (maxCredits === undefined)
|
|
2278
|
+
throw new OxygenError("spend_cap_required", "--max-credits must be positive.", { exitCode: 1 });
|
|
2279
|
+
const fingerprint = readOption(options.reviewedFingerprint);
|
|
2280
|
+
if (options.live && !fingerprint)
|
|
2281
|
+
throw new OxygenError("approval_required", "Live import requires --reviewed-fingerprint from the exact dry-run. Run the same command without --live first.", { exitCode: 1 });
|
|
2282
|
+
const personFields = readOption(options.personFields)
|
|
2283
|
+
?.split(",")
|
|
2284
|
+
.map((entry) => entry.trim())
|
|
2285
|
+
.filter(Boolean) ?? [];
|
|
2286
|
+
const hydrateOrganizations = options.hydrateOrganizations !== false;
|
|
2287
|
+
return requestOxygen("/api/cli/tables/pipedrive-import", {
|
|
2288
|
+
method: "POST",
|
|
2289
|
+
body: {
|
|
2290
|
+
filter_id: readOption(options.filter),
|
|
2291
|
+
dnc_filter_id: dncFilter ?? null,
|
|
2292
|
+
org_dnc_filter_id: orgDncFilter ?? null,
|
|
2293
|
+
table_name: readOption(options.name) ?? null,
|
|
2294
|
+
project: readOption(options.project) ?? null,
|
|
2295
|
+
person_linkedin_field: readOption(options.personLinkedinField) ?? null,
|
|
2296
|
+
person_title_field: readOption(options.personTitleField) ?? null,
|
|
2297
|
+
org_domain_field: readOption(options.orgDomainField) ?? null,
|
|
2298
|
+
org_linkedin_field: readOption(options.orgLinkedinField) ?? null,
|
|
2299
|
+
person_fields: personFields,
|
|
2300
|
+
hydrate_organizations: hydrateOrganizations,
|
|
2301
|
+
max_records_per_filter: maxRecords,
|
|
2302
|
+
refresh: options.once ? "once" : "scheduled",
|
|
2303
|
+
cron: readOption(options.cron) ?? undefined,
|
|
2304
|
+
timezone: readOption(options.timezone) ?? undefined,
|
|
2305
|
+
max_credits: maxCredits,
|
|
2306
|
+
mode: options.live ? "live" : "dry_run",
|
|
2307
|
+
...(options.approved ? { approved: true } : {}),
|
|
2308
|
+
...(fingerprint ? { reviewed_fingerprint: fingerprint } : {}),
|
|
2309
|
+
...(readOption(options.idempotencyKey) ? { idempotency_key: readOption(options.idempotencyKey) } : {}),
|
|
2310
|
+
},
|
|
2311
|
+
});
|
|
2312
|
+
});
|
|
2313
|
+
})))
|
|
2314
|
+
.addCommand(new Command("create")
|
|
2315
|
+
.description("Create a table private to the active workspace. Free — 0 Oxygen credits. To create a table and import a file in one step, use `tables import --create <name>`.")
|
|
2316
|
+
.argument("<name>", "Display name for the table.")
|
|
2317
|
+
.requiredOption("--columns-json <json>", 'Required nonempty JSON array of column definitions, e.g. [{"key":"name","label":"Name","dataType":"text"},{"key":"domain","label":"Domain","dataType":"text"}]. A column may name a field type instead, as `columns add --type` does: {"label":"Stage","type":"select","options":["Lead","Won"]} (text, number, checkbox, date, json, markdown, image, select, multi_select, status). Inspect an existing shape with `oxygen tables describe <table>`.')
|
|
2318
|
+
.option("--project <project>", "Project id or slug. Defaults to General.")
|
|
2319
|
+
.option("--json", "Print a JSON envelope.")
|
|
2320
|
+
.action(async (name, options) => {
|
|
2321
|
+
await handleAsyncAction("tables create", options, () => requestOxygen("/api/cli/tables", {
|
|
2322
|
+
method: "POST",
|
|
2323
|
+
body: {
|
|
2324
|
+
name,
|
|
2325
|
+
columns: parseJsonArray(options.columnsJson),
|
|
2326
|
+
...(readOption(options.project) ? { project: readOption(options.project) } : {}),
|
|
2327
|
+
},
|
|
2328
|
+
}));
|
|
2329
|
+
}))
|
|
2330
|
+
.addCommand(new Command("duplicate")
|
|
2331
|
+
.description("Duplicate a workspace table. Copies row values unless --schema-only is set.")
|
|
2332
|
+
.argument("<table>", "Source table id or slug.")
|
|
2333
|
+
.option("--name <name>", "Display name for the duplicated table. Defaults to '<source> Copy'.")
|
|
2334
|
+
.option("--schema-only", "Duplicate columns and definitions without copying row values.")
|
|
2335
|
+
.option("--json", "Print a JSON envelope.")
|
|
2336
|
+
.action(async (table, options) => {
|
|
2337
|
+
await handleAsyncAction("tables duplicate", options, () => requestOxygen(`/api/cli/tables/${encodeURIComponent(table)}/duplicate`, {
|
|
2338
|
+
method: "POST",
|
|
2339
|
+
body: {
|
|
2340
|
+
...(readOption(options.name) ? { name: readOption(options.name) } : {}),
|
|
2341
|
+
...(options.schemaOnly ? { include_rows: false } : {}),
|
|
2342
|
+
},
|
|
2343
|
+
}));
|
|
2344
|
+
}))
|
|
2345
|
+
.addCommand(new Command("insert")
|
|
2346
|
+
.description("Insert rows into a workspace table. Table writes cost 0 Oxygen credits; paid column runs are separate.")
|
|
2347
|
+
.argument("<table>", "Table id or slug.")
|
|
2348
|
+
.requiredOption("--rows-json <json>", 'JSON array of row objects keyed by column key, e.g. [{"name":"Acme","domain":"acme.test"},{"name":"Globex","domain":"globex.test"}].')
|
|
2349
|
+
.option("--request-id <id>", "Stable retry key. Reuse it after a timeout to return the committed write without duplicating rows.")
|
|
2350
|
+
.option("--json", "Print a JSON envelope.")
|
|
2351
|
+
.action(async (table, options) => {
|
|
2352
|
+
await handleAsyncAction("tables insert", options, () => requestOxygen("/api/cli/tables/rows", {
|
|
2353
|
+
method: "POST",
|
|
2354
|
+
body: {
|
|
2355
|
+
table,
|
|
2356
|
+
rows: parseJsonArray(options.rowsJson),
|
|
2357
|
+
...(readOption(options.requestId) ? { request_id: readOption(options.requestId) } : {}),
|
|
2358
|
+
},
|
|
2359
|
+
}));
|
|
2360
|
+
}))
|
|
2361
|
+
.addCommand(new Command("update")
|
|
2362
|
+
.description("Update one workspace table row.")
|
|
2363
|
+
.argument("<table>", "Table id or slug.")
|
|
2364
|
+
.argument("<row_id>", "Workspace row UUID.")
|
|
2365
|
+
.requiredOption("--values-json <json>", "JSON object of values keyed by column key.")
|
|
2366
|
+
.option("--json", "Print a JSON envelope.")
|
|
2367
|
+
.action(async (table, rowId, options) => {
|
|
2368
|
+
await handleAsyncAction("tables update", options, () => requestOxygen("/api/cli/tables/rows/update", {
|
|
2369
|
+
method: "POST",
|
|
2370
|
+
body: {
|
|
2371
|
+
table,
|
|
2372
|
+
row_id: rowId,
|
|
2373
|
+
values: parseJsonObject(options.valuesJson),
|
|
2374
|
+
},
|
|
2375
|
+
}));
|
|
2376
|
+
}))
|
|
2377
|
+
.addCommand(new Command("delete-row")
|
|
2378
|
+
.description("Delete one workspace table row. A lead enrolled in a campaign stays enrolled; the response names those campaigns.")
|
|
2379
|
+
.argument("<table>", "Table id or slug.")
|
|
2380
|
+
.argument("<row_id>", "Workspace row UUID.")
|
|
2381
|
+
.option("--metadata-json <json-or-file>", "Durable row deletion provenance.")
|
|
2382
|
+
.option("--json", "Print a JSON envelope.")
|
|
2383
|
+
.action(async (table, rowId, options) => {
|
|
2384
|
+
await handleAsyncAction("tables delete-row", options, async () => {
|
|
2385
|
+
const data = await requestOxygen("/api/cli/tables/rows/delete", {
|
|
2386
|
+
method: "POST",
|
|
2387
|
+
body: {
|
|
2388
|
+
table,
|
|
2389
|
+
row_id: rowId,
|
|
2390
|
+
...(options.metadataJson !== undefined ? { metadata: parseJsonObject(readFileIfPresent(options.metadataJson)) } : {}),
|
|
2391
|
+
},
|
|
2392
|
+
});
|
|
2393
|
+
if (!options.json)
|
|
2394
|
+
writeRowDeleteWarnings(data);
|
|
2395
|
+
return data;
|
|
2396
|
+
});
|
|
2397
|
+
}))
|
|
2398
|
+
.addCommand(new Command("delete-rows")
|
|
2399
|
+
.description("Delete exact workspace row IDs. For a matching filtered selection, use tables rows remove-selection. Campaign enrollments stay enrolled; the response names those campaigns.")
|
|
2400
|
+
.addHelpText("after", "\nFor a matching filtered selection, use tables rows remove-selection --help.")
|
|
2401
|
+
.argument("<table>", "Table id or slug.")
|
|
2402
|
+
.option("--row-ids-json <json>", "JSON array of workspace row UUIDs.")
|
|
2403
|
+
.option("--row-ids-file <path>", "File containing row UUIDs or rows with a _row_id column.")
|
|
2404
|
+
.option("--row-id-column <key>", "Column to read from --row-ids-file. Defaults to _row_id.")
|
|
2405
|
+
.option("--format <format>", "File format for --row-ids-file: json, jsonl, csv, or xlsx.")
|
|
2406
|
+
.option("--sheet <name>", "Worksheet name when reading row IDs from an XLSX file.")
|
|
2407
|
+
.option("--metadata-json <json-or-file>", "Durable row deletion provenance.")
|
|
2408
|
+
.option("--json", "Print a JSON envelope.")
|
|
2409
|
+
.action(async (table, options) => {
|
|
2410
|
+
await handleAsyncAction("tables delete-rows", options, async () => {
|
|
2411
|
+
const rowIds = await readDeleteRowIdsOption(options);
|
|
2412
|
+
const data = await requestOxygen("/api/cli/tables/rows/delete", {
|
|
2413
|
+
method: "POST",
|
|
2414
|
+
body: {
|
|
2415
|
+
table,
|
|
2416
|
+
row_ids: rowIds,
|
|
2417
|
+
...(options.metadataJson !== undefined ? { metadata: parseJsonObject(readFileIfPresent(options.metadataJson)) } : {}),
|
|
2418
|
+
},
|
|
2419
|
+
});
|
|
2420
|
+
if (!options.json)
|
|
2421
|
+
writeRowDeleteWarnings(data);
|
|
2422
|
+
return data;
|
|
2423
|
+
});
|
|
2424
|
+
}))
|
|
2425
|
+
.addCommand(new Command("upsert")
|
|
2426
|
+
.description("Insert or update rows in a workspace table by a column key.")
|
|
2427
|
+
.argument("<table>", "Table id or slug.")
|
|
2428
|
+
.requiredOption("--key <key>", "Column key used to match existing rows.")
|
|
2429
|
+
.requiredOption("--rows-json <json>", "JSON array of row objects keyed by column key.")
|
|
2430
|
+
.option("--return <mode>", "Response shape: summary, ids, or rows. Defaults to summary.")
|
|
2431
|
+
.option("--dry-run", "Preview inserts, updates, duplicate keys, and field conflicts without writing rows.")
|
|
2432
|
+
.option("--json", "Print a JSON envelope.")
|
|
2433
|
+
.action(async (table, options) => {
|
|
2434
|
+
await handleAsyncAction("tables upsert", options, () => requestOxygen("/api/cli/tables/rows/upsert", {
|
|
2435
|
+
method: "POST",
|
|
2436
|
+
body: {
|
|
2437
|
+
table,
|
|
2438
|
+
key: options.key,
|
|
2439
|
+
rows: parseJsonArray(options.rowsJson),
|
|
2440
|
+
...(readOption(options["return"]) ? { return: readOption(options["return"]) } : {}),
|
|
2441
|
+
...(options.dryRun ? { dry_run: true } : {}),
|
|
2442
|
+
},
|
|
2443
|
+
}));
|
|
2444
|
+
}))
|
|
2445
|
+
.addCommand(new Command("import")
|
|
2446
|
+
.description(`Import JSON, JSONL, CSV, or XLSX rows into a workspace table. The table write is free — 0 Oxygen credits. Files over ${LARGE_IMPORT_BACKGROUND_ROW_THRESHOLD} rows load durably in the background: the command returns a queued envelope and rows keep landing after it exits, so wait with the printed 'table-ingestions wait <id>' before reading row counts. ${IMPORT_FILE_LIMIT_HELP}`)
|
|
2447
|
+
.argument("[table]", "Table id or slug. Omit when using --create.")
|
|
2448
|
+
.option("--file <path>", "Input file path. Pass this or --url.")
|
|
2449
|
+
.option("--url <https-link>", "Public link to fetch instead of a local file: a CSV, TSV, JSON, JSONL or XLSX file, or a Google Sheet shared with anyone-with-the-link or published to the web. Fetched server-side, up to 10 MB, 0 credits.")
|
|
2450
|
+
.option("--preview", "With --url: fetch and show the columns, a row sample and the row count without creating or writing anything.")
|
|
2451
|
+
.option("--format <format>", "json, jsonl, csv, or xlsx. Defaults from file extension.")
|
|
2452
|
+
.option("--sheet <name>", "Worksheet name for XLSX imports. Defaults to the first sheet.")
|
|
2453
|
+
.option("--create <name>", "Create a new table with columns inferred from the file before importing.")
|
|
2454
|
+
.option("--project <project>", "Project id or slug for --create. Defaults to General.")
|
|
2455
|
+
.option("--upsert-key <key>", "Column key used to upsert instead of inserting.")
|
|
2456
|
+
.option("--skip-blank-cells", "Leave a row's existing value where the file's cell is blank instead of clearing it, so a partial update needs one file.")
|
|
2457
|
+
.option("--batch-size <n>", `Requested rows per import chunk, not a limit on the file. Defaults to 500; values up to 5000 are accepted, but Oxygen splits writes to at most 500 rows (and smaller for wide rows, about ${formatJsonBodyLimit()} of JSON per request) so they fit request and worker lease budgets.`)
|
|
2458
|
+
.option("--background", "Enqueue durable import chunks for the background worker.")
|
|
2459
|
+
.option("--sync", `Force foreground import even for files above ${LARGE_IMPORT_BACKGROUND_ROW_THRESHOLD} rows. That threshold picks foreground vs background; it does not cap how many rows a file may hold.`)
|
|
2460
|
+
.option("--max-concurrency <n>", "Maximum concurrent import chunks for background mode. Defaults to 5.")
|
|
2461
|
+
.option("--json", "Print a JSON envelope.")
|
|
2462
|
+
.action(async (table, options) => {
|
|
2463
|
+
await handleAsyncAction("tables import", options, () => importRows(table, options));
|
|
2464
|
+
}))
|
|
2465
|
+
.addCommand(new Command("export")
|
|
2466
|
+
.description("Export workspace table rows as JSON, JSONL, CSV, or a human-readable table. Rows only, capped at 1000; to copy a whole table including its columns and every row, use `tables export-bundle`.")
|
|
2467
|
+
.argument("<table>", "Table id or slug.")
|
|
2468
|
+
.option("--format <format>", "json, jsonl, csv, or table. Defaults to json. Use table for a typed, human-readable rendering with thousands grouping.")
|
|
2469
|
+
.option("--output <path>", "Write export content to a file.")
|
|
2470
|
+
.option("--limit <n>", "Maximum rows to export. Defaults to 100; hard cap is 1000.")
|
|
2471
|
+
.option("--json", "Print a JSON envelope.")
|
|
2472
|
+
.action(async (table, options) => {
|
|
2473
|
+
if (!options.json && !options.output && options.format?.trim().toLowerCase() === "table") {
|
|
2474
|
+
// The human-readable table itself, not a JSON object holding it as one
|
|
2475
|
+
// escaped string (blind user evals, 2026-09-30). --json keeps the envelope.
|
|
2476
|
+
try {
|
|
2477
|
+
const { content } = await exportRows(table, options);
|
|
2478
|
+
const text = typeof content === "string" ? content : "";
|
|
2479
|
+
process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
2480
|
+
}
|
|
2481
|
+
catch (error) {
|
|
2482
|
+
emitCliFailure("tables export", error);
|
|
2483
|
+
}
|
|
2484
|
+
return;
|
|
2485
|
+
}
|
|
2486
|
+
await handleAsyncAction("tables export", options, () => exportRows(table, options));
|
|
2487
|
+
}))
|
|
2488
|
+
.addCommand(new Command("export-bundle")
|
|
2489
|
+
.description("Export a workspace table as a portable bundle (schema + every row) for cross-environment / cross-org copies. Effect: reads the table and writes only a local file — 0 credits, nothing changes in the workspace. With --output the bundle streams to the file one row per line, so a table of any size exports; without it the whole bundle prints as one JSON document, which only fits a small table.")
|
|
2490
|
+
.argument("<table>", "Table id or slug to export.")
|
|
2491
|
+
.option("--output <path>", "Write the bundle to this file as line-delimited JSON: a header line with the table and columns, then one {\"row\":{…}} line per row carrying every column value plus the source _row_id, _created_at and _updated_at (import-bundle drops those three), then a {\"totals\":{…}} trailer. Streams, so memory stays flat at any row count. Defaults to stdout as a single JSON document.")
|
|
2492
|
+
.option("--page-size <n>", "Rows per cursor-paginated request. Defaults to 500; hard cap is 1000.")
|
|
2493
|
+
.option("--json", "Print a JSON envelope (omit row payload — use --output to keep the rows).")
|
|
2494
|
+
.action(async (table, options) => {
|
|
2495
|
+
await handleAsyncAction("tables export-bundle", options, () => exportTableBundle(table, options));
|
|
2496
|
+
}))
|
|
2497
|
+
.addCommand(new Command("import-bundle")
|
|
2498
|
+
.description("Recreate a workspace table from an export-bundle file in this org. Effect: creates one new table in this workspace and inserts every bundled row — 0 credits, no provider calls, nothing runs. Reads both bundle formats and streams a line-delimited bundle row by row, so a multi-GB file imports without loading into memory. Restores columns (incl. enrichment/tool definitions). Pass --key to make the import idempotent (re-runnable), and --into to resume a failed import into the table it already created.")
|
|
2499
|
+
.requiredOption("--file <path>", "Bundle JSON file produced by `tables export-bundle`.")
|
|
2500
|
+
.option("--name <name>", "Override the table display name. Defaults to the bundle's table name. Ignored with --into.")
|
|
2501
|
+
.option("--project <project>", "Project id or slug for the new table. Defaults to General. Ignored with --into.")
|
|
2502
|
+
.option("--into <table>", "Resume into an existing table (id or slug) instead of creating a new one. Requires --key. Use the table id printed by the failed run.")
|
|
2503
|
+
.option("--key <column>", "Upsert key column. When set, rows are matched by this key instead of blindly inserted, so re-running the import is idempotent (no duplicates). Required with --into.")
|
|
2504
|
+
.option("--batch-size <n>", "Requested rows per write group. Defaults to 500; values up to 5000 are accepted and split into safe writes of at most 500 rows.")
|
|
2505
|
+
.option("--json", "Print a JSON envelope.")
|
|
2506
|
+
.action(async (options) => {
|
|
2507
|
+
await handleAsyncAction("tables import-bundle", options, () => importTableBundle(options, binaryName));
|
|
2508
|
+
}))
|
|
2509
|
+
.addCommand(addContractFlags(new Command("list").description("List workspace tables in the current tenant database."), TABLE_LIST_QUERY_CONTRACT, {
|
|
2510
|
+
project: { help: "Project id or slug to filter by." },
|
|
2511
|
+
tag: { help: "Only tables carrying this workspace tag (see `oxygen tags list`)." },
|
|
2512
|
+
include_archived: {
|
|
2513
|
+
help: "Also list archived, delete-scheduled and purging tables (the trash; each carries a lifecycle with purge_scheduled_at, and one with purgeStartedAt is being purged and can no longer be restored).",
|
|
2514
|
+
},
|
|
2515
|
+
limit: { help: "Maximum tables to list. Defaults to 200; hard cap is 1000.", valueName: "<n>" },
|
|
2516
|
+
})
|
|
2517
|
+
.option("--all", "List every table instead of the newest window.")
|
|
2518
|
+
.option("--json", "Print a JSON envelope.")
|
|
2519
|
+
.addHelpText("after", "\nLists the 200 newest tables by default. Follow nextCursor with --cursor and the same filters, or pass --all. --query searches names across every table; --projection summary keeps results compact. rowCount is null when not reconciled; measure unknown counts with `tables describe <table> --stats`.\n")
|
|
2520
|
+
.action(async (options) => {
|
|
2521
|
+
await handleAsyncAction("tables list", options, async () => {
|
|
2522
|
+
const params = tableListQueryParams(readContractFlags(options, TABLE_LIST_QUERY_CONTRACT));
|
|
2523
|
+
if (options.all)
|
|
2524
|
+
params.set("all", "true");
|
|
2525
|
+
const qs = params.toString() ? `?${params.toString()}` : "";
|
|
2526
|
+
const data = await requestOxygen(`/api/cli/tables${qs}`);
|
|
2527
|
+
// A capped list is where a blind agent filtered 200 rows locally to find
|
|
2528
|
+
// one table by name (acceptance run, 2026-09-18); name the native lookup.
|
|
2529
|
+
if (!options.json)
|
|
2530
|
+
writeListCapNotice(data, "table(s)", "continue with --cursor <nextCursor>, or find a name with --query <text>");
|
|
2531
|
+
return data;
|
|
2532
|
+
});
|
|
2533
|
+
}))
|
|
2534
|
+
.addCommand(addContractFlags(new Command("query"), TABLE_QUERY_GRID_CONTRACT, {
|
|
2535
|
+
format: { valueName: "<format>", help: "Grid cell previews/state: default 50, cap 200, optional stats/fill/window. Omit for raw filters, fields, search, sorts or offsets. JSON cells are null; their digests live in __oxygen_grid_cell_previews. Use tables rows inspect --projection value for complete values." },
|
|
2536
|
+
max_chars: { help: "Maximum compact JSON envelope characters for raw or grid reads (1,000-100,000). Raw defaults to 12,000." },
|
|
2537
|
+
})
|
|
2538
|
+
.description("Read raw Table rows or --format grid cell previews/state with grid.nextCursor, in a JSON envelope. Use tables preview --summary-only for fill rates; tables export <table> --format table renders raw rows. Grid supports stats/fill/window options and refuses raw selectors.")
|
|
2539
|
+
.argument("<table>", "Table id or slug.")
|
|
2540
|
+
.option("--limit <n>", "Raw: default 20, native cap 2000; --full restores default 100. Grid: default 50, cap 200.")
|
|
2541
|
+
.option("--cursor <cursor>", "Pagination cursor returned by a previous query. Walks forward one page at a time; mutually exclusive with --offset.")
|
|
2542
|
+
.option("--offset <n>", "Skip this many rows and start there — jump straight to a row without paging to it. Mutually exclusive with --cursor.")
|
|
2543
|
+
.option("--fields <columns>", "Comma-separated column keys or ids to include.")
|
|
2544
|
+
.option("--filter-json <json>", "Legacy filter object or array, e.g. '{\"column\":\"mobile_phone_e164\",\"op\":\"is_null\"}'. Mutually exclusive with --filter-tree-json/--sort-json.")
|
|
2545
|
+
.option("--filter-tree-json <json>", `Airtable-style filter group, e.g. '{\"type\":\"group\",\"conjunction\":\"and\",\"children\":[{\"type\":\"leaf\",\"columnKey\":\"stage\",\"operator\":\"is\",\"value\":\"won\"}]}'. Add \"path\":\"a.b\" to a leaf to match a key inside a JSON cell, e.g. the rows one Function run touched: {\"columnKey\":\"<function_column>\",\"path\":\"action_run_id\",\"operator\":\"is\",\"value\":\"<run_id>\"}. Leaf operators: ${FILTER_TREE_OPERATORS.join(", ")} (is_true/is_false need a checkbox column; --filter-json's is_null/is_not_null are is_empty/is_not_empty here). Mutually exclusive with --filter-json.`)
|
|
2546
|
+
.option("--search <text>", "Find rows by one piece of text, matched against every text, number, date and checkbox column at once — the grid's magnifier. Combines with --filter-tree-json (both must hold). Use --filter-tree-json when you know which column to match.")
|
|
2547
|
+
.option("--formula-values <mode>", "Formula filters are refused by default because displayed formulas are evaluated live. Refresh the formula's stored cells with `columns run <table> <column> --force` (0 credits), then pass 'materialized'; filtering uses that stored snapshot and returns a freshness note. A fill-empty field a bundle adds (definition fill: \"empty\", such as Headcount or Job title) is stored, not live: its cell is the value, so 'materialized' filters it exactly.")
|
|
2548
|
+
.option("--sort-json <json>", "Ordered sort rules, e.g. '[{\"columnKey\":\"_created_at\",\"direction\":\"desc\"}]'. Earlier rules dominate. Mutually exclusive with --filter-json.")
|
|
2549
|
+
.option("--no-system-fields", "Omit _row_id, _created_at, and _updated_at from returned rows (included by default).")
|
|
2550
|
+
.option("--full", "Read legacy full rows without the default character limit.")
|
|
2551
|
+
.option("--output <path>", "Write full JSON for this page to a new local file; print a small receipt.")
|
|
2552
|
+
.option("--json", "Print a JSON envelope.")
|
|
2553
|
+
.action(async (table, options) => {
|
|
2554
|
+
await handleAsyncAction("tables query", options, async () => {
|
|
2555
|
+
const gridArgs = readContractFlags(options, TABLE_QUERY_GRID_CONTRACT);
|
|
2556
|
+
if (gridArgs.format !== "grid" && Object.keys(gridArgs).some((key) => key !== "max_chars")) {
|
|
2557
|
+
throw new OxygenError("invalid_request", "Grid options require --format grid.", { exitCode: 1 });
|
|
2558
|
+
}
|
|
2559
|
+
const full = options.full || Boolean(options.output);
|
|
2560
|
+
if (full && options.format === "grid")
|
|
2561
|
+
throw new OxygenError("invalid_request", "--full/--output require raw rows; omit --format grid.", { exitCode: 2 });
|
|
2562
|
+
const limit = readPositiveInt(options.limit) ?? (options.format !== "grid" && !full ? TABLE_QUERY_CLI_DEFAULT_LIMIT : undefined);
|
|
2563
|
+
if (options.format !== "grid" && !full && gridArgs.max_chars === undefined)
|
|
2564
|
+
gridArgs.max_chars = TABLE_QUERY_CLI_DEFAULT_MAX_CHARS;
|
|
2565
|
+
const filters = readFilterJsonOption(options.filterJson);
|
|
2566
|
+
const filterTree = readJsonObjectOption(options.filterTreeJson);
|
|
2567
|
+
const formulaValues = readFormulaValuesOption(options.formulaValues);
|
|
2568
|
+
const sorts = readSortJsonOption(options.sortJson);
|
|
2569
|
+
const offset = readNonNegativeInt(options.offset);
|
|
2570
|
+
if (filters && (filterTree || sorts)) {
|
|
2571
|
+
throw new OxygenError("invalid_filter", "Pass either --filter-json (legacy) or --filter-tree-json/--sort-json, not both.", { exitCode: 1 });
|
|
2572
|
+
}
|
|
2573
|
+
if (formulaValues && !filters && !filterTree) {
|
|
2574
|
+
throw new OxygenError("invalid_filter", "--formula-values requires --filter-json or --filter-tree-json.", { exitCode: 1 });
|
|
2575
|
+
}
|
|
2576
|
+
if (readOption(options.cursor) && offset !== undefined) {
|
|
2577
|
+
throw new OxygenError("invalid_request", "Pass either --cursor or --offset, not both.", { exitCode: 1 });
|
|
2578
|
+
}
|
|
2579
|
+
if (options.format === "grid" && (readCsvOption(options.fields).length > 0 || filters?.length || filterTree ||
|
|
2580
|
+
readOption(options.search) || formulaValues || sorts?.length || (offset !== undefined && offset > 0))) {
|
|
2581
|
+
throw new OxygenError("invalid_request", '--format grid takes only table, --limit, --cursor and optional --include-stats/--include-fill; omit --format for filters, fields, search, sorts or offsets.', { exitCode: 1 });
|
|
2582
|
+
}
|
|
2583
|
+
const data = await requestOxygen("/api/cli/tables/query", {
|
|
2584
|
+
method: "POST",
|
|
2585
|
+
body: {
|
|
2586
|
+
table,
|
|
2587
|
+
...gridArgs,
|
|
2588
|
+
...(limit ? { limit } : {}),
|
|
2589
|
+
...(readOption(options.cursor) ? { cursor: readOption(options.cursor) } : {}),
|
|
2590
|
+
...(offset !== undefined ? { offset } : {}),
|
|
2591
|
+
...(readOption(options.fields) ? { fields: readCsvOption(options.fields) } : {}),
|
|
2592
|
+
...(filters ? { filters } : {}),
|
|
2593
|
+
...(filterTree ? { filterTree } : {}),
|
|
2594
|
+
...(readOption(options.search) ? { search: readOption(options.search) } : {}),
|
|
2595
|
+
...(formulaValues ? { formula_values: formulaValues } : {}),
|
|
2596
|
+
...(sorts ? { sorts } : {}),
|
|
2597
|
+
...(options.systemFields === false ? { include_system_fields: false } : {}),
|
|
2598
|
+
},
|
|
2599
|
+
});
|
|
2600
|
+
// Enforce the window even against an older compatible serving API.
|
|
2601
|
+
if (options.format !== "grid" && !tableQueryRawResultFits(data, typeof gridArgs.max_chars === "number" ? gridArgs.max_chars : undefined)) {
|
|
2602
|
+
const firstRow = Array.isArray(data.rows) ? data.rows[0] : undefined;
|
|
2603
|
+
throw new OxygenError("table_query_result_too_large", "Table rows exceed the requested result window.", {
|
|
2604
|
+
details: { max_chars: gridArgs.max_chars, ...tableQueryRawRecovery(table, typeof firstRow?._row_id === "string" ? firstRow._row_id : undefined) }, exitCode: 2,
|
|
2605
|
+
});
|
|
2606
|
+
}
|
|
2607
|
+
if (!options.output)
|
|
2608
|
+
return data;
|
|
2609
|
+
const output = await writeExclusiveJson(options.output, success("tables query", data), process.argv.includes("--pretty"));
|
|
2610
|
+
return { output, returned_count: data.returned_count ?? data.returnedCount, total_count: data.total_count ?? data.totalCount, hasMore: data.hasMore, nextCursor: data.nextCursor, web_url: data.web_url };
|
|
2611
|
+
});
|
|
2612
|
+
}))
|
|
2613
|
+
.addCommand(new Command("preview")
|
|
2614
|
+
.description("Preview a workspace table with projected rows and summary stats.")
|
|
2615
|
+
.argument("<table>", "Table id or slug.")
|
|
2616
|
+
.option("--limit <n>", "Maximum preview rows to return. Defaults to 25; hard cap is 1000.")
|
|
2617
|
+
.option("--fields <columns>", "Comma-separated column keys or ids to include.")
|
|
2618
|
+
.option("--include-system-fields", "Include _row_id, _created_at, and _updated_at in preview rows.")
|
|
2619
|
+
.option("--include-cell-states", "Include per-cell run state in the preview response.")
|
|
2620
|
+
.option("--summary-only", "Return per-column fill stats plus table metadata, without row JSON. Stats cover the whole table independent of --limit: exact up to 20,000 rows, then a 2,000-row sample labelled summary.statsSampled; rowCount stays exact. Higher fidelity than `tables describe --stats`, which samples at 100. fillRate counts non-null cells, nonEmptyRate also excludes blank strings. 0 credits.")
|
|
2621
|
+
.option("--json", "Print a JSON envelope.")
|
|
2622
|
+
.action(async (table, options) => {
|
|
2623
|
+
await handleAsyncAction("tables preview", options, () => {
|
|
2624
|
+
const limit = readPositiveInt(options.limit);
|
|
2625
|
+
return requestOxygen("/api/cli/tables/preview", {
|
|
2626
|
+
method: "POST",
|
|
2627
|
+
body: {
|
|
2628
|
+
table,
|
|
2629
|
+
...(limit ? { limit } : {}),
|
|
2630
|
+
...(readOption(options.fields) ? { fields: readCsvOption(options.fields) } : {}),
|
|
2631
|
+
...(options.includeSystemFields ? { include_system_fields: true } : {}),
|
|
2632
|
+
...(options.includeCellStates ? { include_cell_states: true } : {}),
|
|
2633
|
+
...(options.summaryOnly ? { summary_only: true } : {}),
|
|
2634
|
+
},
|
|
2635
|
+
});
|
|
2636
|
+
});
|
|
2637
|
+
}))
|
|
2638
|
+
.addCommand(new Command("describe")
|
|
2639
|
+
.alias("get")
|
|
2640
|
+
.description("Describe a workspace table and its columns. Add --stats for row count and per-column fill rates (0 credits). Next: `tools search --for-table <table>` lists what these columns can be enriched with and what each costs per row (0 credits).")
|
|
2641
|
+
.argument("<table>", "Table id or slug.")
|
|
2642
|
+
.option("--include-archived", "Include archived columns, and read a table that is archived, scheduled for deletion or being purged.")
|
|
2643
|
+
.option("--stats", "Include stats.rowCount (also summary.rowCount) and per-column fill rates. Row count is exact; above 100 rows fill rates use a 100-row sample (stats.statsSampled). For whole-table fill rates on a bigger list use `tables preview <table> --summary-only` \u2014 exact up to 20,000 rows, 2,000-row sample above. 0 credits.")
|
|
2644
|
+
.option("--column <key>", "Describe just this one column (its kind, definition and whether auto-run includes it) instead of every column.")
|
|
2645
|
+
.option("--json", "Print a JSON envelope.")
|
|
2646
|
+
.action(async (table, options) => {
|
|
2647
|
+
await handleAsyncAction("tables describe", options, () => requestOxygen("/api/cli/tables/describe", {
|
|
2648
|
+
method: "POST",
|
|
2649
|
+
body: {
|
|
2650
|
+
table,
|
|
2651
|
+
...(options.includeArchived ? { include_archived: true } : {}),
|
|
2652
|
+
...(options.stats ? { include_stats: true } : {}),
|
|
2653
|
+
...(readOption(options.column) ? { column: readOption(options.column) } : {}),
|
|
2654
|
+
},
|
|
2655
|
+
}));
|
|
2656
|
+
}))
|
|
2657
|
+
.addCommand(new Command("tidy-suggest")
|
|
2658
|
+
.description("Read-only tidy analysis for a workspace table: duplicate columns, formatting issues, hide candidates, and value-sanity findings.")
|
|
2659
|
+
.argument("<table>", "Table id or slug.")
|
|
2660
|
+
.option("--limit <n>", "Maximum rows to sample for the analysis. Defaults to and is capped at 5000.")
|
|
2661
|
+
.option("--categories <csv>", "Comma-separated subset of duplicates,formatting,hide,valueSanity. Defaults to all four.")
|
|
2662
|
+
.option("--json", "Print a JSON envelope.")
|
|
2663
|
+
.action(async (table, options) => {
|
|
2664
|
+
await handleTablesTidySuggestAction(table, options);
|
|
2665
|
+
}))
|
|
2666
|
+
.addCommand(new Command("activity")
|
|
2667
|
+
.description("Show background runs active on a workspace table right now, with a worker-queue health rollup.")
|
|
2668
|
+
.argument("<table>", "Table id or slug.")
|
|
2669
|
+
.option("--limit <n>", "Maximum active runs to return (1-100). Defaults to 20.")
|
|
2670
|
+
.option("--json", "Print a JSON envelope.")
|
|
2671
|
+
.action(async (table, options) => {
|
|
2672
|
+
await handleAsyncAction("tables activity", options, () => {
|
|
2673
|
+
const params = new URLSearchParams({ table });
|
|
2674
|
+
const limit = readPositiveInt(options.limit);
|
|
2675
|
+
if (limit)
|
|
2676
|
+
params.set("limit", String(limit));
|
|
2677
|
+
return requestOxygen(`/api/cli/tables/activity?${params.toString()}`);
|
|
2678
|
+
});
|
|
2679
|
+
}))
|
|
2680
|
+
.addCommand(new Command("rename")
|
|
2681
|
+
.description("Rename a workspace table display name and slug.")
|
|
2682
|
+
.argument("<table>", "Table id or slug.")
|
|
2683
|
+
.requiredOption("--name <name>", "New table display name.")
|
|
2684
|
+
.option("--json", "Print a JSON envelope.")
|
|
2685
|
+
.action(async (table, options) => {
|
|
2686
|
+
await handleAsyncAction("tables rename", options, () => requestOxygen("/api/cli/tables/rename", {
|
|
2687
|
+
method: "POST",
|
|
2688
|
+
body: { table, name: options.name },
|
|
2689
|
+
}));
|
|
2690
|
+
}))
|
|
2691
|
+
.addCommand(new Command("tag")
|
|
2692
|
+
.description("Replace a table's workspace tags (Tags primitive; \"\" clears). Tags link the table to sequences, wiki pages, and workflows carrying the same tag — browse with `oxygen tags get <tag>`.")
|
|
2693
|
+
.argument("<table>", "Table id or slug.")
|
|
2694
|
+
.requiredOption("--tags <csv>", "Comma-separated tags; \"\" clears them.")
|
|
2695
|
+
.option("--json", "Print a JSON envelope.")
|
|
2696
|
+
.action(async (table, options) => {
|
|
2697
|
+
await handleAsyncAction("tables tag", options, () => requestOxygen("/api/cli/tables/tags", {
|
|
2698
|
+
method: "POST",
|
|
2699
|
+
body: { table, tags: readCsvOption(options.tags) },
|
|
2700
|
+
}));
|
|
2701
|
+
}))
|
|
2702
|
+
.addCommand(new Command("archive")
|
|
2703
|
+
.description("Archive a workspace table without dropping physical data. Hides it indefinitely with no purge timer; for cleanup with a restorable deadline use `tables delete`.")
|
|
2704
|
+
.argument("<table>", "Table id or slug.")
|
|
2705
|
+
.option("--json", "Print a JSON envelope.")
|
|
2706
|
+
.action(async (table, options) => {
|
|
2707
|
+
await handleAsyncAction("tables archive", options, () => requestOxygen(`/api/cli/tables/${encodeURIComponent(table)}`, {
|
|
2708
|
+
method: "DELETE",
|
|
2709
|
+
}));
|
|
2710
|
+
}))
|
|
2711
|
+
.addCommand(new Command("delete")
|
|
2712
|
+
.description("Delete workspace tables: archives now and schedules the physical purge (default 14 days; restorable via `tables restore` until then). --now purges immediately and permanently — it previews and refuses without --yes. A table with a large history answers `purging` and finishes in the background. Free — 0 Oxygen credits.")
|
|
2713
|
+
.argument("<tables...>", "Table ids or slugs.")
|
|
2714
|
+
.option("--now", "Purge immediately instead of scheduling. Irreversible; single table only. A large table finishes in the background.")
|
|
2715
|
+
.option("--yes", "Approve the immediate purge previewed by --now.")
|
|
2716
|
+
.option("--purge-after <days>", "Days until the scheduled purge runs. Defaults to 14; 0 purges on the next background sweep (sweeps visit each workspace every few minutes).")
|
|
2717
|
+
.option("--reason <text>", "Recorded with the delete for the audit trail.")
|
|
2718
|
+
.option("--json", "Print a JSON envelope.")
|
|
2719
|
+
.action(async (tables, options) => {
|
|
2720
|
+
await handleAsyncAction("tables delete", options, () => {
|
|
2721
|
+
const purgeAfter = readOption(options.purgeAfter) ?? undefined;
|
|
2722
|
+
const purgeAfterDays = purgeAfter === undefined ? undefined : Number.parseInt(purgeAfter, 10);
|
|
2723
|
+
if (purgeAfterDays !== undefined && (!Number.isFinite(purgeAfterDays) || purgeAfterDays < 0)) {
|
|
2724
|
+
throw new OxygenError("invalid_request", "--purge-after must be a non-negative number of days.", {
|
|
2725
|
+
exitCode: 2,
|
|
2726
|
+
});
|
|
2727
|
+
}
|
|
2728
|
+
return requestOxygen("/api/cli/tables/delete", {
|
|
2729
|
+
method: "POST",
|
|
2730
|
+
body: {
|
|
2731
|
+
...(tables.length === 1 ? { table: tables[0] } : { tables }),
|
|
2732
|
+
...(options.now ? { now: true } : {}),
|
|
2733
|
+
...(options.yes ? { approved: true } : {}),
|
|
2734
|
+
...(purgeAfterDays !== undefined ? { purge_after_days: purgeAfterDays } : {}),
|
|
2735
|
+
...(readOption(options.reason) ? { reason: readOption(options.reason) } : {}),
|
|
2736
|
+
},
|
|
2737
|
+
});
|
|
2738
|
+
});
|
|
2739
|
+
}))
|
|
2740
|
+
.addCommand(new Command("restore")
|
|
2741
|
+
.description("Restore an archived or delete-scheduled table: cancels the purge timer and reclaims its slug. A table whose purge has begun (lifecycle.purgeStartedAt) can no longer be restored.")
|
|
2742
|
+
.argument("[table]", "Table id or slug (the pre-delete slug works).")
|
|
2743
|
+
.option("--tables <refs>", "Restore a comma-separated batch; alternative to the table argument.")
|
|
2744
|
+
.option("--json", "Print a JSON envelope.")
|
|
2745
|
+
.action(async (table, options) => {
|
|
2746
|
+
await handleAsyncAction("tables restore", options, () => {
|
|
2747
|
+
const refs = splitCommaList(options.tables);
|
|
2748
|
+
if ((!table && refs.length === 0) || (table && refs.length > 0))
|
|
2749
|
+
throw new OxygenError("invalid_selection", "Pass one table argument or --tables, not both.", { exitCode: 2 });
|
|
2750
|
+
return requestOxygen("/api/cli/tables/restore", {
|
|
2751
|
+
method: "POST",
|
|
2752
|
+
body: { ...(options.tables !== undefined ? { tables: splitCommaList(options.tables) } : { table }) },
|
|
2753
|
+
});
|
|
2754
|
+
});
|
|
2755
|
+
}))
|
|
2756
|
+
.addCommand(new Command("move")
|
|
2757
|
+
.description("Move one or more workspace tables into a project (folder) in one call. Rows, columns, runs, and provenance are preserved. Free; reverse it by moving the table back.")
|
|
2758
|
+
.argument("<tables...>", "Table ids or slugs; several move together, each reported separately.")
|
|
2759
|
+
.requiredOption("--project <project>", "Destination project id or slug (see `oxygen projects list`).")
|
|
2760
|
+
.option("--json", "Print a JSON envelope.")
|
|
2761
|
+
.action(async (tables, options) => {
|
|
2762
|
+
await handleAsyncAction("tables move", options, () => requestOxygen("/api/cli/tables/move", {
|
|
2763
|
+
method: "POST",
|
|
2764
|
+
body: {
|
|
2765
|
+
...(tables.length === 1 ? { table: tables[0] } : { tables }),
|
|
2766
|
+
project: options.project,
|
|
2767
|
+
},
|
|
2768
|
+
}));
|
|
2769
|
+
}))
|
|
2770
|
+
.addCommand(new Command("recover-pending")
|
|
2771
|
+
.description("Recover existing BetterContact email or phone requests and inspect terminal verification blockers; no new orders.")
|
|
2772
|
+
.argument("<table>", "Table id or slug.")
|
|
2773
|
+
.option("--max-items <n>", "Max pending cache rows to poll in this run. Defaults to 1000.")
|
|
2774
|
+
.option("--json", "Print a JSON envelope.")
|
|
2775
|
+
.action(async (table, options) => {
|
|
2776
|
+
await handleAsyncAction("tables recover-pending", options, () => {
|
|
2777
|
+
const maxItems = options.maxItems ? Number.parseInt(options.maxItems, 10) : undefined;
|
|
2778
|
+
return requestOxygen("/api/cli/tables/recover-pending", {
|
|
2779
|
+
method: "POST",
|
|
2780
|
+
body: {
|
|
2781
|
+
table,
|
|
2782
|
+
...(Number.isFinite(maxItems) ? { max_items: maxItems } : {}),
|
|
2783
|
+
},
|
|
2784
|
+
});
|
|
2785
|
+
});
|
|
2786
|
+
}));
|
|
2787
|
+
const watcherCommand = tablesCommand.command("watcher")
|
|
2788
|
+
.description("Professional Network Post Engagers: one editable table of daily engagers, source attribution, and current employers from posts in the past 7 days. Start with preview (free); create/resume activate paid daily monitoring after exact approval.");
|
|
2789
|
+
watcherCommand.command("get")
|
|
2790
|
+
.description("Read a table's Professional Network Post Engagers configuration and status. Free.")
|
|
2791
|
+
.argument("<table>", "Watcher table id or slug.")
|
|
2792
|
+
.option("--json", "Print a JSON envelope.")
|
|
2793
|
+
.action(async (table, options) => {
|
|
2794
|
+
await handleAsyncAction("tables watcher get", options, () => requestOxygen(`/api/cli/tables/linkedin-profile-watcher?table=${encodeURIComponent(table)}`, { method: "GET" }));
|
|
2795
|
+
});
|
|
2796
|
+
for (const action of ["preview", "create", "update", "pause", "resume"]) {
|
|
2797
|
+
const command = watcherCommand.command(action)
|
|
2798
|
+
.description({
|
|
2799
|
+
preview: "Free configuration and credit preview: no provider calls, writes, or scheduling. Omit --max-credits for a recommendation and preview hash. Ask for real profile URLs; never invent them.",
|
|
2800
|
+
create: "Create and activate a Professional Network Post Engagers table after approval of its exact preview. Starts collection now and daily at 07:00 UTC under the approved per-cycle cap.",
|
|
2801
|
+
update: "Edit watched profiles or the per-cycle cap from the table. Preview the changes first; approval binds the exact new configuration.",
|
|
2802
|
+
pause: "Pause daily monitoring while retaining the table and collected rows.",
|
|
2803
|
+
resume: "Resume paid daily monitoring after reviewing a fresh preview and approving its per-cycle credit cap.",
|
|
2804
|
+
}[action])
|
|
2805
|
+
.option("--table <table>", "Existing watcher table id or slug; required for get/update/pause/resume.")
|
|
2806
|
+
.option("--json", "Print a JSON envelope.");
|
|
2807
|
+
if (action !== "pause") {
|
|
2808
|
+
command
|
|
2809
|
+
.option("--name <name>", "Watcher table display name.")
|
|
2810
|
+
.option("--project <project>", "Project id or slug; defaults to General.")
|
|
2811
|
+
.option("--profiles-json <json>", "JSON array of 1–10 real public LinkedIn profile URLs; replaces the watched list.")
|
|
2812
|
+
.option("--max-credits <credits>", "Hard credit ceiling for each daily cycle; never a monthly ceiling.")
|
|
2813
|
+
.option("--credential-mode <mode>", "managed (default): Oxygen credits per cycle. user_api_key: every LinkedIn call runs on your own Up2Data key saved in Connections, billed by Up2Data, no Oxygen credits.");
|
|
2814
|
+
if (action !== "preview")
|
|
2815
|
+
command
|
|
2816
|
+
.option("--preview-hash <hash>", "Exact configuration hash returned by preview; re-preview after any change.")
|
|
2817
|
+
.option("--approved", "Approve this exact configuration and recurring per-cycle spending.");
|
|
2818
|
+
}
|
|
2819
|
+
if (action === "create") {
|
|
2820
|
+
command.requiredOption("--request-id <uuid>", "One stable UUID for this new watcher. Reuse it and the identical configuration after a timeout; never generate a new retry key.");
|
|
2821
|
+
}
|
|
2822
|
+
command.action(async (options) => {
|
|
2823
|
+
await handleAsyncAction(`tables watcher ${action}`, options, () => {
|
|
2824
|
+
const profiles = options.profilesJson === undefined ? undefined : parseJsonArray(options.profilesJson);
|
|
2825
|
+
if (profiles !== undefined && profiles.some((profile) => typeof profile !== "string")) {
|
|
2826
|
+
throw new OxygenError("invalid_input", "--profiles-json must be an array of LinkedIn profile URL strings.");
|
|
2827
|
+
}
|
|
2828
|
+
return requestOxygen("/api/cli/tables/linkedin-profile-watcher", {
|
|
2829
|
+
method: "POST",
|
|
2830
|
+
body: {
|
|
2831
|
+
action,
|
|
2832
|
+
...(readOption(options.table) ? { table: readOption(options.table) } : {}),
|
|
2833
|
+
...(readOption(options.name) ? { name: readOption(options.name) } : {}),
|
|
2834
|
+
...(readOption(options.project) ? { project: readOption(options.project) } : {}),
|
|
2835
|
+
...(profiles !== undefined ? { profiles } : {}),
|
|
2836
|
+
...(options.maxCredits !== undefined ? { max_credits_per_cycle: readPositiveNumber(options.maxCredits) } : {}),
|
|
2837
|
+
...(readOption(options.requestId) ? { request_id: readOption(options.requestId) } : {}),
|
|
2838
|
+
...(readOption(options.previewHash) ? { preview_hash: readOption(options.previewHash) } : {}),
|
|
2839
|
+
...(options.approved ? { approved: true } : {}),
|
|
2840
|
+
...(readOption(options.credentialMode) ? { credential_mode: readOption(options.credentialMode) } : {}),
|
|
2841
|
+
},
|
|
2842
|
+
});
|
|
2843
|
+
});
|
|
2844
|
+
});
|
|
2845
|
+
}
|
|
2846
|
+
const followersCommand = tablesCommand.command("followers")
|
|
2847
|
+
.description("Order a LinkedIn company page's followers as a table of leads — a competitor's audience. `check` is free and quotes the order; fee-positive own-key previews leave the follower count unknown. `order` places it. The order is fulfilled by ScrapeLi, so the table is created immediately and its rows land later; there is no published delivery time.");
|
|
2848
|
+
followersCommand.command("check")
|
|
2849
|
+
.description("Free order preview. Managed quotes the follower cost; your own key quotes placement and later check fees, with a total ceiling you choose. No spend or table; `orderable: false` means the order is unavailable.")
|
|
2850
|
+
.requiredOption("--company <url>", "LinkedIn company page URL, e.g. https://www.linkedin.com/company/hubspot.")
|
|
2851
|
+
.option("--records <n>", "How many followers to price. Minimum 100; defaults to 1000.")
|
|
2852
|
+
.option("--package <package>", "Basic (default): profile, job title, location, employer. Advanced (10x the credits): also a verified work email and region.")
|
|
2853
|
+
.option("--credential-mode <mode>", "managed (default): Oxygen credits. user_api_key: your saved ScrapeLi key, billed by ScrapeLi, plus Oxygen placement/recovery platform fees.")
|
|
2854
|
+
.option("--json", "Print a JSON envelope.")
|
|
2855
|
+
.action(async (options) => {
|
|
2856
|
+
await handleAsyncAction("tables followers check", options, () => requestOxygen("/api/cli/tables/linkedin-company-followers", {
|
|
2857
|
+
method: "POST",
|
|
2858
|
+
body: {
|
|
2859
|
+
action: "preview",
|
|
2860
|
+
company_url: readOption(options.company),
|
|
2861
|
+
...(options.records !== undefined ? { records: readPositiveNumber(options.records) } : {}),
|
|
2862
|
+
...(readOption(options.package) ? { package: readOption(options.package) } : {}),
|
|
2863
|
+
...(readOption(options.credentialMode) ? { credential_mode: readOption(options.credentialMode) } : {}),
|
|
2864
|
+
},
|
|
2865
|
+
}));
|
|
2866
|
+
});
|
|
2867
|
+
followersCommand.command("order")
|
|
2868
|
+
.description("Place the follower order after checking it. Requires --approved and --max-credits: managed uses the quoted order cost; own-key uses a TOTAL placement/recovery platform-fee ceiling. A ceiling equal to placement cannot recover rows. Budget exhaustion lands no partial rows; refetched pages consume the same ceiling. Creates the Table now; rows arrive later.")
|
|
2869
|
+
.requiredOption("--approved", "Approve this purchase. Required, as on every command that buys data.")
|
|
2870
|
+
.requiredOption("--company <url>", "LinkedIn company page URL whose followers to order.")
|
|
2871
|
+
.option("--max-credits <credits>", "Approved order ceiling. On your key, TOTAL platform fees for placement and later checks; exhaustion stops recovery. At least the quoted placement fee. ScrapeLi charges separately.")
|
|
2872
|
+
.requiredOption("--request-id <uuid>", "One stable UUID and identical order payload for retries. A new ID can buy the audience again.")
|
|
2873
|
+
.option("--records <n>", "How many followers to buy. Minimum 100; defaults to 1000.")
|
|
2874
|
+
.option("--package <package>", "Basic (default) or Advanced (10x the credits; adds a verified work email and region).")
|
|
2875
|
+
.option("--name <name>", "Table name. Defaults to \"<Company> Followers\".")
|
|
2876
|
+
.option("--project <project>", "Project id or slug; defaults to General.")
|
|
2877
|
+
.option("--credential-mode <mode>", "managed (default): Oxygen credits. user_api_key: your saved ScrapeLi key, billed by ScrapeLi, plus Oxygen placement/recovery platform fees.")
|
|
2878
|
+
.option("--json", "Print a JSON envelope.")
|
|
2879
|
+
.action(async (options) => {
|
|
2880
|
+
await handleAsyncAction("tables followers order", options, () => requestOxygen("/api/cli/tables/linkedin-company-followers", {
|
|
2881
|
+
method: "POST",
|
|
2882
|
+
body: {
|
|
2883
|
+
action: "create",
|
|
2884
|
+
approved: true,
|
|
2885
|
+
company_url: readOption(options.company),
|
|
2886
|
+
...(options.records !== undefined ? { records: readPositiveNumber(options.records) } : {}),
|
|
2887
|
+
...(readOption(options.package) ? { package: readOption(options.package) } : {}),
|
|
2888
|
+
...(readOption(options.name) ? { name: readOption(options.name) } : {}),
|
|
2889
|
+
...(readOption(options.project) ? { project: readOption(options.project) } : {}),
|
|
2890
|
+
...(options.maxCredits !== undefined ? { max_credits: readPositiveNumber(options.maxCredits) } : {}),
|
|
2891
|
+
...(readOption(options.requestId) ? { request_id: readOption(options.requestId) } : {}),
|
|
2892
|
+
...(readOption(options.credentialMode) ? { credential_mode: readOption(options.credentialMode) } : {}),
|
|
2893
|
+
},
|
|
2894
|
+
}));
|
|
2895
|
+
});
|
|
2896
|
+
followersCommand.command("status")
|
|
2897
|
+
.description("Read a fee-budgeted own-key follower order's total fee ceiling, allocated attempts, recorded fees and recovery outcome by its original request ID, including interrupted setup. Optional --table-id verifies the saved Table binding. Recorded fees may settle later. Makes no provider call.")
|
|
2898
|
+
.requiredOption("--request-id <uuid>", "Original order request ID.")
|
|
2899
|
+
.option("--table-id <id>", "Optional saved Table ID; verifies the order belongs to that Table.")
|
|
2900
|
+
.option("--json", "Print a JSON envelope.")
|
|
2901
|
+
.action(async (options) => {
|
|
2902
|
+
await handleAsyncAction("tables followers status", options, () => {
|
|
2903
|
+
const requestId = readOption(options.requestId);
|
|
2904
|
+
if (!requestId)
|
|
2905
|
+
throw new OxygenError("invalid_input", "--request-id must be the original nonblank order request ID.", { exitCode: 2 });
|
|
2906
|
+
const query = new URLSearchParams({ request_id: requestId });
|
|
2907
|
+
const tableId = readOption(options.tableId);
|
|
2908
|
+
if (tableId)
|
|
2909
|
+
query.set("table_id", tableId);
|
|
2910
|
+
return requestOxygen(`/api/cli/tables/linkedin-company-followers?${query}`, { method: "GET" });
|
|
2911
|
+
});
|
|
2912
|
+
});
|
|
2913
|
+
tablesCommand.addCommand(new Command("relate")
|
|
2914
|
+
.description("Link two tables (the web's \"Link to another table\"): creates an empty linked-record column on BOTH tables, so links show and can be removed from either side; matching values are not required. `oxygen tables relate contacts --to accounts --live` names them from the tables (account on contacts, contacts on accounts) and re-running it finds the same link. Then use `oxygen tables link` to populate row-to-row edges. Works on any workspace table; plain tables stay plain and are never registered as CRM objects. Defaults to dry-run.")
|
|
2915
|
+
.argument("<table>", "Source table id or slug.")
|
|
2916
|
+
.argument("[slug]", "Relation slug — also the relation column key on the source, such as client. Omit it to name the relation from the target table (or reuse the one relation that already links them).")
|
|
2917
|
+
.option("--to <table>", "Table to link to: id or slug. May also be a CRM object table (companies, people, deals).")
|
|
2918
|
+
.option("--target-table <table>", "Same as --to.")
|
|
2919
|
+
.option("--display-name <name>", "Display name for the relation column. Defaults to the target table's name, or the slug in Title Case when you name the slug.")
|
|
2920
|
+
.option("--cardinality <cardinality>", "one_to_one, one_to_many, many_to_one, or many_to_many (source:target). Default many_to_many. one_to_many means each source row links many target rows and each target row links back to one source.")
|
|
2921
|
+
.option("--inverse-slug <slug>", "Key of the linked-record column on the target table. Defaults to a name from the source table; a standard CRM object target (companies, people, deals) gets no column there.")
|
|
2922
|
+
.option("--inverse-display-name <name>", "Display name for the inverse side.")
|
|
2923
|
+
.option("--dry-run", "Preview the relation plan without writing.")
|
|
2924
|
+
.option("--live", "Apply the relation. Default is dry-run.")
|
|
2925
|
+
.option("--json", "Print a JSON envelope.")
|
|
2926
|
+
.action(async (table, slug, options) => {
|
|
2927
|
+
await handleAsyncAction("tables relate", options, () => {
|
|
2928
|
+
const to = readOption(options.to);
|
|
2929
|
+
const targetTable = readOption(options.targetTable);
|
|
2930
|
+
if (to && targetTable && to !== targetTable) {
|
|
2931
|
+
throw new OxygenError("invalid_request", "--to and --target-table name different tables; pass one.", { exitCode: 1 });
|
|
2932
|
+
}
|
|
2933
|
+
if (!to && !targetTable) {
|
|
2934
|
+
throw new OxygenError("invalid_request", "Name the table to link to with --to <table>.", { exitCode: 1 });
|
|
2935
|
+
}
|
|
2936
|
+
return requestOxygen("/api/cli/tables/relations", {
|
|
2937
|
+
method: "POST",
|
|
2938
|
+
body: {
|
|
2939
|
+
table,
|
|
2940
|
+
relationship: {
|
|
2941
|
+
...(readOption(slug) ? { slug: readOption(slug) } : {}),
|
|
2942
|
+
target_table: to ?? targetTable,
|
|
2943
|
+
...(options.displayName ? { display_name: options.displayName } : {}),
|
|
2944
|
+
...(options.cardinality ? { cardinality: options.cardinality } : {}),
|
|
2945
|
+
...(options.inverseSlug ? { inverse_slug: options.inverseSlug } : {}),
|
|
2946
|
+
...(options.inverseDisplayName ? { inverse_display_name: options.inverseDisplayName } : {}),
|
|
2947
|
+
},
|
|
2948
|
+
mode: options.live ? "live" : "dry_run",
|
|
2949
|
+
},
|
|
2950
|
+
});
|
|
2951
|
+
});
|
|
2952
|
+
}))
|
|
2953
|
+
.addCommand(new Command("relations")
|
|
2954
|
+
.description("Inspect and manage native Tables-owned relation definitions.")
|
|
2955
|
+
.addCommand(new Command("list")
|
|
2956
|
+
.description("List the native relations visible from one table, including cardinality, target, and active link count.")
|
|
2957
|
+
.argument("<table>", "Table id or slug.")
|
|
2958
|
+
.option("--json", "Print a JSON envelope.")
|
|
2959
|
+
.action(async (table, options) => {
|
|
2960
|
+
await handleAsyncAction("tables relations list", options, () => {
|
|
2961
|
+
const params = new URLSearchParams({ table });
|
|
2962
|
+
return requestOxygen(`/api/cli/tables/relations?${params.toString()}`);
|
|
2963
|
+
});
|
|
2964
|
+
}))
|
|
2965
|
+
.addCommand(new Command("update")
|
|
2966
|
+
.description("Rename either side of a relation or change its cardinality. Endpoints and slugs stay immutable. Previews by default.")
|
|
2967
|
+
.argument("<table>", "Table id or slug.")
|
|
2968
|
+
.argument("<relation>", "Relation slug as seen from this table.")
|
|
2969
|
+
.option("--display-name <name>", "New display name on this table.")
|
|
2970
|
+
.option("--inverse-display-name <name>", "New display name on the other table.")
|
|
2971
|
+
.option("--cardinality <cardinality>", "one_to_one, one_to_many, many_to_one, or many_to_many from this table's perspective.")
|
|
2972
|
+
.option("--approved", "Apply after inspecting the preview.")
|
|
2973
|
+
.option("--dry-run", "Preview without writing. This is the default.")
|
|
2974
|
+
.option("--json", "Print a JSON envelope.")
|
|
2975
|
+
.action(async (table, relation, options) => {
|
|
2976
|
+
await handleAsyncAction("tables relations update", options, () => requestOxygen("/api/cli/tables/relations", {
|
|
2977
|
+
method: "PATCH",
|
|
2978
|
+
body: {
|
|
2979
|
+
table,
|
|
2980
|
+
relation,
|
|
2981
|
+
...(readOption(options.displayName) ? { display_name: readOption(options.displayName) } : {}),
|
|
2982
|
+
...(readOption(options.inverseDisplayName)
|
|
2983
|
+
? { inverse_display_name: readOption(options.inverseDisplayName) }
|
|
2984
|
+
: {}),
|
|
2985
|
+
...(readOption(options.cardinality) ? { cardinality: readOption(options.cardinality) } : {}),
|
|
2986
|
+
mode: options.approved && !options.dryRun ? "live" : "dry_run",
|
|
2987
|
+
},
|
|
2988
|
+
}));
|
|
2989
|
+
}))
|
|
2990
|
+
.addCommand(new Command("archive")
|
|
2991
|
+
.description("Archive a native relation, its active links, and both relation columns while preserving history. Previews by default.")
|
|
2992
|
+
.argument("<table>", "Table id or slug.")
|
|
2993
|
+
.argument("<relation>", "Relation slug as seen from this table.")
|
|
2994
|
+
.option("--approved", "Apply after inspecting the preview.")
|
|
2995
|
+
.option("--dry-run", "Preview without writing. This is the default.")
|
|
2996
|
+
.option("--json", "Print a JSON envelope.")
|
|
2997
|
+
.action(async (table, relation, options) => {
|
|
2998
|
+
await handleAsyncAction("tables relations archive", options, () => requestOxygen("/api/cli/tables/relations", {
|
|
2999
|
+
method: "DELETE",
|
|
3000
|
+
body: {
|
|
3001
|
+
table,
|
|
3002
|
+
relation,
|
|
3003
|
+
mode: options.approved && !options.dryRun ? "live" : "dry_run",
|
|
3004
|
+
},
|
|
3005
|
+
}));
|
|
3006
|
+
})))
|
|
3007
|
+
.addCommand(new Command("unlink")
|
|
3008
|
+
.description("Unlink one exact pair of rows by archiving their relation edge. The relation definition and both rows remain. Previews by default.")
|
|
3009
|
+
.argument("<table>", "Source table id or slug.")
|
|
3010
|
+
.argument("<row_id>", "Source row id.")
|
|
3011
|
+
.requiredOption("--relation <slug>", "Relation slug as seen from the source table.")
|
|
3012
|
+
.requiredOption("--target-row-id <row_id>", "Linked row id to remove.")
|
|
3013
|
+
.option("--approved", "Apply after inspecting the preview.")
|
|
3014
|
+
.option("--live", "Alias for --approved.")
|
|
3015
|
+
.option("--dry-run", "Preview without writing. This is the default.")
|
|
3016
|
+
.option("--json", "Print a JSON envelope.")
|
|
3017
|
+
.action(async (table, rowId, options) => {
|
|
3018
|
+
await handleAsyncAction("tables unlink", options, () => requestOxygen("/api/cli/tables/relations/unlink", {
|
|
3019
|
+
method: "POST",
|
|
3020
|
+
body: {
|
|
3021
|
+
table,
|
|
3022
|
+
row_id: rowId,
|
|
3023
|
+
relation: options.relation,
|
|
3024
|
+
target_row_id: options.targetRowId,
|
|
3025
|
+
mode: (options.approved || options.live) && !options.dryRun ? "live" : "dry_run",
|
|
3026
|
+
},
|
|
3027
|
+
}));
|
|
3028
|
+
}))
|
|
3029
|
+
.addCommand(new Command("link")
|
|
3030
|
+
.description("Link two tables: Oxygen finds the column that joins them, works out the direction, shows you how many rows will match, and on approval links every row. Free. Previews by default; --approved queues a background run and records each row's outcome in a system link_status_<relation> column. Wait for that run with `oxygen table-runs wait <run_id>`. (Also links one row to one row — see --relation.)")
|
|
3031
|
+
.argument("<table>", "Table id or slug to link FROM.")
|
|
3032
|
+
.argument("[row_id]", "Row form only: the source row id to link. Omit for the whole-table form.")
|
|
3033
|
+
.option("--to <table>", "Table id or slug to link TO. May be a CRM object table (companies, people, deals).")
|
|
3034
|
+
.option("--on <column>", "Pin one table's column by name or key. Oxygen must still find a compatible column on the other table: generic text needs a strongly similar label, while domains, emails, LinkedIn URLs, and external IDs pair by semantic role.")
|
|
3035
|
+
.option("--approved", "Queue the bulk link after inspecting the preview; then wait with `oxygen table-runs wait <run_id>`.")
|
|
3036
|
+
.option("--create-missing", "Also create target rows for unmatched source keys (CRM records when filling an existing CRM relationship). Free.")
|
|
3037
|
+
.option("--max-concurrency <n>", "Maximum concurrent row items for the run (1-1000; a ceiling shared with worker and provider capacity). Defaults to 250; native --create-missing runs serialize target-row creation safely.")
|
|
3038
|
+
.option("--undo <run_id>", "Undo a previous link run, archiving only the links that run created.")
|
|
3039
|
+
.option("--relation <slug>", "Row form: relation slug defined on the source table, such as client.")
|
|
3040
|
+
.option("--target-row-id <row_id>", "Row form: target row id in the related table.")
|
|
3041
|
+
.option("--dry-run", "Preview without writing. This is the default.")
|
|
3042
|
+
.option("--live", "Row form: apply the single-row link.")
|
|
3043
|
+
.option("--json", "Print a JSON envelope.")
|
|
3044
|
+
.action(async (table, rowId, options) => {
|
|
3045
|
+
const wholeTableForm = Boolean(readOption(options.to) || readOption(options.undo));
|
|
3046
|
+
const rowForm = Boolean(rowId || readOption(options.relation) || readOption(options.targetRowId));
|
|
3047
|
+
// Usage errors go through emitCliFailure like every other command, NOT a
|
|
3048
|
+
// bare throw: a raw throw escapes commander's action handler as an
|
|
3049
|
+
// uncaught exception, which prints a Node stack trace to stderr instead
|
|
3050
|
+
// of the `{ok:false,error}` envelope every other command emits, and lets
|
|
3051
|
+
// Node's generic exit code override the error's own. This is the first
|
|
3052
|
+
// thing a user sees when they half-remember the command, so it has to be
|
|
3053
|
+
// the standard shape.
|
|
3054
|
+
const usageError = (message) => new OxygenError("invalid_request", message, { exitCode: 1 });
|
|
3055
|
+
// Neither shape is complete: print BOTH rather than guessing which the
|
|
3056
|
+
// user meant. A bare `oxygen tables link people` is far more likely a
|
|
3057
|
+
// half-remembered whole-table command than a malformed row link.
|
|
3058
|
+
if (!wholeTableForm && !rowForm) {
|
|
3059
|
+
emitCliFailure("tables link", usageError([
|
|
3060
|
+
"Say which table to link to, or which two rows to link.",
|
|
3061
|
+
"",
|
|
3062
|
+
` Link two tables: oxygen tables link ${table} --to <table>`,
|
|
3063
|
+
` Link two rows: oxygen tables link ${table} <row_id> --relation <slug> --target-row-id <id>`,
|
|
3064
|
+
].join("\n")));
|
|
3065
|
+
return;
|
|
3066
|
+
}
|
|
3067
|
+
if (wholeTableForm && rowForm) {
|
|
3068
|
+
emitCliFailure("tables link", usageError("Use either the whole-table form (--to) or the row form (<row_id> --relation --target-row-id), not both."));
|
|
3069
|
+
return;
|
|
3070
|
+
}
|
|
3071
|
+
if (rowForm) {
|
|
3072
|
+
if (!rowId || !readOption(options.relation) || !readOption(options.targetRowId)) {
|
|
3073
|
+
emitCliFailure("tables link", usageError(`Linking two rows needs all three: oxygen tables link ${table} <row_id> --relation <slug> --target-row-id <id>`));
|
|
3074
|
+
return;
|
|
3075
|
+
}
|
|
3076
|
+
await handleAsyncAction("tables link", options, () => requestOxygen("/api/cli/tables/relations/link", {
|
|
3077
|
+
method: "POST",
|
|
3078
|
+
body: {
|
|
3079
|
+
table,
|
|
3080
|
+
row_id: rowId,
|
|
3081
|
+
relation: options.relation,
|
|
3082
|
+
target_row_id: options.targetRowId,
|
|
3083
|
+
// --approved is accepted as an alias for --live so one verb does
|
|
3084
|
+
// not teach two different confirm words.
|
|
3085
|
+
mode: options.live || options.approved ? "live" : "dry_run",
|
|
3086
|
+
},
|
|
3087
|
+
}));
|
|
3088
|
+
return;
|
|
3089
|
+
}
|
|
3090
|
+
const maxConcurrency = readPositiveInt(options.maxConcurrency);
|
|
3091
|
+
const undoRunId = readOption(options.undo);
|
|
3092
|
+
const approved = Boolean(options.approved) && !options.dryRun;
|
|
3093
|
+
const result = await requestOxygen("/api/cli/tables/link", {
|
|
3094
|
+
method: "POST",
|
|
3095
|
+
body: {
|
|
3096
|
+
table,
|
|
3097
|
+
...(undoRunId ? { undo_run_id: undoRunId } : { to: readOption(options.to) }),
|
|
3098
|
+
...(readOption(options.on) ? { on: readOption(options.on) } : {}),
|
|
3099
|
+
...(approved ? { approved: true } : { dry_run: true }),
|
|
3100
|
+
...(options.createMissing ? { create_missing: true } : {}),
|
|
3101
|
+
...(maxConcurrency ? { max_concurrency: maxConcurrency } : {}),
|
|
3102
|
+
},
|
|
3103
|
+
}).catch((error) => {
|
|
3104
|
+
emitCliFailure("tables link", error);
|
|
3105
|
+
return null;
|
|
3106
|
+
});
|
|
3107
|
+
if (!result)
|
|
3108
|
+
return;
|
|
3109
|
+
if (options.json) {
|
|
3110
|
+
emitSuccess("tables link", result, options);
|
|
3111
|
+
return;
|
|
3112
|
+
}
|
|
3113
|
+
writeTableLinkPreview(table, result, options);
|
|
3114
|
+
}))
|
|
3115
|
+
.addCommand(new Command("promote")
|
|
3116
|
+
.description("Write bound rows' columns onto their matched CRM records (Records<->Tables). Free, but a truth write: previews by default, then re-run with --approved to write. Approved runs queue as a background run — watch with `oxygen table-runs wait <run_id>`.")
|
|
3117
|
+
.argument("<table>", "Table id or slug whose bound rows to promote.")
|
|
3118
|
+
.option("--object <slug>", "CRM object to promote onto (companies, people, deals, ...). Inferred when the table has exactly one bind column.")
|
|
3119
|
+
.requiredOption("--map <pairs>", "Column-to-attribute mapping as column=attribute pairs (e.g. job_title=job_title,seniority=seniority) or a JSON array/object.")
|
|
3120
|
+
.option("--policy <policy>", "Conflict policy: fill-empty-only (default), overwrite, or skip-conflicts.", "fill-empty-only")
|
|
3121
|
+
.option("--row-id <id...>", "Promote only these row ids. Defaults to every bound row.")
|
|
3122
|
+
.option("--dry-run", "Preview per-field would_fill/would_overwrite/conflicts without writing (the default when --approved is absent).")
|
|
3123
|
+
.option("--approved", "Confirm the truth write onto CRM records after inspecting the preview.")
|
|
3124
|
+
.option("--max-concurrency <n>", "Maximum concurrent row items for the run (1-1000; a ceiling shared with worker and provider capacity). Defaults to 50.")
|
|
3125
|
+
.option("--json", "Print a JSON envelope.")
|
|
3126
|
+
.action(async (table, options) => {
|
|
3127
|
+
if (options.dryRun && options.approved) {
|
|
3128
|
+
throw new OxygenError("invalid_promote", "Pass either --dry-run or --approved, not both.", { exitCode: 1 });
|
|
3129
|
+
}
|
|
3130
|
+
const mappings = parsePromoteMapOption(options.map);
|
|
3131
|
+
const policy = normalizePromotePolicy(options.policy);
|
|
3132
|
+
const maxConcurrency = readPositiveInt(options.maxConcurrency);
|
|
3133
|
+
await handleAsyncAction("tables promote", options, () => requestOxygen("/api/cli/tables/promote", {
|
|
3134
|
+
method: "POST",
|
|
3135
|
+
body: {
|
|
3136
|
+
table,
|
|
3137
|
+
...(readOption(options.object) ? { object: readOption(options.object) } : {}),
|
|
3138
|
+
mappings,
|
|
3139
|
+
policy,
|
|
3140
|
+
...(options.rowId && options.rowId.length > 0 ? { row_ids: options.rowId } : {}),
|
|
3141
|
+
// Preview by default; --approved is the only path that writes.
|
|
3142
|
+
...(options.approved ? { approved: true } : { dry_run: true }),
|
|
3143
|
+
...(maxConcurrency ? { max_concurrency: maxConcurrency } : {}),
|
|
3144
|
+
},
|
|
3145
|
+
}));
|
|
3146
|
+
}));
|
|
3147
|
+
tablesCommand.addCommand(new Command("dedupe")
|
|
3148
|
+
.description("Collapse duplicate rows by normalized (email/domain/linkedin) or fuzzy-label keys, or cross-check a column against another table. Previews by default; --apply --approved deletes losers (kept in row history).")
|
|
3149
|
+
.argument("<table>", "Table id or slug to dedupe.")
|
|
3150
|
+
.requiredOption("--on <cols>", "Key column(s): one column, or two comma-separated for a composite key (e.g. email or first_name,company).")
|
|
3151
|
+
.option("--normalize <mode>", "Match mode: exact (default), email, domain, linkedin, or fuzzy-label. Fuzzy allowed only as a single key.", "exact")
|
|
3152
|
+
.option("--keep <policy>", "Survivor per duplicate group: oldest (default), newest, or most-complete.", "oldest")
|
|
3153
|
+
.option("--merge-values <mode>", "With --apply, fill the survivor's blank cells from the losers first: fill-empty.")
|
|
3154
|
+
.option("--scan-limit <n>", "Max rows to scan. Defaults to 50000, capped at 200000.")
|
|
3155
|
+
.option("--apply", "Delete the duplicate rows (losers). Without this flag the command only previews.")
|
|
3156
|
+
.option("--approved", "Confirm the deletion (required with --apply); without it the API returns the preview to inspect first.")
|
|
3157
|
+
.option("--against <table>", "Cross-table check: report rows whose --on value already exists in this other table (read-only).")
|
|
3158
|
+
.option("--against-column <col>", "Column in the --against table to match against. Required with --against.")
|
|
3159
|
+
.option("--json", "Print a JSON envelope.")
|
|
3160
|
+
.action(async (table, options) => {
|
|
3161
|
+
await handleAsyncAction("tables dedupe", options, () => {
|
|
3162
|
+
const request = buildDedupeRequest(table, options);
|
|
3163
|
+
return requestOxygen(request.endpoint, { method: "POST", body: request.body });
|
|
3164
|
+
});
|
|
3165
|
+
}));
|
|
3166
|
+
tablesCommand.addCommand(new Command("send")
|
|
3167
|
+
.description("Copy rows from one table into another as a durable background run (free, resumable). Map columns with --map (or --automap), optionally flatten an array column into one row per element, and insert or upsert. --dry-run previews the resolved mapping, row estimate, and sample rows without writing.")
|
|
3168
|
+
.argument("<source>", "Source table id or slug.")
|
|
3169
|
+
.argument("<target>", "Target table id or slug (must be a different table).")
|
|
3170
|
+
.option("--map <pairs>", "target=source column pairs, comma-separated and/or repeated (e.g. --map company=company_name,website=domain).", collectRepeatable, [])
|
|
3171
|
+
.option("--mapping <json>", 'Full mapping JSON array for literals and dot-paths, e.g. [{"target":"email","source":{"type":"column","key":"$element"},"path":"email"}]. Escape hatch when --map is not enough.')
|
|
3172
|
+
.option("--automap", "Match columns automatically (exact key, then case-insensitive label). The resolved mapping is echoed back.")
|
|
3173
|
+
.option("--flatten <column>", 'Source column whose array value emits one target row per element (the element is mapping source key "$element"); non-array values pass through as one row.')
|
|
3174
|
+
.option("--flatten-path <path>", "Dot-path to the array inside the --flatten column's value.")
|
|
3175
|
+
.option("--filter <json>", 'Source-row filters JSON, e.g. [{"column":"domain","op":"is_not_null"}]. Ops: eq, neq, is_null, is_not_null, in, not_in.')
|
|
3176
|
+
.option("--mode <mode>", "insert (append, default) or upsert (requires --upsert-key).", "insert")
|
|
3177
|
+
.option("--upsert-key <key>", "Mapped target column that identifies existing rows for upsert.")
|
|
3178
|
+
.option("--page-size <n>", "Rows copied per durable page. Defaults to 500, max 2000.")
|
|
3179
|
+
.option("--dry-run", "Validate and preview only; nothing is written.")
|
|
3180
|
+
.option("--json", "Print a JSON envelope.")
|
|
3181
|
+
.action(async (source, target, options) => {
|
|
3182
|
+
try {
|
|
3183
|
+
const body = buildTablesSendRequest(source, target, options);
|
|
3184
|
+
const data = await requestOxygen("/api/cli/tables/send", { method: "POST", body });
|
|
3185
|
+
emitSuccess("tables send", data, options);
|
|
3186
|
+
writeTablesSendHint(data);
|
|
3187
|
+
}
|
|
3188
|
+
catch (error) {
|
|
3189
|
+
emitCliFailure("tables send", error);
|
|
3190
|
+
}
|
|
3191
|
+
}));
|
|
3192
|
+
tablesCommand.addCommand(new Command("webhook")
|
|
3193
|
+
.description("Create and manage direct table webhooks.")
|
|
3194
|
+
.addCommand(new Command("list")
|
|
3195
|
+
.description("List direct table webhooks.")
|
|
3196
|
+
.argument("[table]", "Optional table id or slug.")
|
|
3197
|
+
.option("--status <status>", "Filter by active or disabled.")
|
|
3198
|
+
.option("--limit <n>", "Maximum webhooks to return. Defaults to 100.")
|
|
3199
|
+
.option("--json", "Print a JSON envelope.")
|
|
3200
|
+
.action((table, options) => handleAsyncAction("tables webhook list", options, () => requestOxygen(tableWebhookListPath({
|
|
3201
|
+
table: table ?? options.table,
|
|
3202
|
+
status: options.status,
|
|
3203
|
+
limit: options.limit,
|
|
3204
|
+
})))))
|
|
3205
|
+
.addCommand(new Command("get")
|
|
3206
|
+
.description("Get one direct table webhook by endpoint id.")
|
|
3207
|
+
.argument("<endpoint_id>", "Webhook endpoint id, such as tw_...")
|
|
3208
|
+
.option("--json", "Print a JSON envelope.")
|
|
3209
|
+
.action((endpointId, options) => handleAsyncAction("tables webhook get", options, () => {
|
|
3210
|
+
const params = new URLSearchParams({ endpoint_id: endpointId });
|
|
3211
|
+
return requestOxygen(`/api/cli/tables/webhooks?${params.toString()}`);
|
|
3212
|
+
})))
|
|
3213
|
+
.addCommand(new Command("create")
|
|
3214
|
+
.description(`Create a direct webhook endpoint that writes inbound JSON into a table (${TABLE_WEBHOOK_RATE_HELP}).`)
|
|
3215
|
+
.argument("<table>", "Table id or slug.")
|
|
3216
|
+
.option("--name <name>", "Display name for the webhook.")
|
|
3217
|
+
.option("--mode <mode>", "insert or upsert. Defaults to upsert when --upsert-key is set, otherwise insert.")
|
|
3218
|
+
.option("--auth-mode <mode>", "none or secret. Defaults to none; use secret when the sender can include an Oxygen webhook secret.")
|
|
3219
|
+
.option("--upsert-key <key>", "Column key used to upsert rows. Required for upsert mode.")
|
|
3220
|
+
.option("--items-path <path>", "Dot path to an array of row objects in the webhook payload.")
|
|
3221
|
+
.option("--event-id-path <path>", "Dot path to the event id. Defaults to event_id, eventId, id, or payload hash.")
|
|
3222
|
+
.option("--event-type-path <path>", "Dot path to the event type. Defaults to type or event.")
|
|
3223
|
+
.option("--occurred-at-path <path>", "Dot path to the event timestamp.")
|
|
3224
|
+
.option("--source <source>", "http (default) writes inbound JSON anyone can POST. unipile subscribes the table to your connected LinkedIn/WhatsApp accounts' events instead — no endpoint URL, no secret to share, and 0 credits: rows appear as the events reach Oxygen.")
|
|
3225
|
+
.option("--account <unipile_account_id>", "unipile source only. Listen to ONE connected account. Omit to listen to every connected account in the workspace.")
|
|
3226
|
+
.option("--events <csv>", "unipile source only. Comma-separated event types, e.g. relation.new,message.new. Omit for every event the receiver handles. Post reactions, post comments, profile viewers and followers are NOT pushed by the provider — capture those with a scheduled feed (`oxygen feeds bind`).")
|
|
3227
|
+
.option("--mapping <mapping>", "unipile source only. curated (default) flattens known events into stable named columns; passthrough lands the raw provider event so you can map any type yourself.")
|
|
3228
|
+
.option("--auto-run-columns <csv>", "Comma-separated enrichment, tool, AI, or formula columns to queue for webhook-written rows.")
|
|
3229
|
+
.option("--auto-run-max-credits <n>", "Credit ceiling per webhook-triggered auto-run batch; items beyond it are skipped with credit_limit_reached. Defaults to your plan's per-delivery ceiling when omitted.")
|
|
3230
|
+
.option("--auto-run-max-concurrency <n>", "Maximum concurrent row items for webhook-triggered auto-runs.")
|
|
3231
|
+
.option("--auto-run-force", "Run auto-run columns even when the target cell already has a value.")
|
|
3232
|
+
.option("--auto-run-connection-id <connection_id>", "Optional provider integration connection id for auto-runs.")
|
|
3233
|
+
.addOption(new Option("--auto-run-approved", "Deprecated no-op. Auto-run is enabled by --auto-run-columns.").hideHelp())
|
|
3234
|
+
.option("--json", "Print a JSON envelope.")
|
|
3235
|
+
.action((table, options) => {
|
|
3236
|
+
const autoRun = readTableWebhookAutoRunOptions(options);
|
|
3237
|
+
const unipile = readOption(options.source) === "unipile";
|
|
3238
|
+
return handleAsyncAction("tables webhook create", options, () => requestOxygen("/api/cli/tables/webhooks", {
|
|
3239
|
+
method: "POST",
|
|
3240
|
+
body: {
|
|
3241
|
+
table,
|
|
3242
|
+
...(readOption(options.name) ? { name: readOption(options.name) } : {}),
|
|
3243
|
+
...(readOption(options.mode) ? { mode: readOption(options.mode) } : {}),
|
|
3244
|
+
...(readOption(options.authMode) ? { auth_mode: readOption(options.authMode) } : {}),
|
|
3245
|
+
...(readOption(options.upsertKey) ? { upsert_key: readOption(options.upsertKey) } : {}),
|
|
3246
|
+
...(readOption(options.itemsPath) ? { items_path: readOption(options.itemsPath) } : {}),
|
|
3247
|
+
...(readOption(options.eventIdPath) ? { event_id_path: readOption(options.eventIdPath) } : {}),
|
|
3248
|
+
...(readOption(options.eventTypePath) ? { event_type_path: readOption(options.eventTypePath) } : {}),
|
|
3249
|
+
...(readOption(options.occurredAtPath) ? { occurred_at_path: readOption(options.occurredAtPath) } : {}),
|
|
3250
|
+
...(unipile
|
|
3251
|
+
? {
|
|
3252
|
+
feed_kind: "unipile",
|
|
3253
|
+
...(readOption(options.account) ? { unipile_account_id: readOption(options.account) } : {}),
|
|
3254
|
+
...(readOption(options.events) ? { event_types: readOption(options.events) } : {}),
|
|
3255
|
+
...(readOption(options.mapping) ? { mapping: readOption(options.mapping) } : {}),
|
|
3256
|
+
}
|
|
3257
|
+
: {}),
|
|
3258
|
+
...(autoRun ? { auto_run: autoRun } : {}),
|
|
3259
|
+
},
|
|
3260
|
+
}));
|
|
3261
|
+
}))
|
|
3262
|
+
.addCommand(new Command("update")
|
|
3263
|
+
.description("Update a direct table webhook endpoint.")
|
|
3264
|
+
.argument("<endpoint_id>", "Webhook endpoint id, such as tw_...")
|
|
3265
|
+
.option("--name <name>", "Update the webhook display name.")
|
|
3266
|
+
.option("--status <status>", "active or disabled.")
|
|
3267
|
+
.option("--auth-mode <mode>", "none or secret. Setting secret rotates and returns a new webhook secret.")
|
|
3268
|
+
.option("--auto-run-columns <csv>", "Comma-separated enrichment, tool, AI, or formula columns to queue for webhook-written rows.")
|
|
3269
|
+
.option("--auto-run-max-credits <n>", "Credit ceiling per webhook-triggered auto-run batch; items beyond it are skipped with credit_limit_reached. Defaults to your plan's per-delivery ceiling when omitted.")
|
|
3270
|
+
.option("--auto-run-max-concurrency <n>", "Maximum concurrent row items for webhook-triggered auto-runs.")
|
|
3271
|
+
.option("--auto-run-force", "Run auto-run columns even when the target cell already has a value.")
|
|
3272
|
+
.option("--auto-run-connection-id <connection_id>", "Optional provider integration connection id for auto-runs.")
|
|
3273
|
+
.addOption(new Option("--auto-run-approved", "Deprecated no-op. Auto-run is enabled by --auto-run-columns.").hideHelp())
|
|
3274
|
+
.option("--clear-auto-run", "Remove the webhook auto-run configuration.")
|
|
3275
|
+
.option("--json", "Print a JSON envelope.")
|
|
3276
|
+
.action((endpointId, options) => {
|
|
3277
|
+
const autoRun = readTableWebhookAutoRunOptions(options);
|
|
3278
|
+
if (autoRun && options.clearAutoRun) {
|
|
3279
|
+
throw new OxygenError("invalid_table_webhook", "Pass either auto-run options or --clear-auto-run, not both.", {
|
|
3280
|
+
exitCode: 1,
|
|
3281
|
+
});
|
|
3282
|
+
}
|
|
3283
|
+
return handleAsyncAction("tables webhook update", options, () => requestOxygen("/api/cli/tables/webhooks", {
|
|
3284
|
+
method: "PATCH",
|
|
3285
|
+
body: {
|
|
3286
|
+
endpoint_id: endpointId,
|
|
3287
|
+
...(readOption(options.name) ? { name: readOption(options.name) } : {}),
|
|
3288
|
+
...(readOption(options.status) ? { status: readOption(options.status) } : {}),
|
|
3289
|
+
...(readOption(options.authMode) ? { auth_mode: readOption(options.authMode) } : {}),
|
|
3290
|
+
...(autoRun ? { auto_run: autoRun } : {}),
|
|
3291
|
+
...(options.clearAutoRun ? { clear_auto_run: true } : {}),
|
|
3292
|
+
},
|
|
3293
|
+
}));
|
|
3294
|
+
}))
|
|
3295
|
+
.addCommand(new Command("deliveries")
|
|
3296
|
+
.description("List direct table webhook deliveries and auto-run enqueue status.")
|
|
3297
|
+
.argument("[endpoint_id]", "Optional webhook endpoint id, such as tw_...")
|
|
3298
|
+
.option("--table <table>", "Filter by table id or slug.")
|
|
3299
|
+
.option("--status <status>", "Filter by received, completed, failed, or rejected (rejected = arrived while the feed was paused and was turned away; no rows written). A delivery refused with HTTP 429 for exceeding the plan's rate is not recorded here: the sender redelivers it.")
|
|
3300
|
+
.option("--auto-run-status <status>", "Filter by not_configured, queued, skipped, or failed_to_enqueue.")
|
|
3301
|
+
.option("--limit <n>", "Maximum deliveries to return. Defaults to 50.")
|
|
3302
|
+
.option("--json", "Print a JSON envelope.")
|
|
3303
|
+
.action((endpointId, options) => handleAsyncAction("tables webhook deliveries", options, () => requestOxygen(tableWebhookDeliveriesPath({
|
|
3304
|
+
endpointId,
|
|
3305
|
+
table: options.table,
|
|
3306
|
+
status: options.status,
|
|
3307
|
+
autoRunStatus: options.autoRunStatus,
|
|
3308
|
+
limit: options.limit,
|
|
3309
|
+
}))))));
|
|
3310
|
+
// One authoritative statement of the config shape, on the flag that stores
|
|
3311
|
+
// it. The previous copy — "(filters, sorts, columns, group). Usually written
|
|
3312
|
+
// by the app." — named the LEGACY key and disowned the flag; a blind-eval
|
|
3313
|
+
// agent (2026-09-20) followed it, wrote {"group":{"columnKey":...}}, and got a
|
|
3314
|
+
// board that grouped by accident. --group-by is the plain path; this is the
|
|
3315
|
+
// full one, and it says which key is the grouping key.
|
|
3316
|
+
const TABLE_VIEW_CONFIG_HELP = 'JSON view config, typed v2 shape: {"version":2,"groupBy":"<select column key>","filterTree":{...},"sorts":[{"columnKey":"...","direction":"asc|desc"}],"visibleColumns":["..."],"cardFields":["..."]}. '
|
|
3317
|
+
+ 'groupBy is the kanban group-by column (--group-by sets it for you); a complete board config is \'{"version":2,"groupBy":"lifecycle_stage"}\'. Malformed v2 is rejected with invalid_view_config.';
|
|
3318
|
+
tablesCommand.addCommand(new Command("views")
|
|
3319
|
+
.description("Create and manage saved table views (grid / kanban configurations).")
|
|
3320
|
+
.addCommand(new Command("list")
|
|
3321
|
+
.description("List a table's saved views.")
|
|
3322
|
+
.argument("<table>", "Table id or slug.")
|
|
3323
|
+
.option("--json", "Print a JSON envelope.")
|
|
3324
|
+
.action((table, options) => handleAsyncAction("tables views list", options, () => requestOxygen(`/api/cli/tables/${encodeURIComponent(table)}/views`))))
|
|
3325
|
+
.addCommand(new Command("get")
|
|
3326
|
+
.description("Get one saved view by id.")
|
|
3327
|
+
.argument("<table>", "Table id or slug.")
|
|
3328
|
+
.argument("<view>", "View id.")
|
|
3329
|
+
.option("--json", "Print a JSON envelope.")
|
|
3330
|
+
.action((table, view, options) => handleAsyncAction("tables views get", options, () => requestOxygen(`/api/cli/tables/views?table=${encodeURIComponent(table)}&view=${encodeURIComponent(view)}`))))
|
|
3331
|
+
.addCommand(new Command("create")
|
|
3332
|
+
.description("Create a saved view.")
|
|
3333
|
+
.argument("<table>", "Table id or slug.")
|
|
3334
|
+
.requiredOption("--name <name>", "View name.")
|
|
3335
|
+
.option("--view-type <type>", "table (grid) or kanban (board split into lanes by a select/status column). Defaults to table; --group-by implies kanban. A kanban created without --group-by is not refused: the board renders on the table's first groupable column and every read reports that fallback as effective_group_by plus a warning, so pin the column with --group-by.")
|
|
3336
|
+
.option("--group-by <column>", "Kanban: the editable select/status column key the board splits on, e.g. --group-by lifecycle_stage. Unknown or non-groupable keys are refused with the groupable candidates. The response's effective_group_by is the column the board actually uses.")
|
|
3337
|
+
.option("--config <json>", TABLE_VIEW_CONFIG_HELP)
|
|
3338
|
+
.option("--default", "Make this the table's default view.")
|
|
3339
|
+
.option("--no-default", "Clear this view's default status.")
|
|
3340
|
+
.option("--position <n>", "Zero-based order among the table's saved views.")
|
|
3341
|
+
.option("--json", "Print a JSON envelope.")
|
|
3342
|
+
.action((table, options) => handleAsyncAction("tables views create", options, () => requestOxygen(`/api/cli/tables/${encodeURIComponent(table)}/views`, {
|
|
3343
|
+
method: "POST",
|
|
3344
|
+
body: {
|
|
3345
|
+
name: readOption(options.name),
|
|
3346
|
+
...(readOption(options.viewType) ? { view_type: readOption(options.viewType) } : {}),
|
|
3347
|
+
...(readOption(options.groupBy) ? { group_by: readOption(options.groupBy) } : {}),
|
|
3348
|
+
...(readOption(options.config)
|
|
3349
|
+
? { config: parseJsonValue(readOption(options.config) ?? "", "--config") }
|
|
3350
|
+
: {}),
|
|
3351
|
+
...(typeof options.default === "boolean" ? { is_default: options.default } : {}),
|
|
3352
|
+
...(readOption(options.position) !== null ? { position: readNonNegativeInt(options.position) } : {}),
|
|
3353
|
+
},
|
|
3354
|
+
}))))
|
|
3355
|
+
.addCommand(new Command("update")
|
|
3356
|
+
.description("Update a saved view.")
|
|
3357
|
+
.argument("<table>", "Table id or slug.")
|
|
3358
|
+
.argument("<view>", "View id.")
|
|
3359
|
+
.option("--name <name>", "Rename the view.")
|
|
3360
|
+
.option("--view-type <type>", "table (grid) or kanban (board). A kanban whose config names no group column renders on the first groupable column; reads report that fallback as effective_group_by plus a warning.")
|
|
3361
|
+
.option("--group-by <column>", "Kanban: change the select/status column the board splits on; folds into the stored config and keeps the rest of it. Refused for a grid view unless --view-type kanban is passed with it.")
|
|
3362
|
+
.option("--config <json>", `${TABLE_VIEW_CONFIG_HELP} Replaces the stored config.`)
|
|
3363
|
+
.option("--default", "Make this the table's default view.")
|
|
3364
|
+
.option("--no-default", "Clear this view's default status.")
|
|
3365
|
+
.option("--position <n>", "0-based order among the table's views.")
|
|
3366
|
+
.option("--json", "Print a JSON envelope.")
|
|
3367
|
+
.action((table, view, options) => handleAsyncAction("tables views update", options, () => requestOxygen(`/api/cli/tables/${encodeURIComponent(table)}/views/${encodeURIComponent(view)}`, {
|
|
3368
|
+
method: "PATCH",
|
|
3369
|
+
body: {
|
|
3370
|
+
...(readOption(options.name) ? { name: readOption(options.name) } : {}),
|
|
3371
|
+
...(readOption(options.viewType) ? { view_type: readOption(options.viewType) } : {}),
|
|
3372
|
+
...(readOption(options.groupBy) ? { group_by: readOption(options.groupBy) } : {}),
|
|
3373
|
+
...(readOption(options.config)
|
|
3374
|
+
? { config: parseJsonValue(readOption(options.config) ?? "", "--config") }
|
|
3375
|
+
: {}),
|
|
3376
|
+
...(typeof options.default === "boolean" ? { is_default: options.default } : {}),
|
|
3377
|
+
...(readOption(options.position) ? { position: Number(readOption(options.position)) } : {}),
|
|
3378
|
+
},
|
|
3379
|
+
}))))
|
|
3380
|
+
.addCommand(new Command("delete")
|
|
3381
|
+
.description("Delete a saved view by id.")
|
|
3382
|
+
.argument("<table>", "Table id or slug.")
|
|
3383
|
+
.argument("<view>", "View id.")
|
|
3384
|
+
.option("--json", "Print a JSON envelope.")
|
|
3385
|
+
.action((table, view, options) => handleAsyncAction("tables views delete", options, () => requestOxygen(`/api/cli/tables/${encodeURIComponent(table)}/views/${encodeURIComponent(view)}`, { method: "DELETE" })))));
|
|
3386
|
+
tablesCommand.addCommand(new Command("auto-run")
|
|
3387
|
+
.description("Inspect and manage a table's standing auto-run configuration (columns queued automatically when new rows are written).")
|
|
3388
|
+
.addCommand(new Command("get")
|
|
3389
|
+
.description("Show a table's standing auto-run configuration (columns queued automatically when new rows are written).")
|
|
3390
|
+
.argument("<table>", "Table id or slug.")
|
|
3391
|
+
.option("--json", "Print a JSON envelope.")
|
|
3392
|
+
.action((table, options) => handleAsyncAction("tables auto-run get", options, () => {
|
|
3393
|
+
const params = new URLSearchParams({ table });
|
|
3394
|
+
return requestOxygen(`/api/cli/tables/auto-run?${params.toString()}`);
|
|
3395
|
+
})))
|
|
3396
|
+
.addCommand(new Command("set")
|
|
3397
|
+
.description("Enable a standing auto-run: automatically queue the given columns for rows written to this table (imports, inserts, upserts), including workflow audience upserts and own-follower capture. A queued cell that already has a value is skipped unless --force, so an upsert that touches an existing, filled row is not charged again. Profile viewers use their separate `viewers enrichment --on` subscription. Default off.")
|
|
3398
|
+
.argument("<table>", "Table id or slug.")
|
|
3399
|
+
.requiredOption("--columns <csv>", "Comma-separated column keys to auto-run on newly written rows (tool, AI, formula, enrichment, bind, or lookup columns).")
|
|
3400
|
+
.option("--sources <csv>", "Which write sources trigger the auto-run: any of import, insert, upsert, send. Defaults to all when omitted.")
|
|
3401
|
+
.option("--force", "Re-run the columns even for rows that already have values.")
|
|
3402
|
+
.option("--max-credits <n>", "Credit ceiling PER enqueued auto-run batch; rows beyond it are skipped with credit_limit_reached. Defaults to your plan's per-delivery ceiling when omitted.")
|
|
3403
|
+
.option("--max-concurrency <n>", "Maximum concurrent row items per auto-run batch.")
|
|
3404
|
+
.option("--json", "Print a JSON envelope.")
|
|
3405
|
+
.action((table, options) => {
|
|
3406
|
+
const columns = readCsvOption(options.columns);
|
|
3407
|
+
const sources = readCsvOption(options.sources);
|
|
3408
|
+
const maxCredits = readPositiveNumber(options.maxCredits);
|
|
3409
|
+
const maxConcurrency = readPositiveInt(options.maxConcurrency);
|
|
3410
|
+
const force = Boolean(options.force);
|
|
3411
|
+
return handleAsyncAction("tables auto-run set", options, () => requestOxygen("/api/cli/tables/auto-run", {
|
|
3412
|
+
method: "POST",
|
|
3413
|
+
body: {
|
|
3414
|
+
table,
|
|
3415
|
+
columns,
|
|
3416
|
+
...(sources.length ? { sources } : {}),
|
|
3417
|
+
...(force ? { force: true } : {}),
|
|
3418
|
+
...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
|
|
3419
|
+
...(maxConcurrency ? { max_concurrency: maxConcurrency } : {}),
|
|
3420
|
+
},
|
|
3421
|
+
}));
|
|
3422
|
+
}))
|
|
3423
|
+
.addCommand(new Command("disable")
|
|
3424
|
+
.description("Disable (clear) a table's standing auto-run configuration.")
|
|
3425
|
+
.argument("<table>", "Table id or slug.")
|
|
3426
|
+
.option("--json", "Print a JSON envelope.")
|
|
3427
|
+
.action((table, options) => handleAsyncAction("tables auto-run disable", options, () => requestOxygen("/api/cli/tables/auto-run", {
|
|
3428
|
+
method: "DELETE",
|
|
3429
|
+
body: { table },
|
|
3430
|
+
})))));
|
|
3431
|
+
tablesCommand.addCommand(new Command("auto-dedupe")
|
|
3432
|
+
.description("Inspect and manage a table's standing auto-dedupe configuration (duplicate rows collapsed automatically after every write; deleted rows are kept in row history).")
|
|
3433
|
+
.addCommand(new Command("get")
|
|
3434
|
+
.description("Show a table's standing auto-dedupe configuration.")
|
|
3435
|
+
.argument("<table>", "Table id or slug.")
|
|
3436
|
+
.option("--json", "Print a JSON envelope.")
|
|
3437
|
+
.action((table, options) => handleAsyncAction("tables auto-dedupe get", options, () => {
|
|
3438
|
+
const params = new URLSearchParams({ table });
|
|
3439
|
+
return requestOxygen(`/api/cli/tables/auto-dedupe?${params.toString()}`);
|
|
3440
|
+
})))
|
|
3441
|
+
.addCommand(new Command("set")
|
|
3442
|
+
.description("Enable a standing auto-dedupe: after every write (insert, upsert, import, send, webhook), duplicate groups containing a just-written row are collapsed by the keep policy — losers are DELETED automatically without per-write approval (kept in row history), and any standing auto-run only enriches the survivors. Runs BEFORE auto-run so credits are never spent on rows about to be deleted. Fuzzy-label matching is on-demand only: use `oxygen tables dedupe --normalize fuzzy-label` instead.")
|
|
3443
|
+
.argument("<table>", "Table id or slug.")
|
|
3444
|
+
.requiredOption("--on <cols>", "Key column(s): one column, or two comma-separated for a composite key (e.g. email or first_name,company).")
|
|
3445
|
+
.option("--normalize <modes>", "Match mode per --on column, comma-separated positionally: exact (default), email, domain, or linkedin. Unpaired columns default to exact; fuzzy-label is rejected (on-demand only).")
|
|
3446
|
+
.option("--keep <policy>", "Survivor per duplicate group (existing rows included): oldest (default), newest, or most-complete.", "oldest")
|
|
3447
|
+
.option("--merge-values <mode>", "Fill the survivor's blank cells from the losers before deleting them: fill-empty.")
|
|
3448
|
+
.option("--json", "Print a JSON envelope.")
|
|
3449
|
+
.action((table, options) => handleAsyncAction("tables auto-dedupe set", options, () => requestOxygen("/api/cli/tables/auto-dedupe", {
|
|
3450
|
+
method: "POST",
|
|
3451
|
+
body: buildTablesAutoDedupeSetBody(table, options),
|
|
3452
|
+
}))))
|
|
3453
|
+
.addCommand(new Command("disable")
|
|
3454
|
+
.description("Disable (clear) a table's standing auto-dedupe configuration.")
|
|
3455
|
+
.argument("<table>", "Table id or slug.")
|
|
3456
|
+
.option("--json", "Print a JSON envelope.")
|
|
3457
|
+
.action((table, options) => handleAsyncAction("tables auto-dedupe disable", options, () => requestOxygen("/api/cli/tables/auto-dedupe", {
|
|
3458
|
+
method: "DELETE",
|
|
3459
|
+
body: { table },
|
|
3460
|
+
})))));
|
|
3461
|
+
tablesCommand.addCommand(new Command("schedule")
|
|
3462
|
+
.description("Scheduled column refresh: a cron workflow re-runs one column on a schedule (only empty/failed cells unless --force). One schedule per column.")
|
|
3463
|
+
.addCommand(new Command("set")
|
|
3464
|
+
.description("Create or replace a column's scheduled refresh (a cron-triggered hosted workflow; visible via `oxygen workflows list`). Paid columns (tool, AI, enrichment) require --max-credits AND --approved; formula columns are free.")
|
|
3465
|
+
.argument("<table>", "Table id or slug.")
|
|
3466
|
+
.argument("<column>", "Column key or id (tool, AI, formula, or enrichment kind).")
|
|
3467
|
+
.option("--cron <expr>", 'Cron expression in UTC, e.g. "0 9 * * *".')
|
|
3468
|
+
.option("--every <sugar>", CADENCE_FLAG_DESCRIPTION)
|
|
3469
|
+
.option("--max-credits <n>", "Credit ceiling PER scheduled refresh; required with --approved for paid columns.")
|
|
3470
|
+
.option("--approved", "Approve the recurring paid runs; required with --max-credits for paid columns.")
|
|
3471
|
+
.option("--force", "Re-run cells that already have values on every tick (full refresh instead of fill-missing).")
|
|
3472
|
+
.option("--json", "Print a JSON envelope.")
|
|
3473
|
+
.action((table, column, options) => {
|
|
3474
|
+
const maxCredits = readPositiveNumber(options.maxCredits);
|
|
3475
|
+
return handleAsyncAction("tables schedule set", options, () => requestOxygen("/api/cli/tables/schedule", {
|
|
3476
|
+
method: "POST",
|
|
3477
|
+
body: {
|
|
3478
|
+
table,
|
|
3479
|
+
column,
|
|
3480
|
+
...(readOption(options.cron) ? { cron: readOption(options.cron) } : {}),
|
|
3481
|
+
...(readOption(options.every) ? { every: readOption(options.every) } : {}),
|
|
3482
|
+
...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
|
|
3483
|
+
...(options.approved ? { approved: true } : {}),
|
|
3484
|
+
...(options.force ? { force: true } : {}),
|
|
3485
|
+
},
|
|
3486
|
+
}));
|
|
3487
|
+
}))
|
|
3488
|
+
.addCommand(new Command("list")
|
|
3489
|
+
.description("List a table's scheduled column refreshes.")
|
|
3490
|
+
.argument("<table>", "Table id or slug.")
|
|
3491
|
+
.option("--json", "Print a JSON envelope.")
|
|
3492
|
+
.action((table, options) => handleAsyncAction("tables schedule list", options, () => {
|
|
3493
|
+
const params = new URLSearchParams({ table });
|
|
3494
|
+
return requestOxygen(`/api/cli/tables/schedule?${params.toString()}`);
|
|
3495
|
+
})))
|
|
3496
|
+
.addCommand(new Command("remove")
|
|
3497
|
+
.description("Remove a column's scheduled refresh (deletes the backing cron workflow; future scheduled runs stop).")
|
|
3498
|
+
.argument("<table>", "Table id or slug.")
|
|
3499
|
+
.argument("<column>", "Column key or id.")
|
|
3500
|
+
.option("--json", "Print a JSON envelope.")
|
|
3501
|
+
.action((table, column, options) => handleAsyncAction("tables schedule remove", options, () => requestOxygen("/api/cli/tables/schedule", {
|
|
3502
|
+
method: "DELETE",
|
|
3503
|
+
body: { table, column },
|
|
3504
|
+
})))));
|
|
3505
|
+
}
|