@oxygen-agent/cli 1.1014.985 → 1.1014.1087

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.
Files changed (131) hide show
  1. package/README.md +1 -1
  2. package/dist/command-families/program.d.ts +2 -0
  3. package/dist/command-families/program.js +241 -0
  4. package/dist/command-families/tables/automation.d.ts +22 -0
  5. package/dist/command-families/tables/automation.js +250 -0
  6. package/dist/command-families/tables/bundle-export.d.ts +7 -0
  7. package/dist/command-families/tables/bundle-export.js +198 -0
  8. package/dist/command-families/tables/bundle-format.d.ts +66 -0
  9. package/dist/command-families/tables/bundle-format.js +274 -0
  10. package/dist/command-families/tables/bundle-import.d.ts +20 -0
  11. package/dist/command-families/tables/bundle-import.js +260 -0
  12. package/dist/command-families/tables/display.d.ts +107 -0
  13. package/dist/command-families/tables/display.js +229 -0
  14. package/dist/command-families/tables/import-batches.d.ts +26 -0
  15. package/dist/command-families/tables/import-batches.js +94 -0
  16. package/dist/command-families/tables/import-columns.d.ts +24 -0
  17. package/dist/command-families/tables/import-columns.js +131 -0
  18. package/dist/command-families/tables/import-upload.d.ts +30 -0
  19. package/dist/command-families/tables/import-upload.js +203 -0
  20. package/dist/command-families/tables/import-values.d.ts +10 -0
  21. package/dist/command-families/tables/import-values.js +60 -0
  22. package/dist/command-families/tables/imports.d.ts +19 -0
  23. package/dist/command-families/tables/imports.js +200 -0
  24. package/dist/command-families/tables/options.d.ts +314 -0
  25. package/dist/command-families/tables/options.js +1 -0
  26. package/dist/command-families/tables/register-automation.d.ts +2 -0
  27. package/dist/command-families/tables/register-automation.js +127 -0
  28. package/dist/command-families/tables/register-external-imports.d.ts +2 -0
  29. package/dist/command-families/tables/register-external-imports.js +293 -0
  30. package/dist/command-families/tables/register-files.d.ts +2 -0
  31. package/dist/command-families/tables/register-files.js +72 -0
  32. package/dist/command-families/tables/register-followers.d.ts +2 -0
  33. package/dist/command-families/tables/register-followers.js +73 -0
  34. package/dist/command-families/tables/register-lifecycle.d.ts +2 -0
  35. package/dist/command-families/tables/register-lifecycle.js +114 -0
  36. package/dist/command-families/tables/register-reads.d.ts +2 -0
  37. package/dist/command-families/tables/register-reads.js +187 -0
  38. package/dist/command-families/tables/register-relations.d.ts +2 -0
  39. package/dist/command-families/tables/register-relations.js +244 -0
  40. package/dist/command-families/tables/register-row-actions.d.ts +2 -0
  41. package/dist/command-families/tables/register-row-actions.js +53 -0
  42. package/dist/command-families/tables/register-row-writes.d.ts +2 -0
  43. package/dist/command-families/tables/register-row-writes.js +140 -0
  44. package/dist/command-families/tables/register-views.d.ts +2 -0
  45. package/dist/command-families/tables/register-views.js +83 -0
  46. package/dist/command-families/tables/register-watchers.d.ts +2 -0
  47. package/dist/command-families/tables/register-watchers.js +65 -0
  48. package/dist/command-families/tables/register-webhooks.d.ts +2 -0
  49. package/dist/command-families/tables/register-webhooks.js +126 -0
  50. package/dist/command-families/tables/registration-context.d.ts +8 -0
  51. package/dist/command-families/tables/registration-context.js +1 -0
  52. package/dist/command-families/tables/registration.d.ts +3 -0
  53. package/dist/command-families/tables/registration.js +30 -0
  54. package/dist/command-families/tables/row-selection.d.ts +5 -0
  55. package/dist/command-families/tables/row-selection.js +82 -0
  56. package/dist/command-families/tables/send.d.ts +6 -0
  57. package/dist/command-families/tables/send.js +111 -0
  58. package/dist/command-manifest.js +1 -0
  59. package/dist/commands/copilot.js +8 -0
  60. package/dist/commands/deliverability.js +4 -2
  61. package/dist/commands/sequences.js +30 -2
  62. package/dist/commands/tables.d.ts +1 -3
  63. package/dist/commands/tables.js +1 -3505
  64. package/dist/credentials.js +1 -1
  65. package/dist/customer-operation-commands.js +62 -3
  66. package/dist/help.js +1 -1
  67. package/dist/index.d.ts +1 -99
  68. package/dist/index.js +5 -337
  69. package/dist/knowledge-mirror.js +1 -1
  70. package/dist/skills.js +4 -2
  71. package/dist/update.js +2 -1
  72. package/dist/workspace-placement-commands.d.ts +3 -0
  73. package/dist/workspace-placement-commands.js +32 -0
  74. package/node_modules/@oxygen/shared/dist/agent-sessions.d.ts +4 -0
  75. package/node_modules/@oxygen/shared/dist/agent-sessions.js +11 -3
  76. package/node_modules/@oxygen/shared/dist/agents-enabled.d.ts +29 -0
  77. package/node_modules/@oxygen/shared/dist/agents-enabled.js +51 -0
  78. package/node_modules/@oxygen/shared/dist/capability-discovery.js +39 -2
  79. package/node_modules/@oxygen/shared/dist/contracts/agent-sessions.js +1 -1
  80. package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +1 -1
  81. package/node_modules/@oxygen/shared/dist/contracts/guided-activation.js +5 -5
  82. package/node_modules/@oxygen/shared/dist/contracts/guided-company-files.d.ts +30 -0
  83. package/node_modules/@oxygen/shared/dist/contracts/guided-company-files.js +53 -0
  84. package/node_modules/@oxygen/shared/dist/contracts/guided-first-sourcing.d.ts +120 -0
  85. package/node_modules/@oxygen/shared/dist/contracts/guided-first-sourcing.js +48 -0
  86. package/node_modules/@oxygen/shared/dist/contracts/guided-onboarding-journey.d.ts +267 -0
  87. package/node_modules/@oxygen/shared/dist/contracts/guided-onboarding-journey.js +63 -0
  88. package/node_modules/@oxygen/shared/dist/contracts/operations.d.ts +6 -0
  89. package/node_modules/@oxygen/shared/dist/contracts/operations.js +9 -4
  90. package/node_modules/@oxygen/shared/dist/contracts/table-query-raw.d.ts +2 -0
  91. package/node_modules/@oxygen/shared/dist/contracts/table-query-raw.js +3 -0
  92. package/node_modules/@oxygen/shared/dist/contracts/teammates.js +17 -15
  93. package/node_modules/@oxygen/shared/dist/email-tracking-token.d.ts +2 -0
  94. package/node_modules/@oxygen/shared/dist/email-tracking-token.js +1 -0
  95. package/node_modules/@oxygen/shared/dist/feature-gates.d.ts +4 -0
  96. package/node_modules/@oxygen/shared/dist/feature-gates.js +5 -0
  97. package/node_modules/@oxygen/shared/dist/file-import.d.ts +46 -0
  98. package/node_modules/@oxygen/shared/dist/file-import.js +138 -0
  99. package/node_modules/@oxygen/shared/dist/guided-activation.d.ts +8 -0
  100. package/node_modules/@oxygen/shared/dist/guided-company-files.d.ts +13 -0
  101. package/node_modules/@oxygen/shared/dist/guided-company-files.js +14 -0
  102. package/node_modules/@oxygen/shared/dist/guided-first-sourcing.d.ts +116 -0
  103. package/node_modules/@oxygen/shared/dist/guided-first-sourcing.js +11 -0
  104. package/node_modules/@oxygen/shared/dist/guided-onboarding-answers.d.ts +4 -0
  105. package/node_modules/@oxygen/shared/dist/guided-onboarding-answers.js +42 -0
  106. package/node_modules/@oxygen/shared/dist/guided-onboarding-company-context.d.ts +22 -0
  107. package/node_modules/@oxygen/shared/dist/guided-onboarding-company-context.js +38 -0
  108. package/node_modules/@oxygen/shared/dist/guided-onboarding-journey.d.ts +147 -0
  109. package/node_modules/@oxygen/shared/dist/guided-onboarding-journey.fixture.d.ts +12 -0
  110. package/node_modules/@oxygen/shared/dist/guided-onboarding-journey.fixture.js +83 -0
  111. package/node_modules/@oxygen/shared/dist/guided-onboarding-journey.js +44 -0
  112. package/node_modules/@oxygen/shared/dist/guided-onboarding-prospects.d.ts +7 -0
  113. package/node_modules/@oxygen/shared/dist/guided-onboarding-prospects.js +13 -0
  114. package/node_modules/@oxygen/shared/dist/guided-onboarding-signals.d.ts +4 -0
  115. package/node_modules/@oxygen/shared/dist/guided-onboarding-signals.js +34 -0
  116. package/node_modules/@oxygen/shared/dist/guided-trial-billing.d.ts +26 -0
  117. package/node_modules/@oxygen/shared/dist/guided-trial-billing.js +19 -0
  118. package/node_modules/@oxygen/shared/dist/index.d.ts +0 -1
  119. package/node_modules/@oxygen/shared/dist/index.js +0 -6
  120. package/node_modules/@oxygen/shared/dist/table-limits.d.ts +1 -0
  121. package/node_modules/@oxygen/shared/dist/table-limits.js +6 -0
  122. package/node_modules/@oxygen/shared/dist/teammates.d.ts +13 -8
  123. package/node_modules/@oxygen/shared/dist/teammates.js +20 -11
  124. package/node_modules/@oxygen/shared/dist/user-capability-routing.js +78 -0
  125. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  126. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  127. package/node_modules/@oxygen/shared/dist/workspace-placement-view.d.ts +149 -0
  128. package/node_modules/@oxygen/shared/dist/workspace-placement-view.js +101 -0
  129. package/node_modules/@oxygen/shared/dist/workspace-placement.d.ts +77 -0
  130. package/node_modules/@oxygen/shared/dist/workspace-placement.js +94 -0
  131. package/package.json +1 -1
@@ -0,0 +1,260 @@
1
+ import { readRecordString } from "../../cli-values.js";
2
+ import { readRecord } from "../../command-support.js";
3
+ import { requestOxygen } from "../../http-client.js";
4
+ import { readOption } from "../../util.js";
5
+ import { OxygenError } from "@oxygen/shared/cli-result";
6
+ import { resolve } from "node:path";
7
+ import { SAFE_IMPORT_WRITE_BATCH_SIZE, normalizeImportBatchSize, tableWebUrl } from "./import-values.js";
8
+ import { openTableBundle, readBundleBatches } from "./bundle-format.js";
9
+ import { writeImportBatchWithAutoSplit } from "./import-batches.js";
10
+ export async function importTableBundle(// skipcq: JS-R1005
11
+ options, binaryName) {
12
+ const path = options.file;
13
+ if (!path || !path.trim()) {
14
+ throw new OxygenError("invalid_input", "--file is required.", { exitCode: 1 });
15
+ }
16
+ const batchSize = normalizeImportBatchSize(options.batchSize);
17
+ const effectiveBatchSize = Math.min(batchSize, SAFE_IMPORT_WRITE_BATCH_SIZE);
18
+ const bundle = await openTableBundle(resolve(path));
19
+ // A native `relation` column is only half of a relation: the other half is a
20
+ // row in ox_tables.relation_definitions that ONLY `tables relate` writes. A
21
+ // bundle carries the column but not the definition, and its `target_table` is
22
+ // the SOURCE workspace's table id, which does not exist here. Importing it
23
+ // produced a column that pointed at a foreign workspace and that no surface
24
+ // could repair, rename, archive or delete — every column lifecycle command
25
+ // refuses a relation-shaped column, and the relation commands refuse a
26
+ // definition that was never written (OXY-4360). Skip them and say so;
27
+ // `tables relate` rebuilds a real relation on this side once both tables
28
+ // exist. Keyed on the NATIVE shape, not `kind === "relation"`: CRM
29
+ // relationship columns (`target_object`, no `storage`) are a different
30
+ // mechanism with their own lifecycle and must still import.
31
+ const isNativeRelationColumn = (column) => column.kind === "relation"
32
+ && (column.definition?.storage === "tables"
33
+ || typeof column.definition?.archived_relation_definition_id === "string");
34
+ const bundleColumns = bundle.columns.map(bundleColumnToCreateInput);
35
+ const skippedRelationColumns = bundleColumns
36
+ .filter(isNativeRelationColumn)
37
+ .map((column) => column.key ?? column.label);
38
+ const columns = bundleColumns.filter((column) => !isNativeRelationColumn(column));
39
+ if (bundleColumns.length === 0) {
40
+ throw new OxygenError("invalid_bundle", "Bundle has no columns; nothing to import.", { exitCode: 1 });
41
+ }
42
+ if (columns.length === 0) {
43
+ 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 });
44
+ }
45
+ const validColumnKeys = new Set(columns.map((column) => column.key).filter((key) => Boolean(key)));
46
+ const into = readOption(options.into);
47
+ const upsertKey = readOption(options.key);
48
+ // Resuming/appending into an existing table without an upsert key would
49
+ // duplicate every already-imported row. Require --key so the resume matches
50
+ // rows instead of re-inserting them.
51
+ if (into && !upsertKey) {
52
+ throw new OxygenError("invalid_input", "--into requires --key so rows already imported are matched instead of duplicated. Pass --key <uniqueColumn>.", { details: { into }, exitCode: 1 });
53
+ }
54
+ if (upsertKey) {
55
+ if (upsertKey.startsWith("_")) {
56
+ throw new OxygenError("invalid_input", "--key must be a workspace column key, not an internal field.", { details: { key: upsertKey }, exitCode: 1 });
57
+ }
58
+ if (validColumnKeys.size > 0 && !validColumnKeys.has(upsertKey)) {
59
+ throw new OxygenError("invalid_input", `--key ${upsertKey} is not a column in this bundle.`, { details: { key: upsertKey, available_keys: [...validColumnKeys] }, exitCode: 1 });
60
+ }
61
+ }
62
+ // Resolve the target table: resume into an existing one (--into), or create a
63
+ // fresh one from the bundle schema.
64
+ let newTableId;
65
+ let newTableSlug;
66
+ let createdTable;
67
+ if (into) {
68
+ const describe = await requestOxygen("/api/cli/tables/describe", { method: "POST", body: { table: into } });
69
+ const tableMeta = describe.table ?? null;
70
+ const resolvedId = tableMeta ? readRecordString(tableMeta, "id") : null;
71
+ if (!resolvedId) {
72
+ throw new OxygenError("table_not_found", `--into table ${into} was not found in this org.`, { details: { into }, exitCode: 1 });
73
+ }
74
+ newTableId = resolvedId;
75
+ newTableSlug = tableMeta ? readRecordString(tableMeta, "slug") ?? null : null;
76
+ createdTable = false;
77
+ }
78
+ else {
79
+ const tableName = readOption(options.name) ?? bundle.tableName;
80
+ if (!tableName) {
81
+ throw new OxygenError("invalid_bundle", "Bundle has no table.name and no --name override was provided.", { exitCode: 1 });
82
+ }
83
+ const createResponse = await requestOxygen("/api/cli/tables", {
84
+ method: "POST",
85
+ body: {
86
+ name: tableName,
87
+ columns,
88
+ ...(readOption(options.project) ? { project: readOption(options.project) } : {}),
89
+ },
90
+ });
91
+ const created = readRecord(createResponse, "table");
92
+ const createdId = readRecordString(createResponse, "table_id")
93
+ ?? (created ? readRecordString(created, "id") : null);
94
+ if (!createdId) {
95
+ throw new OxygenError("invalid_response", "Table create response did not include an id; bundle import cannot proceed.", { exitCode: 1 });
96
+ }
97
+ newTableId = createdId;
98
+ newTableSlug = readRecordString(createResponse, "table_slug")
99
+ ?? (created ? readRecordString(created, "slug") : null);
100
+ createdTable = true;
101
+ }
102
+ // In upsert mode every row must carry the key value; otherwise the upsert
103
+ // route rejects the whole batch midway. A single-document bundle has every
104
+ // row in memory, so it is checked before anything is written and never
105
+ // leaves a half-imported table behind for a predictable data problem. A
106
+ // streamed bundle is checked row by row as it is read; a miss stops the
107
+ // import with the row number, and --key makes the rerun idempotent.
108
+ if (upsertKey && bundle.preloadedRows) {
109
+ const missingIndex = bundle.preloadedRows.findIndex((row) => row[upsertKey] === undefined || row[upsertKey] === null);
110
+ if (missingIndex >= 0) {
111
+ 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 });
112
+ }
113
+ }
114
+ const resumeCommand = buildBundleResumeCommand({
115
+ file: path,
116
+ tableId: newTableId,
117
+ key: upsertKey ?? null,
118
+ batchSize: effectiveBatchSize,
119
+ binaryName,
120
+ });
121
+ let processed = 0;
122
+ let insertedCount = 0;
123
+ let updatedCount = 0;
124
+ const batches = readBundleBatches({
125
+ bundle,
126
+ validColumnKeys,
127
+ upsertKey: upsertKey ?? null,
128
+ batchSize: effectiveBatchSize,
129
+ tableId: newTableId,
130
+ resumeCommand,
131
+ });
132
+ for await (const batch of batches) {
133
+ try {
134
+ // Same byte-aware writer as `tables import`: a wide batch pre-splits (and a
135
+ // server 413 halves) instead of dead-ending the bundle at an unchanged
136
+ // resume batch size. No request_id — bundle resumes match on --key.
137
+ const result = await writeImportBatchWithAutoSplit({
138
+ tableRef: newTableId,
139
+ upsertKey: upsertKey ?? undefined,
140
+ rows: batch,
141
+ });
142
+ if (upsertKey) {
143
+ insertedCount += result.insertedCount;
144
+ updatedCount += result.updatedCount;
145
+ }
146
+ else {
147
+ insertedCount += batch.length;
148
+ }
149
+ processed += batch.length;
150
+ }
151
+ catch (error) {
152
+ // A mid-loop failure used to leave a partial table with no way forward.
153
+ // Surface exactly where it stopped and how to resume so the import is
154
+ // recoverable instead of a silent half-write.
155
+ const cause = error instanceof OxygenError
156
+ ? error.code
157
+ : error instanceof Error
158
+ ? error.message
159
+ : "unknown";
160
+ const recovery = upsertKey
161
+ ? `Re-run with --into ${newTableId} --key ${upsertKey} to resume — already-imported rows are matched by "${upsertKey}", not duplicated.`
162
+ : `Resume with: ${resumeCommand}`;
163
+ throw new OxygenError("bundle_import_incomplete", `Bundle import stopped after ${processed}/${bundle.rowCount} rows. ${recovery}`, {
164
+ details: {
165
+ table_id: newTableId,
166
+ table_slug: newTableSlug,
167
+ rows_total: bundle.rowCount,
168
+ rows_processed: processed,
169
+ failed_batch_offset: processed,
170
+ failed_batch_size: batch.length,
171
+ mode: upsertKey ? "upsert" : "insert",
172
+ ...(upsertKey ? { key: upsertKey } : {}),
173
+ resume_command: resumeCommand,
174
+ web_url: tableWebUrl(newTableSlug ?? newTableId),
175
+ cause,
176
+ },
177
+ exitCode: 1,
178
+ });
179
+ }
180
+ }
181
+ return {
182
+ table_id: newTableId,
183
+ table_slug: newTableSlug ?? null,
184
+ created_table: createdTable,
185
+ column_count: columns.length,
186
+ row_count: processed,
187
+ mode: upsertKey ? "upsert" : "insert",
188
+ ...(skippedRelationColumns.length > 0
189
+ ? {
190
+ skipped_relation_columns: skippedRelationColumns,
191
+ 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.`,
192
+ }
193
+ : {}),
194
+ ...(upsertKey
195
+ ? { upsert_key: upsertKey, inserted_count: insertedCount, updated_count: updatedCount }
196
+ : {}),
197
+ source_table: bundle.tableSummary,
198
+ schema_version: bundle.schemaVersion,
199
+ web_url: tableWebUrl(newTableSlug ?? newTableId),
200
+ };
201
+ }
202
+ // Builds the exact CLI command that resumes a failed `import-bundle` into the
203
+ // table it already created. Always targets that table with --into + --key so the
204
+ // resume is idempotent; falls back to a <uniqueColumn> placeholder when the
205
+ // original run had no key (insert mode can't be safely auto-resumed).
206
+ export function buildBundleResumeCommand(input) {
207
+ const fileArg = /\s/.test(input.file) ? `"${input.file}"` : input.file;
208
+ const parts = [
209
+ // Must be the binary the user actually invoked. Hardcoding "oxygen" meant an
210
+ // `oxygen-dev` import printed a resume command that, pasted verbatim, re-ran
211
+ // a DEV bundle against PRODUCTION (OXY-4360).
212
+ `${input.binaryName} tables import-bundle`,
213
+ `--file ${fileArg}`,
214
+ `--into ${input.tableId}`,
215
+ `--key ${input.key ?? "<uniqueColumn>"}`,
216
+ ];
217
+ // 500 is the default in normalizeImportBatchSize; only echo --batch-size when
218
+ // the user picked something else, to keep the resume command minimal.
219
+ if (input.batchSize !== 500) {
220
+ parts.push(`--batch-size ${input.batchSize}`);
221
+ }
222
+ return parts.join(" ");
223
+ }
224
+ export function toBundleColumn(column) {
225
+ // Persist only the fields `tables create` knows how to ingest. Drop ids,
226
+ // physical column names, archivedAt — those are tenant-local and would
227
+ // collide on import.
228
+ return {
229
+ key: column.key,
230
+ ...(column.label !== undefined ? { label: column.label } : {}),
231
+ ...(column.dataType !== undefined ? { dataType: column.dataType } : {}),
232
+ ...(column.kind !== undefined ? { kind: column.kind } : {}),
233
+ ...(column.semanticType !== undefined ? { semanticType: column.semanticType } : {}),
234
+ ...(column.definition !== undefined ? { definition: column.definition } : {}),
235
+ ...(column.position !== undefined ? { position: column.position } : {}),
236
+ };
237
+ }
238
+ export function bundleColumnToCreateInput(column) {
239
+ const label = typeof column.label === "string" && column.label.trim()
240
+ ? column.label.trim()
241
+ : typeof column.key === "string"
242
+ ? column.key
243
+ : null;
244
+ if (!label) {
245
+ throw new OxygenError("invalid_bundle", "Bundle column is missing label/key.", {
246
+ details: { column },
247
+ exitCode: 1,
248
+ });
249
+ }
250
+ return {
251
+ label,
252
+ ...(typeof column.key === "string" ? { key: column.key } : {}),
253
+ ...(typeof column.dataType === "string" ? { dataType: column.dataType } : {}),
254
+ ...(typeof column.kind === "string" ? { kind: column.kind } : {}),
255
+ ...(typeof column.semanticType === "string" ? { semanticType: column.semanticType } : {}),
256
+ ...(column.definition && typeof column.definition === "object" && !Array.isArray(column.definition)
257
+ ? { definition: column.definition }
258
+ : {}),
259
+ };
260
+ }
@@ -0,0 +1,107 @@
1
+ import { ansi } from "../../command-support.js";
2
+ import { TablesLinkOptions, TablesTidySuggestOptions } from "./options.js";
3
+ export type TableLinkCandidateResponse = {
4
+ matchExplanation?: string;
5
+ coverage?: number;
6
+ matchedRows?: number;
7
+ ambiguousRows?: number;
8
+ unmatchedRows?: number;
9
+ source?: {
10
+ columnLabel?: string;
11
+ };
12
+ target?: {
13
+ columnLabel?: string;
14
+ };
15
+ samplePairs?: Array<{
16
+ key?: string;
17
+ }>;
18
+ };
19
+ export type TableLinkResponse = {
20
+ dry_run?: boolean;
21
+ cardinalitySentence?: string;
22
+ chosen?: TableLinkCandidateResponse | null;
23
+ alternatives?: TableLinkCandidateResponse[];
24
+ sampled?: boolean;
25
+ source?: {
26
+ tableLabel?: string;
27
+ rowCount?: number;
28
+ scannedRows?: number;
29
+ };
30
+ target?: {
31
+ tableLabel?: string;
32
+ rowCount?: number;
33
+ };
34
+ relationStorage?: "tables" | "crm_legacy";
35
+ registersEntity?: boolean;
36
+ requiresDefinition?: boolean;
37
+ existingRelationshipSlug?: string | null;
38
+ blockers?: Array<{
39
+ code?: string;
40
+ message?: string;
41
+ }>;
42
+ next_action?: string;
43
+ web_url?: string;
44
+ action_run_id?: string;
45
+ workspace_run_id?: string;
46
+ archived_edge_count?: number;
47
+ edges_to_archive?: number;
48
+ };
49
+ export declare function linkBar(coverage: number, width?: number): string;
50
+ export declare function writeTableLinkPreview(table: string, data: TableLinkResponse, options: TablesLinkOptions): void;
51
+ export type TidyDuplicateSuggestion = {
52
+ survivorColumnId?: string;
53
+ survivorKey?: string;
54
+ loserColumnId?: string;
55
+ loserKey?: string;
56
+ similarity?: number;
57
+ dataType?: string;
58
+ sampledOverlap?: number;
59
+ loserHasUniqueValues?: boolean;
60
+ recommendation?: string;
61
+ };
62
+ export type TidyFormattingIssue = {
63
+ columnId?: string;
64
+ columnKey?: string;
65
+ issue?: string;
66
+ sampleRowIds?: string[];
67
+ detail?: string;
68
+ };
69
+ export type TidyHideSuggestion = {
70
+ columnId?: string;
71
+ columnKey?: string;
72
+ reason?: string;
73
+ fillRate?: number;
74
+ suggestedPosition?: number | null;
75
+ };
76
+ export type TidyValueSanityIssue = {
77
+ columnId?: string;
78
+ columnKey?: string;
79
+ rowId?: string;
80
+ value?: unknown;
81
+ reason?: string;
82
+ };
83
+ export type TidyNotComputedColumn = {
84
+ columnId?: string;
85
+ columnKey?: string;
86
+ reason?: string;
87
+ message?: string;
88
+ };
89
+ export type TidySuggestResponse = {
90
+ tableId?: string;
91
+ sampledRows?: number;
92
+ rowCount?: number;
93
+ duplicateColumns?: TidyDuplicateSuggestion[];
94
+ formattingIssues?: TidyFormattingIssue[];
95
+ hideSuggestions?: TidyHideSuggestion[];
96
+ valueSanity?: TidyValueSanityIssue[];
97
+ notComputed?: TidyNotComputedColumn[];
98
+ url?: string;
99
+ web_url?: string;
100
+ deepLink?: string;
101
+ };
102
+ export declare function handleTablesTidySuggestAction(table: string, options: TablesTidySuggestOptions): Promise<void>;
103
+ export declare function formatTidyCount(value: unknown): string;
104
+ export declare function formatTidyPercent(value: unknown): string;
105
+ export declare function pushTidySection<T>(lines: string[], styles: ReturnType<typeof ansi>, title: string, items: T[], renderItem: (item: T) => string): void;
106
+ export declare function formatTidySuggestions(data: TidySuggestResponse): string;
107
+ export declare function writeRowDeleteWarnings(data: unknown): void;
@@ -0,0 +1,229 @@
1
+ import { writeJson, emitCliFailure, asPayloadRecord } from "../../cli-output.js";
2
+ import { readPositiveInt } from "../../cli-values.js";
3
+ import { readCsvOption, ansi } from "../../command-support.js";
4
+ import { requestOxygen } from "../../http-client.js";
5
+ import { readOption } from "../../util.js";
6
+ import { success } from "@oxygen/shared/cli-result";
7
+ import { stdout as output } from "node:process";
8
+ export function linkBar(coverage, width = 36) {
9
+ const filled = Math.max(0, Math.min(width, Math.round(coverage * width)));
10
+ return `[${"#".repeat(filled)}${"-".repeat(width - filled)}]`;
11
+ }
12
+ export function writeTableLinkPreview(table, data, options) {
13
+ const out = (line = "") => process.stdout.write(`${line}\n`);
14
+ // Undo has no plan to render — just the count and what it touched.
15
+ if (typeof data.edges_to_archive === "number" || typeof data.archived_edge_count === "number") {
16
+ if (typeof data.archived_edge_count === "number") {
17
+ out(`Undone — ${data.archived_edge_count.toLocaleString()} links archived.`);
18
+ out("Links are archived, not deleted, and only the ones that run created were touched.");
19
+ }
20
+ else {
21
+ out(`This will archive ${(data.edges_to_archive ?? 0).toLocaleString()} links created by that run.`);
22
+ out(`Apply: oxygen tables link ${table} --undo ${readOption(options.undo) ?? "<run_id>"} --approved`);
23
+ }
24
+ if (data.web_url)
25
+ out(data.web_url);
26
+ return;
27
+ }
28
+ const blockers = data.blockers ?? [];
29
+ if (blockers.length > 0) {
30
+ for (const blocker of blockers)
31
+ out(blocker.message ?? blocker.code ?? "This link cannot run yet.");
32
+ if (data.web_url)
33
+ out(data.web_url);
34
+ return;
35
+ }
36
+ // Approved run: it is queued, so print how to watch it, not the plan again.
37
+ // The two follow-ups take DIFFERENT ids and that is not cosmetic — undo
38
+ // archives by edge run_id, which is the parent workspace run.
39
+ const watchId = data.action_run_id ?? data.workspace_run_id;
40
+ const undoId = data.workspace_run_id;
41
+ if (!data.dry_run && watchId) {
42
+ out(`Linking rows in the background.`);
43
+ if (data.web_url)
44
+ out(`Run: ${data.web_url}`);
45
+ out(`Watch: oxygen table-runs wait ${watchId}`);
46
+ if (undoId)
47
+ out(`Undo: oxygen tables link ${table} --undo ${undoId}`);
48
+ return;
49
+ }
50
+ const chosen = data.chosen ?? null;
51
+ const sourceLabel = data.source?.tableLabel ?? table;
52
+ const targetLabel = data.target?.tableLabel ?? readOption(options.to) ?? "the other table";
53
+ out(`Linking "${sourceLabel}" -> "${targetLabel}"`);
54
+ out();
55
+ if (!chosen) {
56
+ out(" No column in these two tables matches well enough to link them.");
57
+ out(" Add a shared value to both — a website, a domain, a LinkedIn URL, or an email.");
58
+ if (data.web_url)
59
+ out(data.web_url);
60
+ return;
61
+ }
62
+ const pad = (label) => label.padEnd(14);
63
+ out(` ${pad("Matching on")}${chosen.source?.columnLabel ?? "?"} -> ${chosen.target?.columnLabel ?? "?"}`);
64
+ if (chosen.matchExplanation)
65
+ out(` ${pad("")}(${chosen.matchExplanation})`);
66
+ if (data.cardinalitySentence)
67
+ out(` ${pad("Direction")}${data.cardinalitySentence}`);
68
+ const scanned = data.source?.scannedRows ?? 0;
69
+ const total = data.source?.rowCount ?? scanned;
70
+ // Always name the sample. `sampled` means the scan hit its bound, so these are
71
+ // estimates over a prefix of the table, not the whole thing.
72
+ if (data.sampled && scanned < total) {
73
+ out(` ${pad("Measured on")}${scanned.toLocaleString()} of ${total.toLocaleString()} rows`);
74
+ }
75
+ else {
76
+ out(` ${pad("Measured on")}all ${total.toLocaleString()} rows`);
77
+ }
78
+ out();
79
+ const coverage = chosen.coverage ?? 0;
80
+ out(` ${linkBar(coverage)} ${(chosen.matchedRows ?? 0).toLocaleString()} will link ${Math.round(coverage * 100)}%`);
81
+ if ((chosen.ambiguousRows ?? 0) > 0) {
82
+ out(` ${" ".repeat(38)}${(chosen.ambiguousRows ?? 0).toLocaleString()} ambiguous (skipped)`);
83
+ }
84
+ if ((chosen.unmatchedRows ?? 0) > 0) {
85
+ out(` ${" ".repeat(38)}${(chosen.unmatchedRows ?? 0).toLocaleString()} with no match yet`);
86
+ }
87
+ out();
88
+ const samples = chosen.samplePairs ?? [];
89
+ if (samples.length > 0) {
90
+ for (const sample of samples.slice(0, 3))
91
+ out(` matched on "${sample.key ?? ""}"`);
92
+ if (samples.length > 3)
93
+ out(` ... ${samples.length - 3} more`);
94
+ out();
95
+ }
96
+ if ((chosen.ambiguousRows ?? 0) > 0) {
97
+ out(` Ambiguous rows match more than one row on the other side, so they are left`);
98
+ out(` unlinked rather than guessed. Fix the duplicates first:`);
99
+ out(` oxygen tables dedupe ${targetLabel} --on <column>`);
100
+ out();
101
+ }
102
+ if ((chosen.unmatchedRows ?? 0) > 0 && !options.createMissing) {
103
+ out(` Add --create-missing to create the missing target rows too (free).`);
104
+ out();
105
+ }
106
+ const alternatives = data.alternatives ?? [];
107
+ if (alternatives.length > 0) {
108
+ const best = alternatives[0];
109
+ if (best?.source?.columnLabel) {
110
+ out(` Also measured: ${best.source.columnLabel} -> ${best.target?.columnLabel ?? "?"} ${Math.round((best.coverage ?? 0) * 100)}%`);
111
+ out(` retry with --on "${best.source.columnLabel}"`);
112
+ out();
113
+ }
114
+ }
115
+ if (data.relationStorage === "tables") {
116
+ out(` Stored in Tables — neither table is registered as a CRM object.`);
117
+ out();
118
+ }
119
+ out(" Free — 0 credits. Nothing has been written.");
120
+ const applyFlags = [`--to ${readOption(options.to) ?? "<table>"}`];
121
+ if (readOption(options.on))
122
+ applyFlags.push(`--on "${readOption(options.on)}"`);
123
+ if (options.createMissing)
124
+ applyFlags.push("--create-missing");
125
+ out(` Apply: oxygen tables link ${table} ${applyFlags.join(" ")} --approved`);
126
+ if (data.web_url)
127
+ out(` ${data.web_url}`);
128
+ }
129
+ // `tables tidy-suggest` is a read-only analysis (no credits, no writes): it
130
+ // surfaces duplicate columns, formatting inconsistencies, hide candidates, and
131
+ // value-sanity findings so the caller can pick which reversible cleanup to run
132
+ // next. --json returns the raw report envelope; the default rendering compacts
133
+ // the four sections for a terminal reader and echoes the deep-link.
134
+ export async function handleTablesTidySuggestAction(table, options) {
135
+ try {
136
+ const limit = readPositiveInt(options.limit);
137
+ const categories = readCsvOption(options.categories);
138
+ const data = await requestOxygen("/api/cli/tables/tidy/suggest", {
139
+ method: "POST",
140
+ body: {
141
+ table,
142
+ ...(limit ? { limit } : {}),
143
+ ...(categories.length ? { categories } : {}),
144
+ },
145
+ });
146
+ if (options.json) {
147
+ writeJson(success("tables tidy-suggest", data));
148
+ return;
149
+ }
150
+ process.stdout.write(formatTidySuggestions(data));
151
+ }
152
+ catch (error) {
153
+ emitCliFailure("tables tidy-suggest", error);
154
+ }
155
+ }
156
+ export function formatTidyCount(value) {
157
+ return typeof value === "number" && Number.isFinite(value) ? String(value) : "0";
158
+ }
159
+ export function formatTidyPercent(value) {
160
+ if (typeof value !== "number" || !Number.isFinite(value))
161
+ return "—";
162
+ return `${(value * 100).toFixed(0)}%`;
163
+ }
164
+ // Render one tidy-suggest section (header + count, then either a dim "None" or
165
+ // one formatted line per item) into the shared `lines` buffer. Extracted so the
166
+ // four sections share a single control-flow shape instead of repeating it.
167
+ export function pushTidySection(lines, styles, title, items, renderItem) {
168
+ lines.push("", styles.bold(`${title} (${items.length})`));
169
+ if (items.length === 0) {
170
+ lines.push(` ${styles.dim("None")}`);
171
+ return;
172
+ }
173
+ for (const item of items) {
174
+ lines.push(renderItem(item));
175
+ }
176
+ }
177
+ export function formatTidySuggestions(data) {
178
+ const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
179
+ const duplicates = Array.isArray(data.duplicateColumns) ? data.duplicateColumns : [];
180
+ const formatting = Array.isArray(data.formattingIssues) ? data.formattingIssues : [];
181
+ const hide = Array.isArray(data.hideSuggestions) ? data.hideSuggestions : [];
182
+ const valueSanity = Array.isArray(data.valueSanity) ? data.valueSanity : [];
183
+ const notComputed = Array.isArray(data.notComputed) ? data.notComputed : [];
184
+ const lines = [
185
+ "",
186
+ `${styles.bold("Table tidy suggestions")} ${styles.dim(`(sampled ${formatTidyCount(data.sampledRows)} of ${formatTidyCount(data.rowCount)} rows)`)}`,
187
+ ];
188
+ pushTidySection(lines, styles, "Duplicate columns", duplicates, (dup) => {
189
+ const loser = dup.loserKey ?? dup.loserColumnId ?? "?";
190
+ const survivor = dup.survivorKey ?? dup.survivorColumnId ?? "?";
191
+ return ` ${loser} ${styles.dim("→ duplicate of")} ${survivor} ${styles.dim(`(${formatTidyPercent(dup.similarity)} match, ${dup.recommendation ?? "review"})`)}`;
192
+ });
193
+ pushTidySection(lines, styles, "Formatting issues", formatting, (issue) => {
194
+ const sampleCount = Array.isArray(issue.sampleRowIds) ? issue.sampleRowIds.length : 0;
195
+ const column = issue.columnKey ?? issue.columnId ?? "?";
196
+ return ` ${column} ${styles.dim(`— ${issue.issue ?? "issue"}`)}${issue.detail ? `: ${issue.detail}` : ""} ${styles.dim(`(${sampleCount} sample row(s))`)}`;
197
+ });
198
+ pushTidySection(lines, styles, "Hide suggestions", hide, (suggestion) => {
199
+ const column = suggestion.columnKey ?? suggestion.columnId ?? "?";
200
+ return ` ${column} ${styles.dim(`— ${suggestion.reason ?? "hide"} (${formatTidyPercent(suggestion.fillRate)} filled)`)}`;
201
+ });
202
+ pushTidySection(lines, styles, "Value-sanity findings", valueSanity, (finding) => {
203
+ const column = finding.columnKey ?? finding.columnId ?? "?";
204
+ return ` ${column} ${styles.dim(`row ${finding.rowId ?? "?"} — ${finding.reason ?? "check value"}`)}`;
205
+ });
206
+ // Printed even when empty, like the four finding sections: a reader who never
207
+ // sees this heading cannot tell a measured column from an unmeasured one.
208
+ pushTidySection(lines, styles, "Not measured", notComputed, (entry) => {
209
+ const column = entry.columnKey ?? entry.columnId ?? "?";
210
+ return ` ${column} ${styles.dim(`— ${entry.message ?? entry.reason ?? "could not be measured"}`)}`;
211
+ });
212
+ const link = data.deepLink ?? data.web_url ?? data.url;
213
+ if (link) {
214
+ lines.push("", styles.dim(`View table: ${link}`));
215
+ }
216
+ lines.push("");
217
+ return lines.join("\n");
218
+ }
219
+ // `tables delete-row|delete-rows` for a human reader: a deleted row's lead stays
220
+ // enrolled in its campaigns, so name them where the delete was typed.
221
+ export function writeRowDeleteWarnings(data) {
222
+ const payload = asPayloadRecord(data);
223
+ const warnings = Array.isArray(payload?.warnings) ? payload.warnings : [];
224
+ for (const entry of warnings) {
225
+ const warning = asPayloadRecord(entry);
226
+ if (warning && typeof warning.message === "string")
227
+ process.stderr.write(`Warning: ${warning.message}\n`);
228
+ }
229
+ }
@@ -0,0 +1,26 @@
1
+ export type ImportBatchWriteSummary = {
2
+ rowCount: number;
3
+ insertedCount: number;
4
+ updatedCount: number;
5
+ warningCount: number;
6
+ warnings: unknown[];
7
+ warningsTruncated: boolean;
8
+ writeRequestCount: number;
9
+ requestTooLargeRetries: number;
10
+ byteSplits: number;
11
+ minimumBatchSizeUsed: number;
12
+ lastResult: Record<string, unknown> | null;
13
+ };
14
+ export declare function writeImportBatchWithAutoSplit(input: {
15
+ tableRef: string;
16
+ upsertKey: string | undefined;
17
+ rows: Record<string, unknown>[];
18
+ requestId?: string;
19
+ }): Promise<ImportBatchWriteSummary>;
20
+ export declare function writeImportBatchHalves(input: Parameters<typeof writeImportBatchWithAutoSplit>[0], midpoint: number): Promise<[ImportBatchWriteSummary, ImportBatchWriteSummary]>;
21
+ export declare function summarizeImportBatchWrite(result: Record<string, unknown>, batchSize: number): ImportBatchWriteSummary;
22
+ export declare function combineImportBatchWriteSummaries(first: ImportBatchWriteSummary, second: ImportBatchWriteSummary, extra: {
23
+ requestTooLargeRetries?: number;
24
+ byteSplits?: number;
25
+ }): ImportBatchWriteSummary;
26
+ export declare function isRequestTooLargeError(error: unknown): boolean;