@ganju/utils 0.0.16 → 0.0.17

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 (57) hide show
  1. package/dist/constants.d.ts +3 -0
  2. package/dist/constants.d.ts.map +1 -1
  3. package/dist/constants.js +12 -1
  4. package/dist/customCodeToken.d.ts +11 -0
  5. package/dist/customCodeToken.d.ts.map +1 -1
  6. package/dist/customCodeToken.js +41 -1
  7. package/dist/data.d.ts +4 -0
  8. package/dist/data.d.ts.map +1 -1
  9. package/dist/data.js +26 -1
  10. package/dist/dataApi.d.ts +68 -1
  11. package/dist/dataApi.d.ts.map +1 -1
  12. package/dist/dataApi.js +45 -1
  13. package/dist/dataCompiler.d.ts.map +1 -1
  14. package/dist/dataCompiler.js +5 -1
  15. package/dist/dataCsv.d.ts +20 -0
  16. package/dist/dataCsv.d.ts.map +1 -0
  17. package/dist/dataCsv.js +102 -0
  18. package/dist/dataEngine.d.ts.map +1 -1
  19. package/dist/dataEngine.js +24 -4
  20. package/dist/dataImport.d.ts +2 -0
  21. package/dist/dataImport.d.ts.map +1 -1
  22. package/dist/dataImport.js +4 -1
  23. package/dist/dataQueries.d.ts +73 -0
  24. package/dist/dataQueries.d.ts.map +1 -0
  25. package/dist/dataQueries.js +513 -0
  26. package/dist/dataRpc.d.ts +19 -0
  27. package/dist/dataRpc.d.ts.map +1 -1
  28. package/dist/dataTools.d.ts +40 -0
  29. package/dist/dataTools.d.ts.map +1 -0
  30. package/dist/dataTools.js +280 -0
  31. package/dist/dataTypes.d.ts +2 -0
  32. package/dist/dataTypes.d.ts.map +1 -1
  33. package/dist/index.d.ts +86 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +2 -0
  36. package/dist/schema.d.ts +91 -0
  37. package/dist/schema.d.ts.map +1 -1
  38. package/dist/schema.js +65 -1
  39. package/dist/sdkConstants.d.ts +1 -0
  40. package/dist/sdkConstants.d.ts.map +1 -1
  41. package/dist/sdkConstants.js +2 -1
  42. package/package.json +1 -1
  43. package/src/constants.ts +13 -1
  44. package/src/customCodeToken.ts +74 -0
  45. package/src/data.ts +34 -0
  46. package/src/dataApi.ts +53 -2
  47. package/src/dataCompiler.ts +5 -1
  48. package/src/dataCsv.ts +99 -0
  49. package/src/dataEngine.ts +24 -4
  50. package/src/dataImport.ts +3 -0
  51. package/src/dataQueries.ts +730 -0
  52. package/src/dataRpc.ts +11 -0
  53. package/src/dataTools.ts +338 -0
  54. package/src/dataTypes.ts +6 -0
  55. package/src/index.ts +9 -1
  56. package/src/schema.ts +69 -1
  57. package/src/sdkConstants.ts +1 -0
@@ -0,0 +1,730 @@
1
+ // Named queries: a SELECT the owner writes once, with named parameters, for
2
+ // reports the structured query can't express. Functions run them by name, and
3
+ // so may the assistant when the owner allows it; nobody sends SQL at run time.
4
+ //
5
+ // A Durable Object refuses `PRAGMA query_only`, so nothing in SQLite stops a
6
+ // statement from writing. The check when a query is saved is the whole guard,
7
+ // in two layers:
8
+ // - our tokenizer: one statement, starting with SELECT or WITH, naming none
9
+ // of SQLite's or our own tables, with named parameters only. A Durable
10
+ // Object's exec runs every statement in a string ("SELECT 1; DELETE FROM
11
+ // orders" deletes), so the single statement is checked here, on tokens.
12
+ // - SQLite's own parser: the statement is compiled with EXPLAIN, never run,
13
+ // and refused if its program opens anything for writing or reads a table
14
+ // that isn't one of the owner's.
15
+ import {
16
+ DATA_IDENTIFIER_PATTERN,
17
+ quoteIdentifier as q
18
+ } from './dataIdentifiers';
19
+ import { isScoped, toStored } from './dataCompiler';
20
+ import type { SqlValue } from './dataCompiler';
21
+ import {
22
+ DATA_LARGE_TABLE_ROWS,
23
+ DATA_MAX_DESCRIPTION,
24
+ DATA_MAX_PAGE,
25
+ DATA_MAX_RESULT_BYTES,
26
+ DATA_ACCESS_RANK,
27
+ DataError
28
+ } from './dataTypes';
29
+ import type {
30
+ DataCaller,
31
+ DataColumn,
32
+ DataSchema,
33
+ DataTable
34
+ } from './dataTypes';
35
+ import type { SqlRow, SqlRunner } from './dataEngine';
36
+
37
+ export const DATA_QUERY_PARAM_TYPES = [
38
+ 'text',
39
+ 'integer',
40
+ 'decimal',
41
+ 'boolean',
42
+ 'date',
43
+ 'datetime'
44
+ ] as const;
45
+ export type DataQueryParamType = (typeof DATA_QUERY_PARAM_TYPES)[number];
46
+
47
+ export interface DataQueryParam {
48
+ name: string;
49
+ type: DataQueryParamType;
50
+ description?: string | null;
51
+ // Required unless false; a missing optional parameter binds NULL.
52
+ required?: boolean;
53
+ }
54
+
55
+ // A named query as the owner writes it: the dashboard's form, the API body
56
+ // and `ganju.data.json` all use this shape.
57
+ export interface DataQueryInput {
58
+ name: string;
59
+ description?: string | null;
60
+ sql: string;
61
+ params?: DataQueryParam[];
62
+ // The assistant may run it from data-query. Off by default.
63
+ assistant?: boolean;
64
+ }
65
+
66
+ export interface DataQuery {
67
+ name: string;
68
+ description: string | null;
69
+ sql: string;
70
+ params: DataQueryParam[];
71
+ assistant: boolean;
72
+ // The owner's tables it reads, found when it was saved.
73
+ tables: string[];
74
+ // EXPLAIN QUERY PLAN when it was saved: "SCAN orders" means it reads the
75
+ // whole table.
76
+ plan: string[];
77
+ updatedAt: string;
78
+ updatedBy: string | null;
79
+ }
80
+
81
+ export const DATA_MAX_QUERIES = 100;
82
+ export const DATA_MAX_QUERY_SQL = 10_000;
83
+ export const DATA_MAX_QUERY_PARAMS = 20;
84
+ export const DATA_DEFAULT_QUERY_ROWS = 100;
85
+
86
+ // Tokens
87
+
88
+ type TokenKind =
89
+ | 'word'
90
+ | 'quoted'
91
+ | 'string'
92
+ | 'number'
93
+ | 'param'
94
+ | 'positional'
95
+ | 'semicolon'
96
+ | 'other';
97
+
98
+ interface Token {
99
+ kind: TokenKind;
100
+ text: string;
101
+ start: number;
102
+ end: number;
103
+ }
104
+
105
+ const WORD_START = /[\p{L}_]/u;
106
+ const WORD_PART = /[\p{L}\p{N}_$]/u;
107
+
108
+ const unterminated = (what: string) =>
109
+ new DataError(`The SQL has an unterminated ${what}.`);
110
+
111
+ // SQLite's lexical rules, as far as telling statements, names, literals and
112
+ // parameters apart needs: comments and whitespace are dropped, everything
113
+ // else becomes a token that keeps its place in the text.
114
+ const tokenize = (sql: string): Token[] => {
115
+ const tokens: Token[] = [];
116
+ let i = 0;
117
+ const push = (kind: TokenKind, start: number, end: number) =>
118
+ tokens.push({ kind, text: sql.slice(start, end), start, end });
119
+ while (i < sql.length) {
120
+ const ch = sql[i];
121
+ if (/\s/.test(ch)) {
122
+ i++;
123
+ } else if (ch === '-' && sql[i + 1] === '-') {
124
+ const end = sql.indexOf('\n', i);
125
+ i = end === -1 ? sql.length : end + 1;
126
+ } else if (ch === '/' && sql[i + 1] === '*') {
127
+ const end = sql.indexOf('*/', i + 2);
128
+ if (end === -1) throw unterminated('comment');
129
+ i = end + 2;
130
+ } else if (ch === "'" || ch === '"' || ch === '`' || ch === '[') {
131
+ const close = ch === '[' ? ']' : ch;
132
+ let j = i + 1;
133
+ for (;;) {
134
+ if (j >= sql.length) {
135
+ throw unterminated(ch === "'" ? 'string' : 'quoted name');
136
+ }
137
+ if (sql[j] === close) {
138
+ // A doubled quote is the quote itself, except in [brackets].
139
+ if (close !== ']' && sql[j + 1] === close) {
140
+ j += 2;
141
+ continue;
142
+ }
143
+ break;
144
+ }
145
+ j++;
146
+ }
147
+ push(ch === "'" ? 'string' : 'quoted', i, j + 1);
148
+ i = j + 1;
149
+ } else if (ch === ':' || ch === '@' || ch === '$') {
150
+ let j = i + 1;
151
+ while (j < sql.length && WORD_PART.test(sql[j])) j++;
152
+ if (j === i + 1) {
153
+ push('other', i, j);
154
+ } else {
155
+ push('param', i, j);
156
+ }
157
+ i = j;
158
+ } else if (ch === '?') {
159
+ let j = i + 1;
160
+ while (j < sql.length && /\d/.test(sql[j])) j++;
161
+ push('positional', i, j);
162
+ i = j;
163
+ } else if (ch === ';') {
164
+ push('semicolon', i, i + 1);
165
+ i++;
166
+ } else if (/\d/.test(ch) || (ch === '.' && /\d/.test(sql[i + 1] ?? ''))) {
167
+ let j = i + 1;
168
+ while (j < sql.length && /[\w.]/.test(sql[j])) j++;
169
+ push('number', i, j);
170
+ i = j;
171
+ } else if (WORD_START.test(ch)) {
172
+ let j = i + 1;
173
+ while (j < sql.length && WORD_PART.test(sql[j])) j++;
174
+ push('word', i, j);
175
+ i = j;
176
+ } else {
177
+ push('other', i, i + 1);
178
+ i++;
179
+ }
180
+ }
181
+ return tokens;
182
+ };
183
+
184
+ // A quoted name's text without its quotes, the way SQLite reads it.
185
+ const nameOf = (token: Token): string => {
186
+ if (token.kind !== 'quoted') return token.text.toLowerCase();
187
+ const inner = token.text.slice(1, -1);
188
+ const quote = token.text[0];
189
+ return (
190
+ quote === '[' ? inner : inner.split(quote + quote).join(quote)
191
+ ).toLowerCase();
192
+ };
193
+
194
+ const FORBIDDEN_WORDS = new Set(['pragma', 'attach', 'detach', 'vacuum']);
195
+
196
+ const reservedName = (name: string) =>
197
+ name.startsWith('_ganju_') ||
198
+ name.startsWith('sqlite_') ||
199
+ name.startsWith('pragma_') ||
200
+ name.includes('__');
201
+
202
+ export interface CompiledQuerySql {
203
+ // The SQL as run: each `:name` replaced by `?N`, its position in `params`.
204
+ sql: string;
205
+ params: string[];
206
+ }
207
+
208
+ /**
209
+ * Checks a named query's text and compiles its parameters to positions. Pure:
210
+ * what only SQLite can tell (that it reads, and what) is checked when it is
211
+ * saved, by checkQueryPlan.
212
+ */
213
+ export const compileQuerySql = (sql: unknown): CompiledQuerySql => {
214
+ if (typeof sql !== 'string' || !sql.trim()) {
215
+ throw new DataError('"sql" is the SELECT to run.');
216
+ }
217
+ if (sql.length > DATA_MAX_QUERY_SQL) {
218
+ throw new DataError(
219
+ `A named query is up to ${DATA_MAX_QUERY_SQL.toLocaleString('en-US')} characters.`
220
+ );
221
+ }
222
+ const tokens = tokenize(sql);
223
+ // Trailing semicolons are harmless; any other ends the statement early, and
224
+ // what follows would run as a second one.
225
+ while (tokens.length && tokens[tokens.length - 1].kind === 'semicolon') {
226
+ tokens.pop();
227
+ }
228
+ if (tokens.some(t => t.kind === 'semicolon')) {
229
+ throw new DataError(
230
+ 'A named query is one statement: remove the ";" inside it.'
231
+ );
232
+ }
233
+ const head = tokens[0];
234
+ if (
235
+ !head ||
236
+ head.kind !== 'word' ||
237
+ !['select', 'with'].includes(head.text.toLowerCase())
238
+ ) {
239
+ throw new DataError(
240
+ 'A named query starts with SELECT or WITH; it only reads.'
241
+ );
242
+ }
243
+ const params: string[] = [];
244
+ let out = '';
245
+ let last = 0;
246
+ for (const token of tokens) {
247
+ if (token.kind === 'positional') {
248
+ throw new DataError(
249
+ 'Use named parameters (:from, :to) instead of "?", so each one can be described.'
250
+ );
251
+ }
252
+ if (
253
+ token.kind === 'word' &&
254
+ FORBIDDEN_WORDS.has(token.text.toLowerCase())
255
+ ) {
256
+ throw new DataError(
257
+ `${token.text.toUpperCase()} isn't allowed in a named query.`
258
+ );
259
+ }
260
+ if (
261
+ (token.kind === 'word' || token.kind === 'quoted') &&
262
+ reservedName(nameOf(token))
263
+ ) {
264
+ throw new DataError(
265
+ `"${nameOf(token)}" isn't one of this database's tables or columns; named queries read the tables you defined.`
266
+ );
267
+ }
268
+ if (token.kind === 'param') {
269
+ const name = token.text.slice(1);
270
+ if (!DATA_IDENTIFIER_PATTERN.test(name)) {
271
+ throw new DataError(
272
+ `Parameter ${token.text}: use lowercase letters, digits and _, starting with a letter.`
273
+ );
274
+ }
275
+ let index = params.indexOf(name);
276
+ if (index === -1) index = params.push(name) - 1;
277
+ out += `${sql.slice(last, token.start)}?${index + 1}`;
278
+ last = token.end;
279
+ }
280
+ }
281
+ out += sql.slice(last, tokens.length ? tokens[tokens.length - 1].end : 0);
282
+ if (params.length > DATA_MAX_QUERY_PARAMS) {
283
+ throw new DataError(
284
+ `A named query takes up to ${DATA_MAX_QUERY_PARAMS} parameters.`
285
+ );
286
+ }
287
+ return { sql: out, params };
288
+ };
289
+
290
+ // What SQLite's program does
291
+
292
+ // Opcodes that only appear in a program that changes the database file. A
293
+ // SELECT's own scratch tables (ORDER BY, DISTINCT, IN lists) use ephemeral
294
+ // cursors and never these.
295
+ const WRITE_OPCODES = new Set([
296
+ 'OpenWrite',
297
+ 'Clear',
298
+ 'Destroy',
299
+ 'CreateBtree',
300
+ 'ParseSchema',
301
+ 'DropTable',
302
+ 'DropIndex',
303
+ 'DropTrigger',
304
+ 'VUpdate',
305
+ 'VCreate',
306
+ 'VDestroy',
307
+ 'SqlExec',
308
+ 'Vacuum',
309
+ 'IncrVacuum',
310
+ 'SetCookie',
311
+ 'JournalMode'
312
+ ]);
313
+
314
+ // Virtual tables are opened with VOpen, not OpenRead, and the program names
315
+ // no module. What it does give is the virtual table's address, and the
316
+ // built-in ones (json_each, dbstat) keep one per connection: so json_each's
317
+ // and json_tree's are read off a query of their own, and any other is
318
+ // refused. dbstat, for one, lists every table with its size.
319
+ const ALLOWED_VIRTUAL_TABLES = [
320
+ "SELECT 1 FROM json_each('[]')",
321
+ "SELECT 1 FROM json_tree('[]')"
322
+ ];
323
+
324
+ const virtualTablesOf = (program: SqlRow[]): unknown[] =>
325
+ program.filter(step => String(step.opcode) === 'VOpen').map(step => step.p4);
326
+
327
+ // SQLite's own error, without the code and offset it appends.
328
+ const sqliteMessage = (error: unknown) =>
329
+ (error instanceof Error ? error.message : String(error))
330
+ .replace(/\s*at offset \d+/, '')
331
+ .replace(/:\s*SQLITE_\w+$/, '')
332
+ .trim();
333
+
334
+ const placeholders = (count: number): SqlValue[] => Array(count).fill(null);
335
+
336
+ export interface QueryPlanCheck {
337
+ tables: string[];
338
+ plan: string[];
339
+ }
340
+
341
+ /**
342
+ * Compiles the statement with EXPLAIN (nothing runs) and checks its program:
343
+ * it may not open anything for writing or start a write transaction, and every
344
+ * table it reads must be one of `schema`'s. Returns those tables and the plan.
345
+ */
346
+ export const checkQueryPlan = (
347
+ runner: SqlRunner,
348
+ schema: DataSchema,
349
+ compiled: CompiledQuerySql
350
+ ): QueryPlanCheck => {
351
+ let program: SqlRow[];
352
+ try {
353
+ program = runner.all(
354
+ `EXPLAIN ${compiled.sql}`,
355
+ placeholders(compiled.params.length)
356
+ );
357
+ } catch (error) {
358
+ throw new DataError(`The SQL doesn't compile: ${sqliteMessage(error)}.`);
359
+ }
360
+ const roots = new Map<number, string>();
361
+ for (const row of runner.all(
362
+ "SELECT rootpage, tbl_name FROM sqlite_schema WHERE type IN ('table', 'index')"
363
+ )) {
364
+ roots.set(Number(row.rootpage), String(row.tbl_name));
365
+ }
366
+ const owned = new Set(schema.tables.map(t => t.name));
367
+ const opened = virtualTablesOf(program);
368
+ if (opened.length > 0) {
369
+ const allowed = new Set(
370
+ ALLOWED_VIRTUAL_TABLES.flatMap(sql =>
371
+ virtualTablesOf(runner.all(`EXPLAIN ${sql}`))
372
+ )
373
+ );
374
+ if (opened.some(vtab => typeof vtab !== 'string' || !allowed.has(vtab))) {
375
+ throw new DataError(
376
+ "A named query reads only this database's tables, with json_each and json_tree for JSON; not other virtual tables such as dbstat."
377
+ );
378
+ }
379
+ }
380
+ const tables = new Set<string>();
381
+ for (const step of program) {
382
+ const opcode = String(step.opcode);
383
+ if (
384
+ WRITE_OPCODES.has(opcode) ||
385
+ (opcode === 'Transaction' && Number(step.p2) !== 0)
386
+ ) {
387
+ throw new DataError(
388
+ 'A named query only reads: this one would change the database.'
389
+ );
390
+ }
391
+ if (opcode === 'OpenRead') {
392
+ const table = roots.get(Number(step.p2));
393
+ if (!table || !owned.has(table)) {
394
+ throw new DataError(
395
+ `A named query reads only this database's tables${table ? `, not "${table}"` : ''}.`
396
+ );
397
+ }
398
+ tables.add(table);
399
+ }
400
+ }
401
+ const plan = runner
402
+ .all(
403
+ `EXPLAIN QUERY PLAN ${compiled.sql}`,
404
+ placeholders(compiled.params.length)
405
+ )
406
+ .map(row => String(row.detail));
407
+ return { tables: [...tables].sort(), plan };
408
+ };
409
+
410
+ // Storage
411
+
412
+ interface StoredQueryRow {
413
+ name: string;
414
+ description: string | null;
415
+ sql: string;
416
+ params: string;
417
+ assistant: number;
418
+ tables: string;
419
+ plan: string;
420
+ updated_at: string;
421
+ updated_by: string | null;
422
+ }
423
+
424
+ const parseList = <T>(raw: unknown): T[] => {
425
+ try {
426
+ const value = JSON.parse(String(raw));
427
+ return Array.isArray(value) ? (value as T[]) : [];
428
+ } catch {
429
+ return [];
430
+ }
431
+ };
432
+
433
+ const fromRow = (row: SqlRow): DataQuery => {
434
+ const stored = row as unknown as StoredQueryRow;
435
+ return {
436
+ name: stored.name,
437
+ description: stored.description,
438
+ sql: stored.sql,
439
+ params: parseList<DataQueryParam>(stored.params),
440
+ assistant: Boolean(stored.assistant),
441
+ tables: parseList<string>(stored.tables),
442
+ plan: parseList<string>(stored.plan),
443
+ updatedAt: stored.updated_at,
444
+ updatedBy: stored.updated_by
445
+ };
446
+ };
447
+
448
+ export const listQueries = (runner: SqlRunner): DataQuery[] =>
449
+ runner.all('SELECT * FROM _ganju_queries ORDER BY name').map(fromRow);
450
+
451
+ const findQuery = (runner: SqlRunner, name: unknown): DataQuery => {
452
+ const row =
453
+ typeof name === 'string'
454
+ ? runner.all('SELECT * FROM _ganju_queries WHERE name = ?', [name])[0]
455
+ : undefined;
456
+ if (!row) {
457
+ const names = listQueries(runner).map(query => query.name);
458
+ throw new DataError(
459
+ `There is no named query "${String(name)}". Named queries: ${names.join(', ') || '(none)'}.`,
460
+ 'not_found'
461
+ );
462
+ }
463
+ return fromRow(row);
464
+ };
465
+
466
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
467
+ value !== null && typeof value === 'object' && !Array.isArray(value);
468
+
469
+ const normaliseParams = (raw: unknown, used: string[]): DataQueryParam[] => {
470
+ const list = raw === undefined || raw === null ? [] : raw;
471
+ if (!Array.isArray(list)) {
472
+ throw new DataError('"params" is a list of { name, type, description }.');
473
+ }
474
+ const params = list.map((entry, i): DataQueryParam => {
475
+ if (!isPlainObject(entry) || typeof entry.name !== 'string') {
476
+ throw new DataError(`params[${i}] needs a name.`);
477
+ }
478
+ if (!DATA_QUERY_PARAM_TYPES.includes(entry.type as DataQueryParamType)) {
479
+ throw new DataError(
480
+ `Parameter ${entry.name}: "type" is one of ${DATA_QUERY_PARAM_TYPES.join(', ')}.`
481
+ );
482
+ }
483
+ const description =
484
+ typeof entry.description === 'string' && entry.description.trim()
485
+ ? entry.description.trim().slice(0, DATA_MAX_DESCRIPTION)
486
+ : null;
487
+ return {
488
+ name: entry.name,
489
+ type: entry.type as DataQueryParamType,
490
+ ...(description ? { description } : {}),
491
+ ...(entry.required === false ? { required: false } : {})
492
+ };
493
+ });
494
+ const declared = params.map(p => p.name);
495
+ const undeclared = used.filter(name => !declared.includes(name));
496
+ if (undeclared.length) {
497
+ throw new DataError(
498
+ `Declare the type of ${undeclared.map(n => `:${n}`).join(', ')} in "params".`
499
+ );
500
+ }
501
+ const unused = declared.filter(name => !used.includes(name));
502
+ if (unused.length) {
503
+ throw new DataError(
504
+ `The SQL doesn't use ${unused.map(n => `:${n}`).join(', ')}.`
505
+ );
506
+ }
507
+ return used.map(name => params.find(p => p.name === name)!);
508
+ };
509
+
510
+ /**
511
+ * Saves a named query, replacing one by the same name. Refused unless it
512
+ * passes both checks; the tables it reads and its plan are kept with it.
513
+ */
514
+ export const saveQuery = (
515
+ runner: SqlRunner,
516
+ schema: DataSchema,
517
+ caller: DataCaller,
518
+ input: unknown
519
+ ): DataQuery => {
520
+ if (!isPlainObject(input)) throw new DataError('A named query is an object.');
521
+ const name = input.name;
522
+ if (typeof name !== 'string' || !DATA_IDENTIFIER_PATTERN.test(name)) {
523
+ throw new DataError(
524
+ 'A named query needs a name: lowercase letters, digits and _, starting with a letter.'
525
+ );
526
+ }
527
+ const compiled = compileQuerySql(input.sql);
528
+ const params = normaliseParams(input.params, compiled.params);
529
+ const { tables, plan } = checkQueryPlan(runner, schema, compiled);
530
+ const description =
531
+ typeof input.description === 'string' && input.description.trim()
532
+ ? input.description.trim().slice(0, DATA_MAX_DESCRIPTION)
533
+ : null;
534
+ const exists =
535
+ runner.all('SELECT 1 FROM _ganju_queries WHERE name = ?', [name]).length >
536
+ 0;
537
+ if (!exists) {
538
+ const count = Number(
539
+ runner.all('SELECT count(*) AS n FROM _ganju_queries')[0]?.n ?? 0
540
+ );
541
+ if (count >= DATA_MAX_QUERIES) {
542
+ throw new DataError(
543
+ `A database holds up to ${DATA_MAX_QUERIES} named queries.`
544
+ );
545
+ }
546
+ }
547
+ runner.all(
548
+ `INSERT INTO _ganju_queries (name, description, sql, params, assistant, tables, plan, updated_at, updated_by)
549
+ VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), ?)
550
+ ON CONFLICT(name) DO UPDATE SET description = excluded.description, sql = excluded.sql,
551
+ params = excluded.params, assistant = excluded.assistant, tables = excluded.tables,
552
+ plan = excluded.plan, updated_at = excluded.updated_at, updated_by = excluded.updated_by`,
553
+ [
554
+ name,
555
+ description,
556
+ (input.sql as string).trim(),
557
+ JSON.stringify(params),
558
+ input.assistant === true ? 1 : 0,
559
+ JSON.stringify(tables),
560
+ JSON.stringify(plan),
561
+ caller.actor
562
+ ]
563
+ );
564
+ return findQuery(runner, name);
565
+ };
566
+
567
+ export const dropQuery = (
568
+ runner: SqlRunner,
569
+ name: unknown
570
+ ): { dropped: string } => {
571
+ const query = findQuery(runner, name);
572
+ runner.all('DELETE FROM _ganju_queries WHERE name = ?', [query.name]);
573
+ return { dropped: query.name };
574
+ };
575
+
576
+ // The queries a caller may run: all of them, or for a channel bot those the
577
+ // owner let the assistant run that read only tables it may read, none of
578
+ // them scoped to a customer. A named query's SQL can't be scoped, so it could
579
+ // read every customer's rows.
580
+ const mayRun = (schema: DataSchema, caller: DataCaller, query: DataQuery) => {
581
+ if (
582
+ caller.tables &&
583
+ !query.tables.every(name => caller.tables?.includes(name))
584
+ ) {
585
+ return false;
586
+ }
587
+ if (!caller.restricted) return true;
588
+ if (!query.assistant) return false;
589
+ return query.tables.every(name => {
590
+ const table = schema.tables.find(t => t.name === name);
591
+ return Boolean(
592
+ table && DATA_ACCESS_RANK[table.access] >= 1 && !isScoped(caller, table)
593
+ );
594
+ });
595
+ };
596
+
597
+ export const queriesFor = (
598
+ runner: SqlRunner,
599
+ schema: DataSchema,
600
+ caller: DataCaller
601
+ ): DataQuery[] =>
602
+ listQueries(runner).filter(query => mayRun(schema, caller, query));
603
+
604
+ // A parameter's value as the column of its type would store it.
605
+ const bindParam = (
606
+ param: DataQueryParam,
607
+ value: unknown,
608
+ timeZone?: string
609
+ ): SqlValue => {
610
+ const column = {
611
+ name: param.name,
612
+ type: param.type,
613
+ required: param.required !== false
614
+ } as DataColumn;
615
+ try {
616
+ return toStored({ name: 'params' } as DataTable, column, value, timeZone);
617
+ } catch (error) {
618
+ if (error instanceof DataError) {
619
+ throw new DataError(error.message.replace(/^params\./, 'Parameter '));
620
+ }
621
+ throw error;
622
+ }
623
+ };
624
+
625
+ /**
626
+ * Runs a named query with `params`, up to `limit` rows (100 by default, 500 at
627
+ * most) and 1 MB. A query that would scan a table over 200,000 rows is refused
628
+ * before it runs: the database serves one request at a time.
629
+ */
630
+ export const runQuery = (
631
+ runner: SqlRunner,
632
+ schema: DataSchema,
633
+ caller: DataCaller,
634
+ args: unknown
635
+ ) => {
636
+ if (!isPlainObject(args)) throw new DataError('Arguments are an object.');
637
+ const query = findQuery(runner, args.name ?? args.query);
638
+ if (!mayRun(schema, caller, query)) {
639
+ throw new DataError(
640
+ `There is no named query "${query.name}" the assistant may run.`,
641
+ 'not_found'
642
+ );
643
+ }
644
+ const given =
645
+ args.params === undefined || args.params === null ? {} : args.params;
646
+ if (!isPlainObject(given)) {
647
+ throw new DataError(
648
+ '"params" is an object, e.g. { "from": "2026-10-01" }.'
649
+ );
650
+ }
651
+ const unknown = Object.keys(given).filter(
652
+ key => !query.params.some(p => p.name === key)
653
+ );
654
+ if (unknown.length) {
655
+ throw new DataError(
656
+ `${query.name} takes ${query.params.map(p => p.name).join(', ') || 'no parameters'}; not ${unknown.join(', ')}.`
657
+ );
658
+ }
659
+ const limit =
660
+ args.limit === undefined || args.limit === null
661
+ ? DATA_DEFAULT_QUERY_ROWS
662
+ : args.limit;
663
+ if (
664
+ typeof limit !== 'number' ||
665
+ !Number.isInteger(limit) ||
666
+ limit < 1 ||
667
+ limit > DATA_MAX_PAGE
668
+ ) {
669
+ throw new DataError(`"limit" is 1 to ${DATA_MAX_PAGE}.`);
670
+ }
671
+ const values = query.params.map(param =>
672
+ bindParam(param, given[param.name], caller.timeZone)
673
+ );
674
+ const compiled = compileQuerySql(query.sql);
675
+
676
+ // A full scan shows in the plan as "SCAN x", with or without "USING
677
+ // (COVERING) INDEX": either way it reads every row, and on a large table
678
+ // that is the request everyone else waits behind.
679
+ const scans = query.plan.some(line => {
680
+ const detail = line.trim();
681
+ return (
682
+ detail.startsWith('SCAN ') &&
683
+ !detail.startsWith('SCAN CONSTANT ROW') &&
684
+ !detail.includes('VIRTUAL TABLE')
685
+ );
686
+ });
687
+ if (scans) {
688
+ for (const name of query.tables) {
689
+ const n = Number(
690
+ runner.all(`SELECT max(id) AS n FROM ${q(name)}`)[0]?.n ?? 0
691
+ );
692
+ if (n > DATA_LARGE_TABLE_ROWS) {
693
+ throw new DataError(
694
+ `${query.name} reads all of ${name}, which has about ${n.toLocaleString('en-US')} rows; filter on an indexed column (id, or a column marked filterable, unique or a relation).`,
695
+ 'too_large'
696
+ );
697
+ }
698
+ }
699
+ }
700
+
701
+ const rows: SqlRow[] = [];
702
+ let bytes = 0;
703
+ let truncated = false;
704
+ try {
705
+ for (const row of runner.iterate(compiled.sql, values)) {
706
+ if (rows.length === limit) {
707
+ truncated = true;
708
+ break;
709
+ }
710
+ const size = JSON.stringify(row).length;
711
+ if (rows.length > 0 && bytes + size > DATA_MAX_RESULT_BYTES) {
712
+ truncated = true;
713
+ break;
714
+ }
715
+ bytes += size;
716
+ rows.push({ ...row });
717
+ }
718
+ } catch (error) {
719
+ throw new DataError(`${query.name} failed: ${sqliteMessage(error)}.`);
720
+ }
721
+ return {
722
+ query: query.name,
723
+ rows,
724
+ ...(truncated
725
+ ? {
726
+ truncated: `stopped at ${rows.length} rows; narrow the parameters or ask the owner for a query that returns fewer`
727
+ }
728
+ : {})
729
+ };
730
+ };