@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.
Files changed (80) hide show
  1. package/LICENSE +7 -7
  2. package/README.md +770 -770
  3. package/agents/AGENTS.md +192 -192
  4. package/agents/project/custom.md +21 -21
  5. package/agents/project/next-mui.md +21 -21
  6. package/agents/project/next.md +24 -24
  7. package/agents/project/vanilla.md +22 -22
  8. package/agents/project/vite.md +19 -19
  9. package/bin/hawk-app.mjs +0 -0
  10. package/docs/bridge/README.md +1315 -1315
  11. package/docs/showcase/README.md +47 -47
  12. package/docs/showcase/functions/echo.ts +12 -12
  13. package/docs/showcase/functions/long-task.ts +31 -31
  14. package/docs/showcase/functions/showcase.ts +186 -186
  15. package/docs/showcase/hawk-app.json +73 -73
  16. package/docs/showcase/queries/assistant-prompts-by-day.json +6 -6
  17. package/docs/showcase/queries/assistant-prompts-by-day.sql +8 -8
  18. package/docs/showcase/queries/searches-by-day.json +6 -6
  19. package/docs/showcase/queries/searches-by-day.sql +7 -7
  20. package/docs/showcase/src/App.tsx +1436 -1436
  21. package/docs/showcase/src/main.tsx +10 -10
  22. package/docs/showcase/src/styles.css +71 -71
  23. package/docs/showcase/src/useHawkApp.ts +44 -44
  24. package/docs/showcase/src/vite-env.d.ts +1 -1
  25. package/docs/ui/README.md +166 -166
  26. package/package.json +1 -1
  27. package/schema/hawk-app.schema.json +611 -611
  28. package/src/accessibility.mjs +105 -105
  29. package/src/agents.mjs +145 -145
  30. package/src/aliases.mjs +30 -30
  31. package/src/app-icon.mjs +82 -82
  32. package/src/channel.mjs +116 -116
  33. package/src/cli.mjs +825 -825
  34. package/src/clients.mjs +221 -221
  35. package/src/config.mjs +148 -148
  36. package/src/create.mjs +186 -186
  37. package/src/credentials.mjs +56 -56
  38. package/src/data.mjs +169 -169
  39. package/src/deploy.mjs +349 -349
  40. package/src/dev.mjs +459 -459
  41. package/src/doctor.mjs +176 -176
  42. package/src/env.mjs +140 -140
  43. package/src/functions.mjs +313 -313
  44. package/src/login.mjs +166 -166
  45. package/src/logs.mjs +64 -64
  46. package/src/manifest.mjs +425 -425
  47. package/src/mcp-docs.mjs +168 -168
  48. package/src/mcp-hawksearch.mjs +342 -342
  49. package/src/mcp-reports.mjs +130 -130
  50. package/src/mcp-rules.mjs +109 -109
  51. package/src/mcp.mjs +539 -539
  52. package/src/platform.mjs +116 -116
  53. package/src/project-checks.mjs +153 -153
  54. package/src/queries.mjs +289 -289
  55. package/src/scope-check.mjs +91 -91
  56. package/src/secrets.mjs +86 -86
  57. package/src/slug-guard.mjs +80 -80
  58. package/src/sso.mjs +182 -182
  59. package/src/telemetry.mjs +50 -50
  60. package/src/upgrade.mjs +406 -406
  61. package/src/versions.mjs +102 -102
  62. package/templates/next/next.config.ts +13 -13
  63. package/templates/next/package.json +26 -26
  64. package/templates/next/src/lib/useHawkApp.ts +46 -46
  65. package/templates/next-mui/next.config.ts +13 -13
  66. package/templates/next-mui/package.json +30 -30
  67. package/templates/next-mui/src/app/layout.tsx +19 -19
  68. package/templates/next-mui/src/app/page.tsx +78 -78
  69. package/templates/next-mui/src/app/providers.tsx +10 -10
  70. package/templates/preact/package.json +26 -26
  71. package/templates/preact/src/useHawkApp.ts +44 -44
  72. package/templates/preact/src/vite-env.d.ts +1 -1
  73. package/templates/preact/tsconfig.json +17 -17
  74. package/templates/preact/vite.config.ts +11 -11
  75. package/templates/react/package.json +29 -29
  76. package/templates/react/src/useHawkApp.ts +44 -44
  77. package/templates/react/src/vite-env.d.ts +1 -1
  78. package/templates/react/tsconfig.json +16 -16
  79. package/templates/react/vite.config.ts +11 -11
  80. package/templates/vanilla/package.json +15 -15
package/src/queries.mjs CHANGED
@@ -1,289 +1,289 @@
1
- import { existsSync } from 'node:fs';
2
- import { readdir, readFile, stat } from 'node:fs/promises';
3
- import { join } from 'node:path';
4
-
5
- // App queries (`data:read`): queries/<name>.sql + queries/<name>.json, deployed with each version and run by the
6
- // platform against the current client's data. Same rules as the platform, which validates again on deploy.
7
- export const QUERIES_DIR = 'queries';
8
- export const DATA_SCOPE = 'data:read';
9
- export const MAX_QUERIES = 50;
10
- export const MAX_SQL_BYTES = 64 * 1024;
11
- export const MAX_STRING_LENGTH = 1000;
12
- export const MAX_LIST_ITEMS = 100;
13
- export const PARAM_TYPES = ['string', 'int', 'decimal', 'date', 'timestamp', 'boolean', 'string[]'];
14
-
15
- const NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
16
- const PARAM_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
17
- const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
18
- const PARAM_KEYS = ['name', 'type', 'required', 'default', 'min', 'max', 'maxItems'];
19
- const DATE = /^\d{4}-\d{2}-\d{2}$/;
20
- const TIMESTAMP = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
21
-
22
- /**
23
- * Reads and checks `<projectDir>/queries`. `files` are the upload entries (`queries/<name>.sql|.json`, size, full
24
- * path); `queries` the parsed definitions by name. Returns null when the folder doesn't exist.
25
- * @returns {Promise<{ files: { path: string, size: number, full: string }[], queries: { name: string, sql: string, params: object[] }[], errors: string[] } | null>}
26
- */
27
- export async function loadQueries(projectDir) {
28
- const dir = join(projectDir, QUERIES_DIR);
29
- if (!existsSync(dir)) return null;
30
- const errors = [];
31
- const files = [];
32
- const byName = new Map();
33
-
34
- for (const entry of await readdir(dir, { withFileTypes: true })) {
35
- if (entry.name.startsWith('.')) continue;
36
- if (!entry.isFile()) {
37
- errors.push(`${QUERIES_DIR}/${entry.name}: queries can't be in subfolders; put <name>.sql and <name>.json straight in ${QUERIES_DIR}/.`);
38
- continue;
39
- }
40
- const match = /^(.*)\.(sql|json)$/.exec(entry.name);
41
- if (!match) {
42
- errors.push(`${QUERIES_DIR}/${entry.name}: only .sql and .json files belong in ${QUERIES_DIR}/.`);
43
- continue;
44
- }
45
- const [, name, ext] = match;
46
- const full = join(dir, entry.name);
47
- files.push({ path: `${QUERIES_DIR}/${entry.name}`, size: (await stat(full)).size, full });
48
- const pair = byName.get(name) ?? {};
49
- pair[ext] = await readFile(full, 'utf8');
50
- byName.set(name, pair);
51
- }
52
-
53
- const queries = [];
54
- for (const [name, pair] of [...byName].sort(([a], [b]) => a.localeCompare(b))) {
55
- if (!NAME.test(name) || name.length > 64) {
56
- errors.push(`${QUERIES_DIR}/${name}: query names must be 1-64 lowercase letters, digits and single dashes (e.g. "top-searches").`);
57
- continue;
58
- }
59
- if (pair.sql === undefined) {
60
- errors.push(`${QUERIES_DIR}/${name}.json has no ${name}.sql.`);
61
- continue;
62
- }
63
- if (pair.json === undefined) {
64
- errors.push(`${QUERIES_DIR}/${name}.sql has no ${name}.json (declare its parameters there, e.g. { "params": [] }).`);
65
- continue;
66
- }
67
- let definition;
68
- try {
69
- definition = JSON.parse(pair.json);
70
- } catch (error) {
71
- errors.push(`${QUERIES_DIR}/${name}.json is not valid JSON: ${error.message}`);
72
- continue;
73
- }
74
- const queryErrors = validateQuery(name, pair.sql, definition);
75
- errors.push(...queryErrors);
76
- if (!queryErrors.length) queries.push({ name, sql: pair.sql, params: definition.params ?? [] });
77
- }
78
- if (byName.size > MAX_QUERIES) errors.push(`${QUERIES_DIR}/: at most ${MAX_QUERIES} queries per app (found ${byName.size}).`);
79
- if (!byName.size && !errors.length) errors.push(`${QUERIES_DIR}/ is empty: add <name>.sql and <name>.json, or remove the folder.`);
80
-
81
- files.sort((a, b) => a.path.localeCompare(b.path));
82
- return { files, queries, errors };
83
- }
84
-
85
- /**
86
- * Checks one query: its SQL text and its parsed .json definition. Error messages start with the file they're about.
87
- * @returns {string[]}
88
- */
89
- export function validateQuery(name, sql, definition) {
90
- const errors = [];
91
- const sqlFile = `${QUERIES_DIR}/${name}.sql`;
92
- const jsonFile = `${QUERIES_DIR}/${name}.json`;
93
-
94
- const params = checkParams(definition, jsonFile, errors);
95
-
96
- if (Buffer.byteLength(sql, 'utf8') > MAX_SQL_BYTES) {
97
- errors.push(`${sqlFile} is larger than ${MAX_SQL_BYTES / 1024} KB.`);
98
- return errors;
99
- }
100
- const code = stripLiterals(sql);
101
- if (code === null) {
102
- errors.push(`${sqlFile}: unterminated string, quoted identifier or comment.`);
103
- return errors;
104
- }
105
- if (!code.trim()) {
106
- errors.push(`${sqlFile} is empty.`);
107
- return errors;
108
- }
109
- if (code.includes(';')) {
110
- errors.push(`${sqlFile}: one statement only, without ";".`);
111
- }
112
- if (!/^(select|with)\b/i.test(code.trimStart())) {
113
- errors.push(`${sqlFile}: must be a SELECT (or WITH … SELECT) statement.`);
114
- }
115
-
116
- // Macros: {{partitions(from, to)}} or {{partitions(from, to, alias)}}; the params must be declared dates.
117
- const macro = /\{\{([\s\S]*?)\}\}/g;
118
- for (const [whole, inner] of code.matchAll(macro)) {
119
- const call = /^\s*partitions\s*\(([^()]*)\)\s*$/.exec(inner);
120
- if (!call) {
121
- errors.push(`${sqlFile}: unknown macro ${whole.trim()}; the only one is {{partitions(fromParam, toParam)}}.`);
122
- continue;
123
- }
124
- const args = call[1].split(',').map((a) => a.trim());
125
- if (args.length < 2 || args.length > 3 || args.some((a) => !a)) {
126
- errors.push(`${sqlFile}: write {{partitions(fromParam, toParam)}} or {{partitions(fromParam, toParam, tableAlias)}}.`);
127
- continue;
128
- }
129
- for (const arg of args.slice(0, 2)) {
130
- const param = params?.find((p) => p.name === arg);
131
- if (!param) errors.push(`${sqlFile}: {{partitions(…)}} uses "${arg}", which isn't a parameter in ${jsonFile}.`);
132
- else if (param.type !== 'date') errors.push(`${sqlFile}: {{partitions(…)}} parameter "${arg}" must be declared "date" (it is "${param.type}").`);
133
- }
134
- if (args[2] !== undefined && !IDENTIFIER.test(args[2])) errors.push(`${sqlFile}: {{partitions(…)}} table alias "${args[2]}" is not a plain identifier.`);
135
- }
136
- const withoutMacros = code.replace(macro, ' ');
137
- if (/\{\{|\}\}/.test(withoutMacros)) errors.push(`${sqlFile}: unbalanced {{ }} macro.`);
138
-
139
- if (params) {
140
- const placeholders = (withoutMacros.match(/\?/g) ?? []).length;
141
- if (placeholders !== params.length) {
142
- errors.push(
143
- `${sqlFile} has ${placeholders} "?" placeholder${placeholders === 1 ? '' : 's'} but ${jsonFile} declares ${params.length} parameter${params.length === 1 ? '' : 's'}; they must match, in order (a string[] parameter is one "?", written as IN ?).`,
144
- );
145
- }
146
- }
147
- return errors;
148
- }
149
-
150
- function checkParams(definition, file, errors) {
151
- if (!definition || typeof definition !== 'object' || Array.isArray(definition)) {
152
- errors.push(`${file} must be an object: { "params": [ { "name": "from", "type": "date" } ] }.`);
153
- return null;
154
- }
155
- const unknown = Object.keys(definition).filter((k) => k !== 'params');
156
- if (unknown.length) errors.push(`${file}: unknown field${unknown.length === 1 ? '' : 's'} ${unknown.join(', ')} (only "params").`);
157
- const params = definition.params ?? [];
158
- if (!Array.isArray(params)) {
159
- errors.push(`${file}: "params" must be a list.`);
160
- return null;
161
- }
162
- const seen = new Set();
163
- params.forEach((param, i) => {
164
- const at = `${file} params[${i}]`;
165
- if (!param || typeof param !== 'object' || Array.isArray(param)) {
166
- errors.push(`${at} must be an object with "name" and "type".`);
167
- return;
168
- }
169
- const label = typeof param.name === 'string' ? `${file} "${param.name}"` : at;
170
- if (typeof param.name !== 'string' || !PARAM_NAME.test(param.name) || param.name.length > 64) {
171
- errors.push(`${at}: "name" must be letters, digits and _ (starting with a letter or _).`);
172
- } else if (seen.has(param.name)) {
173
- errors.push(`${file}: parameter "${param.name}" is declared more than once.`);
174
- } else {
175
- seen.add(param.name);
176
- }
177
- const extra = Object.keys(param).filter((k) => !PARAM_KEYS.includes(k));
178
- if (extra.length) errors.push(`${label}: unknown field${extra.length === 1 ? '' : 's'} ${extra.join(', ')} (allowed: ${PARAM_KEYS.join(', ')}).`);
179
- if (!PARAM_TYPES.includes(param.type)) {
180
- errors.push(`${label}: "type" must be one of ${PARAM_TYPES.join(', ')}.`);
181
- return;
182
- }
183
- if (param.required !== undefined && typeof param.required !== 'boolean') errors.push(`${label}: "required" must be true or false.`);
184
- const numeric = param.type === 'int' || param.type === 'decimal';
185
- for (const key of ['min', 'max']) {
186
- if (param[key] === undefined) continue;
187
- if (!numeric) errors.push(`${label}: "${key}" only applies to int and decimal parameters.`);
188
- else if (typeof param[key] !== 'number' || !Number.isFinite(param[key]) || (param.type === 'int' && !Number.isInteger(param[key]))) {
189
- errors.push(`${label}: "${key}" must be ${param.type === 'int' ? 'an integer' : 'a number'}.`);
190
- }
191
- }
192
- if (typeof param.min === 'number' && typeof param.max === 'number' && param.min > param.max) errors.push(`${label}: "min" is greater than "max".`);
193
- if (param.maxItems !== undefined) {
194
- if (param.type !== 'string[]') errors.push(`${label}: "maxItems" only applies to string[] parameters.`);
195
- else if (!Number.isInteger(param.maxItems) || param.maxItems < 1 || param.maxItems > MAX_LIST_ITEMS) errors.push(`${label}: "maxItems" must be 1-${MAX_LIST_ITEMS}.`);
196
- }
197
- if (param.default !== undefined) {
198
- const problem = checkValue(param, param.default);
199
- if (problem) errors.push(`${label}: "default" ${problem}.`);
200
- }
201
- });
202
- return params.filter((p) => p && typeof p === 'object' && typeof p.name === 'string');
203
- }
204
-
205
- /**
206
- * Checks a value against a declared parameter. Returns a reason ("must be …") or null when it's fine.
207
- */
208
- export function checkValue(param, value) {
209
- if (value === null) return null;
210
- switch (param.type) {
211
- case 'string':
212
- return typeof value === 'string' && value.length <= MAX_STRING_LENGTH ? null : `must be a string of up to ${MAX_STRING_LENGTH} characters`;
213
- case 'int':
214
- case 'decimal': {
215
- if (typeof value !== 'number' || !Number.isFinite(value) || (param.type === 'int' && !Number.isInteger(value))) return `must be ${param.type === 'int' ? 'an integer' : 'a number'}`;
216
- if (typeof param.min === 'number' && value < param.min) return `must be at least ${param.min}`;
217
- if (typeof param.max === 'number' && value > param.max) return `must be at most ${param.max}`;
218
- return null;
219
- }
220
- case 'date':
221
- return typeof value === 'string' && DATE.test(value) && !Number.isNaN(Date.parse(value)) ? null : 'must be a date (YYYY-MM-DD)';
222
- case 'timestamp':
223
- return typeof value === 'string' && TIMESTAMP.test(value) && !Number.isNaN(Date.parse(value)) ? null : 'must be an ISO 8601 timestamp';
224
- case 'boolean':
225
- return typeof value === 'boolean' ? null : 'must be true or false';
226
- case 'string[]': {
227
- const max = param.maxItems ?? MAX_LIST_ITEMS;
228
- if (!Array.isArray(value) || value.some((v) => typeof v !== 'string' || v.length > MAX_STRING_LENGTH)) return 'must be a list of strings';
229
- return value.length <= max ? null : `must have at most ${max} items`;
230
- }
231
- default:
232
- return 'has an unknown type';
233
- }
234
- }
235
-
236
- /** Turns a command-line value (`--param name=value`) into the declared type: numbers, booleans, comma-separated lists. */
237
- export function parseParamValue(param, text) {
238
- switch (param.type) {
239
- case 'int':
240
- case 'decimal':
241
- return text.trim() === '' ? NaN : Number(text);
242
- case 'boolean':
243
- return text === 'true' ? true : text === 'false' ? false : text;
244
- case 'string[]':
245
- return text === '' ? [] : text.split(',').map((v) => v.trim());
246
- default:
247
- return text;
248
- }
249
- }
250
-
251
- /**
252
- * The SQL with string literals, quoted identifiers and comments blanked out (same length), so `?`, `;`, macros and
253
- * the first keyword can be found without being fooled by text. Null when something is left open.
254
- */
255
- export function stripLiterals(sql) {
256
- let out = '';
257
- let i = 0;
258
- while (i < sql.length) {
259
- const c = sql[i];
260
- const next = sql[i + 1];
261
- if (c === '-' && next === '-') {
262
- const end = sql.indexOf('\n', i);
263
- const stop = end === -1 ? sql.length : end;
264
- out += ' '.repeat(stop - i);
265
- i = stop;
266
- } else if (c === '/' && next === '*') {
267
- const end = sql.indexOf('*/', i + 2);
268
- if (end === -1) return null;
269
- out += ' '.repeat(end + 2 - i);
270
- i = end + 2;
271
- } else if (c === "'" || c === '"') {
272
- // '' and "" escape the quote inside.
273
- let j = i + 1;
274
- for (;;) {
275
- if (j >= sql.length) return null;
276
- if (sql[j] === c) {
277
- if (sql[j + 1] === c) j += 2;
278
- else break;
279
- } else j++;
280
- }
281
- out += ' '.repeat(j + 1 - i);
282
- i = j + 1;
283
- } else {
284
- out += c;
285
- i++;
286
- }
287
- }
288
- return out;
289
- }
1
+ import { existsSync } from 'node:fs';
2
+ import { readdir, readFile, stat } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+
5
+ // App queries (`data:read`): queries/<name>.sql + queries/<name>.json, deployed with each version and run by the
6
+ // platform against the current client's data. Same rules as the platform, which validates again on deploy.
7
+ export const QUERIES_DIR = 'queries';
8
+ export const DATA_SCOPE = 'data:read';
9
+ export const MAX_QUERIES = 50;
10
+ export const MAX_SQL_BYTES = 64 * 1024;
11
+ export const MAX_STRING_LENGTH = 1000;
12
+ export const MAX_LIST_ITEMS = 100;
13
+ export const PARAM_TYPES = ['string', 'int', 'decimal', 'date', 'timestamp', 'boolean', 'string[]'];
14
+
15
+ const NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
16
+ const PARAM_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
17
+ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
18
+ const PARAM_KEYS = ['name', 'type', 'required', 'default', 'min', 'max', 'maxItems'];
19
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
20
+ const TIMESTAMP = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
21
+
22
+ /**
23
+ * Reads and checks `<projectDir>/queries`. `files` are the upload entries (`queries/<name>.sql|.json`, size, full
24
+ * path); `queries` the parsed definitions by name. Returns null when the folder doesn't exist.
25
+ * @returns {Promise<{ files: { path: string, size: number, full: string }[], queries: { name: string, sql: string, params: object[] }[], errors: string[] } | null>}
26
+ */
27
+ export async function loadQueries(projectDir) {
28
+ const dir = join(projectDir, QUERIES_DIR);
29
+ if (!existsSync(dir)) return null;
30
+ const errors = [];
31
+ const files = [];
32
+ const byName = new Map();
33
+
34
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
35
+ if (entry.name.startsWith('.')) continue;
36
+ if (!entry.isFile()) {
37
+ errors.push(`${QUERIES_DIR}/${entry.name}: queries can't be in subfolders; put <name>.sql and <name>.json straight in ${QUERIES_DIR}/.`);
38
+ continue;
39
+ }
40
+ const match = /^(.*)\.(sql|json)$/.exec(entry.name);
41
+ if (!match) {
42
+ errors.push(`${QUERIES_DIR}/${entry.name}: only .sql and .json files belong in ${QUERIES_DIR}/.`);
43
+ continue;
44
+ }
45
+ const [, name, ext] = match;
46
+ const full = join(dir, entry.name);
47
+ files.push({ path: `${QUERIES_DIR}/${entry.name}`, size: (await stat(full)).size, full });
48
+ const pair = byName.get(name) ?? {};
49
+ pair[ext] = await readFile(full, 'utf8');
50
+ byName.set(name, pair);
51
+ }
52
+
53
+ const queries = [];
54
+ for (const [name, pair] of [...byName].sort(([a], [b]) => a.localeCompare(b))) {
55
+ if (!NAME.test(name) || name.length > 64) {
56
+ errors.push(`${QUERIES_DIR}/${name}: query names must be 1-64 lowercase letters, digits and single dashes (e.g. "top-searches").`);
57
+ continue;
58
+ }
59
+ if (pair.sql === undefined) {
60
+ errors.push(`${QUERIES_DIR}/${name}.json has no ${name}.sql.`);
61
+ continue;
62
+ }
63
+ if (pair.json === undefined) {
64
+ errors.push(`${QUERIES_DIR}/${name}.sql has no ${name}.json (declare its parameters there, e.g. { "params": [] }).`);
65
+ continue;
66
+ }
67
+ let definition;
68
+ try {
69
+ definition = JSON.parse(pair.json);
70
+ } catch (error) {
71
+ errors.push(`${QUERIES_DIR}/${name}.json is not valid JSON: ${error.message}`);
72
+ continue;
73
+ }
74
+ const queryErrors = validateQuery(name, pair.sql, definition);
75
+ errors.push(...queryErrors);
76
+ if (!queryErrors.length) queries.push({ name, sql: pair.sql, params: definition.params ?? [] });
77
+ }
78
+ if (byName.size > MAX_QUERIES) errors.push(`${QUERIES_DIR}/: at most ${MAX_QUERIES} queries per app (found ${byName.size}).`);
79
+ if (!byName.size && !errors.length) errors.push(`${QUERIES_DIR}/ is empty: add <name>.sql and <name>.json, or remove the folder.`);
80
+
81
+ files.sort((a, b) => a.path.localeCompare(b.path));
82
+ return { files, queries, errors };
83
+ }
84
+
85
+ /**
86
+ * Checks one query: its SQL text and its parsed .json definition. Error messages start with the file they're about.
87
+ * @returns {string[]}
88
+ */
89
+ export function validateQuery(name, sql, definition) {
90
+ const errors = [];
91
+ const sqlFile = `${QUERIES_DIR}/${name}.sql`;
92
+ const jsonFile = `${QUERIES_DIR}/${name}.json`;
93
+
94
+ const params = checkParams(definition, jsonFile, errors);
95
+
96
+ if (Buffer.byteLength(sql, 'utf8') > MAX_SQL_BYTES) {
97
+ errors.push(`${sqlFile} is larger than ${MAX_SQL_BYTES / 1024} KB.`);
98
+ return errors;
99
+ }
100
+ const code = stripLiterals(sql);
101
+ if (code === null) {
102
+ errors.push(`${sqlFile}: unterminated string, quoted identifier or comment.`);
103
+ return errors;
104
+ }
105
+ if (!code.trim()) {
106
+ errors.push(`${sqlFile} is empty.`);
107
+ return errors;
108
+ }
109
+ if (code.includes(';')) {
110
+ errors.push(`${sqlFile}: one statement only, without ";".`);
111
+ }
112
+ if (!/^(select|with)\b/i.test(code.trimStart())) {
113
+ errors.push(`${sqlFile}: must be a SELECT (or WITH … SELECT) statement.`);
114
+ }
115
+
116
+ // Macros: {{partitions(from, to)}} or {{partitions(from, to, alias)}}; the params must be declared dates.
117
+ const macro = /\{\{([\s\S]*?)\}\}/g;
118
+ for (const [whole, inner] of code.matchAll(macro)) {
119
+ const call = /^\s*partitions\s*\(([^()]*)\)\s*$/.exec(inner);
120
+ if (!call) {
121
+ errors.push(`${sqlFile}: unknown macro ${whole.trim()}; the only one is {{partitions(fromParam, toParam)}}.`);
122
+ continue;
123
+ }
124
+ const args = call[1].split(',').map((a) => a.trim());
125
+ if (args.length < 2 || args.length > 3 || args.some((a) => !a)) {
126
+ errors.push(`${sqlFile}: write {{partitions(fromParam, toParam)}} or {{partitions(fromParam, toParam, tableAlias)}}.`);
127
+ continue;
128
+ }
129
+ for (const arg of args.slice(0, 2)) {
130
+ const param = params?.find((p) => p.name === arg);
131
+ if (!param) errors.push(`${sqlFile}: {{partitions(…)}} uses "${arg}", which isn't a parameter in ${jsonFile}.`);
132
+ else if (param.type !== 'date') errors.push(`${sqlFile}: {{partitions(…)}} parameter "${arg}" must be declared "date" (it is "${param.type}").`);
133
+ }
134
+ if (args[2] !== undefined && !IDENTIFIER.test(args[2])) errors.push(`${sqlFile}: {{partitions(…)}} table alias "${args[2]}" is not a plain identifier.`);
135
+ }
136
+ const withoutMacros = code.replace(macro, ' ');
137
+ if (/\{\{|\}\}/.test(withoutMacros)) errors.push(`${sqlFile}: unbalanced {{ }} macro.`);
138
+
139
+ if (params) {
140
+ const placeholders = (withoutMacros.match(/\?/g) ?? []).length;
141
+ if (placeholders !== params.length) {
142
+ errors.push(
143
+ `${sqlFile} has ${placeholders} "?" placeholder${placeholders === 1 ? '' : 's'} but ${jsonFile} declares ${params.length} parameter${params.length === 1 ? '' : 's'}; they must match, in order (a string[] parameter is one "?", written as IN ?).`,
144
+ );
145
+ }
146
+ }
147
+ return errors;
148
+ }
149
+
150
+ function checkParams(definition, file, errors) {
151
+ if (!definition || typeof definition !== 'object' || Array.isArray(definition)) {
152
+ errors.push(`${file} must be an object: { "params": [ { "name": "from", "type": "date" } ] }.`);
153
+ return null;
154
+ }
155
+ const unknown = Object.keys(definition).filter((k) => k !== 'params');
156
+ if (unknown.length) errors.push(`${file}: unknown field${unknown.length === 1 ? '' : 's'} ${unknown.join(', ')} (only "params").`);
157
+ const params = definition.params ?? [];
158
+ if (!Array.isArray(params)) {
159
+ errors.push(`${file}: "params" must be a list.`);
160
+ return null;
161
+ }
162
+ const seen = new Set();
163
+ params.forEach((param, i) => {
164
+ const at = `${file} params[${i}]`;
165
+ if (!param || typeof param !== 'object' || Array.isArray(param)) {
166
+ errors.push(`${at} must be an object with "name" and "type".`);
167
+ return;
168
+ }
169
+ const label = typeof param.name === 'string' ? `${file} "${param.name}"` : at;
170
+ if (typeof param.name !== 'string' || !PARAM_NAME.test(param.name) || param.name.length > 64) {
171
+ errors.push(`${at}: "name" must be letters, digits and _ (starting with a letter or _).`);
172
+ } else if (seen.has(param.name)) {
173
+ errors.push(`${file}: parameter "${param.name}" is declared more than once.`);
174
+ } else {
175
+ seen.add(param.name);
176
+ }
177
+ const extra = Object.keys(param).filter((k) => !PARAM_KEYS.includes(k));
178
+ if (extra.length) errors.push(`${label}: unknown field${extra.length === 1 ? '' : 's'} ${extra.join(', ')} (allowed: ${PARAM_KEYS.join(', ')}).`);
179
+ if (!PARAM_TYPES.includes(param.type)) {
180
+ errors.push(`${label}: "type" must be one of ${PARAM_TYPES.join(', ')}.`);
181
+ return;
182
+ }
183
+ if (param.required !== undefined && typeof param.required !== 'boolean') errors.push(`${label}: "required" must be true or false.`);
184
+ const numeric = param.type === 'int' || param.type === 'decimal';
185
+ for (const key of ['min', 'max']) {
186
+ if (param[key] === undefined) continue;
187
+ if (!numeric) errors.push(`${label}: "${key}" only applies to int and decimal parameters.`);
188
+ else if (typeof param[key] !== 'number' || !Number.isFinite(param[key]) || (param.type === 'int' && !Number.isInteger(param[key]))) {
189
+ errors.push(`${label}: "${key}" must be ${param.type === 'int' ? 'an integer' : 'a number'}.`);
190
+ }
191
+ }
192
+ if (typeof param.min === 'number' && typeof param.max === 'number' && param.min > param.max) errors.push(`${label}: "min" is greater than "max".`);
193
+ if (param.maxItems !== undefined) {
194
+ if (param.type !== 'string[]') errors.push(`${label}: "maxItems" only applies to string[] parameters.`);
195
+ else if (!Number.isInteger(param.maxItems) || param.maxItems < 1 || param.maxItems > MAX_LIST_ITEMS) errors.push(`${label}: "maxItems" must be 1-${MAX_LIST_ITEMS}.`);
196
+ }
197
+ if (param.default !== undefined) {
198
+ const problem = checkValue(param, param.default);
199
+ if (problem) errors.push(`${label}: "default" ${problem}.`);
200
+ }
201
+ });
202
+ return params.filter((p) => p && typeof p === 'object' && typeof p.name === 'string');
203
+ }
204
+
205
+ /**
206
+ * Checks a value against a declared parameter. Returns a reason ("must be …") or null when it's fine.
207
+ */
208
+ export function checkValue(param, value) {
209
+ if (value === null) return null;
210
+ switch (param.type) {
211
+ case 'string':
212
+ return typeof value === 'string' && value.length <= MAX_STRING_LENGTH ? null : `must be a string of up to ${MAX_STRING_LENGTH} characters`;
213
+ case 'int':
214
+ case 'decimal': {
215
+ if (typeof value !== 'number' || !Number.isFinite(value) || (param.type === 'int' && !Number.isInteger(value))) return `must be ${param.type === 'int' ? 'an integer' : 'a number'}`;
216
+ if (typeof param.min === 'number' && value < param.min) return `must be at least ${param.min}`;
217
+ if (typeof param.max === 'number' && value > param.max) return `must be at most ${param.max}`;
218
+ return null;
219
+ }
220
+ case 'date':
221
+ return typeof value === 'string' && DATE.test(value) && !Number.isNaN(Date.parse(value)) ? null : 'must be a date (YYYY-MM-DD)';
222
+ case 'timestamp':
223
+ return typeof value === 'string' && TIMESTAMP.test(value) && !Number.isNaN(Date.parse(value)) ? null : 'must be an ISO 8601 timestamp';
224
+ case 'boolean':
225
+ return typeof value === 'boolean' ? null : 'must be true or false';
226
+ case 'string[]': {
227
+ const max = param.maxItems ?? MAX_LIST_ITEMS;
228
+ if (!Array.isArray(value) || value.some((v) => typeof v !== 'string' || v.length > MAX_STRING_LENGTH)) return 'must be a list of strings';
229
+ return value.length <= max ? null : `must have at most ${max} items`;
230
+ }
231
+ default:
232
+ return 'has an unknown type';
233
+ }
234
+ }
235
+
236
+ /** Turns a command-line value (`--param name=value`) into the declared type: numbers, booleans, comma-separated lists. */
237
+ export function parseParamValue(param, text) {
238
+ switch (param.type) {
239
+ case 'int':
240
+ case 'decimal':
241
+ return text.trim() === '' ? NaN : Number(text);
242
+ case 'boolean':
243
+ return text === 'true' ? true : text === 'false' ? false : text;
244
+ case 'string[]':
245
+ return text === '' ? [] : text.split(',').map((v) => v.trim());
246
+ default:
247
+ return text;
248
+ }
249
+ }
250
+
251
+ /**
252
+ * The SQL with string literals, quoted identifiers and comments blanked out (same length), so `?`, `;`, macros and
253
+ * the first keyword can be found without being fooled by text. Null when something is left open.
254
+ */
255
+ export function stripLiterals(sql) {
256
+ let out = '';
257
+ let i = 0;
258
+ while (i < sql.length) {
259
+ const c = sql[i];
260
+ const next = sql[i + 1];
261
+ if (c === '-' && next === '-') {
262
+ const end = sql.indexOf('\n', i);
263
+ const stop = end === -1 ? sql.length : end;
264
+ out += ' '.repeat(stop - i);
265
+ i = stop;
266
+ } else if (c === '/' && next === '*') {
267
+ const end = sql.indexOf('*/', i + 2);
268
+ if (end === -1) return null;
269
+ out += ' '.repeat(end + 2 - i);
270
+ i = end + 2;
271
+ } else if (c === "'" || c === '"') {
272
+ // '' and "" escape the quote inside.
273
+ let j = i + 1;
274
+ for (;;) {
275
+ if (j >= sql.length) return null;
276
+ if (sql[j] === c) {
277
+ if (sql[j + 1] === c) j += 2;
278
+ else break;
279
+ } else j++;
280
+ }
281
+ out += ' '.repeat(j + 1 - i);
282
+ i = j + 1;
283
+ } else {
284
+ out += c;
285
+ i++;
286
+ }
287
+ }
288
+ return out;
289
+ }