@ganju/utils 0.0.18 → 0.0.21

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 (72) hide show
  1. package/dist/constants.d.ts +18 -0
  2. package/dist/constants.d.ts.map +1 -1
  3. package/dist/constants.js +50 -3
  4. package/dist/crypto.d.ts +2 -2
  5. package/dist/crypto.d.ts.map +1 -1
  6. package/dist/crypto.js +1 -0
  7. package/dist/data.d.ts +6 -4
  8. package/dist/data.d.ts.map +1 -1
  9. package/dist/data.js +13 -3
  10. package/dist/dataApi.d.ts +54 -8
  11. package/dist/dataApi.d.ts.map +1 -1
  12. package/dist/dataApi.js +40 -7
  13. package/dist/dataAttachment.d.ts +8 -0
  14. package/dist/dataAttachment.d.ts.map +1 -0
  15. package/dist/dataAttachment.js +19 -0
  16. package/dist/dataCompiler.d.ts +3 -0
  17. package/dist/dataCompiler.d.ts.map +1 -1
  18. package/dist/dataCompiler.js +25 -1
  19. package/dist/dataDdl.d.ts.map +1 -1
  20. package/dist/dataDdl.js +5 -0
  21. package/dist/dataEngine.d.ts +9 -3
  22. package/dist/dataEngine.d.ts.map +1 -1
  23. package/dist/dataEngine.js +141 -5
  24. package/dist/dataIndexAdvice.d.ts +36 -0
  25. package/dist/dataIndexAdvice.d.ts.map +1 -0
  26. package/dist/dataIndexAdvice.js +89 -0
  27. package/dist/dataRpc.d.ts +12 -1
  28. package/dist/dataRpc.d.ts.map +1 -1
  29. package/dist/dataTools.js +3 -3
  30. package/dist/dataTriggers.d.ts +23 -0
  31. package/dist/dataTriggers.d.ts.map +1 -1
  32. package/dist/dataTriggers.js +46 -2
  33. package/dist/dataTypes.d.ts +46 -0
  34. package/dist/dataTypes.d.ts.map +1 -1
  35. package/dist/dataTypes.js +7 -1
  36. package/dist/index.d.ts +138 -4
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +31 -0
  39. package/dist/localizeDataError.d.ts.map +1 -1
  40. package/dist/localizeDataError.js +64 -2
  41. package/dist/schema.d.ts +85 -2
  42. package/dist/schema.d.ts.map +1 -1
  43. package/dist/schema.js +94 -1
  44. package/dist/sdkConstants.d.ts +1 -0
  45. package/dist/sdkConstants.d.ts.map +1 -1
  46. package/dist/sdkConstants.js +2 -1
  47. package/dist/whatsappTemplates.d.ts +77 -0
  48. package/dist/whatsappTemplates.d.ts.map +1 -0
  49. package/dist/whatsappTemplates.js +402 -0
  50. package/dist/whatsappWebhook.d.ts +28 -0
  51. package/dist/whatsappWebhook.d.ts.map +1 -0
  52. package/dist/whatsappWebhook.js +30 -0
  53. package/package.json +1 -1
  54. package/src/constants.ts +57 -3
  55. package/src/crypto.ts +3 -2
  56. package/src/data.ts +14 -0
  57. package/src/dataApi.ts +44 -6
  58. package/src/dataAttachment.ts +23 -0
  59. package/src/dataCompiler.ts +37 -0
  60. package/src/dataDdl.ts +7 -0
  61. package/src/dataEngine.ts +195 -5
  62. package/src/dataIndexAdvice.ts +127 -0
  63. package/src/dataRpc.ts +17 -1
  64. package/src/dataTools.ts +3 -3
  65. package/src/dataTriggers.ts +71 -1
  66. package/src/dataTypes.ts +62 -0
  67. package/src/index.ts +74 -1
  68. package/src/localizeDataError.ts +101 -3
  69. package/src/schema.ts +115 -1
  70. package/src/sdkConstants.ts +1 -0
  71. package/src/whatsappTemplates.ts +465 -0
  72. package/src/whatsappWebhook.ts +55 -0
package/src/dataApi.ts CHANGED
@@ -59,6 +59,7 @@ const {
59
59
  DATA_TRIGGER_INPUT,
60
60
  DATA_TRIGGER_PATCH,
61
61
  DATA_TRIGGER_DEPLOY_BODY,
62
+ DATA_TRIGGER_TEST_SEND_BODY,
62
63
  DATA_TRIGGER_RUN_LIST_QUERY
63
64
  } = Schema;
64
65
 
@@ -69,7 +70,7 @@ export const DATA_API_ROUTES = {
69
70
  tag: 'Database',
70
71
  summary: "The project's database",
71
72
  description:
72
- 'Its status and size, and its tables when it is ready. `database` is null when the project has none.',
73
+ 'Its status and size, and its tables when it is ready, with `indexAdvice`: the open index suggestions. `database` is null when the project has none.',
73
74
  returns: '{ database }'
74
75
  },
75
76
  create: {
@@ -139,6 +140,24 @@ export const DATA_API_ROUTES = {
139
140
  'A column change on a table over 100,000 rows copies it in the background; table changes, and writes that would change its rows, wait until it finishes (`copy` on `GET` shows how far it got). A copy that failed left the table as it was and stays in `copy` with its `error` until dismissed, or until the next column change.',
140
141
  returns: '{ copy: null }'
141
142
  },
143
+ applyIndexAdvice: {
144
+ method: 'post',
145
+ path: '/advice/:adviceId',
146
+ tag: 'Tables',
147
+ summary: 'Add a suggested index',
148
+ description:
149
+ "`GET` lists the open suggestions in `indexAdvice`: a column filtered or sorted on by 50 or more queries in a week that each read 1,000 or more rows, 20 for every row returned. This marks the column filterable, which indexes it, and records it in the change log. A suggestion of kind `unused` is only a flag; it can't be applied here, only dismissed.",
150
+ returns: '{ table }'
151
+ },
152
+ dismissIndexAdvice: {
153
+ method: 'delete',
154
+ path: '/advice/:adviceId',
155
+ tag: 'Tables',
156
+ summary: 'Dismiss an index suggestion',
157
+ description:
158
+ 'It stays dismissed until its numbers double (queries, rows read per query, or rows written for an unused index).',
159
+ returns: '{ dismissed: true }'
160
+ },
142
161
  listTables: {
143
162
  method: 'get',
144
163
  path: '/table',
@@ -207,7 +226,7 @@ export const DATA_API_ROUTES = {
207
226
  tag: 'Rows',
208
227
  summary: 'List, filter and search rows',
209
228
  description:
210
- 'Up to 500 rows or 1 MB a page; `nextCursor` reads the next. `where` is JSON, e.g. `{"status":"open","price":{"lt":30000}}`. `sort` is a list of columns, `-` for descending. `q` searches the searchable columns.',
229
+ 'Up to 500 rows or 1 MB a page; `nextCursor` reads the next. `where` is JSON, e.g. `{"status":"open","price":{"lt":30000}}`. `sort` is a list of columns, `-` for descending. `q` searches the searchable columns. A relation comes back as `{ id, label }`; a file column as `{ uri, id, title, mimeType }`, or `{ uri, missing: true }` when no resource has the uri any more. Writes take a file column\'s uri.',
211
230
  query: DATA_ROW_LIST_QUERY,
212
231
  returns: '{ table, rows, total?, nextCursor? }'
213
232
  },
@@ -328,9 +347,9 @@ export const DATA_API_ROUTES = {
328
347
  tag: 'Triggers',
329
348
  summary: 'Save a trigger',
330
349
  description:
331
- 'Runs one action when rows of `table` are inserted, updated or deleted, and `when` holds: `{ "status": { "changedTo": "READY" } }` fires on the transition only; `changedFrom`, `changed: true` and `equals` test a column too, and `all` / `any` combine them. The action is one of `function` (a function of this project, given `{ event, table, row, previous, actor, eventId, triggerId }`), `webhook` (an https URL, sent a POST signed in `x-ganju-signature`), `chat` (`{ channelId, chatId, text }`), `email` (`{ to, subject, text }`) or `customer` (`{ text, template? }`, to the row\'s customer on a customer-scoped table). Texts take `{{field}}` from the row, inserted as plain text. Saving under an existing name replaces it, unless `replace` is false; with no `name`, it always creates a new trigger, named from the table, event and action and numbered past those taken. Left out, `enabled` keeps a saved trigger on or off as it was. A new webhook answers with its signing `secret`, shown this once. Delivery is at least once: every event carries an `eventId`.',
350
+ 'Runs one action when rows of `table` are inserted, updated or deleted, and `when` holds: `{ "status": { "changedTo": "READY" } }` fires on the transition only; `changedFrom`, `changed: true` and `equals` test a column too, and `all` / `any` combine them. The action is one of `function` (a function of this project, given `{ event, table, row, previous, actor, eventId, triggerId }`), `webhook` (an https URL, sent a POST signed in `x-ganju-signature`), `chat` (`{ channelId, chatId, text }`), `email` (`{ to, subject, text }`) or `customer` (`{ text, template? }`, to the row\'s customer on a customer-scoped table). Texts take `{{field}}` from the row, inserted as plain text. Saving under an existing name replaces it, unless `replace` is false; with no `name`, it always creates a new trigger, named from the table, event and action and numbered past those taken. Left out, `enabled` keeps a saved trigger on or off as it was. A new webhook answers with its signing `secret`, shown this once. An `email` goes to the organization\'s members; any other address is sent a link to confirm first, and those still waiting come back as `confirming`. Delivery is at least once: every event carries an `eventId`.',
332
351
  body: DATA_TRIGGER_INPUT,
333
- returns: '{ trigger, secret? }',
352
+ returns: '{ trigger, secret?, confirming? }',
334
353
  status: 201
335
354
  },
336
355
  updateTrigger: {
@@ -356,9 +375,28 @@ export const DATA_API_ROUTES = {
356
375
  tag: 'Triggers',
357
376
  summary: 'Deploy the triggers of ganju.json',
358
377
  description:
359
- "What `ganju deploy` sends: every trigger listed is saved as managed by the CLI, and the CLI's earlier ones not listed are deleted. Triggers made in the dashboard are left alone. All or nothing. New webhooks' secrets come back once, by name.",
378
+ "What `ganju deploy` sends: every trigger listed is saved as managed by the CLI, and the CLI's earlier ones not listed are deleted. Triggers made in the dashboard are left alone. All or nothing. New webhooks' secrets come back once, by name, and email addresses still to confirm as `confirming`.",
360
379
  body: DATA_TRIGGER_DEPLOY_BODY,
361
- returns: '{ triggers, removed, secrets }'
380
+ returns: '{ triggers, removed, secrets, confirming? }'
381
+ },
382
+ listTestRecipients: {
383
+ method: 'get',
384
+ path: '/trigger/test-send',
385
+ tag: 'Triggers',
386
+ summary: 'Where a test send can go',
387
+ description:
388
+ "The caller's own chats with this project's bots: the ones they linked to their Ganju account by sending `/link` to the bot. Empty when there are none.",
389
+ returns: '{ recipients }'
390
+ },
391
+ testSend: {
392
+ method: 'post',
393
+ path: '/trigger/test-send',
394
+ tag: 'Triggers',
395
+ summary: 'Send a test of a customer message',
396
+ description:
397
+ "Sends a `customer` action, saved or not, to the caller's own chat with the bot instead of the row's customer: built from the row `rowId` (the table's newest row by default), the same as a real run. `participantId` picks one of `GET /trigger/test-send`'s recipients when there are several. With `asTemplate`, a WhatsApp template goes out even inside the 24-hour window. Not a run: it isn't listed with any trigger's runs and doesn't count toward the daily limit per customer. Five a minute per user. A channel's refusal (the template not approved, outside the window with no template, the daily limit) comes back as the error.",
398
+ body: DATA_TRIGGER_TEST_SEND_BODY,
399
+ returns: '{ sent, text, template, rowId, recipient }'
362
400
  },
363
401
  listTriggerRuns: {
364
402
  method: 'get',
@@ -0,0 +1,23 @@
1
+ import { Schema } from './schema';
2
+
3
+ export interface DataAttachment {
4
+ table: string;
5
+ column: string;
6
+ index: boolean;
7
+ }
8
+
9
+ // The Data upload a knowledge item came from, read from its metadata, or
10
+ // null for any other item.
11
+ export const dataAttachmentOf = (metadata: unknown): DataAttachment | null => {
12
+ if (!metadata || typeof metadata !== 'object') return null;
13
+ const parsed = Schema.DATA_ATTACHMENT_METADATA.safeParse(
14
+ (metadata as { dataAttachment?: unknown }).dataAttachment
15
+ );
16
+ return parsed.success ? parsed.data : null;
17
+ };
18
+
19
+ // Uploads from Data are stored, not indexed, unless their column asks.
20
+ export const skipsIndexing = (metadata: unknown): boolean => {
21
+ const attachment = dataAttachmentOf(metadata);
22
+ return attachment !== null && !attachment.index;
23
+ };
@@ -8,6 +8,7 @@ import {
8
8
  DATA_ACCESS_RANK,
9
9
  DATA_MAX_IN_VALUES,
10
10
  DATA_MAX_PARAMS,
11
+ DATA_FILE_URI_MAX,
11
12
  DATA_MAX_VALUE_BYTES,
12
13
  DataError
13
14
  } from './dataTypes';
@@ -260,6 +261,37 @@ const checkSize = (label: string, value: string) => {
260
261
  return value;
261
262
  };
262
263
 
264
+ // A knowledge item's uri, given as the text or as { uri } (how reads return
265
+ // it). Any scheme: knowledge holds resource://, website and drive uris.
266
+ export const fileUriOf = (value: unknown, label: string): string => {
267
+ const raw =
268
+ value !== null && typeof value === 'object' && !Array.isArray(value)
269
+ ? (value as { uri?: unknown }).uri
270
+ : value;
271
+ const uri = typeof raw === 'string' ? raw.trim() : '';
272
+ if (!/^[a-z][a-z0-9+.-]*:\/\/\S+$/i.test(uri)) {
273
+ throw new DataError(
274
+ `${label} takes a knowledge item's uri, e.g. resource://menu-photo.`
275
+ );
276
+ }
277
+ if (uri.length > DATA_FILE_URI_MAX) {
278
+ throw new DataError(
279
+ `${label}: a uri is at most ${DATA_FILE_URI_MAX} characters.`,
280
+ 'too_large'
281
+ );
282
+ }
283
+ return uri;
284
+ };
285
+
286
+ // A write that links uris no item of the project has: the workers check,
287
+ // since the database object can't see knowledge.
288
+ export const unknownFileUris = (uris: string[]) =>
289
+ new DataError(
290
+ `No resource in this project has the uri ${uris.join(', ')}; link an existing resource by its uri.`,
291
+ 'invalid',
292
+ { uris }
293
+ );
294
+
263
295
  // Turns a value from a caller into what the column stores, or explains why
264
296
  // it can't. `timeZone` is the project's, for datetimes sent without an
265
297
  // offset.
@@ -337,6 +369,8 @@ export const toStored = (
337
369
  throw new DataError(`${label} must be a URL starting with http(s)://.`);
338
370
  }
339
371
  return checkSize(label, value.trim());
372
+ case 'file':
373
+ return fileUriOf(value, label);
340
374
  case 'phone': {
341
375
  if (typeof value !== 'string' && typeof value !== 'number') {
342
376
  throw new DataError(`${label} must be a phone number.`);
@@ -429,6 +463,8 @@ export interface CompileContext {
429
463
  table: DataTable;
430
464
  joins: Map<string, SqlJoin>;
431
465
  params: SqlValue[];
466
+ // The queried table's own columns a `where` filters on, for index advice.
467
+ filtered?: Set<string>;
432
468
  }
433
469
 
434
470
  // "column" or "relation.column", adding the join a relation path needs.
@@ -548,6 +584,7 @@ export const compileWhere = (
548
584
  continue;
549
585
  }
550
586
  const resolved = resolvePath(ctx, key);
587
+ if (resolved.own) ctx.filtered?.add(resolved.column.name);
551
588
  // { "customer": { "name": "Ana" } } reads as "customer.name": models
552
589
  // write relation filters this way unprompted.
553
590
  if (
package/src/dataDdl.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // ours, and every owner-supplied value is a literal escaped by sqlLiteral.
4
4
  import { changeCaptureTriggers } from './dataTriggers';
5
5
  import { quoteIdentifier as q } from './dataIdentifiers';
6
+ import { DATA_FILE_URI_MAX } from './dataTypes';
6
7
  import type {
7
8
  DataColumn,
8
9
  DataColumnType,
@@ -412,6 +413,12 @@ export const invalidAfterConversionSql = (
412
413
  `NOT ((${converted}) LIKE 'http://%' OR (${converted}) LIKE 'https://%')`
413
414
  );
414
415
  }
416
+ // A file column holds a knowledge item's uri: scheme://…, no spaces.
417
+ if (to.type === 'file') {
418
+ parts.push(
419
+ `NOT ((${converted}) GLOB '[A-Za-z]*://?*' AND (${converted}) NOT GLOB '*[ ' || char(9) || char(10) || char(13) || ']*' AND length(${converted}) <= ${DATA_FILE_URI_MAX})`
420
+ );
421
+ }
415
422
  if (to.type === 'relation' && to.table) {
416
423
  parts.push(
417
424
  `((${converted}) IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${q(to.table)} WHERE id = (${converted})))`
package/src/dataEngine.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  compileWhere,
31
31
  decodeCursor,
32
32
  encodeCursor,
33
+ fileUriOf,
33
34
  fromStored,
34
35
  ftsMatch,
35
36
  isScoped,
@@ -74,6 +75,7 @@ import {
74
75
  DATA_COPY_STEP_ROWS,
75
76
  DATA_IMPACT_MAX_IDS,
76
77
  DATA_IMPACT_MAX_ROWS,
78
+ DATA_FILE_LINKS_MAX_URIS,
77
79
  DATA_REBUILD_MAX_ROWS,
78
80
  DataError
79
81
  } from './dataTypes';
@@ -85,11 +87,17 @@ import type {
85
87
  DataColumnType,
86
88
  DataDefaultValue,
87
89
  DataDeleteImpact,
90
+ DataFileLinkChange,
91
+ DataFileLinks,
92
+ DataFileLinksReplaced,
88
93
  DataOnDelete,
94
+ DataQueryShape,
89
95
  DataSchema,
90
96
  DataTable,
91
97
  DataTableCopy,
92
- DataTableInput
98
+ DataTableInput,
99
+ DataUsage,
100
+ DataWriteShape
93
101
  } from './dataTypes';
94
102
 
95
103
  export type SqlRow = Record<string, SqlValue>;
@@ -101,6 +109,11 @@ export interface SqlRunner {
101
109
  iterate(sql: string, params?: readonly SqlValue[]): Iterable<SqlRow>;
102
110
  transaction<T>(fn: () => T): T;
103
111
  databaseSize(): number;
112
+ // Rows read and written so far in this call, and where a query or write
113
+ // notes what it did. Optional: tests and internal runners skip them.
114
+ usageSoFar?(): DataUsage;
115
+ noteQuery?(shape: DataQueryShape): void;
116
+ noteWrite?(shape: DataWriteShape): void;
104
117
  }
105
118
 
106
119
  const first = (runner: SqlRunner, sql: string, params?: readonly SqlValue[]) =>
@@ -483,9 +496,14 @@ export const normaliseColumn = (
483
496
  if (column.unique && !UNIQUE_TYPES.has(column.type)) {
484
497
  throw new DataError(`${label}: a ${column.type} column can't be unique.`);
485
498
  }
486
- if (column.searchable && !SEARCHABLE_TYPES.has(column.type)) {
499
+ // On a file column, searchable means its uploads are indexed in knowledge.
500
+ if (
501
+ column.searchable &&
502
+ !SEARCHABLE_TYPES.has(column.type) &&
503
+ column.type !== 'file'
504
+ ) {
487
505
  throw new DataError(
488
- `${label}: only text columns can be searchable (text, long_text, email, url, phone, enum).`
506
+ `${label}: only text and file columns can be searchable (text, long_text, email, url, phone, enum, file).`
489
507
  );
490
508
  }
491
509
  // The search table has a column of its own called rank.
@@ -786,6 +804,8 @@ const shapeRow = (
786
804
  ? fromStored(display, label, timeZone)
787
805
  : label
788
806
  };
807
+ } else if (column.type === 'file') {
808
+ row[column.name] = value === null ? null : { uri: String(value) };
789
809
  } else {
790
810
  row[column.name] = value;
791
811
  }
@@ -890,8 +910,20 @@ export const query = (
890
910
  caller,
891
911
  table,
892
912
  joins: new Map(),
893
- params: []
913
+ params: [],
914
+ filtered: new Set()
894
915
  };
916
+ // What this query read, noted for index advice once it has its rows.
917
+ const readBefore = runner.usageSoFar?.().rowsRead ?? 0;
918
+ const sorted: string[] = [];
919
+ const noteShape = (returned: number) =>
920
+ runner.noteQuery?.({
921
+ table: table.name,
922
+ filtered: [...(ctx.filtered ?? [])],
923
+ sorted,
924
+ rowsRead: (runner.usageSoFar?.().rowsRead ?? 0) - readBefore,
925
+ returned
926
+ });
895
927
  const indexed = new Set<string>();
896
928
  const where = compileWhere(ctx, args.where, indexed);
897
929
  if (isScoped(caller, table)) {
@@ -907,7 +939,10 @@ export const query = (
907
939
  }
908
940
 
909
941
  if (args.aggregate !== undefined && args.aggregate !== null) {
910
- return aggregate(runner, ctx, args, where, searching, indexed.size > 0);
942
+ const result = aggregate(runner, ctx, args, where, searching, indexed.size > 0);
943
+ const groups = (result as { groups?: unknown }).groups;
944
+ noteShape(Array.isArray(groups) ? groups.length : 1);
945
+ return result;
911
946
  }
912
947
 
913
948
  const limit = args.limit === undefined || args.limit === null ? DATA_DEFAULT_PAGE : args.limit;
@@ -946,6 +981,7 @@ export const query = (
946
981
  descending,
947
982
  ...(servedByIndex ? {} : { unindexed: resolved.column.name })
948
983
  });
984
+ if (resolved.own && resolved.column.name !== 'id') sorted.push(resolved.column.name);
949
985
  if (resolved.own && resolved.column.name === 'id') {
950
986
  endsUnique = true;
951
987
  break;
@@ -1043,6 +1079,7 @@ export const query = (
1043
1079
  }
1044
1080
 
1045
1081
  const { rows: out, last, more } = result;
1082
+ noteShape(out.length);
1046
1083
  const includeTruncated =
1047
1084
  args.include !== undefined && args.include !== null
1048
1085
  ? includeRelations(
@@ -1055,11 +1092,13 @@ export const query = (
1055
1092
  DATA_MAX_RESULT_BYTES - result.bytes
1056
1093
  )
1057
1094
  : false;
1095
+ const fileFields = fileFieldPaths(schema, table, args.include);
1058
1096
  return {
1059
1097
  table: table.name,
1060
1098
  ...(isScoped(caller, table) ? { scope: SCOPE_NOTE } : {}),
1061
1099
  ...(result.total !== undefined ? { total: result.total } : {}),
1062
1100
  ...(matched ? { searchMatched: matched } : {}),
1101
+ ...(fileFields.length ? { fileFields } : {}),
1063
1102
  rows: out,
1064
1103
  ...(includeTruncated
1065
1104
  ? {
@@ -1079,6 +1118,29 @@ export const query = (
1079
1118
  };
1080
1119
  };
1081
1120
 
1121
+ // Where a query's rows hold file values, as key paths within each row: the
1122
+ // table's file columns, and those of each included relation. The workers
1123
+ // fill those in from knowledge and drop this before answering; a json
1124
+ // column holding { "uri": … } is never mistaken for one.
1125
+ const fileFieldPaths = (
1126
+ schema: DataSchema,
1127
+ table: DataTable,
1128
+ include: unknown
1129
+ ): string[][] => {
1130
+ const own = table.columns
1131
+ .filter(column => column.type === 'file')
1132
+ .map(column => [column.name]);
1133
+ const included = (Array.isArray(include) ? include : []).flatMap(name => {
1134
+ const column = typeof name === 'string' ? columnFor(table, name) : undefined;
1135
+ if (!column || isSystemColumn(column) || column.type !== 'relation') return [];
1136
+ const target = schema.tables.find(t => t.name === column.table);
1137
+ return (target?.columns ?? [])
1138
+ .filter(c => c.type === 'file')
1139
+ .map(c => [name as string, c.name]);
1140
+ });
1141
+ return [...own, ...included];
1142
+ };
1143
+
1082
1144
  // Replaces each relation in `rows` named in `include` with the related row,
1083
1145
  // read as the caller may see it, within `budget` bytes. Returns whether any
1084
1146
  // was left as { id, label } to stay inside it.
@@ -1840,7 +1902,12 @@ export const write = (
1840
1902
  const results: WriteResult[] = [];
1841
1903
  operations.forEach((op, index) => {
1842
1904
  try {
1905
+ const before = runner.usageSoFar?.().rowsWritten ?? 0;
1843
1906
  results.push(runOperation(runner, schema, caller, op, index, results));
1907
+ runner.noteWrite?.({
1908
+ table: results[results.length - 1].table,
1909
+ rowsWritten: (runner.usageSoFar?.().rowsWritten ?? 0) - before
1910
+ });
1844
1911
  } catch (error) {
1845
1912
  if (error instanceof DataError) {
1846
1913
  const label = isPlainObject(op) ? `${String(op.op)} ${String(op.table)}` : '?';
@@ -1979,6 +2046,129 @@ export const deleteImpact = (
1979
2046
  };
1980
2047
  };
1981
2048
 
2049
+ // File links
2050
+
2051
+ const fileColumns = (schema: DataSchema) =>
2052
+ schema.tables.flatMap(table =>
2053
+ table.columns
2054
+ .filter(column => column.type === 'file')
2055
+ .map(column => ({ table, column }))
2056
+ );
2057
+
2058
+ const fileUriList = (value: unknown): string[] => {
2059
+ if (
2060
+ !Array.isArray(value) ||
2061
+ value.length === 0 ||
2062
+ value.length > DATA_FILE_LINKS_MAX_URIS
2063
+ ) {
2064
+ throw new DataError(
2065
+ `"uris" lists 1 to ${DATA_FILE_LINKS_MAX_URIS} knowledge item uris.`
2066
+ );
2067
+ }
2068
+ return [...new Set(value.map(uri => fileUriOf(uri, 'uris')))];
2069
+ };
2070
+
2071
+ // The rows whose file columns point at `uris`, per column: knowledge asks
2072
+ // before it deletes an item. Each file column is indexed.
2073
+ export const fileLinks = (
2074
+ runner: SqlRunner,
2075
+ schema: DataSchema,
2076
+ caller: DataCaller,
2077
+ rawArgs: unknown
2078
+ ): DataFileLinks => {
2079
+ assertActor(caller);
2080
+ if (caller.restricted) {
2081
+ throw new DataError('File links are for the owner.', 'forbidden');
2082
+ }
2083
+ const uris = JSON.stringify(fileUriList(tidyArgs(rawArgs).uris));
2084
+ const links: DataFileLinks['links'] = [];
2085
+ for (const { table, column } of fileColumns(schema)) {
2086
+ const rows = Number(
2087
+ first(
2088
+ runner,
2089
+ `SELECT count(*) AS n FROM ${q(table.name)} WHERE ${q(column.name)} IN (SELECT value FROM json_each(?))`,
2090
+ [uris]
2091
+ )?.n ?? 0
2092
+ );
2093
+ if (rows === 0) continue;
2094
+ links.push({
2095
+ table: table.name,
2096
+ tableLabel: table.label,
2097
+ column: column.name,
2098
+ columnLabel: column.label,
2099
+ required: column.required,
2100
+ rows
2101
+ });
2102
+ }
2103
+ return { links };
2104
+ };
2105
+
2106
+ // Points the links at `from` to `to`, or clears them (`to: null`) once the
2107
+ // item is gone. A required column keeps its link, which then reads as
2108
+ // missing. Recorded in row history; triggers don't see it.
2109
+ export const replaceFileLinks = (
2110
+ runner: SqlRunner,
2111
+ schema: DataSchema,
2112
+ caller: DataCaller,
2113
+ rawArgs: unknown
2114
+ ): DataFileLinksReplaced => {
2115
+ assertActor(caller);
2116
+ if (caller.restricted) {
2117
+ throw new DataError('File links are for the owner.', 'forbidden');
2118
+ }
2119
+ const raw = tidyArgs(rawArgs).changes;
2120
+ if (
2121
+ !Array.isArray(raw) ||
2122
+ raw.length === 0 ||
2123
+ raw.length > DATA_FILE_LINKS_MAX_URIS
2124
+ ) {
2125
+ throw new DataError(
2126
+ `"changes" lists 1 to ${DATA_FILE_LINKS_MAX_URIS} { from, to } pairs.`
2127
+ );
2128
+ }
2129
+ const changes: DataFileLinkChange[] = raw.map(change => {
2130
+ if (!isPlainObject(change)) {
2131
+ throw new DataError('Each change is { from, to }; to is null to clear.');
2132
+ }
2133
+ return {
2134
+ from: fileUriOf(change.from, 'from'),
2135
+ to: change.to === null ? null : fileUriOf(change.to, 'to')
2136
+ };
2137
+ });
2138
+ const columns = fileColumns(schema);
2139
+ if (columns.length === 0) return { updated: 0, kept: [] };
2140
+ assertWritable(runner, caller, changes.some(change => change.to !== null));
2141
+ return runner.transaction(() => {
2142
+ setContextActor(runner, caller.actor);
2143
+ runner.all('DELETE FROM _ganju_changes');
2144
+ let updated = 0;
2145
+ const kept: DataFileLinksReplaced['kept'] = [];
2146
+ for (const { table, column } of columns) {
2147
+ for (const change of changes) {
2148
+ if (change.to === null && column.required) {
2149
+ const rows = Number(
2150
+ first(
2151
+ runner,
2152
+ `SELECT count(*) AS n FROM ${q(table.name)} WHERE ${q(column.name)} = ?`,
2153
+ [change.from]
2154
+ )?.n ?? 0
2155
+ );
2156
+ if (rows > 0) kept.push({ table: table.name, column: column.name, rows });
2157
+ continue;
2158
+ }
2159
+ updated += runner.all(
2160
+ `UPDATE ${q(table.name)} SET ${q(column.name)} = ?, version = version + 1, updated_at = ${DATA_NOW_SQL}, updated_by = ? WHERE ${q(column.name)} = ? RETURNING id`,
2161
+ [change.to, caller.actor, change.from]
2162
+ ).length;
2163
+ }
2164
+ }
2165
+ // Tidying links after a knowledge change isn't the owner's edit: it
2166
+ // stays in row history but fires no trigger.
2167
+ runner.all('DELETE FROM _ganju_changes');
2168
+ return { updated, kept };
2169
+ });
2170
+ };
2171
+
1982
2172
  // Row history
1983
2173
 
1984
2174
  export const rowHistory = (
@@ -0,0 +1,127 @@
1
+ // Which columns of a database are worth an index, and which indexes nobody
2
+ // uses, from the API's daily query stats and the database's schema. Pure, so
3
+ // the daily job and the tests apply the same rule.
4
+ import { constants } from './constants';
5
+ import { isIndexedColumn } from './dataDdl';
6
+ import type { DataSchema } from './dataTypes';
7
+
8
+ // A column's (or, with column '', a table's) sums over a window.
9
+ export interface IndexAdviceStat {
10
+ tableName: string;
11
+ columnName: string;
12
+ queries: number;
13
+ rowsRead: number;
14
+ rowsReturned: number;
15
+ rowsWritten: number;
16
+ }
17
+
18
+ export interface IndexAdviceCandidate {
19
+ tableName: string;
20
+ columnName: string;
21
+ kind: 'add' | 'unused';
22
+ queries: number;
23
+ avgRowsRead: number;
24
+ avgRowsReturned: number;
25
+ rowsWritten: number;
26
+ }
27
+
28
+ export interface IndexAdviceInput {
29
+ schema: DataSchema;
30
+ // Sums over the last 7 days, and over the last 30.
31
+ week: IndexAdviceStat[];
32
+ month: IndexAdviceStat[];
33
+ // Whether stats go back the whole 30 days, so "no query used it" means it.
34
+ monthCovered: boolean;
35
+ // "table.column" made filterable, or "table" created, in the last 30 days.
36
+ recentlyIndexed: Set<string>;
37
+ }
38
+
39
+ // Indexes the owner can add or remove: a column marked filterable. Unique and
40
+ // relation columns are indexed for what they are, and stay.
41
+ const canIndex = (type: string) => type !== 'json' && type !== 'long_text';
42
+
43
+ export const adviseIndexes = (
44
+ input: IndexAdviceInput
45
+ ): IndexAdviceCandidate[] => {
46
+ const out: IndexAdviceCandidate[] = [];
47
+ const tables = new Map(input.schema.tables.map(t => [t.name, t]));
48
+
49
+ for (const stat of input.week) {
50
+ if (!stat.columnName) continue;
51
+ const table = tables.get(stat.tableName);
52
+ const column = table?.columns.find(c => c.name === stat.columnName);
53
+ if (!column || isIndexedColumn(column) || !canIndex(column.type)) continue;
54
+ if (stat.queries < constants.DATA_INDEX_ADVICE_MIN_QUERIES) continue;
55
+ const avgRead = stat.rowsRead / stat.queries;
56
+ const avgReturned = stat.rowsReturned / stat.queries;
57
+ if (avgRead < constants.DATA_INDEX_ADVICE_MIN_ROWS_READ) continue;
58
+ // Scanning, not just a small table: many rows read for each one returned.
59
+ if (
60
+ stat.rowsRead <
61
+ constants.DATA_INDEX_ADVICE_MIN_READ_RATIO * stat.rowsReturned
62
+ ) {
63
+ continue;
64
+ }
65
+ out.push({
66
+ tableName: stat.tableName,
67
+ columnName: stat.columnName,
68
+ kind: constants.DATA_INDEX_ADVICE_ADD,
69
+ queries: stat.queries,
70
+ avgRowsRead: Math.round(avgRead),
71
+ avgRowsReturned: Math.round(avgReturned),
72
+ rowsWritten: 0
73
+ });
74
+ }
75
+
76
+ if (input.monthCovered) {
77
+ const used = new Set(
78
+ input.month
79
+ .filter(s => s.columnName && s.queries > 0)
80
+ .map(s => `${s.tableName}.${s.columnName}`)
81
+ );
82
+ for (const table of input.schema.tables) {
83
+ const totals = input.month.find(
84
+ s => s.tableName === table.name && s.columnName === ''
85
+ );
86
+ const written = totals?.rowsWritten ?? 0;
87
+ if (written < constants.DATA_INDEX_UNUSED_MIN_ROWS_WRITTEN) continue;
88
+ if (written <= (totals?.queries ?? 0)) continue;
89
+ if (input.recentlyIndexed.has(table.name)) continue;
90
+ for (const column of table.columns) {
91
+ if (!column.filterable || column.unique) continue;
92
+ if (column.type === 'relation' || column.type === 'file') continue;
93
+ const key = `${table.name}.${column.name}`;
94
+ if (used.has(key) || input.recentlyIndexed.has(key)) continue;
95
+ out.push({
96
+ tableName: table.name,
97
+ columnName: column.name,
98
+ kind: constants.DATA_INDEX_ADVICE_UNUSED,
99
+ queries: 0,
100
+ avgRowsRead: 0,
101
+ avgRowsReturned: 0,
102
+ rowsWritten: written
103
+ });
104
+ }
105
+ }
106
+ }
107
+ return out;
108
+ };
109
+
110
+ // A dismissed suggestion comes back once its numbers have doubled.
111
+ export const adviceDoubled = (
112
+ current: { queries: number; avgRowsRead: number; rowsWritten: number },
113
+ dismissed: {
114
+ queries: number | null;
115
+ avgRowsRead: number | null;
116
+ rowsWritten: number | null;
117
+ }
118
+ ): boolean =>
119
+ (dismissed.queries !== null &&
120
+ dismissed.queries > 0 &&
121
+ current.queries >= 2 * dismissed.queries) ||
122
+ (dismissed.avgRowsRead !== null &&
123
+ dismissed.avgRowsRead > 0 &&
124
+ current.avgRowsRead >= 2 * dismissed.avgRowsRead) ||
125
+ (dismissed.rowsWritten !== null &&
126
+ dismissed.rowsWritten > 0 &&
127
+ current.rowsWritten >= 2 * dismissed.rowsWritten);