@ganju/utils 0.0.17 → 0.0.20

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 +8 -0
  2. package/dist/constants.d.ts.map +1 -1
  3. package/dist/constants.js +21 -4
  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/customCodeToken.d.ts +4 -0
  8. package/dist/customCodeToken.d.ts.map +1 -1
  9. package/dist/customCodeToken.js +9 -1
  10. package/dist/data.d.ts +2 -0
  11. package/dist/data.d.ts.map +1 -1
  12. package/dist/data.js +28 -1
  13. package/dist/dataApi.d.ts +133 -1
  14. package/dist/dataApi.d.ts.map +1 -1
  15. package/dist/dataApi.js +62 -1
  16. package/dist/dataDdl.d.ts.map +1 -1
  17. package/dist/dataDdl.js +3 -1
  18. package/dist/dataEngine.d.ts.map +1 -1
  19. package/dist/dataEngine.js +62 -0
  20. package/dist/dataRpc.d.ts +25 -0
  21. package/dist/dataRpc.d.ts.map +1 -1
  22. package/dist/dataTriggers.d.ts +203 -0
  23. package/dist/dataTriggers.d.ts.map +1 -0
  24. package/dist/dataTriggers.js +760 -0
  25. package/dist/dataTypes.d.ts +1 -0
  26. package/dist/dataTypes.d.ts.map +1 -1
  27. package/dist/index.d.ts +198 -2
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +27 -0
  30. package/dist/localizeDataError.d.ts +18 -0
  31. package/dist/localizeDataError.d.ts.map +1 -0
  32. package/dist/localizeDataError.js +359 -0
  33. package/dist/schema.d.ts +161 -0
  34. package/dist/schema.d.ts.map +1 -1
  35. package/dist/schema.js +146 -1
  36. package/dist/sdkConstants.d.ts +3 -0
  37. package/dist/sdkConstants.d.ts.map +1 -1
  38. package/dist/sdkConstants.js +4 -1
  39. package/dist/whatsappTemplates.d.ts +77 -0
  40. package/dist/whatsappTemplates.d.ts.map +1 -0
  41. package/dist/whatsappTemplates.js +402 -0
  42. package/package.json +1 -1
  43. package/src/constants.ts +28 -4
  44. package/src/crypto.ts +3 -2
  45. package/src/customCodeToken.ts +14 -1
  46. package/src/data.ts +40 -0
  47. package/src/dataApi.ts +78 -2
  48. package/src/dataDdl.ts +3 -1
  49. package/src/dataEngine.ts +70 -0
  50. package/src/dataRpc.ts +15 -0
  51. package/src/dataTriggers.ts +1134 -0
  52. package/src/dataTypes.ts +3 -0
  53. package/src/index.ts +65 -1
  54. package/src/localizeDataError.ts +593 -0
  55. package/src/schema.ts +170 -1
  56. package/src/sdkConstants.ts +3 -0
  57. package/src/whatsappTemplates.ts +465 -0
package/src/dataApi.ts CHANGED
@@ -16,7 +16,13 @@ export interface DataApiRoute {
16
16
  path: string;
17
17
  summary: string;
18
18
  description?: string;
19
- tag: 'Database' | 'Tables' | 'Rows' | 'Queries' | 'History and export';
19
+ tag:
20
+ | 'Database'
21
+ | 'Tables'
22
+ | 'Rows'
23
+ | 'Queries'
24
+ | 'Triggers'
25
+ | 'History and export';
20
26
  body?: z.ZodType;
21
27
  query?: z.ZodObject;
22
28
  // What a 200/201 answers with, in a sentence.
@@ -49,7 +55,11 @@ const {
49
55
  DATA_EXPORT_FILE_QUERY,
50
56
  DATA_CHANGE_LIST_QUERY,
51
57
  DATA_QUERY_INPUT,
52
- DATA_QUERY_RUN_BODY
58
+ DATA_QUERY_RUN_BODY,
59
+ DATA_TRIGGER_INPUT,
60
+ DATA_TRIGGER_PATCH,
61
+ DATA_TRIGGER_DEPLOY_BODY,
62
+ DATA_TRIGGER_RUN_LIST_QUERY
53
63
  } = Schema;
54
64
 
55
65
  export const DATA_API_ROUTES = {
@@ -303,6 +313,71 @@ export const DATA_API_ROUTES = {
303
313
  body: DATA_QUERY_RUN_BODY,
304
314
  returns: '{ query, rows, truncated? }'
305
315
  },
316
+ listTriggers: {
317
+ method: 'get',
318
+ path: '/trigger',
319
+ tag: 'Triggers',
320
+ summary: 'Triggers',
321
+ description:
322
+ 'Each with its table, event, condition and action. `pending` counts events not yet handed to the queue. Webhook secrets are never listed.',
323
+ returns: '{ triggers, pending }'
324
+ },
325
+ saveTrigger: {
326
+ method: 'post',
327
+ path: '/trigger',
328
+ tag: 'Triggers',
329
+ summary: 'Save a trigger',
330
+ 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. 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
+ body: DATA_TRIGGER_INPUT,
333
+ returns: '{ trigger, secret?, confirming? }',
334
+ status: 201
335
+ },
336
+ updateTrigger: {
337
+ method: 'patch',
338
+ path: '/trigger/:name',
339
+ tag: 'Triggers',
340
+ summary: 'Turn a trigger on or off',
341
+ description:
342
+ 'Turning one back on clears why Ganju turned it off (20 failed runs in a row).',
343
+ body: DATA_TRIGGER_PATCH,
344
+ returns: '{ trigger }'
345
+ },
346
+ dropTrigger: {
347
+ method: 'delete',
348
+ path: '/trigger/:name',
349
+ tag: 'Triggers',
350
+ summary: 'Delete a trigger',
351
+ returns: '{ dropped }'
352
+ },
353
+ deployTriggers: {
354
+ method: 'post',
355
+ path: '/trigger/deploy',
356
+ tag: 'Triggers',
357
+ summary: 'Deploy the triggers of ganju.json',
358
+ 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, and email addresses still to confirm as `confirming`.",
360
+ body: DATA_TRIGGER_DEPLOY_BODY,
361
+ returns: '{ triggers, removed, secrets, confirming? }'
362
+ },
363
+ listTriggerRuns: {
364
+ method: 'get',
365
+ path: '/trigger/:name/run',
366
+ tag: 'Triggers',
367
+ summary: "A trigger's recent runs",
368
+ description:
369
+ 'Newest first, up to 50: `ok`, `failed` with its error, `skipped`, or `running`.',
370
+ query: DATA_TRIGGER_RUN_LIST_QUERY,
371
+ returns: '{ runs }'
372
+ },
373
+ retryTriggerRun: {
374
+ method: 'post',
375
+ path: '/trigger/:name/run/:runId/retry',
376
+ tag: 'Triggers',
377
+ summary: 'Run a failed event again',
378
+ description: 'The same event, with the same `eventId`.',
379
+ returns: '{ run }'
380
+ },
306
381
  startExport: {
307
382
  method: 'post',
308
383
  path: '/export',
@@ -448,6 +523,7 @@ export const dataOpenApi = (serverUrl: string) => {
448
523
  { name: 'Tables' },
449
524
  { name: 'Rows' },
450
525
  { name: 'Queries' },
526
+ { name: 'Triggers' },
451
527
  { name: 'History and export' }
452
528
  ],
453
529
  paths,
package/src/dataDdl.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // The SQL a project's database runs to build and change its tables. Pure
2
2
  // string building: every name in here passed identifierIssue or is one of
3
3
  // ours, and every owner-supplied value is a literal escaped by sqlLiteral.
4
+ import { changeCaptureTriggers } from './dataTriggers';
4
5
  import { quoteIdentifier as q } from './dataIdentifiers';
5
6
  import type {
6
7
  DataColumn,
@@ -339,7 +340,8 @@ const historyTriggers = (table: DataTable): NamedStatement[] => {
339
340
 
340
341
  export const desiredTriggers = (table: DataTable): NamedStatement[] => [
341
342
  ...ftsTriggers(table),
342
- ...historyTriggers(table)
343
+ ...historyTriggers(table),
344
+ ...changeCaptureTriggers(table)
343
345
  ];
344
346
 
345
347
  // Type changes
package/src/dataEngine.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // the tests and the tool-design eval. The runner executes; everything about
4
4
  // which SQL is allowed and how results look is decided here.
5
5
  import { identifierIssue, quoteIdentifier as q } from './dataIdentifiers';
6
+ import { collectTriggerEvents, renameColumnInTriggers, triggersUsingColumn } from './dataTriggers';
6
7
  import {
7
8
  DATA_NOW_SQL,
8
9
  NUMERIC_TYPES,
@@ -188,6 +189,44 @@ export const INTERNAL_MIGRATIONS: readonly (readonly string[])[] = [
188
189
  updated_at TEXT NOT NULL DEFAULT (${DATA_NOW_SQL}),
189
190
  updated_by TEXT
190
191
  ) WITHOUT ROWID`
192
+ ],
193
+ // Triggers: the owner's rules, the rows a write changed while one listens
194
+ // (emptied before the write commits), and the events waiting for the queue.
195
+ [
196
+ `CREATE TABLE IF NOT EXISTS _ganju_triggers (
197
+ id TEXT PRIMARY KEY,
198
+ name TEXT NOT NULL UNIQUE,
199
+ description TEXT,
200
+ table_id INTEGER NOT NULL,
201
+ event TEXT NOT NULL,
202
+ condition TEXT,
203
+ action TEXT NOT NULL,
204
+ enabled INTEGER NOT NULL DEFAULT 1,
205
+ disabled_reason TEXT,
206
+ managed TEXT,
207
+ rate_limit INTEGER NOT NULL DEFAULT 60,
208
+ secret TEXT,
209
+ created_at TEXT NOT NULL DEFAULT (${DATA_NOW_SQL}),
210
+ updated_at TEXT NOT NULL DEFAULT (${DATA_NOW_SQL}),
211
+ updated_by TEXT
212
+ ) WITHOUT ROWID`,
213
+ 'CREATE INDEX IF NOT EXISTS _ganju_triggers_table ON _ganju_triggers(table_id, event)',
214
+ `CREATE TABLE IF NOT EXISTS _ganju_changes (
215
+ id INTEGER PRIMARY KEY,
216
+ table_id INTEGER NOT NULL,
217
+ event TEXT NOT NULL,
218
+ row_id INTEGER NOT NULL,
219
+ actor TEXT,
220
+ old TEXT,
221
+ new TEXT
222
+ )`,
223
+ `CREATE TABLE IF NOT EXISTS _ganju_outbox (
224
+ seq INTEGER PRIMARY KEY,
225
+ id TEXT NOT NULL UNIQUE,
226
+ trigger_id TEXT NOT NULL,
227
+ message TEXT NOT NULL,
228
+ created_at TEXT NOT NULL DEFAULT (${DATA_NOW_SQL})
229
+ )`
191
230
  ]
192
231
  ];
193
232
 
@@ -1796,6 +1835,8 @@ export const write = (
1796
1835
  assertWritable(runner, caller, addsData);
1797
1836
  return runner.transaction(() => {
1798
1837
  setContextActor(runner, caller.actor);
1838
+ // Rows left by a change that wasn't a write (a schema change) fire nothing.
1839
+ runner.all('DELETE FROM _ganju_changes');
1799
1840
  const results: WriteResult[] = [];
1800
1841
  operations.forEach((op, index) => {
1801
1842
  try {
@@ -1812,6 +1853,7 @@ export const write = (
1812
1853
  throw error;
1813
1854
  }
1814
1855
  });
1856
+ collectTriggerEvents(runner, schema, caller);
1815
1857
  return { ok: true as const, results };
1816
1858
  });
1817
1859
  };
@@ -2288,6 +2330,8 @@ export const dropTable = (
2288
2330
  dropTriggers(runner, table);
2289
2331
  runner.all(`DROP TABLE IF EXISTS ${q(ftsTableName(table))}`);
2290
2332
  runner.all(`DROP TABLE ${q(table.name)}`);
2333
+ // Its triggers go with it; a later table of the same name starts with none.
2334
+ runner.all('DELETE FROM _ganju_triggers WHERE table_id = ?', [table.id]);
2291
2335
  runner.all('UPDATE _ganju_tables SET display_column_id = NULL WHERE id = ?', [table.id]);
2292
2336
  runner.all('DELETE FROM _ganju_columns WHERE table_id = ?', [table.id]);
2293
2337
  runner.all('DELETE FROM _ganju_tables WHERE id = ?', [table.id]);
@@ -2662,6 +2706,20 @@ export const copyTableStep = (
2662
2706
  }
2663
2707
  };
2664
2708
 
2709
+ // Triggers name columns in their conditions and texts, checked only when
2710
+ // saved: a column one of them uses can't be dropped under it.
2711
+ const assertNoTriggerUses = (runner: SqlRunner, table: DataTable, column: string) => {
2712
+ const triggers = triggersUsingColumn(runner, table.id, column);
2713
+ if (triggers.length === 0) return;
2714
+ throw new DataError(
2715
+ triggers.length === 1
2716
+ ? `${table.name}.${column} can't be dropped while the trigger ${triggers[0]} uses it; change or delete that trigger first.`
2717
+ : `${table.name}.${column} can't be dropped while the triggers ${triggers.join(', ')} use it; change or delete those triggers first.`,
2718
+ 'constraint',
2719
+ { triggers }
2720
+ );
2721
+ };
2722
+
2665
2723
  export const updateColumn = (
2666
2724
  runner: SqlRunner,
2667
2725
  schema: DataSchema,
@@ -2690,6 +2748,16 @@ export const updateColumn = (
2690
2748
  }
2691
2749
 
2692
2750
  if (columnChangeNeedsRebuild(schema, table.name, column.name, patch)) {
2751
+ // A rename renames the column in its triggers too; a copy can't, so the
2752
+ // two go one after the other.
2753
+ const triggers = draft.name !== column.name ? triggersUsingColumn(runner, table.id, column.name) : [];
2754
+ if (triggers.length) {
2755
+ throw new DataError(
2756
+ `${table.name}.${column.name} is used by triggers (${triggers.join(', ')}): rename it on its own, then make the other changes.`,
2757
+ 'constraint',
2758
+ { triggers }
2759
+ );
2760
+ }
2693
2761
  return rebuildTable(
2694
2762
  runner,
2695
2763
  schema,
@@ -2707,6 +2775,7 @@ export const updateColumn = (
2707
2775
  runner.all(
2708
2776
  `ALTER TABLE ${q(table.name)} RENAME COLUMN ${q(column.name)} TO ${q(draft.name)}`
2709
2777
  );
2778
+ renameColumnInTriggers(runner, table.id, column.name, draft.name);
2710
2779
  }
2711
2780
  updateColumnMeta(runner, schema, table, column.id, draft);
2712
2781
  touchTable(runner, table.id);
@@ -2729,6 +2798,7 @@ export const dropColumn = (
2729
2798
  if (table.columns.length === 1) {
2730
2799
  throw new DataError(`${table.name} needs at least one column; drop the table instead.`);
2731
2800
  }
2801
+ assertNoTriggerUses(runner, table, column.name);
2732
2802
  if (dropColumnNeedsRebuild(schema, table.name, column.name)) {
2733
2803
  return rebuildTable(
2734
2804
  runner,
package/src/dataRpc.ts CHANGED
@@ -10,6 +10,7 @@ import type {
10
10
  DataTableCopy
11
11
  } from './dataTypes';
12
12
  import type { DataQuery } from './dataQueries';
13
+ import type { DataTrigger, DataTriggerWithSecret, SavedTrigger } from './dataTriggers';
13
14
 
14
15
  export type ExportFormat = 'jsonl' | 'csv';
15
16
 
@@ -124,6 +125,20 @@ export interface ProjectDatabaseRpc {
124
125
  caller: DataCaller,
125
126
  args: { name: string; params?: Record<string, unknown>; limit?: number }
126
127
  ): R<{ query: string; rows: Record<string, unknown>[]; truncated?: string }>;
128
+ // Triggers. `pending` counts events waiting for the queue.
129
+ listTriggers(caller: DataCaller): R<{ triggers: DataTrigger[]; pending: number }>;
130
+ getTrigger(nameOrId: string): R<DataTriggerWithSecret>;
131
+ saveTrigger(caller: DataCaller, input: unknown): R<SavedTrigger>;
132
+ dropTrigger(caller: DataCaller, name: string): R<{ dropped: string }>;
133
+ setTriggerEnabled(
134
+ caller: DataCaller,
135
+ args: { name: string; enabled: boolean; reason?: string | null }
136
+ ): R<DataTrigger>;
137
+ replaceManagedTriggers(
138
+ caller: DataCaller,
139
+ inputs: unknown
140
+ ): R<{ triggers: DataTrigger[]; removed: string[]; secrets: Record<string, string> }>;
141
+ clearOutbox(args: { before: number }): R<{ cleared: number }>;
127
142
  exportStart(
128
143
  caller: DataCaller,
129
144
  args?: { format?: ExportFormat }