@ganju/utils 0.0.17 → 0.0.18

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 (49) hide show
  1. package/dist/constants.d.ts +6 -0
  2. package/dist/constants.d.ts.map +1 -1
  3. package/dist/constants.js +14 -1
  4. package/dist/customCodeToken.d.ts +4 -0
  5. package/dist/customCodeToken.d.ts.map +1 -1
  6. package/dist/customCodeToken.js +9 -1
  7. package/dist/data.d.ts +2 -0
  8. package/dist/data.d.ts.map +1 -1
  9. package/dist/data.js +28 -1
  10. package/dist/dataApi.d.ts +133 -1
  11. package/dist/dataApi.d.ts.map +1 -1
  12. package/dist/dataApi.js +62 -1
  13. package/dist/dataDdl.d.ts.map +1 -1
  14. package/dist/dataDdl.js +3 -1
  15. package/dist/dataEngine.d.ts.map +1 -1
  16. package/dist/dataEngine.js +62 -0
  17. package/dist/dataRpc.d.ts +25 -0
  18. package/dist/dataRpc.d.ts.map +1 -1
  19. package/dist/dataTriggers.d.ts +203 -0
  20. package/dist/dataTriggers.d.ts.map +1 -0
  21. package/dist/dataTriggers.js +760 -0
  22. package/dist/dataTypes.d.ts +1 -0
  23. package/dist/dataTypes.d.ts.map +1 -1
  24. package/dist/index.d.ts +115 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -0
  27. package/dist/localizeDataError.d.ts +18 -0
  28. package/dist/localizeDataError.d.ts.map +1 -0
  29. package/dist/localizeDataError.js +359 -0
  30. package/dist/schema.d.ts +110 -0
  31. package/dist/schema.d.ts.map +1 -1
  32. package/dist/schema.js +84 -0
  33. package/dist/sdkConstants.d.ts +2 -0
  34. package/dist/sdkConstants.d.ts.map +1 -1
  35. package/dist/sdkConstants.js +3 -1
  36. package/package.json +1 -1
  37. package/src/constants.ts +17 -1
  38. package/src/customCodeToken.ts +14 -1
  39. package/src/data.ts +40 -0
  40. package/src/dataApi.ts +78 -2
  41. package/src/dataDdl.ts +3 -1
  42. package/src/dataEngine.ts +70 -0
  43. package/src/dataRpc.ts +15 -0
  44. package/src/dataTriggers.ts +1134 -0
  45. package/src/dataTypes.ts +3 -0
  46. package/src/index.ts +2 -0
  47. package/src/localizeDataError.ts +593 -0
  48. package/src/schema.ts +89 -0
  49. package/src/sdkConstants.ts +2 -0
@@ -0,0 +1,760 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renameColumnInTriggers = exports.triggersUsingColumn = exports.clearOutbox = exports.outboxSize = exports.deleteOutbox = exports.readOutbox = exports.collectTriggerEvents = exports.ensureChangeCapture = exports.changeCaptureTriggers = exports.replaceManagedTriggers = exports.setTriggerEnabled = exports.dropTrigger = exports.saveTrigger = exports.getTrigger = exports.listTriggers = exports.normaliseTrigger = exports.renderTriggerText = exports.conditionMatches = exports.utf8Bytes = exports.DATA_TRIGGER_MAX_MESSAGE_BYTES = exports.DATA_TRIGGER_MAX_ROW_BYTES = exports.DATA_OUTBOX_BATCH = exports.DATA_TRIGGER_MAX_EMAILS = exports.DATA_TRIGGER_MAX_TEXT = exports.DATA_TRIGGER_MAX_RATE = exports.DATA_TRIGGER_DEFAULT_RATE = exports.DATA_TRIGGER_MAX_DEPTH = exports.DATA_MAX_TRIGGERS = exports.DATA_TRIGGER_ACTIONS = exports.DATA_TRIGGER_EVENTS = void 0;
4
+ const dataCompiler_1 = require("./dataCompiler");
5
+ const dataTypes_1 = require("./dataTypes");
6
+ exports.DATA_TRIGGER_EVENTS = ['insert', 'update', 'delete'];
7
+ exports.DATA_TRIGGER_ACTIONS = [
8
+ 'function',
9
+ 'webhook',
10
+ 'chat',
11
+ 'email',
12
+ 'customer'
13
+ ];
14
+ exports.DATA_MAX_TRIGGERS = 50;
15
+ // A chain of triggers (a triggered function's write firing another trigger)
16
+ // stops at this depth with a visible error.
17
+ exports.DATA_TRIGGER_MAX_DEPTH = 3;
18
+ // Runs a minute per trigger; past it, events wait in the queue.
19
+ exports.DATA_TRIGGER_DEFAULT_RATE = 60;
20
+ exports.DATA_TRIGGER_MAX_RATE = 600;
21
+ exports.DATA_TRIGGER_MAX_TEXT = 2000;
22
+ exports.DATA_TRIGGER_MAX_EMAILS = 10;
23
+ // Conditions nest `all` / `any` this deep, with this many tests in all.
24
+ const MAX_CONDITION_DEPTH = 4;
25
+ const MAX_CONDITION_TESTS = 20;
26
+ // Events handed to the queue per alarm step, and the most of a row an event
27
+ // carries: a queue message holds 128 KB, so a larger row is left out and the
28
+ // receiver reads it by id. Sizes are UTF-8 bytes, not characters.
29
+ exports.DATA_OUTBOX_BATCH = 100;
30
+ exports.DATA_TRIGGER_MAX_ROW_BYTES = 48_000;
31
+ exports.DATA_TRIGGER_MAX_MESSAGE_BYTES = 120_000;
32
+ const encoder = new TextEncoder();
33
+ const utf8Bytes = (value) => encoder.encode(typeof value === 'string' ? value : JSON.stringify(value))
34
+ .length;
35
+ exports.utf8Bytes = utf8Bytes;
36
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
37
+ const NAME_PATTERN = /^[a-z][a-z0-9_-]{0,62}$/;
38
+ const SYSTEM_FIELDS = [
39
+ 'id',
40
+ 'created_at',
41
+ 'updated_at',
42
+ 'version',
43
+ 'created_by',
44
+ 'updated_by',
45
+ 'participant_id'
46
+ ];
47
+ const isScalar = (value) => value === null ||
48
+ typeof value === 'string' ||
49
+ typeof value === 'boolean' ||
50
+ (typeof value === 'number' && Number.isFinite(value));
51
+ // Conditions
52
+ const TEST_KEYS = ['changedTo', 'changedFrom', 'changed', 'equals'];
53
+ const normaliseCondition = (table, raw, depth = 0, count = { tests: 0 }) => {
54
+ if (!isPlainObject(raw) || Object.keys(raw).length === 0) {
55
+ throw new dataTypes_1.DataError('"when" is an object such as { "status": { "changedTo": "READY" } }.');
56
+ }
57
+ if (depth >= MAX_CONDITION_DEPTH) {
58
+ throw new dataTypes_1.DataError(`"when" nests all / any at most ${MAX_CONDITION_DEPTH} deep.`);
59
+ }
60
+ if ('all' in raw || 'any' in raw) {
61
+ const key = 'all' in raw ? 'all' : 'any';
62
+ if (Object.keys(raw).length !== 1 ||
63
+ !Array.isArray(raw[key]) ||
64
+ raw[key].length === 0) {
65
+ throw new dataTypes_1.DataError(`"${key}" is a non-empty list of conditions, alone in its object.`);
66
+ }
67
+ const list = raw[key].map(item => normaliseCondition(table, item, depth + 1, count));
68
+ return key === 'all' ? { all: list } : { any: list };
69
+ }
70
+ const out = {};
71
+ for (const [column, test] of Object.entries(raw)) {
72
+ if (!table.columns.some(c => c.name === column) &&
73
+ !SYSTEM_FIELDS.includes(column)) {
74
+ throw new dataTypes_1.DataError(`"when" names ${table.name}.${column}, which doesn't exist.`);
75
+ }
76
+ if (!isPlainObject(test) || Object.keys(test).length === 0) {
77
+ throw new dataTypes_1.DataError(`"when".${column} is a test: changedTo, changedFrom, changed or equals.`);
78
+ }
79
+ const clean = {};
80
+ for (const [key, value] of Object.entries(test)) {
81
+ if (!TEST_KEYS.includes(key)) {
82
+ throw new dataTypes_1.DataError(`"when".${column}.${key}: a test is changedTo, changedFrom, changed or equals.`);
83
+ }
84
+ if (key === 'changed') {
85
+ if (value !== true)
86
+ throw new dataTypes_1.DataError(`"when".${column}.changed is true.`);
87
+ clean.changed = true;
88
+ }
89
+ else {
90
+ if (!isScalar(value)) {
91
+ throw new dataTypes_1.DataError(`"when".${column}.${key} is a text, number, true / false or null.`);
92
+ }
93
+ clean[key] = value;
94
+ }
95
+ if (++count.tests > MAX_CONDITION_TESTS) {
96
+ throw new dataTypes_1.DataError(`"when" holds at most ${MAX_CONDITION_TESTS} tests.`);
97
+ }
98
+ }
99
+ out[column] = clean;
100
+ }
101
+ return out;
102
+ };
103
+ // Values compare as JSON, so 3 and 3.0 match and "3" doesn't.
104
+ const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
105
+ /**
106
+ * Whether a change matches a condition. A missing side counts as all null:
107
+ * an insert "changed to" the values it was created with, and a delete
108
+ * "changed from" the values it had. `equals` tests the row after the change,
109
+ * or the deleted row.
110
+ */
111
+ const conditionMatches = (when, row, previous) => {
112
+ if (!when)
113
+ return true;
114
+ if ('all' in when && Array.isArray(when.all)) {
115
+ return when.all.every(c => (0, exports.conditionMatches)(c, row, previous));
116
+ }
117
+ if ('any' in when && Array.isArray(when.any)) {
118
+ return when.any.some(c => (0, exports.conditionMatches)(c, row, previous));
119
+ }
120
+ const after = row ?? {};
121
+ const before = previous ?? {};
122
+ const current = row ?? previous ?? {};
123
+ return Object.entries(when).every(([column, test]) => {
124
+ const now = after[column] ?? null;
125
+ const then = before[column] ?? null;
126
+ if (test.changed && same(now, then))
127
+ return false;
128
+ if ('changedTo' in test &&
129
+ !(same(now, test.changedTo) && !same(then, test.changedTo))) {
130
+ return false;
131
+ }
132
+ if ('changedFrom' in test &&
133
+ !(same(then, test.changedFrom) && !same(now, test.changedFrom))) {
134
+ return false;
135
+ }
136
+ if ('equals' in test && !same(current[column] ?? null, test.equals))
137
+ return false;
138
+ return true;
139
+ });
140
+ };
141
+ exports.conditionMatches = conditionMatches;
142
+ // Templates
143
+ /**
144
+ * A message built from a row: each {{field}} becomes that field's value as
145
+ * plain text. Never markup: the row is customer-written content, and the
146
+ * senders send it as text.
147
+ */
148
+ const renderTriggerText = (text, row) => text.replace(/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/g, (_, field) => {
149
+ const value = row?.[field];
150
+ if (value === null || value === undefined)
151
+ return '';
152
+ return typeof value === 'object' ? JSON.stringify(value) : String(value);
153
+ });
154
+ exports.renderTriggerText = renderTriggerText;
155
+ const templateFields = (text) => [...text.matchAll(/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/g)].map(m => m[1]);
156
+ // Definitions
157
+ const cleanText = (value, label, max = exports.DATA_TRIGGER_MAX_TEXT) => {
158
+ if (typeof value !== 'string' || !value.trim())
159
+ throw new dataTypes_1.DataError(`${label} is required.`);
160
+ if (value.length > max)
161
+ throw new dataTypes_1.DataError(`${label} is at most ${max} characters.`);
162
+ return value.trim();
163
+ };
164
+ const assertFields = (table, text, label) => {
165
+ for (const field of templateFields(text)) {
166
+ if (!table.columns.some(c => c.name === field) &&
167
+ !SYSTEM_FIELDS.includes(field)) {
168
+ throw new dataTypes_1.DataError(`${label} uses {{${field}}}, which isn't a column of ${table.name}.`);
169
+ }
170
+ }
171
+ };
172
+ const normaliseAction = (table, input) => {
173
+ const given = exports.DATA_TRIGGER_ACTIONS.filter(key => input[key] !== undefined && input[key] !== null);
174
+ if (given.length !== 1) {
175
+ throw new dataTypes_1.DataError('A trigger runs one thing: "function", "webhook", "chat", "email" or "customer".');
176
+ }
177
+ const type = given[0];
178
+ const value = input[type];
179
+ switch (type) {
180
+ case 'function':
181
+ if (typeof value !== 'string' || !/^[a-zA-Z0-9_-]{1,64}$/.test(value)) {
182
+ throw new dataTypes_1.DataError('"function" is the name of one of this project\'s functions.');
183
+ }
184
+ return { type, function: value };
185
+ case 'webhook': {
186
+ if (typeof value !== 'string' || value.length > 2000) {
187
+ throw new dataTypes_1.DataError('"webhook" is an https URL.');
188
+ }
189
+ let url;
190
+ try {
191
+ url = new URL(value);
192
+ }
193
+ catch {
194
+ throw new dataTypes_1.DataError('"webhook" is an https URL.');
195
+ }
196
+ if (url.protocol !== 'https:')
197
+ throw new dataTypes_1.DataError('"webhook" must use https.');
198
+ return { type, url: url.toString() };
199
+ }
200
+ case 'chat': {
201
+ if (!isPlainObject(value)) {
202
+ throw new dataTypes_1.DataError('"chat" is { "channelId", "chatId", "text" }.');
203
+ }
204
+ const text = cleanText(value.text, '"chat.text"');
205
+ assertFields(table, text, '"chat.text"');
206
+ return {
207
+ type,
208
+ channelId: cleanText(value.channelId, '"chat.channelId"', 100),
209
+ chatId: cleanText(String(value.chatId ?? ''), '"chat.chatId"', 200),
210
+ text
211
+ };
212
+ }
213
+ case 'email': {
214
+ if (!isPlainObject(value))
215
+ throw new dataTypes_1.DataError('"email" is { "to", "subject", "text" }.');
216
+ const to = (Array.isArray(value.to) ? value.to : [value.to]).map(v => typeof v === 'string' ? v.trim().toLowerCase() : '');
217
+ if (to.length === 0 ||
218
+ to.length > exports.DATA_TRIGGER_MAX_EMAILS ||
219
+ to.some(v => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v))) {
220
+ throw new dataTypes_1.DataError(`"email.to" is 1 to ${exports.DATA_TRIGGER_MAX_EMAILS} email addresses.`);
221
+ }
222
+ const text = cleanText(value.text, '"email.text"');
223
+ const subject = value.subject === undefined || value.subject === null
224
+ ? `${table.name}: {{id}}`
225
+ : cleanText(value.subject, '"email.subject"', 200);
226
+ assertFields(table, text, '"email.text"');
227
+ assertFields(table, subject, '"email.subject"');
228
+ return { type, to: [...new Set(to)], subject, text };
229
+ }
230
+ case 'customer': {
231
+ if (!table.customerScoped) {
232
+ throw new dataTypes_1.DataError(`"customer" messages the row's customer, so ${table.name} must be customer-scoped.`);
233
+ }
234
+ if (!isPlainObject(value))
235
+ throw new dataTypes_1.DataError('"customer" is { "text", "template"? }.');
236
+ const text = cleanText(value.text, '"customer.text"');
237
+ assertFields(table, text, '"customer.text"');
238
+ if (value.template === undefined || value.template === null)
239
+ return { type, text };
240
+ const t = value.template;
241
+ if (!isPlainObject(t)) {
242
+ throw new dataTypes_1.DataError('"customer.template" is { "name", "language", "params"? }.');
243
+ }
244
+ const params = t.params === undefined ? [] : t.params;
245
+ if (!Array.isArray(params) ||
246
+ params.length > 20 ||
247
+ !params.every(p => typeof p === 'string')) {
248
+ throw new dataTypes_1.DataError('"customer.template.params" is a list of texts.');
249
+ }
250
+ for (const param of params)
251
+ assertFields(table, param, '"customer.template.params"');
252
+ return {
253
+ type,
254
+ text,
255
+ template: {
256
+ name: cleanText(t.name, '"customer.template.name"', 512),
257
+ language: cleanText(t.language, '"customer.template.language"', 20),
258
+ params: params
259
+ }
260
+ };
261
+ }
262
+ }
263
+ };
264
+ const defaultName = (table, on, action) => {
265
+ const target = action.type === 'function'
266
+ ? action.function.toLowerCase().replace(/[^a-z0-9_-]/g, '-')
267
+ : action.type;
268
+ return `${table}-${on}-${target}`.slice(0, 63);
269
+ };
270
+ const normaliseTrigger = (schema, input) => {
271
+ if (!isPlainObject(input))
272
+ throw new dataTypes_1.DataError('A trigger is an object.');
273
+ const table = schema.tables.find(t => t.name === input.table);
274
+ if (!table) {
275
+ throw new dataTypes_1.DataError(typeof input.table === 'string'
276
+ ? `There is no table named ${input.table}.`
277
+ : 'A trigger needs "table".', typeof input.table === 'string' ? 'not_found' : 'invalid');
278
+ }
279
+ const on = input.on;
280
+ if (!exports.DATA_TRIGGER_EVENTS.includes(on)) {
281
+ throw new dataTypes_1.DataError('"on" is insert, update or delete.');
282
+ }
283
+ const action = normaliseAction(table, input);
284
+ const unnamed = input.name === undefined || input.name === null;
285
+ const name = unnamed
286
+ ? defaultName(table.name, on, action)
287
+ : input.name;
288
+ if (typeof name !== 'string' || !NAME_PATTERN.test(name)) {
289
+ throw new dataTypes_1.DataError("A trigger's name is lowercase letters, digits, _ and -, starting with a letter.");
290
+ }
291
+ const rate = input.rateLimit ?? exports.DATA_TRIGGER_DEFAULT_RATE;
292
+ if (typeof rate !== 'number' ||
293
+ !Number.isInteger(rate) ||
294
+ rate < 1 ||
295
+ rate > exports.DATA_TRIGGER_MAX_RATE) {
296
+ throw new dataTypes_1.DataError(`"rateLimit" is 1 to ${exports.DATA_TRIGGER_MAX_RATE} runs a minute.`);
297
+ }
298
+ if (input.enabled !== undefined && typeof input.enabled !== 'boolean') {
299
+ throw new dataTypes_1.DataError('"enabled" is true or false.');
300
+ }
301
+ if (input.replace !== undefined && typeof input.replace !== 'boolean') {
302
+ throw new dataTypes_1.DataError('"replace" is true or false.');
303
+ }
304
+ return {
305
+ name,
306
+ defaultName: unnamed,
307
+ description: typeof input.description === 'string' && input.description.trim()
308
+ ? input.description.trim().slice(0, dataTypes_1.DATA_MAX_DESCRIPTION)
309
+ : null,
310
+ table,
311
+ on: on,
312
+ when: input.when === undefined || input.when === null
313
+ ? null
314
+ : normaliseCondition(table, input.when),
315
+ action,
316
+ enabled: typeof input.enabled === 'boolean' ? input.enabled : null,
317
+ replace: input.replace !== false,
318
+ rateLimit: rate
319
+ };
320
+ };
321
+ exports.normaliseTrigger = normaliseTrigger;
322
+ // The default name, numbered past the names already taken: two unnamed
323
+ // triggers on the same event never replace each other.
324
+ const freeName = (runner, base) => {
325
+ const taken = new Set(runner.all('SELECT name FROM _ganju_triggers').map(row => String(row.name)));
326
+ if (!taken.has(base))
327
+ return base;
328
+ for (let n = 2;; n++) {
329
+ const suffix = `-${n}`;
330
+ const name = `${base.slice(0, 63 - suffix.length)}${suffix}`;
331
+ if (!taken.has(name))
332
+ return name;
333
+ }
334
+ };
335
+ const parse = (value) => {
336
+ if (typeof value !== 'string')
337
+ return null;
338
+ try {
339
+ return JSON.parse(value);
340
+ }
341
+ catch {
342
+ return null;
343
+ }
344
+ };
345
+ const toTrigger = (schema, row) => ({
346
+ id: String(row.id),
347
+ name: String(row.name),
348
+ description: row.description ?? null,
349
+ table: schema.tables.find(t => t.id === Number(row.table_id))?.name ?? '',
350
+ on: row.event,
351
+ when: parse(row.condition),
352
+ action: parse(row.action),
353
+ enabled: row.enabled === 1,
354
+ disabledReason: row.disabled_reason ?? null,
355
+ managed: row.managed === 'cli' ? 'cli' : null,
356
+ rateLimit: Number(row.rate_limit),
357
+ createdAt: String(row.created_at),
358
+ updatedAt: String(row.updated_at),
359
+ updatedBy: row.updated_by ?? null
360
+ });
361
+ const listTriggers = (runner, schema) => runner
362
+ .all('SELECT * FROM _ganju_triggers ORDER BY name')
363
+ .map(row => toTrigger(schema, row));
364
+ exports.listTriggers = listTriggers;
365
+ const findRow = (runner, nameOrId) => {
366
+ const row = runner.all('SELECT * FROM _ganju_triggers WHERE name = ? OR id = ?', [String(nameOrId), String(nameOrId)])[0];
367
+ if (!row)
368
+ throw new dataTypes_1.DataError(`There is no trigger named ${String(nameOrId)}.`, 'not_found');
369
+ return row;
370
+ };
371
+ const getTrigger = (runner, schema, nameOrId) => {
372
+ const row = findRow(runner, nameOrId);
373
+ return {
374
+ ...toTrigger(schema, row),
375
+ secret: row.secret ?? null
376
+ };
377
+ };
378
+ exports.getTrigger = getTrigger;
379
+ const newSecret = () => {
380
+ const bytes = new Uint8Array(24);
381
+ crypto.getRandomValues(bytes);
382
+ return `whsec_${Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')}`;
383
+ };
384
+ const upsert = (runner, schema, caller, trigger, managed) => {
385
+ const existing = runner.all('SELECT * FROM _ganju_triggers WHERE name = ?', [
386
+ trigger.name
387
+ ])[0];
388
+ if (existing && !trigger.replace) {
389
+ throw new dataTypes_1.DataError(`A trigger named ${trigger.name} already exists; give this one another name.`, 'conflict');
390
+ }
391
+ if (existing && (existing.managed === 'cli') !== (managed === 'cli')) {
392
+ throw new dataTypes_1.DataError(existing.managed === 'cli'
393
+ ? `${trigger.name} is deployed from ganju.json; change it there.`
394
+ : `A trigger named ${trigger.name} was made in the dashboard; give this one another name.`, 'conflict');
395
+ }
396
+ if (!existing) {
397
+ const count = Number(runner.all('SELECT count(*) AS n FROM _ganju_triggers')[0]?.n ?? 0);
398
+ if (count >= exports.DATA_MAX_TRIGGERS) {
399
+ throw new dataTypes_1.DataError(`A database holds up to ${exports.DATA_MAX_TRIGGERS} triggers.`);
400
+ }
401
+ }
402
+ // A webhook keeps its secret while it stays a webhook.
403
+ const keep = existing?.secret && trigger.action.type === 'webhook'
404
+ ? String(existing.secret)
405
+ : null;
406
+ const secret = trigger.action.type === 'webhook' ? (keep ?? newSecret()) : null;
407
+ const id = existing
408
+ ? String(existing.id)
409
+ : `trg_${crypto.randomUUID().replace(/-/g, '')}`;
410
+ // Left out, "enabled" keeps the trigger as it was, and why Ganju turned it
411
+ // off: a deploy doesn't turn back on what the dashboard or Ganju turned off.
412
+ const enabled = trigger.enabled ?? (existing ? existing.enabled === 1 : true);
413
+ const reason = enabled
414
+ ? null
415
+ : (existing?.disabled_reason ?? null);
416
+ runner.all(`INSERT INTO _ganju_triggers (id, name, description, table_id, event, condition, action, enabled, disabled_reason, managed, rate_limit, secret, updated_at, updated_by)
417
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), ?)
418
+ ON CONFLICT(id) DO UPDATE SET name = excluded.name, description = excluded.description,
419
+ table_id = excluded.table_id, event = excluded.event, condition = excluded.condition,
420
+ action = excluded.action, enabled = excluded.enabled, disabled_reason = excluded.disabled_reason,
421
+ managed = excluded.managed, rate_limit = excluded.rate_limit, secret = excluded.secret,
422
+ updated_at = excluded.updated_at, updated_by = excluded.updated_by`, [
423
+ id,
424
+ trigger.name,
425
+ trigger.description,
426
+ trigger.table.id,
427
+ trigger.on,
428
+ trigger.when ? JSON.stringify(trigger.when) : null,
429
+ JSON.stringify(trigger.action),
430
+ enabled ? 1 : 0,
431
+ reason,
432
+ managed,
433
+ trigger.rateLimit,
434
+ secret,
435
+ caller.actor
436
+ ]);
437
+ (0, exports.ensureChangeCapture)(runner, trigger.table);
438
+ const saved = toTrigger(schema, findRow(runner, id));
439
+ return secret && secret !== keep
440
+ ? { trigger: saved, secret }
441
+ : { trigger: saved };
442
+ };
443
+ const saveTrigger = (runner, schema, caller, input) => runner.transaction(() => {
444
+ const trigger = (0, exports.normaliseTrigger)(schema, input);
445
+ if (trigger.defaultName)
446
+ trigger.name = freeName(runner, trigger.name);
447
+ return upsert(runner, schema, caller, trigger, null);
448
+ });
449
+ exports.saveTrigger = saveTrigger;
450
+ const dropTrigger = (runner, nameOrId) => {
451
+ const row = findRow(runner, nameOrId);
452
+ runner.all('DELETE FROM _ganju_triggers WHERE id = ?', [row.id]);
453
+ return { dropped: String(row.name) };
454
+ };
455
+ exports.dropTrigger = dropTrigger;
456
+ // On / off, by the owner or by Ganju after failures in a row (with why).
457
+ const setTriggerEnabled = (runner, schema, caller, nameOrId, enabled, reason = null) => {
458
+ const row = findRow(runner, nameOrId);
459
+ runner.all(`UPDATE _ganju_triggers SET enabled = ?, disabled_reason = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), updated_by = ? WHERE id = ?`, [enabled ? 1 : 0, enabled ? null : reason, caller.actor, row.id]);
460
+ if (enabled) {
461
+ const table = schema.tables.find(t => t.id === Number(row.table_id));
462
+ if (table)
463
+ (0, exports.ensureChangeCapture)(runner, table);
464
+ }
465
+ return toTrigger(schema, findRow(runner, row.id));
466
+ };
467
+ exports.setTriggerEnabled = setTriggerEnabled;
468
+ /**
469
+ * The triggers ganju.json deploys: each one saved, and every other trigger
470
+ * the CLI deployed before removed. Those made in the dashboard stay. One
471
+ * transaction, so a bad entry changes nothing.
472
+ */
473
+ const replaceManagedTriggers = (runner, schema, caller, inputs) => {
474
+ if (!Array.isArray(inputs) || inputs.length > exports.DATA_MAX_TRIGGERS) {
475
+ throw new dataTypes_1.DataError(`"triggers" is a list of up to ${exports.DATA_MAX_TRIGGERS} triggers.`);
476
+ }
477
+ return runner.transaction(() => {
478
+ const normalised = inputs.map((input, i) => {
479
+ try {
480
+ return (0, exports.normaliseTrigger)(schema, input);
481
+ }
482
+ catch (error) {
483
+ if (error instanceof dataTypes_1.DataError) {
484
+ throw new dataTypes_1.DataError(`Trigger ${i}: ${error.message}`, error.code, error.details);
485
+ }
486
+ throw error;
487
+ }
488
+ });
489
+ const names = new Set();
490
+ for (const trigger of normalised) {
491
+ if (names.has(trigger.name)) {
492
+ throw new dataTypes_1.DataError(`Two triggers are named ${trigger.name}; give each a "name" of its own.`);
493
+ }
494
+ names.add(trigger.name);
495
+ }
496
+ const removed = [];
497
+ for (const row of runner.all("SELECT id, name FROM _ganju_triggers WHERE managed = 'cli'")) {
498
+ if (names.has(String(row.name)))
499
+ continue;
500
+ runner.all('DELETE FROM _ganju_triggers WHERE id = ?', [row.id]);
501
+ removed.push(String(row.name));
502
+ }
503
+ const secrets = {};
504
+ const triggers = normalised.map(trigger => {
505
+ const saved = upsert(runner, schema, caller, trigger, 'cli');
506
+ if (saved.secret)
507
+ secrets[saved.trigger.name] = saved.secret;
508
+ return saved.trigger;
509
+ });
510
+ return { triggers, removed, secrets };
511
+ });
512
+ };
513
+ exports.replaceManagedTriggers = replaceManagedTriggers;
514
+ // Change capture
515
+ const jsonRowSql = (table, side) => {
516
+ const fields = [...SYSTEM_FIELDS, ...table.columns.map(c => c.name)];
517
+ // json_set takes its arguments in pairs, and SQLite caps a function at 127
518
+ // arguments, so wide tables are built in pieces.
519
+ let out = "'{}'";
520
+ for (let i = 0; i < fields.length; i += 50) {
521
+ const pairs = fields
522
+ .slice(i, i + 50)
523
+ .map(f => `'$."${f}"', ${side}."${f}"`)
524
+ .join(', ');
525
+ out = `json_set(${out}, ${pairs})`;
526
+ }
527
+ return out;
528
+ };
529
+ /**
530
+ * The SQLite triggers that copy a table's changed rows into _ganju_changes.
531
+ * Each runs only while an enabled trigger of the owner's listens for that
532
+ * event on the table. Named like the table's other triggers, so they are
533
+ * dropped and remade with them when its columns change.
534
+ */
535
+ const changeCaptureTriggers = (table) => {
536
+ const t = `"${table.name}"`;
537
+ const prefix = `tr_${table.id}_`;
538
+ const listening = (event) => `EXISTS (SELECT 1 FROM _ganju_triggers WHERE table_id = ${table.id} AND event = '${event}' AND enabled = 1)`;
539
+ const capture = (event, rowId, oldJson, newJson) => `INSERT INTO _ganju_changes (table_id, event, row_id, actor, old, new) VALUES (${table.id}, '${event}', ${rowId}, (SELECT actor FROM _ganju_context WHERE id = 1), ${oldJson}, ${newJson})`;
540
+ const full = table;
541
+ return [
542
+ {
543
+ name: `${prefix}ev_ai`,
544
+ sql: `CREATE TRIGGER IF NOT EXISTS "${prefix}ev_ai" AFTER INSERT ON ${t} WHEN ${listening('insert')} BEGIN ${capture('insert', 'new.id', 'NULL', jsonRowSql(full, 'new'))}; END`
545
+ },
546
+ {
547
+ name: `${prefix}ev_au`,
548
+ sql: `CREATE TRIGGER IF NOT EXISTS "${prefix}ev_au" AFTER UPDATE ON ${t} WHEN ${listening('update')} BEGIN ${capture('update', 'new.id', jsonRowSql(full, 'old'), jsonRowSql(full, 'new'))}; END`
549
+ },
550
+ {
551
+ name: `${prefix}ev_ad`,
552
+ sql: `CREATE TRIGGER IF NOT EXISTS "${prefix}ev_ad" AFTER DELETE ON ${t} WHEN ${listening('delete')} BEGIN ${capture('delete', 'old.id', jsonRowSql(full, 'old'), 'NULL')}; END`
553
+ }
554
+ ];
555
+ };
556
+ exports.changeCaptureTriggers = changeCaptureTriggers;
557
+ // Tables made before triggers existed have no capture triggers until their
558
+ // columns next change; the first trigger on one adds them.
559
+ const ensureChangeCapture = (runner, table) => {
560
+ for (const trigger of (0, exports.changeCaptureTriggers)(table))
561
+ runner.all(trigger.sql);
562
+ };
563
+ exports.ensureChangeCapture = ensureChangeCapture;
564
+ const shape = (table, raw) => {
565
+ const stored = parse(raw);
566
+ if (!stored)
567
+ return null;
568
+ const out = {};
569
+ for (const field of SYSTEM_FIELDS)
570
+ out[field] = stored[field] ?? null;
571
+ for (const column of table.columns) {
572
+ out[column.name] = (0, dataCompiler_1.fromStored)(column, stored[column.name] ?? null);
573
+ }
574
+ if (!table.customerScoped)
575
+ delete out.participant_id;
576
+ return out;
577
+ };
578
+ const DEPTH_PATTERN = /^trigger:([^\s]+)$/;
579
+ /**
580
+ * Turns the changes a write captured into events, inside the write's
581
+ * transaction. A trigger never matches the writes of a run it started
582
+ * (actor `trigger:<id>`), and a chain stops at DATA_TRIGGER_MAX_DEPTH with
583
+ * an event that only records why. Returns how many events were stored.
584
+ */
585
+ const collectTriggerEvents = (runner, schema, caller) => {
586
+ const changes = runner.all('SELECT * FROM _ganju_changes ORDER BY id');
587
+ if (changes.length === 0)
588
+ return 0;
589
+ runner.all('DELETE FROM _ganju_changes');
590
+ const artifactId = runner.all("SELECT value FROM _ganju_meta WHERE key = 'artifact_id'")[0]
591
+ ?.value ?? null;
592
+ const triggers = runner
593
+ .all('SELECT * FROM _ganju_triggers WHERE enabled = 1')
594
+ .map(row => toTrigger(schema, row));
595
+ const ownTrigger = DEPTH_PATTERN.exec(caller.actor)?.[1] ?? null;
596
+ const depth = (caller.triggerDepth ?? 0) + 1;
597
+ let stored = 0;
598
+ for (const change of changes) {
599
+ const table = schema.tables.find(t => t.id === Number(change.table_id));
600
+ if (!table)
601
+ continue;
602
+ const event = change.event;
603
+ const listening = triggers.filter(t => t.table === table.name && t.on === event);
604
+ if (listening.length === 0)
605
+ continue;
606
+ const row = shape(table, change.new);
607
+ const previous = shape(table, change.old);
608
+ for (const trigger of listening) {
609
+ if (trigger.id === ownTrigger)
610
+ continue;
611
+ if (!(0, exports.conditionMatches)(trigger.when, row, previous))
612
+ continue;
613
+ const message = {
614
+ eventId: `evt_${crypto.randomUUID().replace(/-/g, '')}`,
615
+ artifactId: artifactId ?? '',
616
+ triggerId: trigger.id,
617
+ triggerName: trigger.name,
618
+ table: table.name,
619
+ event,
620
+ rowId: Number(change.row_id),
621
+ row,
622
+ previous,
623
+ actor: change.actor ?? null,
624
+ depth,
625
+ at: new Date().toISOString()
626
+ };
627
+ if (depth > exports.DATA_TRIGGER_MAX_DEPTH) {
628
+ message.row = null;
629
+ message.previous = null;
630
+ message.error = `Not run: a chain of triggers stops at depth ${exports.DATA_TRIGGER_MAX_DEPTH} (this change was made by ${caller.actor}).`;
631
+ }
632
+ else if ((0, exports.utf8Bytes)([row, previous]) > exports.DATA_TRIGGER_MAX_ROW_BYTES) {
633
+ message.row = null;
634
+ message.previous = null;
635
+ message.truncated = true;
636
+ }
637
+ runner.all('INSERT INTO _ganju_outbox (id, trigger_id, message) VALUES (?, ?, ?)', [message.eventId, trigger.id, JSON.stringify(message)]);
638
+ stored++;
639
+ }
640
+ }
641
+ return stored;
642
+ };
643
+ exports.collectTriggerEvents = collectTriggerEvents;
644
+ // The outbox, oldest first, for the alarm to hand to the queue.
645
+ const readOutbox = (runner, limit = exports.DATA_OUTBOX_BATCH) => runner
646
+ .all('SELECT seq, message FROM _ganju_outbox ORDER BY seq LIMIT ?', [limit])
647
+ .map(row => ({
648
+ seq: Number(row.seq),
649
+ message: JSON.parse(String(row.message))
650
+ }));
651
+ exports.readOutbox = readOutbox;
652
+ const deleteOutbox = (runner, throughSeq) => {
653
+ runner.all('DELETE FROM _ganju_outbox WHERE seq <= ?', [throughSeq]);
654
+ };
655
+ exports.deleteOutbox = deleteOutbox;
656
+ const outboxSize = (runner) => Number(runner.all('SELECT count(*) AS n FROM _ganju_outbox')[0]?.n ?? 0);
657
+ exports.outboxSize = outboxSize;
658
+ // After a restore: events pending at the restored moment were sent then.
659
+ // Those stored before `before` came back with the restore; later ones are
660
+ // writes made since, and stay.
661
+ const clearOutbox = (runner, before) => {
662
+ const n = Number(runner.all('SELECT count(*) AS n FROM _ganju_outbox WHERE created_at < ?', [
663
+ before
664
+ ])[0]?.n ?? 0);
665
+ runner.all('DELETE FROM _ganju_outbox WHERE created_at < ?', [before]);
666
+ runner.all('DELETE FROM _ganju_changes');
667
+ return n;
668
+ };
669
+ exports.clearOutbox = clearOutbox;
670
+ const conditionColumns = (condition) => {
671
+ if (!isPlainObject(condition))
672
+ return [];
673
+ if (Array.isArray(condition.all))
674
+ return condition.all.flatMap(conditionColumns);
675
+ if (Array.isArray(condition.any))
676
+ return condition.any.flatMap(conditionColumns);
677
+ return Object.keys(condition);
678
+ };
679
+ const actionTexts = (action) => {
680
+ switch (action?.type) {
681
+ case 'chat':
682
+ return [action.text];
683
+ case 'email':
684
+ return [action.subject, action.text];
685
+ case 'customer':
686
+ return [action.text, ...(action.template?.params ?? [])];
687
+ default:
688
+ return [];
689
+ }
690
+ };
691
+ /**
692
+ * The triggers whose condition or {{field}} texts name a column. Dropping it
693
+ * is refused while any does, since they are only checked when saved and
694
+ * would quietly stop matching.
695
+ */
696
+ const triggersUsingColumn = (runner, tableId, column) => runner
697
+ .all('SELECT name, condition, action FROM _ganju_triggers WHERE table_id = ? ORDER BY name', [tableId])
698
+ .filter(row => conditionColumns(parse(row.condition)).includes(column) ||
699
+ actionTexts(parse(row.action)).some(text => templateFields(text).includes(column)))
700
+ .map(row => String(row.name));
701
+ exports.triggersUsingColumn = triggersUsingColumn;
702
+ const renameInCondition = (condition, from, to) => {
703
+ if (!isPlainObject(condition))
704
+ return condition;
705
+ if (Array.isArray(condition.all))
706
+ return { all: condition.all.map(c => renameInCondition(c, from, to)) };
707
+ if (Array.isArray(condition.any))
708
+ return { any: condition.any.map(c => renameInCondition(c, from, to)) };
709
+ return Object.fromEntries(Object.entries(condition).map(([key, test]) => [
710
+ key === from ? to : key,
711
+ test
712
+ ]));
713
+ };
714
+ const renameInText = (text, from, to) => text.replace(/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/g, (match, field) => field === from ? `{{${to}}}` : match);
715
+ const renameInAction = (action, from, to) => {
716
+ const r = (text) => renameInText(text, from, to);
717
+ switch (action.type) {
718
+ case 'chat':
719
+ return { ...action, text: r(action.text) };
720
+ case 'email':
721
+ return { ...action, subject: r(action.subject), text: r(action.text) };
722
+ case 'customer':
723
+ return {
724
+ ...action,
725
+ text: r(action.text),
726
+ ...(action.template
727
+ ? {
728
+ template: {
729
+ ...action.template,
730
+ params: action.template.params?.map(r)
731
+ }
732
+ }
733
+ : {})
734
+ };
735
+ default:
736
+ return action;
737
+ }
738
+ };
739
+ /**
740
+ * A renamed column, renamed in the triggers that use it too, inside the
741
+ * rename's transaction. Those deployed from ganju.json then differ from the
742
+ * file until it is changed to match. Returns the triggers changed.
743
+ */
744
+ const renameColumnInTriggers = (runner, tableId, from, to) => {
745
+ const names = (0, exports.triggersUsingColumn)(runner, tableId, from);
746
+ for (const name of names) {
747
+ const row = runner.all('SELECT id, condition, action FROM _ganju_triggers WHERE name = ?', [name])[0];
748
+ const condition = parse(row.condition);
749
+ const action = parse(row.action);
750
+ runner.all(`UPDATE _ganju_triggers SET condition = ?, action = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`, [
751
+ condition === null
752
+ ? null
753
+ : JSON.stringify(renameInCondition(condition, from, to)),
754
+ action ? JSON.stringify(renameInAction(action, from, to)) : row.action,
755
+ row.id
756
+ ]);
757
+ }
758
+ return names;
759
+ };
760
+ exports.renameColumnInTriggers = renameColumnInTriggers;