@bridgeline-digital/hawk-app-cli 0.1.0-dev.20261020202308 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +7 -7
- package/README.md +770 -770
- package/agents/AGENTS.md +192 -192
- package/agents/project/custom.md +21 -21
- package/agents/project/next-mui.md +21 -21
- package/agents/project/next.md +24 -24
- package/agents/project/vanilla.md +22 -22
- package/agents/project/vite.md +19 -19
- package/bin/hawk-app.mjs +0 -0
- package/docs/bridge/README.md +1315 -1315
- package/docs/showcase/README.md +47 -47
- package/docs/showcase/functions/echo.ts +12 -12
- package/docs/showcase/functions/long-task.ts +31 -31
- package/docs/showcase/functions/showcase.ts +186 -186
- package/docs/showcase/hawk-app.json +73 -73
- package/docs/showcase/queries/assistant-prompts-by-day.json +6 -6
- package/docs/showcase/queries/assistant-prompts-by-day.sql +8 -8
- package/docs/showcase/queries/searches-by-day.json +6 -6
- package/docs/showcase/queries/searches-by-day.sql +7 -7
- package/docs/showcase/src/App.tsx +1436 -1436
- package/docs/showcase/src/main.tsx +10 -10
- package/docs/showcase/src/styles.css +71 -71
- package/docs/showcase/src/useHawkApp.ts +44 -44
- package/docs/showcase/src/vite-env.d.ts +1 -1
- package/docs/ui/README.md +166 -166
- package/package.json +1 -1
- package/schema/hawk-app.schema.json +611 -611
- package/src/accessibility.mjs +105 -105
- package/src/agents.mjs +145 -145
- package/src/aliases.mjs +30 -30
- package/src/app-icon.mjs +82 -82
- package/src/channel.mjs +116 -116
- package/src/cli.mjs +825 -825
- package/src/clients.mjs +221 -221
- package/src/config.mjs +148 -148
- package/src/create.mjs +186 -186
- package/src/credentials.mjs +56 -56
- package/src/data.mjs +169 -169
- package/src/deploy.mjs +349 -349
- package/src/dev.mjs +459 -459
- package/src/doctor.mjs +176 -176
- package/src/env.mjs +140 -140
- package/src/functions.mjs +313 -313
- package/src/login.mjs +166 -166
- package/src/logs.mjs +64 -64
- package/src/manifest.mjs +425 -425
- package/src/mcp-docs.mjs +168 -168
- package/src/mcp-hawksearch.mjs +342 -342
- package/src/mcp-reports.mjs +130 -130
- package/src/mcp-rules.mjs +109 -109
- package/src/mcp.mjs +539 -539
- package/src/platform.mjs +116 -116
- package/src/project-checks.mjs +153 -153
- package/src/queries.mjs +289 -289
- package/src/scope-check.mjs +91 -91
- package/src/secrets.mjs +86 -86
- package/src/slug-guard.mjs +80 -80
- package/src/sso.mjs +182 -182
- package/src/telemetry.mjs +50 -50
- package/src/upgrade.mjs +406 -406
- package/src/versions.mjs +102 -102
- package/templates/next/next.config.ts +13 -13
- package/templates/next/package.json +26 -26
- package/templates/next/src/lib/useHawkApp.ts +46 -46
- package/templates/next-mui/next.config.ts +13 -13
- package/templates/next-mui/package.json +30 -30
- package/templates/next-mui/src/app/layout.tsx +19 -19
- package/templates/next-mui/src/app/page.tsx +78 -78
- package/templates/next-mui/src/app/providers.tsx +10 -10
- package/templates/preact/package.json +26 -26
- package/templates/preact/src/useHawkApp.ts +44 -44
- package/templates/preact/src/vite-env.d.ts +1 -1
- package/templates/preact/tsconfig.json +17 -17
- package/templates/preact/vite.config.ts +11 -11
- package/templates/react/package.json +29 -29
- package/templates/react/src/useHawkApp.ts +44 -44
- package/templates/react/src/vite-env.d.ts +1 -1
- package/templates/react/tsconfig.json +16 -16
- package/templates/react/vite.config.ts +11 -11
- package/templates/vanilla/package.json +15 -15
package/src/data.mjs
CHANGED
|
@@ -1,169 +1,169 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import { basename } from 'node:path';
|
|
3
|
-
import { checkValue, parseParamValue, validateQuery } from './queries.mjs';
|
|
4
|
-
|
|
5
|
-
const MAX_ROWS_SHOWN = 50;
|
|
6
|
-
const MAX_CELL_WIDTH = 40;
|
|
7
|
-
const RUN_TIMEOUT_MS = 5 * 60_000;
|
|
8
|
-
const MAX_POLL_DELAY_MS = 5_000;
|
|
9
|
-
const CLIENT_ID = /^[A-Za-z0-9]+$/;
|
|
10
|
-
|
|
11
|
-
const USAGE = `Usage:
|
|
12
|
-
hawk-app data tables [--app <slug>]
|
|
13
|
-
hawk-app data describe <table>
|
|
14
|
-
hawk-app data run <queries/name.sql> --client <id> [--param name=value …]`;
|
|
15
|
-
|
|
16
|
-
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* `hawk-app data …`: the tables app queries can use, and running a query against a client's data before deploying it.
|
|
20
|
-
* `client` is a factory, so a usage mistake shows before any login check.
|
|
21
|
-
*/
|
|
22
|
-
export async function dataCommand({ client, sub, args = [], flags = {}, sleep = wait, log = console.log }) {
|
|
23
|
-
switch (sub) {
|
|
24
|
-
case 'tables': {
|
|
25
|
-
const app = typeof flags.app === 'string' ? flags.app : undefined;
|
|
26
|
-
const all = (await client().dataTables(app)).tables ?? [];
|
|
27
|
-
const tables = all.filter((t) => !t.sensitive);
|
|
28
|
-
const sensitive = all.filter((t) => t.sensitive);
|
|
29
|
-
const width = Math.max(0, ...all.map((t) => t.name.length));
|
|
30
|
-
const line = (table) => ` ${table.name.padEnd(width)} ${String(table.columns?.length ?? 0).padStart(3)} columns${table.partitioned ? ' partitioned (use {{partitions(from, to)}})' : ''}`;
|
|
31
|
-
log(`${tables.length} table${tables.length === 1 ? '' : 's'} available to queries (\`hawk-app data describe <table>\` for columns):`);
|
|
32
|
-
for (const table of tables) log(line(table));
|
|
33
|
-
if (sensitive.length) {
|
|
34
|
-
log('');
|
|
35
|
-
log(app
|
|
36
|
-
? `Sensitive tables (visitor-level data; only for apps Engineering opted in), for ${app}:`
|
|
37
|
-
: 'Sensitive tables (visitor-level data; only for apps Engineering opted in; pass --app <slug> to check yours):');
|
|
38
|
-
for (const table of sensitive) log(`${line(table)} ${table.available ? 'available' : 'not available'}`);
|
|
39
|
-
}
|
|
40
|
-
return 0;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
case 'describe': {
|
|
44
|
-
const name = args[0];
|
|
45
|
-
if (!name) throw new Error(USAGE);
|
|
46
|
-
const { tables = [] } = await client().dataTables();
|
|
47
|
-
const table = tables.find((t) => t.name.toLowerCase() === name.toLowerCase());
|
|
48
|
-
if (!table) throw new Error(`Unknown table "${name}". Run \`hawk-app data tables\` to list the tables queries can use.`);
|
|
49
|
-
log(`${table.name}${table.partitioned ? ' (partitioned by year/month: filter with {{partitions(from, to)}})' : ''}`);
|
|
50
|
-
if (table.sensitive) log(' Sensitive: only apps Engineering opted in can query it.');
|
|
51
|
-
const width = Math.max(0, ...table.columns.map((c) => c.name.length));
|
|
52
|
-
for (const column of table.columns) log(` ${column.name.padEnd(width)} ${column.type}`);
|
|
53
|
-
return 0;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
case 'run':
|
|
57
|
-
return run({ client, file: args[0], flags, sleep, log });
|
|
58
|
-
|
|
59
|
-
default:
|
|
60
|
-
throw new Error(sub ? `Unknown data command "${sub}".\n${USAGE}` : USAGE);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async function run({ client, file, flags, sleep, log }) {
|
|
65
|
-
if (!file || !file.endsWith('.sql')) throw new Error(USAGE);
|
|
66
|
-
const clientId = flags.client;
|
|
67
|
-
if (typeof clientId !== 'string' || !CLIENT_ID.test(clientId)) throw new Error('Pass the client to run against: --client <id> (letters and digits, e.g. e3141).');
|
|
68
|
-
|
|
69
|
-
const jsonFile = file.replace(/\.sql$/, '.json');
|
|
70
|
-
const name = basename(file, '.sql');
|
|
71
|
-
const sql = await readText(file);
|
|
72
|
-
let definition;
|
|
73
|
-
try {
|
|
74
|
-
definition = JSON.parse(await readText(jsonFile));
|
|
75
|
-
} catch (error) {
|
|
76
|
-
throw new Error(error instanceof SyntaxError ? `${jsonFile} is not valid JSON: ${error.message}` : error.message);
|
|
77
|
-
}
|
|
78
|
-
const errors = validateQuery(name, sql, definition);
|
|
79
|
-
if (errors.length) {
|
|
80
|
-
for (const error of errors) log(`✖ ${error}`);
|
|
81
|
-
throw new Error(`Fix ${file} first.`);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const params = definition.params ?? [];
|
|
85
|
-
const values = {};
|
|
86
|
-
for (const entry of flags.param ?? []) {
|
|
87
|
-
const eq = typeof entry === 'string' ? entry.indexOf('=') : -1;
|
|
88
|
-
if (eq <= 0) throw new Error(`--param needs name=value, not "${entry}".`);
|
|
89
|
-
const key = entry.slice(0, eq);
|
|
90
|
-
const param = params.find((p) => p.name === key);
|
|
91
|
-
if (!param) throw new Error(`"${key}" is not a parameter of ${name} (declared: ${params.map((p) => p.name).join(', ') || 'none'}).`);
|
|
92
|
-
const value = parseParamValue(param, entry.slice(eq + 1));
|
|
93
|
-
const problem = checkValue(param, value);
|
|
94
|
-
if (problem) throw new Error(`--param ${key} ${problem}.`);
|
|
95
|
-
values[key] = value;
|
|
96
|
-
}
|
|
97
|
-
const missing = params.filter((p) => p.required && p.default === undefined && !(p.name in values)).map((p) => p.name);
|
|
98
|
-
if (missing.length) throw new Error(`Missing required parameter${missing.length === 1 ? '' : 's'}: ${missing.map((m) => `--param ${m}=…`).join(' ')}`);
|
|
99
|
-
|
|
100
|
-
const platform = client();
|
|
101
|
-
const started = Date.now();
|
|
102
|
-
// 429 (too many queries running): wait and send again, 1 s, 2 s, 4 s, then every 5 s, never sooner than
|
|
103
|
-
// Retry-After, until the run's time is up.
|
|
104
|
-
let waited = false;
|
|
105
|
-
const retrying = async (send) => {
|
|
106
|
-
for (let attempt = 0; ; attempt++) {
|
|
107
|
-
try {
|
|
108
|
-
return await send();
|
|
109
|
-
} catch (error) {
|
|
110
|
-
if (error.status !== 429) throw error;
|
|
111
|
-
const wait = Math.max(Math.min(1000 * 2 ** attempt, MAX_POLL_DELAY_MS), error.retryAfterMs ?? 0);
|
|
112
|
-
if (Date.now() - started + wait > RUN_TIMEOUT_MS) throw error;
|
|
113
|
-
if (!waited) log('Too many queries are running; retrying…');
|
|
114
|
-
waited = true;
|
|
115
|
-
await sleep(wait);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
};
|
|
119
|
-
let { status, data } = await retrying(() => platform.dataRun({ clientId, sql, params, values })).catch((error) => {
|
|
120
|
-
if (error.status === 403 && error.data?.code === 'not_available') {
|
|
121
|
-
error.message += '\n `hawk-app data run` only works against the dev platform (--platform <dev url>).';
|
|
122
|
-
}
|
|
123
|
-
throw error;
|
|
124
|
-
});
|
|
125
|
-
for (let attempt = 0; status === 202; attempt++) {
|
|
126
|
-
if (Date.now() - started > RUN_TIMEOUT_MS) throw new Error(`The query did not finish within ${RUN_TIMEOUT_MS / 60_000} minutes.`);
|
|
127
|
-
if (attempt === 0) log('Running…');
|
|
128
|
-
await sleep(Math.min(Math.max(1000 * 2 ** attempt, data.retryAfterMs ?? 0), MAX_POLL_DELAY_MS));
|
|
129
|
-
({ status, data } = await retrying(() => platform.dataExecution(data.executionId)));
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
printTable(data.columns ?? [], data.rows ?? [], log);
|
|
134
|
-
const rowCount = data.rowCount ?? data.rows?.length ?? 0;
|
|
135
|
-
const shown = Math.min(rowCount, MAX_ROWS_SHOWN, data.rows?.length ?? 0);
|
|
136
|
-
log(`${rowCount} row${rowCount === 1 ? '' : 's'}${rowCount > shown ? ` (showing the first ${shown})` : ''}, ${formatBytes(data.bytesScanned ?? 0)} scanned${data.cached ? ' (cached result)' : ''}.`);
|
|
137
|
-
return 0;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
async function readText(file) {
|
|
141
|
-
try {
|
|
142
|
-
return await readFile(file, 'utf8');
|
|
143
|
-
} catch {
|
|
144
|
-
throw new Error(`Cannot read ${file}.`);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Prints up to 50 rows as an aligned text table. */
|
|
149
|
-
export function printTable(columns, rows, log) {
|
|
150
|
-
if (!columns.length) return;
|
|
151
|
-
const cell = (value) => {
|
|
152
|
-
const text = value === null || value === undefined ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
153
|
-
const flat = text.replace(/\s+/g, ' ');
|
|
154
|
-
return flat.length > MAX_CELL_WIDTH ? `${flat.slice(0, MAX_CELL_WIDTH - 1)}…` : flat;
|
|
155
|
-
};
|
|
156
|
-
const shown = rows.slice(0, MAX_ROWS_SHOWN).map((row) => columns.map((_, i) => cell(row[i])));
|
|
157
|
-
const widths = columns.map((c, i) => Math.max(cell(c.name).length, ...shown.map((r) => r[i].length)));
|
|
158
|
-
const line = (cells) => cells.map((c, i) => c.padEnd(widths[i])).join(' ').trimEnd();
|
|
159
|
-
log(line(columns.map((c) => cell(c.name))));
|
|
160
|
-
log(widths.map((w) => '-'.repeat(w)).join(' '));
|
|
161
|
-
for (const row of shown) log(line(row));
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export function formatBytes(bytes) {
|
|
165
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
166
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
167
|
-
if (bytes < 1024 ** 3) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
168
|
-
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
|
169
|
-
}
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
import { checkValue, parseParamValue, validateQuery } from './queries.mjs';
|
|
4
|
+
|
|
5
|
+
const MAX_ROWS_SHOWN = 50;
|
|
6
|
+
const MAX_CELL_WIDTH = 40;
|
|
7
|
+
const RUN_TIMEOUT_MS = 5 * 60_000;
|
|
8
|
+
const MAX_POLL_DELAY_MS = 5_000;
|
|
9
|
+
const CLIENT_ID = /^[A-Za-z0-9]+$/;
|
|
10
|
+
|
|
11
|
+
const USAGE = `Usage:
|
|
12
|
+
hawk-app data tables [--app <slug>]
|
|
13
|
+
hawk-app data describe <table>
|
|
14
|
+
hawk-app data run <queries/name.sql> --client <id> [--param name=value …]`;
|
|
15
|
+
|
|
16
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `hawk-app data …`: the tables app queries can use, and running a query against a client's data before deploying it.
|
|
20
|
+
* `client` is a factory, so a usage mistake shows before any login check.
|
|
21
|
+
*/
|
|
22
|
+
export async function dataCommand({ client, sub, args = [], flags = {}, sleep = wait, log = console.log }) {
|
|
23
|
+
switch (sub) {
|
|
24
|
+
case 'tables': {
|
|
25
|
+
const app = typeof flags.app === 'string' ? flags.app : undefined;
|
|
26
|
+
const all = (await client().dataTables(app)).tables ?? [];
|
|
27
|
+
const tables = all.filter((t) => !t.sensitive);
|
|
28
|
+
const sensitive = all.filter((t) => t.sensitive);
|
|
29
|
+
const width = Math.max(0, ...all.map((t) => t.name.length));
|
|
30
|
+
const line = (table) => ` ${table.name.padEnd(width)} ${String(table.columns?.length ?? 0).padStart(3)} columns${table.partitioned ? ' partitioned (use {{partitions(from, to)}})' : ''}`;
|
|
31
|
+
log(`${tables.length} table${tables.length === 1 ? '' : 's'} available to queries (\`hawk-app data describe <table>\` for columns):`);
|
|
32
|
+
for (const table of tables) log(line(table));
|
|
33
|
+
if (sensitive.length) {
|
|
34
|
+
log('');
|
|
35
|
+
log(app
|
|
36
|
+
? `Sensitive tables (visitor-level data; only for apps Engineering opted in), for ${app}:`
|
|
37
|
+
: 'Sensitive tables (visitor-level data; only for apps Engineering opted in; pass --app <slug> to check yours):');
|
|
38
|
+
for (const table of sensitive) log(`${line(table)} ${table.available ? 'available' : 'not available'}`);
|
|
39
|
+
}
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
case 'describe': {
|
|
44
|
+
const name = args[0];
|
|
45
|
+
if (!name) throw new Error(USAGE);
|
|
46
|
+
const { tables = [] } = await client().dataTables();
|
|
47
|
+
const table = tables.find((t) => t.name.toLowerCase() === name.toLowerCase());
|
|
48
|
+
if (!table) throw new Error(`Unknown table "${name}". Run \`hawk-app data tables\` to list the tables queries can use.`);
|
|
49
|
+
log(`${table.name}${table.partitioned ? ' (partitioned by year/month: filter with {{partitions(from, to)}})' : ''}`);
|
|
50
|
+
if (table.sensitive) log(' Sensitive: only apps Engineering opted in can query it.');
|
|
51
|
+
const width = Math.max(0, ...table.columns.map((c) => c.name.length));
|
|
52
|
+
for (const column of table.columns) log(` ${column.name.padEnd(width)} ${column.type}`);
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
case 'run':
|
|
57
|
+
return run({ client, file: args[0], flags, sleep, log });
|
|
58
|
+
|
|
59
|
+
default:
|
|
60
|
+
throw new Error(sub ? `Unknown data command "${sub}".\n${USAGE}` : USAGE);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function run({ client, file, flags, sleep, log }) {
|
|
65
|
+
if (!file || !file.endsWith('.sql')) throw new Error(USAGE);
|
|
66
|
+
const clientId = flags.client;
|
|
67
|
+
if (typeof clientId !== 'string' || !CLIENT_ID.test(clientId)) throw new Error('Pass the client to run against: --client <id> (letters and digits, e.g. e3141).');
|
|
68
|
+
|
|
69
|
+
const jsonFile = file.replace(/\.sql$/, '.json');
|
|
70
|
+
const name = basename(file, '.sql');
|
|
71
|
+
const sql = await readText(file);
|
|
72
|
+
let definition;
|
|
73
|
+
try {
|
|
74
|
+
definition = JSON.parse(await readText(jsonFile));
|
|
75
|
+
} catch (error) {
|
|
76
|
+
throw new Error(error instanceof SyntaxError ? `${jsonFile} is not valid JSON: ${error.message}` : error.message);
|
|
77
|
+
}
|
|
78
|
+
const errors = validateQuery(name, sql, definition);
|
|
79
|
+
if (errors.length) {
|
|
80
|
+
for (const error of errors) log(`✖ ${error}`);
|
|
81
|
+
throw new Error(`Fix ${file} first.`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const params = definition.params ?? [];
|
|
85
|
+
const values = {};
|
|
86
|
+
for (const entry of flags.param ?? []) {
|
|
87
|
+
const eq = typeof entry === 'string' ? entry.indexOf('=') : -1;
|
|
88
|
+
if (eq <= 0) throw new Error(`--param needs name=value, not "${entry}".`);
|
|
89
|
+
const key = entry.slice(0, eq);
|
|
90
|
+
const param = params.find((p) => p.name === key);
|
|
91
|
+
if (!param) throw new Error(`"${key}" is not a parameter of ${name} (declared: ${params.map((p) => p.name).join(', ') || 'none'}).`);
|
|
92
|
+
const value = parseParamValue(param, entry.slice(eq + 1));
|
|
93
|
+
const problem = checkValue(param, value);
|
|
94
|
+
if (problem) throw new Error(`--param ${key} ${problem}.`);
|
|
95
|
+
values[key] = value;
|
|
96
|
+
}
|
|
97
|
+
const missing = params.filter((p) => p.required && p.default === undefined && !(p.name in values)).map((p) => p.name);
|
|
98
|
+
if (missing.length) throw new Error(`Missing required parameter${missing.length === 1 ? '' : 's'}: ${missing.map((m) => `--param ${m}=…`).join(' ')}`);
|
|
99
|
+
|
|
100
|
+
const platform = client();
|
|
101
|
+
const started = Date.now();
|
|
102
|
+
// 429 (too many queries running): wait and send again, 1 s, 2 s, 4 s, then every 5 s, never sooner than
|
|
103
|
+
// Retry-After, until the run's time is up.
|
|
104
|
+
let waited = false;
|
|
105
|
+
const retrying = async (send) => {
|
|
106
|
+
for (let attempt = 0; ; attempt++) {
|
|
107
|
+
try {
|
|
108
|
+
return await send();
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error.status !== 429) throw error;
|
|
111
|
+
const wait = Math.max(Math.min(1000 * 2 ** attempt, MAX_POLL_DELAY_MS), error.retryAfterMs ?? 0);
|
|
112
|
+
if (Date.now() - started + wait > RUN_TIMEOUT_MS) throw error;
|
|
113
|
+
if (!waited) log('Too many queries are running; retrying…');
|
|
114
|
+
waited = true;
|
|
115
|
+
await sleep(wait);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
let { status, data } = await retrying(() => platform.dataRun({ clientId, sql, params, values })).catch((error) => {
|
|
120
|
+
if (error.status === 403 && error.data?.code === 'not_available') {
|
|
121
|
+
error.message += '\n `hawk-app data run` only works against the dev platform (--platform <dev url>).';
|
|
122
|
+
}
|
|
123
|
+
throw error;
|
|
124
|
+
});
|
|
125
|
+
for (let attempt = 0; status === 202; attempt++) {
|
|
126
|
+
if (Date.now() - started > RUN_TIMEOUT_MS) throw new Error(`The query did not finish within ${RUN_TIMEOUT_MS / 60_000} minutes.`);
|
|
127
|
+
if (attempt === 0) log('Running…');
|
|
128
|
+
await sleep(Math.min(Math.max(1000 * 2 ** attempt, data.retryAfterMs ?? 0), MAX_POLL_DELAY_MS));
|
|
129
|
+
({ status, data } = await retrying(() => platform.dataExecution(data.executionId)));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
printTable(data.columns ?? [], data.rows ?? [], log);
|
|
134
|
+
const rowCount = data.rowCount ?? data.rows?.length ?? 0;
|
|
135
|
+
const shown = Math.min(rowCount, MAX_ROWS_SHOWN, data.rows?.length ?? 0);
|
|
136
|
+
log(`${rowCount} row${rowCount === 1 ? '' : 's'}${rowCount > shown ? ` (showing the first ${shown})` : ''}, ${formatBytes(data.bytesScanned ?? 0)} scanned${data.cached ? ' (cached result)' : ''}.`);
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function readText(file) {
|
|
141
|
+
try {
|
|
142
|
+
return await readFile(file, 'utf8');
|
|
143
|
+
} catch {
|
|
144
|
+
throw new Error(`Cannot read ${file}.`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Prints up to 50 rows as an aligned text table. */
|
|
149
|
+
export function printTable(columns, rows, log) {
|
|
150
|
+
if (!columns.length) return;
|
|
151
|
+
const cell = (value) => {
|
|
152
|
+
const text = value === null || value === undefined ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
153
|
+
const flat = text.replace(/\s+/g, ' ');
|
|
154
|
+
return flat.length > MAX_CELL_WIDTH ? `${flat.slice(0, MAX_CELL_WIDTH - 1)}…` : flat;
|
|
155
|
+
};
|
|
156
|
+
const shown = rows.slice(0, MAX_ROWS_SHOWN).map((row) => columns.map((_, i) => cell(row[i])));
|
|
157
|
+
const widths = columns.map((c, i) => Math.max(cell(c.name).length, ...shown.map((r) => r[i].length)));
|
|
158
|
+
const line = (cells) => cells.map((c, i) => c.padEnd(widths[i])).join(' ').trimEnd();
|
|
159
|
+
log(line(columns.map((c) => cell(c.name))));
|
|
160
|
+
log(widths.map((w) => '-'.repeat(w)).join(' '));
|
|
161
|
+
for (const row of shown) log(line(row));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function formatBytes(bytes) {
|
|
165
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
166
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
167
|
+
if (bytes < 1024 ** 3) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
168
|
+
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
|
169
|
+
}
|