@ganju/utils 0.0.20 → 0.0.22

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 +16 -0
  2. package/dist/constants.d.ts.map +1 -1
  3. package/dist/constants.js +43 -0
  4. package/dist/data.d.ts +7 -5
  5. package/dist/data.d.ts.map +1 -1
  6. package/dist/data.js +27 -3
  7. package/dist/dataApi.d.ts +82 -6
  8. package/dist/dataApi.d.ts.map +1 -1
  9. package/dist/dataApi.js +47 -4
  10. package/dist/dataAttachment.d.ts +8 -0
  11. package/dist/dataAttachment.d.ts.map +1 -0
  12. package/dist/dataAttachment.js +19 -0
  13. package/dist/dataCompiler.d.ts +4 -0
  14. package/dist/dataCompiler.d.ts.map +1 -1
  15. package/dist/dataCompiler.js +27 -2
  16. package/dist/dataDdl.d.ts.map +1 -1
  17. package/dist/dataDdl.js +5 -0
  18. package/dist/dataEngine.d.ts +25 -3
  19. package/dist/dataEngine.d.ts.map +1 -1
  20. package/dist/dataEngine.js +508 -14
  21. package/dist/dataImport.d.ts +3 -0
  22. package/dist/dataImport.d.ts.map +1 -1
  23. package/dist/dataImport.js +12 -2
  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 +20 -1
  28. package/dist/dataRpc.d.ts.map +1 -1
  29. package/dist/dataSheet.d.ts +34 -0
  30. package/dist/dataSheet.d.ts.map +1 -0
  31. package/dist/dataSheet.js +100 -0
  32. package/dist/dataTools.js +3 -3
  33. package/dist/dataTriggers.d.ts +66 -4
  34. package/dist/dataTriggers.d.ts.map +1 -1
  35. package/dist/dataTriggers.js +446 -20
  36. package/dist/dataTypes.d.ts +69 -0
  37. package/dist/dataTypes.d.ts.map +1 -1
  38. package/dist/dataTypes.js +21 -1
  39. package/dist/index.d.ts +91 -7
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +8 -0
  42. package/dist/localizeDataError.d.ts.map +1 -1
  43. package/dist/localizeDataError.js +109 -3
  44. package/dist/schema.d.ts +67 -6
  45. package/dist/schema.d.ts.map +1 -1
  46. package/dist/schema.js +57 -2
  47. package/dist/untrustedContent.d.ts +7 -0
  48. package/dist/untrustedContent.d.ts.map +1 -1
  49. package/dist/untrustedContent.js +13 -1
  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 +46 -0
  55. package/src/data.ts +33 -3
  56. package/src/dataApi.ts +54 -4
  57. package/src/dataAttachment.ts +23 -0
  58. package/src/dataCompiler.ts +41 -1
  59. package/src/dataDdl.ts +7 -0
  60. package/src/dataEngine.ts +680 -15
  61. package/src/dataImport.ts +16 -1
  62. package/src/dataIndexAdvice.ts +127 -0
  63. package/src/dataRpc.ts +27 -2
  64. package/src/dataSheet.ts +147 -0
  65. package/src/dataTools.ts +3 -3
  66. package/src/dataTriggers.ts +599 -20
  67. package/src/dataTypes.ts +108 -0
  68. package/src/index.ts +15 -0
  69. package/src/localizeDataError.ts +183 -4
  70. package/src/schema.ts +60 -2
  71. package/src/untrustedContent.ts +15 -0
  72. package/src/whatsappWebhook.ts +55 -0
@@ -1,9 +1,35 @@
1
1
  "use strict";
2
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;
3
+ exports.scanTimeTriggers = exports.hasTimeTriggers = exports.syncTimeIndexes = exports.timeTriggersCountingFrom = exports.renameColumnInTriggers = exports.triggersUsingColumn = exports.clearOutbox = exports.outboxSize = exports.deleteOutbox = exports.readOutbox = exports.collectTriggerEvents = exports.readEventRow = exports.ensureChangeCapture = exports.changeCaptureTriggers = exports.replaceManagedTriggers = exports.setTriggerEnabled = exports.dropTrigger = exports.saveTrigger = exports.getTrigger = exports.listTriggers = exports.normaliseTrigger = exports.normaliseCustomerAction = exports.renderCustomerMessage = 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_TIME_EVENTS_PER_DAY = exports.DATA_TIME_ROWS_PER_SCAN = exports.DATA_TIME_EVENTS_PER_SCAN = exports.DATA_MAX_TIME_TRIGGERS = exports.DATA_TIME_MAX_MINUTES = exports.DATA_TIME_MIN_MINUTES = exports.DATA_TIME_SCAN_MS = exports.DATA_CHANGE_EVENTS = exports.DATA_TRIGGER_EVENTS = void 0;
4
4
  const dataCompiler_1 = require("./dataCompiler");
5
5
  const dataTypes_1 = require("./dataTypes");
6
- exports.DATA_TRIGGER_EVENTS = ['insert', 'update', 'delete'];
6
+ exports.DATA_TRIGGER_EVENTS = [
7
+ 'insert',
8
+ 'update',
9
+ 'delete',
10
+ 'time'
11
+ ];
12
+ // The events a write fires; "time" fires from the alarm's scan instead.
13
+ exports.DATA_CHANGE_EVENTS = ['insert', 'update', 'delete'];
14
+ // Time triggers: the alarm scans this often, a trigger waits at least this
15
+ // long (before or after its column's time) and at most 30 days, and a
16
+ // database holds this many of them.
17
+ exports.DATA_TIME_SCAN_MS = 300_000;
18
+ exports.DATA_TIME_MIN_MINUTES = 5;
19
+ exports.DATA_TIME_MAX_MINUTES = 43_200;
20
+ exports.DATA_MAX_TIME_TRIGGERS = 10;
21
+ // Events one time trigger fires per scan (more wait for the next), rows one
22
+ // scan looks at per trigger, and events a database's time triggers fire a
23
+ // day (UTC) before the one that goes past it is turned off.
24
+ exports.DATA_TIME_EVENTS_PER_SCAN = 100;
25
+ exports.DATA_TIME_ROWS_PER_SCAN = 1_000;
26
+ exports.DATA_TIME_EVENTS_PER_DAY = 2_000;
27
+ // A scan looks back at most this far: a due time missed for longer (the
28
+ // object down for a day) is skipped rather than sent late.
29
+ const TIME_LOOKBACK_MS = 86_400_000;
30
+ // Fired marks are kept this long past their due time; a scan never looks
31
+ // that far back, so older ones can't stop anything.
32
+ const FIRED_KEEP_MS = 2 * TIME_LOOKBACK_MS;
7
33
  exports.DATA_TRIGGER_ACTIONS = [
8
34
  'function',
9
35
  'webhook',
@@ -50,7 +76,9 @@ const isScalar = (value) => value === null ||
50
76
  (typeof value === 'number' && Number.isFinite(value));
51
77
  // Conditions
52
78
  const TEST_KEYS = ['changedTo', 'changedFrom', 'changed', 'equals'];
53
- const normaliseCondition = (table, raw, depth = 0, count = { tests: 0 }) => {
79
+ // A time trigger reads the row as it is, with nothing changing, so only
80
+ // `equals` means anything there.
81
+ const normaliseCondition = (table, raw, depth = 0, count = { tests: 0 }, equalsOnly = false) => {
54
82
  if (!isPlainObject(raw) || Object.keys(raw).length === 0) {
55
83
  throw new dataTypes_1.DataError('"when" is an object such as { "status": { "changedTo": "READY" } }.');
56
84
  }
@@ -64,7 +92,7 @@ const normaliseCondition = (table, raw, depth = 0, count = { tests: 0 }) => {
64
92
  raw[key].length === 0) {
65
93
  throw new dataTypes_1.DataError(`"${key}" is a non-empty list of conditions, alone in its object.`);
66
94
  }
67
- const list = raw[key].map(item => normaliseCondition(table, item, depth + 1, count));
95
+ const list = raw[key].map(item => normaliseCondition(table, item, depth + 1, count, equalsOnly));
68
96
  return key === 'all' ? { all: list } : { any: list };
69
97
  }
70
98
  const out = {};
@@ -81,6 +109,9 @@ const normaliseCondition = (table, raw, depth = 0, count = { tests: 0 }) => {
81
109
  if (!TEST_KEYS.includes(key)) {
82
110
  throw new dataTypes_1.DataError(`"when".${column}.${key}: a test is changedTo, changedFrom, changed or equals.`);
83
111
  }
112
+ if (equalsOnly && key !== 'equals') {
113
+ throw new dataTypes_1.DataError(`"when".${column}.${key}: a time trigger reads the row as it is when it comes due, so its tests are "equals" only.`);
114
+ }
84
115
  if (key === 'changed') {
85
116
  if (value !== true)
86
117
  throw new dataTypes_1.DataError(`"when".${column}.changed is true.`);
@@ -152,6 +183,19 @@ const renderTriggerText = (text, row) => text.replace(/\{\{\s*([a-z][a-z0-9_]*)\
152
183
  return typeof value === 'object' ? JSON.stringify(value) : String(value);
153
184
  });
154
185
  exports.renderTriggerText = renderTriggerText;
186
+ // What a "customer" action sends for a row: its text, and the template with
187
+ // each value filled in. A test send builds it the same way as a real run.
188
+ const renderCustomerMessage = (action, row) => ({
189
+ text: (0, exports.renderTriggerText)(action.text, row),
190
+ template: action.template
191
+ ? {
192
+ name: action.template.name,
193
+ language: action.template.language,
194
+ params: (action.template.params ?? []).map(p => (0, exports.renderTriggerText)(p, row))
195
+ }
196
+ : null
197
+ });
198
+ exports.renderCustomerMessage = renderCustomerMessage;
155
199
  const templateFields = (text) => [...text.matchAll(/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/g)].map(m => m[1]);
156
200
  // Definitions
157
201
  const cleanText = (value, label, max = exports.DATA_TRIGGER_MAX_TEXT) => {
@@ -261,6 +305,49 @@ const normaliseAction = (table, input) => {
261
305
  }
262
306
  }
263
307
  };
308
+ // A "customer" action as the dialog has it, saved or not, checked against
309
+ // its table the way saving it would be.
310
+ const normaliseCustomerAction = (schema, tableName, value) => {
311
+ const table = schema.tables.find(t => t.name === tableName);
312
+ if (!table) {
313
+ throw new dataTypes_1.DataError(`There is no table named ${tableName}.`, 'not_found');
314
+ }
315
+ return normaliseAction(table, { customer: value });
316
+ };
317
+ exports.normaliseCustomerAction = normaliseCustomerAction;
318
+ // The columns a time trigger can count from, and whether `minutes` may be
319
+ // negative: "before" a row was created or changed has already passed.
320
+ const scheduleColumn = (table, name) => {
321
+ if (name === 'created_at' || name === 'updated_at')
322
+ return { kind: 'datetime', system: true };
323
+ const column = table.columns.find(c => c.name === name);
324
+ if (column?.type === 'datetime')
325
+ return { kind: 'datetime', system: false };
326
+ if (column?.type === 'date')
327
+ return { kind: 'date', system: false };
328
+ return null;
329
+ };
330
+ const normaliseSchedule = (table, raw) => {
331
+ if (!isPlainObject(raw)) {
332
+ throw new dataTypes_1.DataError('A time trigger needs "after": { "column", "minutes" }, such as { "column": "created_at", "minutes": 30 }.');
333
+ }
334
+ const name = raw.column;
335
+ const column = typeof name === 'string' ? scheduleColumn(table, name) : null;
336
+ if (typeof name !== 'string' || !column) {
337
+ throw new dataTypes_1.DataError(`"after.column" is created_at, updated_at or a date or datetime column of ${table.name}.`);
338
+ }
339
+ const minutes = raw.minutes;
340
+ if (typeof minutes !== 'number' ||
341
+ !Number.isInteger(minutes) ||
342
+ Math.abs(minutes) < exports.DATA_TIME_MIN_MINUTES ||
343
+ Math.abs(minutes) > exports.DATA_TIME_MAX_MINUTES) {
344
+ throw new dataTypes_1.DataError(`"after.minutes" is a whole number of minutes from ${exports.DATA_TIME_MIN_MINUTES} to ${exports.DATA_TIME_MAX_MINUTES} (30 days), negative for before the column's time.`);
345
+ }
346
+ if (minutes < 0 && column.system) {
347
+ throw new dataTypes_1.DataError(`"after.minutes" can't be negative on ${name}: that time has already passed when the row is written. Use a date or datetime column for a reminder before.`);
348
+ }
349
+ return { column: name, minutes };
350
+ };
264
351
  const defaultName = (table, on, action) => {
265
352
  const target = action.type === 'function'
266
353
  ? action.function.toLowerCase().replace(/[^a-z0-9_-]/g, '-')
@@ -278,7 +365,12 @@ const normaliseTrigger = (schema, input) => {
278
365
  }
279
366
  const on = input.on;
280
367
  if (!exports.DATA_TRIGGER_EVENTS.includes(on)) {
281
- throw new dataTypes_1.DataError('"on" is insert, update or delete.');
368
+ throw new dataTypes_1.DataError('"on" is insert, update, delete or time.');
369
+ }
370
+ const timed = on === 'time';
371
+ const hasAfter = input.after !== undefined && input.after !== null;
372
+ if (!timed && hasAfter) {
373
+ throw new dataTypes_1.DataError('"after" is for "on": "time" only.');
282
374
  }
283
375
  const action = normaliseAction(table, input);
284
376
  const unnamed = input.name === undefined || input.name === null;
@@ -309,9 +401,10 @@ const normaliseTrigger = (schema, input) => {
309
401
  : null,
310
402
  table,
311
403
  on: on,
404
+ after: timed ? normaliseSchedule(table, input.after) : null,
312
405
  when: input.when === undefined || input.when === null
313
406
  ? null
314
- : normaliseCondition(table, input.when),
407
+ : normaliseCondition(table, input.when, 0, { tests: 0 }, timed),
315
408
  action,
316
409
  enabled: typeof input.enabled === 'boolean' ? input.enabled : null,
317
410
  replace: input.replace !== false,
@@ -348,6 +441,7 @@ const toTrigger = (schema, row) => ({
348
441
  description: row.description ?? null,
349
442
  table: schema.tables.find(t => t.id === Number(row.table_id))?.name ?? '',
350
443
  on: row.event,
444
+ after: parse(row.schedule),
351
445
  when: parse(row.condition),
352
446
  action: parse(row.action),
353
447
  enabled: row.enabled === 1,
@@ -399,6 +493,13 @@ const upsert = (runner, schema, caller, trigger, managed) => {
399
493
  throw new dataTypes_1.DataError(`A database holds up to ${exports.DATA_MAX_TRIGGERS} triggers.`);
400
494
  }
401
495
  }
496
+ const wasTimed = existing?.event === 'time';
497
+ if (trigger.on === 'time' && !wasTimed) {
498
+ const timed = Number(runner.all("SELECT count(*) AS n FROM _ganju_triggers WHERE event = 'time'")[0]?.n ?? 0);
499
+ if (timed >= exports.DATA_MAX_TIME_TRIGGERS) {
500
+ throw new dataTypes_1.DataError(`A database holds up to ${exports.DATA_MAX_TIME_TRIGGERS} time triggers.`);
501
+ }
502
+ }
402
503
  // A webhook keeps its secret while it stays a webhook.
403
504
  const keep = existing?.secret && trigger.action.type === 'webhook'
404
505
  ? String(existing.secret)
@@ -413,10 +514,23 @@ const upsert = (runner, schema, caller, trigger, managed) => {
413
514
  const reason = enabled
414
515
  ? null
415
516
  : (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'), ?)
517
+ // A time trigger counts due times from now: when it is new, when it
518
+ // counts from another column or delay, and when it is turned back on.
519
+ const schedule = trigger.after ? JSON.stringify(trigger.after) : null;
520
+ const restart = trigger.on === 'time' &&
521
+ (!wasTimed ||
522
+ existing?.schedule !== schedule ||
523
+ (enabled && existing?.enabled !== 1));
524
+ const scanState = restart
525
+ ? freshScan()
526
+ : trigger.on === 'time'
527
+ ? (existing?.scan_state ?? freshScan())
528
+ : null;
529
+ runner.all(`INSERT INTO _ganju_triggers (id, name, description, table_id, event, schedule, scan_state, condition, action, enabled, disabled_reason, managed, rate_limit, secret, updated_at, updated_by)
530
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), ?)
418
531
  ON CONFLICT(id) DO UPDATE SET name = excluded.name, description = excluded.description,
419
- table_id = excluded.table_id, event = excluded.event, condition = excluded.condition,
532
+ table_id = excluded.table_id, event = excluded.event, schedule = excluded.schedule,
533
+ scan_state = excluded.scan_state, condition = excluded.condition,
420
534
  action = excluded.action, enabled = excluded.enabled, disabled_reason = excluded.disabled_reason,
421
535
  managed = excluded.managed, rate_limit = excluded.rate_limit, secret = excluded.secret,
422
536
  updated_at = excluded.updated_at, updated_by = excluded.updated_by`, [
@@ -425,6 +539,8 @@ const upsert = (runner, schema, caller, trigger, managed) => {
425
539
  trigger.description,
426
540
  trigger.table.id,
427
541
  trigger.on,
542
+ schedule,
543
+ scanState,
428
544
  trigger.when ? JSON.stringify(trigger.when) : null,
429
545
  JSON.stringify(trigger.action),
430
546
  enabled ? 1 : 0,
@@ -434,7 +550,10 @@ const upsert = (runner, schema, caller, trigger, managed) => {
434
550
  secret,
435
551
  caller.actor
436
552
  ]);
437
- (0, exports.ensureChangeCapture)(runner, trigger.table);
553
+ if (trigger.on === 'time' || wasTimed)
554
+ (0, exports.syncTimeIndexes)(runner);
555
+ else
556
+ (0, exports.ensureChangeCapture)(runner, trigger.table);
438
557
  const saved = toTrigger(schema, findRow(runner, id));
439
558
  return secret && secret !== keep
440
559
  ? { trigger: saved, secret }
@@ -450,6 +569,12 @@ exports.saveTrigger = saveTrigger;
450
569
  const dropTrigger = (runner, nameOrId) => {
451
570
  const row = findRow(runner, nameOrId);
452
571
  runner.all('DELETE FROM _ganju_triggers WHERE id = ?', [row.id]);
572
+ if (row.event === 'time') {
573
+ runner.all('DELETE FROM _ganju_trigger_fired WHERE trigger_id = ?', [
574
+ row.id
575
+ ]);
576
+ (0, exports.syncTimeIndexes)(runner);
577
+ }
453
578
  return { dropped: String(row.name) };
454
579
  };
455
580
  exports.dropTrigger = dropTrigger;
@@ -457,7 +582,15 @@ exports.dropTrigger = dropTrigger;
457
582
  const setTriggerEnabled = (runner, schema, caller, nameOrId, enabled, reason = null) => {
458
583
  const row = findRow(runner, nameOrId);
459
584
  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) {
585
+ // Back on, a time trigger counts from now: what came due while it was off
586
+ // doesn't all go out at once.
587
+ if (enabled && row.event === 'time' && row.enabled !== 1) {
588
+ runner.all('UPDATE _ganju_triggers SET scan_state = ? WHERE id = ?', [
589
+ freshScan(),
590
+ row.id
591
+ ]);
592
+ }
593
+ if (enabled && row.event !== 'time') {
461
594
  const table = schema.tables.find(t => t.id === Number(row.table_id));
462
595
  if (table)
463
596
  (0, exports.ensureChangeCapture)(runner, table);
@@ -498,6 +631,9 @@ const replaceManagedTriggers = (runner, schema, caller, inputs) => {
498
631
  if (names.has(String(row.name)))
499
632
  continue;
500
633
  runner.all('DELETE FROM _ganju_triggers WHERE id = ?', [row.id]);
634
+ runner.all('DELETE FROM _ganju_trigger_fired WHERE trigger_id = ?', [
635
+ row.id
636
+ ]);
501
637
  removed.push(String(row.name));
502
638
  }
503
639
  const secrets = {};
@@ -507,6 +643,7 @@ const replaceManagedTriggers = (runner, schema, caller, inputs) => {
507
643
  secrets[saved.trigger.name] = saved.secret;
508
644
  return saved.trigger;
509
645
  });
646
+ (0, exports.syncTimeIndexes)(runner);
510
647
  return { triggers, removed, secrets };
511
648
  });
512
649
  };
@@ -575,6 +712,23 @@ const shape = (table, raw) => {
575
712
  delete out.participant_id;
576
713
  return out;
577
714
  };
715
+ /**
716
+ * A row as a trigger event carries it, read from the table: `id`, or the
717
+ * newest row. Null when there is none. A test send and a run whose event
718
+ * was too large to carry its row build their message from it, so it reads
719
+ * as it does for the event, system fields and the customer included.
720
+ */
721
+ const readEventRow = (runner, schema, tableName, id) => {
722
+ const table = schema.tables.find(t => t.name === tableName);
723
+ if (!table) {
724
+ throw new dataTypes_1.DataError(`There is no table named ${tableName}.`, 'not_found');
725
+ }
726
+ const [stored] = id === undefined
727
+ ? runner.all(`SELECT * FROM "${table.name}" ORDER BY id DESC LIMIT 1`)
728
+ : runner.all(`SELECT * FROM "${table.name}" WHERE id = ?`, [id]);
729
+ return stored ? shape(table, JSON.stringify(stored)) : null;
730
+ };
731
+ exports.readEventRow = readEventRow;
578
732
  const DEPTH_PATTERN = /^trigger:([^\s]+)$/;
579
733
  /**
580
734
  * Turns the changes a write captured into events, inside the write's
@@ -605,6 +759,7 @@ const collectTriggerEvents = (runner, schema, caller) => {
605
759
  continue;
606
760
  const row = shape(table, change.new);
607
761
  const previous = shape(table, change.old);
762
+ const customer = row?.participant_id ?? previous?.participant_id;
608
763
  for (const trigger of listening) {
609
764
  if (trigger.id === ownTrigger)
610
765
  continue;
@@ -622,7 +777,10 @@ const collectTriggerEvents = (runner, schema, caller) => {
622
777
  previous,
623
778
  actor: change.actor ?? null,
624
779
  depth,
625
- at: new Date().toISOString()
780
+ at: new Date().toISOString(),
781
+ ...(typeof customer === 'string' && customer
782
+ ? { participantId: customer }
783
+ : {})
626
784
  };
627
785
  if (depth > exports.DATA_TRIGGER_MAX_DEPTH) {
628
786
  message.row = null;
@@ -657,13 +815,17 @@ const outboxSize = (runner) => Number(runner.all('SELECT count(*) AS n FROM _gan
657
815
  exports.outboxSize = outboxSize;
658
816
  // After a restore: events pending at the restored moment were sent then.
659
817
  // Those stored before `before` came back with the restore; later ones are
660
- // writes made since, and stay.
818
+ // writes made since, and stay. Time triggers count from now again, since
819
+ // what came due after the restored moment was sent then too.
661
820
  const clearOutbox = (runner, before) => {
662
821
  const n = Number(runner.all('SELECT count(*) AS n FROM _ganju_outbox WHERE created_at < ?', [
663
822
  before
664
823
  ])[0]?.n ?? 0);
665
824
  runner.all('DELETE FROM _ganju_outbox WHERE created_at < ?', [before]);
666
825
  runner.all('DELETE FROM _ganju_changes');
826
+ runner.all("UPDATE _ganju_triggers SET scan_state = ? WHERE event = 'time'", [
827
+ freshScan()
828
+ ]);
667
829
  return n;
668
830
  };
669
831
  exports.clearOutbox = clearOutbox;
@@ -689,13 +851,14 @@ const actionTexts = (action) => {
689
851
  }
690
852
  };
691
853
  /**
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.
854
+ * The triggers whose condition, {{field}} texts or time column name a
855
+ * column. Dropping it is refused while any does, since they are only checked
856
+ * when saved and would quietly stop matching.
695
857
  */
696
858
  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) ||
859
+ .all('SELECT name, schedule, condition, action FROM _ganju_triggers WHERE table_id = ? ORDER BY name', [tableId])
860
+ .filter(row => parse(row.schedule)?.column === column ||
861
+ conditionColumns(parse(row.condition)).includes(column) ||
699
862
  actionTexts(parse(row.action)).some(text => templateFields(text).includes(column)))
700
863
  .map(row => String(row.name));
701
864
  exports.triggersUsingColumn = triggersUsingColumn;
@@ -744,10 +907,14 @@ const renameInAction = (action, from, to) => {
744
907
  const renameColumnInTriggers = (runner, tableId, from, to) => {
745
908
  const names = (0, exports.triggersUsingColumn)(runner, tableId, from);
746
909
  for (const name of names) {
747
- const row = runner.all('SELECT id, condition, action FROM _ganju_triggers WHERE name = ?', [name])[0];
910
+ const row = runner.all('SELECT id, schedule, condition, action FROM _ganju_triggers WHERE name = ?', [name])[0];
911
+ const schedule = parse(row.schedule);
748
912
  const condition = parse(row.condition);
749
913
  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 = ?`, [
914
+ runner.all(`UPDATE _ganju_triggers SET schedule = ?, condition = ?, action = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`, [
915
+ schedule
916
+ ? JSON.stringify(schedule.column === from ? { ...schedule, column: to } : schedule)
917
+ : null,
751
918
  condition === null
752
919
  ? null
753
920
  : JSON.stringify(renameInCondition(condition, from, to)),
@@ -758,3 +925,262 @@ const renameColumnInTriggers = (runner, tableId, from, to) => {
758
925
  return names;
759
926
  };
760
927
  exports.renameColumnInTriggers = renameColumnInTriggers;
928
+ // Time triggers
929
+ // The time triggers counting from a column: it must stay a date or datetime.
930
+ const timeTriggersCountingFrom = (runner, tableId, column) => runner
931
+ .all("SELECT name, schedule FROM _ganju_triggers WHERE table_id = ? AND event = 'time' ORDER BY name", [tableId])
932
+ .filter(row => parse(row.schedule)?.column === column)
933
+ .map(row => String(row.name));
934
+ exports.timeTriggersCountingFrom = timeTriggersCountingFrom;
935
+ const freshScan = () => JSON.stringify({ through: (0, dataCompiler_1.isoSeconds)(new Date()) });
936
+ const shiftIso = (iso, minutes) => (0, dataCompiler_1.isoSeconds)(new Date(Date.parse(iso) + minutes * 60_000));
937
+ /**
938
+ * The dates of a date column whose due time falls in (from, to]: midnight
939
+ * in the database's zone plus `minutes`. Each date maps to its due time.
940
+ */
941
+ const dueDates = (from, to, minutes, timeZone) => {
942
+ const out = new Map();
943
+ const day = 86_400_000;
944
+ // A day either side covers any zone's offset.
945
+ const start = Math.floor((Date.parse(from) - minutes * 60_000) / day) * day - day;
946
+ const end = Date.parse(to) - minutes * 60_000 + day;
947
+ for (let at = start; at <= end; at += day) {
948
+ const date = new Date(at).toISOString().slice(0, 10);
949
+ const midnight = (0, dataCompiler_1.localDatetime)(`${date}T00:00:00`, timeZone);
950
+ if (!midnight || Number.isNaN(midnight.getTime()))
951
+ continue;
952
+ const due = (0, dataCompiler_1.isoSeconds)(new Date(midnight.getTime() + minutes * 60_000));
953
+ if (due > from && due <= to)
954
+ out.set(date, due);
955
+ }
956
+ return out;
957
+ };
958
+ // A time trigger's own index on its column, named apart from the table's
959
+ // (ix_ / ux_), which the table's own changes remake from its columns.
960
+ const timeIndexName = (tableId, column) => `it_${tableId}_${column}`;
961
+ /**
962
+ * Indexes the columns time triggers count from, and drops the ones no time
963
+ * trigger uses any more. A column the table already indexes (filterable or
964
+ * unique) needs nothing more. Cheap, so every scan runs it too: a table
965
+ * rebuilt for a column change loses the index and gets it back.
966
+ */
967
+ const syncTimeIndexes = (runner) => {
968
+ const wanted = new Map();
969
+ for (const row of runner.all("SELECT tr.schedule, t.id AS table_id, t.name AS table_name FROM _ganju_triggers tr JOIN _ganju_tables t ON t.id = tr.table_id WHERE tr.event = 'time'")) {
970
+ const after = parse(row.schedule);
971
+ if (!after)
972
+ continue;
973
+ const tableId = Number(row.table_id);
974
+ let name;
975
+ if (after.column === 'created_at' || after.column === 'updated_at') {
976
+ name = timeIndexName(tableId, after.column);
977
+ }
978
+ else {
979
+ const column = runner.all('SELECT id, filterable, is_unique FROM _ganju_columns WHERE table_id = ? AND name = ?', [tableId, after.column])[0];
980
+ if (!column || column.filterable === 1 || column.is_unique === 1)
981
+ continue;
982
+ name = timeIndexName(tableId, Number(column.id));
983
+ }
984
+ wanted.set(name, `CREATE INDEX IF NOT EXISTS "${name}" ON "${String(row.table_name)}"("${after.column}")`);
985
+ }
986
+ const existing = runner
987
+ .all("SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'it\\_%' ESCAPE '\\'")
988
+ .map(row => String(row.name));
989
+ for (const name of existing) {
990
+ if (!wanted.has(name))
991
+ runner.all(`DROP INDEX IF EXISTS "${name}"`);
992
+ }
993
+ for (const [name, sql] of wanted) {
994
+ if (!existing.includes(name))
995
+ runner.all(sql);
996
+ }
997
+ };
998
+ exports.syncTimeIndexes = syncTimeIndexes;
999
+ const hasTimeTriggers = (runner) => runner.all("SELECT 1 AS found FROM _ganju_triggers WHERE event = 'time' AND enabled = 1 LIMIT 1").length > 0;
1000
+ exports.hasTimeTriggers = hasTimeTriggers;
1001
+ const DAY_COUNT_KEY = 'time_events_day';
1002
+ /**
1003
+ * One scan: for each enabled time trigger, the rows that came due since its
1004
+ * last scan, matching its `when`, not fired for that due time before, become
1005
+ * events in the outbox. One transaction, so a crash fires nothing twice.
1006
+ */
1007
+ const scanTimeTriggers = (runner, schema, now = new Date()) => runner.transaction(() => {
1008
+ const result = { events: 0, disabled: [] };
1009
+ const rows = runner.all("SELECT * FROM _ganju_triggers WHERE event = 'time' AND enabled = 1 ORDER BY created_at, id");
1010
+ if (rows.length === 0)
1011
+ return result;
1012
+ (0, exports.syncTimeIndexes)(runner);
1013
+ const to = (0, dataCompiler_1.isoSeconds)(now);
1014
+ const floor = (0, dataCompiler_1.isoSeconds)(new Date(now.getTime() - TIME_LOOKBACK_MS));
1015
+ runner.all('DELETE FROM _ganju_trigger_fired WHERE due_at < ?', [
1016
+ (0, dataCompiler_1.isoSeconds)(new Date(now.getTime() - FIRED_KEEP_MS))
1017
+ ]);
1018
+ const today = to.slice(0, 10);
1019
+ const counted = parse(runner.all('SELECT value FROM _ganju_meta WHERE key = ?', [
1020
+ DAY_COUNT_KEY
1021
+ ])[0]?.value);
1022
+ let firedToday = counted?.day === today ? counted.count : 0;
1023
+ const artifactId = runner.all("SELECT value FROM _ganju_meta WHERE key = 'artifact_id'")[0]
1024
+ ?.value ?? '';
1025
+ const timeZone = schema.timeZone ?? undefined;
1026
+ const turnOff = (row, reason) => {
1027
+ runner.all(`UPDATE _ganju_triggers SET enabled = 0, disabled_reason = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), updated_by = 'system:time' WHERE id = ?`, [reason, row.id]);
1028
+ result.disabled.push({ name: String(row.name), reason });
1029
+ };
1030
+ for (const row of rows) {
1031
+ const trigger = toTrigger(schema, row);
1032
+ const table = schema.tables.find(t => t.id === Number(row.table_id));
1033
+ const after = trigger.after;
1034
+ if (!table || !after)
1035
+ continue;
1036
+ const kind = scheduleColumn(table, after.column)?.kind;
1037
+ if (!kind) {
1038
+ turnOff(row, `${table.name}.${after.column} is no longer a date or datetime column.`);
1039
+ continue;
1040
+ }
1041
+ const state = parse(row.scan_state) ?? {
1042
+ through: (0, dataCompiler_1.isoSeconds)(new Date(trigger.createdAt))
1043
+ };
1044
+ const from = state.through > floor ? state.through : floor;
1045
+ if (from >= to)
1046
+ continue;
1047
+ const column = `t."${after.column}"`;
1048
+ const params = [];
1049
+ let range;
1050
+ let due;
1051
+ if (kind === 'date') {
1052
+ const dates = dueDates(from, to, after.minutes, timeZone);
1053
+ if (dates.size === 0) {
1054
+ runner.all('UPDATE _ganju_triggers SET scan_state = ? WHERE id = ?', [
1055
+ JSON.stringify({ through: to }),
1056
+ row.id
1057
+ ]);
1058
+ continue;
1059
+ }
1060
+ due = `CASE ${column} ${[...dates].map(() => 'WHEN ? THEN ?').join(' ')} END`;
1061
+ for (const [date, at] of dates)
1062
+ params.push(date, at);
1063
+ range = `${column} IN (${[...dates.keys()].map(() => '?').join(', ')})`;
1064
+ params.push(...dates.keys());
1065
+ }
1066
+ else {
1067
+ const sign = after.minutes >= 0 ? '+' : '';
1068
+ due = `strftime('%Y-%m-%dT%H:%M:%SZ', ${column}, '${sign}${after.minutes} minutes')`;
1069
+ range = `${column} > ? AND ${column} <= ?`;
1070
+ params.push(shiftIso(from, -after.minutes), shiftIso(to, -after.minutes));
1071
+ }
1072
+ if (state.resume) {
1073
+ range += ` AND (${column} > ? OR (${column} = ? AND t.id > ?))`;
1074
+ params.push(state.resume.value, state.resume.value, state.resume.id);
1075
+ }
1076
+ params.push(trigger.id);
1077
+ // Read first, written after: nothing else runs while the read is open.
1078
+ let looked = 0;
1079
+ let more = false;
1080
+ let stoppedAt = null;
1081
+ // The last row this scan is done with: fired, or not matching.
1082
+ let done = null;
1083
+ let capped = false;
1084
+ const firing = [];
1085
+ for (const stored of runner.iterate(`SELECT x.* FROM (SELECT t.*, ${due} AS _ganju_due FROM "${table.name}" t WHERE ${range}) x
1086
+ WHERE NOT EXISTS (SELECT 1 FROM _ganju_trigger_fired f WHERE f.trigger_id = ? AND f.row_id = x.id AND f.due_at = x._ganju_due)
1087
+ ORDER BY x."${after.column}", x.id`, params)) {
1088
+ if (looked >= exports.DATA_TIME_ROWS_PER_SCAN ||
1089
+ firing.length >= exports.DATA_TIME_EVENTS_PER_SCAN) {
1090
+ more = true;
1091
+ break;
1092
+ }
1093
+ looked++;
1094
+ const at = {
1095
+ value: String(stored[after.column]),
1096
+ id: Number(stored.id)
1097
+ };
1098
+ const current = shape(table, JSON.stringify(stored));
1099
+ stoppedAt = at;
1100
+ if (!(0, exports.conditionMatches)(trigger.when, current, null)) {
1101
+ done = at;
1102
+ continue;
1103
+ }
1104
+ if (firedToday + firing.length >= exports.DATA_TIME_EVENTS_PER_DAY) {
1105
+ capped = true;
1106
+ break;
1107
+ }
1108
+ done = at;
1109
+ const customer = current?.participant_id;
1110
+ const message = {
1111
+ eventId: `evt_${crypto.randomUUID().replace(/-/g, '')}`,
1112
+ artifactId,
1113
+ triggerId: trigger.id,
1114
+ triggerName: trigger.name,
1115
+ table: table.name,
1116
+ event: 'time',
1117
+ rowId: at.id,
1118
+ row: current,
1119
+ previous: null,
1120
+ actor: null,
1121
+ depth: 1,
1122
+ at: now.toISOString(),
1123
+ dueAt: String(stored._ganju_due),
1124
+ ...(typeof customer === 'string' && customer
1125
+ ? { participantId: customer }
1126
+ : {})
1127
+ };
1128
+ if ((0, exports.utf8Bytes)(current) > exports.DATA_TRIGGER_MAX_ROW_BYTES) {
1129
+ message.row = null;
1130
+ message.truncated = true;
1131
+ }
1132
+ firing.push(message);
1133
+ }
1134
+ for (const message of firing) {
1135
+ runner.all('INSERT INTO _ganju_outbox (id, trigger_id, message) VALUES (?, ?, ?)', [message.eventId, trigger.id, JSON.stringify(message)]);
1136
+ runner.all('INSERT OR IGNORE INTO _ganju_trigger_fired (trigger_id, row_id, due_at) VALUES (?, ?, ?)', [trigger.id, message.rowId, message.dueAt ?? '']);
1137
+ }
1138
+ firedToday += firing.length;
1139
+ result.events += firing.length;
1140
+ // Events stored in this scan still run: the queue skips those of a
1141
+ // trigger that is off. So a trigger that fired some before the cap stays
1142
+ // on, carries on after them, and is turned off by the next scan.
1143
+ if (capped && firing.length > 0) {
1144
+ capped = false;
1145
+ more = true;
1146
+ stoppedAt = done;
1147
+ }
1148
+ if (capped) {
1149
+ const reason = `This database's time triggers fired ${exports.DATA_TIME_EVENTS_PER_DAY.toLocaleString('en-US')} events today (UTC), the most a day, so each one with more due today is turned off. Turn it back on to continue; due times from while it was off are skipped.`;
1150
+ turnOff(row, reason);
1151
+ // The queue records it on the runs list and tells the admins.
1152
+ const notice = {
1153
+ eventId: `evt_${crypto.randomUUID().replace(/-/g, '')}`,
1154
+ artifactId,
1155
+ triggerId: trigger.id,
1156
+ triggerName: trigger.name,
1157
+ table: table.name,
1158
+ event: 'time',
1159
+ rowId: stoppedAt?.id ?? 0,
1160
+ row: null,
1161
+ previous: null,
1162
+ actor: null,
1163
+ depth: 1,
1164
+ at: now.toISOString(),
1165
+ error: `Not run: ${reason}`,
1166
+ turnedOff: true
1167
+ };
1168
+ runner.all('INSERT INTO _ganju_outbox (id, trigger_id, message) VALUES (?, ?, ?)', [notice.eventId, trigger.id, JSON.stringify(notice)]);
1169
+ continue;
1170
+ }
1171
+ // Stopped early: the next scan carries on after the last row looked at,
1172
+ // within the same window. Otherwise the window is done.
1173
+ const next = more && stoppedAt
1174
+ ? { through: from, resume: stoppedAt }
1175
+ : { through: to };
1176
+ runner.all('UPDATE _ganju_triggers SET scan_state = ? WHERE id = ?', [
1177
+ JSON.stringify(next),
1178
+ row.id
1179
+ ]);
1180
+ }
1181
+ if (result.events > 0) {
1182
+ runner.all('INSERT INTO _ganju_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', [DAY_COUNT_KEY, JSON.stringify({ day: today, count: firedToday })]);
1183
+ }
1184
+ return result;
1185
+ });
1186
+ exports.scanTimeTriggers = scanTimeTriggers;