@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
@@ -11,14 +11,45 @@
11
11
  // runs the action.
12
12
  // Conditions are data, never code, evaluated here against the old and new
13
13
  // row.
14
+ // A time trigger ("on": "time") fires a set time after a row's date, with
15
+ // nothing written: the object's alarm scans for rows that came due since
16
+ // its last scan and stores their events in the same outbox.
14
17
  import { DATA_IDENTIFIER_PATTERN } from './dataIdentifiers';
15
- import { fromStored } from './dataCompiler';
18
+ import { fromStored, isoSeconds, localDatetime } from './dataCompiler';
19
+ import type { SqlValue } from './dataCompiler';
16
20
  import { DATA_MAX_DESCRIPTION, DataError } from './dataTypes';
17
21
  import type { DataCaller, DataSchema, DataTable } from './dataTypes';
18
22
  import type { SqlRow, SqlRunner } from './dataEngine';
19
23
 
20
- export const DATA_TRIGGER_EVENTS = ['insert', 'update', 'delete'] as const;
24
+ export const DATA_TRIGGER_EVENTS = [
25
+ 'insert',
26
+ 'update',
27
+ 'delete',
28
+ 'time'
29
+ ] as const;
21
30
  export type DataTriggerEvent = (typeof DATA_TRIGGER_EVENTS)[number];
31
+ // The events a write fires; "time" fires from the alarm's scan instead.
32
+ export const DATA_CHANGE_EVENTS = ['insert', 'update', 'delete'] as const;
33
+
34
+ // Time triggers: the alarm scans this often, a trigger waits at least this
35
+ // long (before or after its column's time) and at most 30 days, and a
36
+ // database holds this many of them.
37
+ export const DATA_TIME_SCAN_MS = 300_000;
38
+ export const DATA_TIME_MIN_MINUTES = 5;
39
+ export const DATA_TIME_MAX_MINUTES = 43_200;
40
+ export const DATA_MAX_TIME_TRIGGERS = 10;
41
+ // Events one time trigger fires per scan (more wait for the next), rows one
42
+ // scan looks at per trigger, and events a database's time triggers fire a
43
+ // day (UTC) before the one that goes past it is turned off.
44
+ export const DATA_TIME_EVENTS_PER_SCAN = 100;
45
+ export const DATA_TIME_ROWS_PER_SCAN = 1_000;
46
+ export const DATA_TIME_EVENTS_PER_DAY = 2_000;
47
+ // A scan looks back at most this far: a due time missed for longer (the
48
+ // object down for a day) is skipped rather than sent late.
49
+ const TIME_LOOKBACK_MS = 86_400_000;
50
+ // Fired marks are kept this long past their due time; a scan never looks
51
+ // that far back, so older ones can't stop anything.
52
+ const FIRED_KEEP_MS = 2 * TIME_LOOKBACK_MS;
22
53
 
23
54
  export const DATA_TRIGGER_ACTIONS = [
24
55
  'function',
@@ -84,6 +115,13 @@ export type DataTriggerAction =
84
115
  | { type: 'email'; to: string[]; subject: string; text: string }
85
116
  | { type: 'customer'; text: string; template?: DataTriggerTemplate };
86
117
 
118
+ // When a time trigger fires: `minutes` after the row's `column` (before it
119
+ // when negative). A date column counts from midnight in the database's zone.
120
+ export interface DataTriggerSchedule {
121
+ column: string;
122
+ minutes: number;
123
+ }
124
+
87
125
  // A trigger as the owner writes it: the dashboard, the API and ganju.json's
88
126
  // "triggers" use this shape, with exactly one action key.
89
127
  export interface DataTriggerInput {
@@ -91,6 +129,8 @@ export interface DataTriggerInput {
91
129
  description?: string | null;
92
130
  table: string;
93
131
  on: DataTriggerEvent;
132
+ // With "time" only.
133
+ after?: DataTriggerSchedule | null;
94
134
  when?: DataTriggerCondition | null;
95
135
  enabled?: boolean;
96
136
  // false: refuse to save over a trigger that already has this name.
@@ -109,6 +149,7 @@ export interface DataTrigger {
109
149
  description: string | null;
110
150
  table: string;
111
151
  on: DataTriggerEvent;
152
+ after: DataTriggerSchedule | null;
112
153
  when: DataTriggerCondition | null;
113
154
  action: DataTriggerAction;
114
155
  enabled: boolean;
@@ -129,7 +170,8 @@ export interface DataTriggerWithSecret extends DataTrigger {
129
170
 
130
171
  // One event, as the queue carries it. `row` and `previous` are the row after
131
172
  // and before the change (null on insert / delete); both are left out, with
132
- // `truncated`, when the row is too large for a message.
173
+ // `truncated`, when the row is too large for a message. A time event carries
174
+ // the row as it was when it came due, no `previous`, and `dueAt`.
133
175
  export interface DataTriggerEventMessage {
134
176
  eventId: string;
135
177
  artifactId: string;
@@ -145,9 +187,16 @@ export interface DataTriggerEventMessage {
145
187
  // function's writes fire at its depth + 1.
146
188
  depth: number;
147
189
  at: string;
190
+ dueAt?: string;
148
191
  truncated?: true;
192
+ // The row's customer on a customer-scoped table, kept when the row is
193
+ // left out for its size.
194
+ participantId?: string;
149
195
  // Not run: the chain went too deep. Recorded as a failed run.
150
196
  error?: string;
197
+ // Ganju turned the trigger off here (a time trigger past the daily cap):
198
+ // recorded as a failed run, and the organization's admins are told.
199
+ turnedOff?: true;
151
200
  }
152
201
 
153
202
  const isPlainObject = (value: unknown): value is Record<string, unknown> =>
@@ -174,11 +223,14 @@ const isScalar = (value: unknown): value is Scalar =>
174
223
 
175
224
  const TEST_KEYS = ['changedTo', 'changedFrom', 'changed', 'equals'] as const;
176
225
 
226
+ // A time trigger reads the row as it is, with nothing changing, so only
227
+ // `equals` means anything there.
177
228
  const normaliseCondition = (
178
229
  table: DataTable,
179
230
  raw: unknown,
180
231
  depth = 0,
181
- count = { tests: 0 }
232
+ count = { tests: 0 },
233
+ equalsOnly = false
182
234
  ): DataTriggerCondition => {
183
235
  if (!isPlainObject(raw) || Object.keys(raw).length === 0) {
184
236
  throw new DataError(
@@ -202,7 +254,7 @@ const normaliseCondition = (
202
254
  );
203
255
  }
204
256
  const list = (raw[key] as unknown[]).map(item =>
205
- normaliseCondition(table, item, depth + 1, count)
257
+ normaliseCondition(table, item, depth + 1, count, equalsOnly)
206
258
  );
207
259
  return key === 'all' ? { all: list } : { any: list };
208
260
  }
@@ -228,6 +280,11 @@ const normaliseCondition = (
228
280
  `"when".${column}.${key}: a test is changedTo, changedFrom, changed or equals.`
229
281
  );
230
282
  }
283
+ if (equalsOnly && key !== 'equals') {
284
+ throw new DataError(
285
+ `"when".${column}.${key}: a time trigger reads the row as it is when it comes due, so its tests are "equals" only.`
286
+ );
287
+ }
231
288
  if (key === 'changed') {
232
289
  if (value !== true)
233
290
  throw new DataError(`"when".${column}.changed is true.`);
@@ -321,6 +378,29 @@ export const renderTriggerText = (
321
378
  return typeof value === 'object' ? JSON.stringify(value) : String(value);
322
379
  });
323
380
 
381
+ export interface DataTriggerCustomerMessage {
382
+ text: string;
383
+ template: { name: string; language: string; params: string[] } | null;
384
+ }
385
+
386
+ // What a "customer" action sends for a row: its text, and the template with
387
+ // each value filled in. A test send builds it the same way as a real run.
388
+ export const renderCustomerMessage = (
389
+ action: { text: string; template?: DataTriggerTemplate },
390
+ row: Record<string, unknown> | null
391
+ ): DataTriggerCustomerMessage => ({
392
+ text: renderTriggerText(action.text, row),
393
+ template: action.template
394
+ ? {
395
+ name: action.template.name,
396
+ language: action.template.language,
397
+ params: (action.template.params ?? []).map(p =>
398
+ renderTriggerText(p, row)
399
+ )
400
+ }
401
+ : null
402
+ });
403
+
324
404
  const templateFields = (text: string) =>
325
405
  [...text.matchAll(/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/g)].map(m => m[1]);
326
406
 
@@ -465,6 +545,72 @@ const normaliseAction = (
465
545
  }
466
546
  };
467
547
 
548
+ // A "customer" action as the dialog has it, saved or not, checked against
549
+ // its table the way saving it would be.
550
+ export const normaliseCustomerAction = (
551
+ schema: DataSchema,
552
+ tableName: string,
553
+ value: unknown
554
+ ): Extract<DataTriggerAction, { type: 'customer' }> => {
555
+ const table = schema.tables.find(t => t.name === tableName);
556
+ if (!table) {
557
+ throw new DataError(`There is no table named ${tableName}.`, 'not_found');
558
+ }
559
+ return normaliseAction(table, { customer: value }) as Extract<
560
+ DataTriggerAction,
561
+ { type: 'customer' }
562
+ >;
563
+ };
564
+
565
+ // The columns a time trigger can count from, and whether `minutes` may be
566
+ // negative: "before" a row was created or changed has already passed.
567
+ const scheduleColumn = (
568
+ table: DataTable,
569
+ name: string
570
+ ): { kind: 'datetime' | 'date'; system: boolean } | null => {
571
+ if (name === 'created_at' || name === 'updated_at')
572
+ return { kind: 'datetime', system: true };
573
+ const column = table.columns.find(c => c.name === name);
574
+ if (column?.type === 'datetime') return { kind: 'datetime', system: false };
575
+ if (column?.type === 'date') return { kind: 'date', system: false };
576
+ return null;
577
+ };
578
+
579
+ const normaliseSchedule = (
580
+ table: DataTable,
581
+ raw: unknown
582
+ ): DataTriggerSchedule => {
583
+ if (!isPlainObject(raw)) {
584
+ throw new DataError(
585
+ 'A time trigger needs "after": { "column", "minutes" }, such as { "column": "created_at", "minutes": 30 }.'
586
+ );
587
+ }
588
+ const name = raw.column;
589
+ const column = typeof name === 'string' ? scheduleColumn(table, name) : null;
590
+ if (typeof name !== 'string' || !column) {
591
+ throw new DataError(
592
+ `"after.column" is created_at, updated_at or a date or datetime column of ${table.name}.`
593
+ );
594
+ }
595
+ const minutes = raw.minutes;
596
+ if (
597
+ typeof minutes !== 'number' ||
598
+ !Number.isInteger(minutes) ||
599
+ Math.abs(minutes) < DATA_TIME_MIN_MINUTES ||
600
+ Math.abs(minutes) > DATA_TIME_MAX_MINUTES
601
+ ) {
602
+ throw new DataError(
603
+ `"after.minutes" is a whole number of minutes from ${DATA_TIME_MIN_MINUTES} to ${DATA_TIME_MAX_MINUTES} (30 days), negative for before the column's time.`
604
+ );
605
+ }
606
+ if (minutes < 0 && column.system) {
607
+ throw new DataError(
608
+ `"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.`
609
+ );
610
+ }
611
+ return { column: name, minutes };
612
+ };
613
+
468
614
  const defaultName = (table: string, on: string, action: DataTriggerAction) => {
469
615
  const target =
470
616
  action.type === 'function'
@@ -480,6 +626,7 @@ interface NormalisedTrigger {
480
626
  description: string | null;
481
627
  table: DataTable;
482
628
  on: DataTriggerEvent;
629
+ after: DataTriggerSchedule | null;
483
630
  when: DataTriggerCondition | null;
484
631
  action: DataTriggerAction;
485
632
  // null: not given, so a saved trigger stays on or off as it was.
@@ -504,7 +651,12 @@ export const normaliseTrigger = (
504
651
  }
505
652
  const on = input.on;
506
653
  if (!(DATA_TRIGGER_EVENTS as readonly unknown[]).includes(on)) {
507
- throw new DataError('"on" is insert, update or delete.');
654
+ throw new DataError('"on" is insert, update, delete or time.');
655
+ }
656
+ const timed = on === 'time';
657
+ const hasAfter = input.after !== undefined && input.after !== null;
658
+ if (!timed && hasAfter) {
659
+ throw new DataError('"after" is for "on": "time" only.');
508
660
  }
509
661
  const action = normaliseAction(table, input);
510
662
  const unnamed = input.name === undefined || input.name === null;
@@ -542,10 +694,11 @@ export const normaliseTrigger = (
542
694
  : null,
543
695
  table,
544
696
  on: on as DataTriggerEvent,
697
+ after: timed ? normaliseSchedule(table, input.after) : null,
545
698
  when:
546
699
  input.when === undefined || input.when === null
547
700
  ? null
548
- : normaliseCondition(table, input.when),
701
+ : normaliseCondition(table, input.when, 0, { tests: 0 }, timed),
549
702
  action,
550
703
  enabled: typeof input.enabled === 'boolean' ? input.enabled : null,
551
704
  replace: input.replace !== false,
@@ -582,6 +735,7 @@ const toTrigger = (schema: DataSchema, row: SqlRow): DataTrigger => ({
582
735
  description: (row.description as string | null) ?? null,
583
736
  table: schema.tables.find(t => t.id === Number(row.table_id))?.name ?? '',
584
737
  on: row.event as DataTriggerEvent,
738
+ after: parse<DataTriggerSchedule>(row.schedule),
585
739
  when: parse<DataTriggerCondition>(row.condition),
586
740
  action: parse<DataTriggerAction>(row.action) as DataTriggerAction,
587
741
  enabled: row.enabled === 1,
@@ -672,6 +826,19 @@ const upsert = (
672
826
  );
673
827
  }
674
828
  }
829
+ const wasTimed = existing?.event === 'time';
830
+ if (trigger.on === 'time' && !wasTimed) {
831
+ const timed = Number(
832
+ runner.all(
833
+ "SELECT count(*) AS n FROM _ganju_triggers WHERE event = 'time'"
834
+ )[0]?.n ?? 0
835
+ );
836
+ if (timed >= DATA_MAX_TIME_TRIGGERS) {
837
+ throw new DataError(
838
+ `A database holds up to ${DATA_MAX_TIME_TRIGGERS} time triggers.`
839
+ );
840
+ }
841
+ }
675
842
  // A webhook keeps its secret while it stays a webhook.
676
843
  const keep =
677
844
  existing?.secret && trigger.action.type === 'webhook'
@@ -688,11 +855,25 @@ const upsert = (
688
855
  const reason = enabled
689
856
  ? null
690
857
  : ((existing?.disabled_reason as string | null | undefined) ?? null);
858
+ // A time trigger counts due times from now: when it is new, when it
859
+ // counts from another column or delay, and when it is turned back on.
860
+ const schedule = trigger.after ? JSON.stringify(trigger.after) : null;
861
+ const restart =
862
+ trigger.on === 'time' &&
863
+ (!wasTimed ||
864
+ existing?.schedule !== schedule ||
865
+ (enabled && existing?.enabled !== 1));
866
+ const scanState = restart
867
+ ? freshScan()
868
+ : trigger.on === 'time'
869
+ ? ((existing?.scan_state as string | null | undefined) ?? freshScan())
870
+ : null;
691
871
  runner.all(
692
- `INSERT INTO _ganju_triggers (id, name, description, table_id, event, condition, action, enabled, disabled_reason, managed, rate_limit, secret, updated_at, updated_by)
693
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), ?)
872
+ `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)
873
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), ?)
694
874
  ON CONFLICT(id) DO UPDATE SET name = excluded.name, description = excluded.description,
695
- table_id = excluded.table_id, event = excluded.event, condition = excluded.condition,
875
+ table_id = excluded.table_id, event = excluded.event, schedule = excluded.schedule,
876
+ scan_state = excluded.scan_state, condition = excluded.condition,
696
877
  action = excluded.action, enabled = excluded.enabled, disabled_reason = excluded.disabled_reason,
697
878
  managed = excluded.managed, rate_limit = excluded.rate_limit, secret = excluded.secret,
698
879
  updated_at = excluded.updated_at, updated_by = excluded.updated_by`,
@@ -702,6 +883,8 @@ const upsert = (
702
883
  trigger.description,
703
884
  trigger.table.id,
704
885
  trigger.on,
886
+ schedule,
887
+ scanState,
705
888
  trigger.when ? JSON.stringify(trigger.when) : null,
706
889
  JSON.stringify(trigger.action),
707
890
  enabled ? 1 : 0,
@@ -712,7 +895,8 @@ const upsert = (
712
895
  caller.actor
713
896
  ]
714
897
  );
715
- ensureChangeCapture(runner, trigger.table);
898
+ if (trigger.on === 'time' || wasTimed) syncTimeIndexes(runner);
899
+ else ensureChangeCapture(runner, trigger.table);
716
900
  const saved = toTrigger(schema, findRow(runner, id));
717
901
  return secret && secret !== keep
718
902
  ? { trigger: saved, secret }
@@ -737,6 +921,12 @@ export const dropTrigger = (
737
921
  ): { dropped: string } => {
738
922
  const row = findRow(runner, nameOrId);
739
923
  runner.all('DELETE FROM _ganju_triggers WHERE id = ?', [row.id]);
924
+ if (row.event === 'time') {
925
+ runner.all('DELETE FROM _ganju_trigger_fired WHERE trigger_id = ?', [
926
+ row.id
927
+ ]);
928
+ syncTimeIndexes(runner);
929
+ }
740
930
  return { dropped: String(row.name) };
741
931
  };
742
932
 
@@ -754,7 +944,15 @@ export const setTriggerEnabled = (
754
944
  `UPDATE _ganju_triggers SET enabled = ?, disabled_reason = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), updated_by = ? WHERE id = ?`,
755
945
  [enabled ? 1 : 0, enabled ? null : reason, caller.actor, row.id]
756
946
  );
757
- if (enabled) {
947
+ // Back on, a time trigger counts from now: what came due while it was off
948
+ // doesn't all go out at once.
949
+ if (enabled && row.event === 'time' && row.enabled !== 1) {
950
+ runner.all('UPDATE _ganju_triggers SET scan_state = ? WHERE id = ?', [
951
+ freshScan(),
952
+ row.id
953
+ ]);
954
+ }
955
+ if (enabled && row.event !== 'time') {
758
956
  const table = schema.tables.find(t => t.id === Number(row.table_id));
759
957
  if (table) ensureChangeCapture(runner, table);
760
958
  }
@@ -811,6 +1009,9 @@ export const replaceManagedTriggers = (
811
1009
  )) {
812
1010
  if (names.has(String(row.name))) continue;
813
1011
  runner.all('DELETE FROM _ganju_triggers WHERE id = ?', [row.id]);
1012
+ runner.all('DELETE FROM _ganju_trigger_fired WHERE trigger_id = ?', [
1013
+ row.id
1014
+ ]);
814
1015
  removed.push(String(row.name));
815
1016
  }
816
1017
  const secrets: Record<string, string> = {};
@@ -819,6 +1020,7 @@ export const replaceManagedTriggers = (
819
1020
  if (saved.secret) secrets[saved.trigger.name] = saved.secret;
820
1021
  return saved.trigger;
821
1022
  });
1023
+ syncTimeIndexes(runner);
822
1024
  return { triggers, removed, secrets };
823
1025
  });
824
1026
  };
@@ -898,6 +1100,29 @@ const shape = (
898
1100
  return out;
899
1101
  };
900
1102
 
1103
+ /**
1104
+ * A row as a trigger event carries it, read from the table: `id`, or the
1105
+ * newest row. Null when there is none. A test send and a run whose event
1106
+ * was too large to carry its row build their message from it, so it reads
1107
+ * as it does for the event, system fields and the customer included.
1108
+ */
1109
+ export const readEventRow = (
1110
+ runner: SqlRunner,
1111
+ schema: DataSchema,
1112
+ tableName: string,
1113
+ id?: number
1114
+ ): Record<string, unknown> | null => {
1115
+ const table = schema.tables.find(t => t.name === tableName);
1116
+ if (!table) {
1117
+ throw new DataError(`There is no table named ${tableName}.`, 'not_found');
1118
+ }
1119
+ const [stored] =
1120
+ id === undefined
1121
+ ? runner.all(`SELECT * FROM "${table.name}" ORDER BY id DESC LIMIT 1`)
1122
+ : runner.all(`SELECT * FROM "${table.name}" WHERE id = ?`, [id]);
1123
+ return stored ? shape(table, JSON.stringify(stored)) : null;
1124
+ };
1125
+
901
1126
  const DEPTH_PATTERN = /^trigger:([^\s]+)$/;
902
1127
 
903
1128
  /**
@@ -933,6 +1158,7 @@ export const collectTriggerEvents = (
933
1158
  if (listening.length === 0) continue;
934
1159
  const row = shape(table, change.new);
935
1160
  const previous = shape(table, change.old);
1161
+ const customer = row?.participant_id ?? previous?.participant_id;
936
1162
  for (const trigger of listening) {
937
1163
  if (trigger.id === ownTrigger) continue;
938
1164
  if (!conditionMatches(trigger.when, row, previous)) continue;
@@ -948,7 +1174,10 @@ export const collectTriggerEvents = (
948
1174
  previous,
949
1175
  actor: (change.actor as string | null) ?? null,
950
1176
  depth,
951
- at: new Date().toISOString()
1177
+ at: new Date().toISOString(),
1178
+ ...(typeof customer === 'string' && customer
1179
+ ? { participantId: customer }
1180
+ : {})
952
1181
  };
953
1182
  if (depth > DATA_TRIGGER_MAX_DEPTH) {
954
1183
  message.row = null;
@@ -990,7 +1219,8 @@ export const outboxSize = (runner: SqlRunner): number =>
990
1219
 
991
1220
  // After a restore: events pending at the restored moment were sent then.
992
1221
  // Those stored before `before` came back with the restore; later ones are
993
- // writes made since, and stay.
1222
+ // writes made since, and stay. Time triggers count from now again, since
1223
+ // what came due after the restored moment was sent then too.
994
1224
  export const clearOutbox = (runner: SqlRunner, before: string): number => {
995
1225
  const n = Number(
996
1226
  runner.all('SELECT count(*) AS n FROM _ganju_outbox WHERE created_at < ?', [
@@ -999,6 +1229,9 @@ export const clearOutbox = (runner: SqlRunner, before: string): number => {
999
1229
  );
1000
1230
  runner.all('DELETE FROM _ganju_outbox WHERE created_at < ?', [before]);
1001
1231
  runner.all('DELETE FROM _ganju_changes');
1232
+ runner.all("UPDATE _ganju_triggers SET scan_state = ? WHERE event = 'time'", [
1233
+ freshScan()
1234
+ ]);
1002
1235
  return n;
1003
1236
  };
1004
1237
 
@@ -1025,9 +1258,9 @@ const actionTexts = (action: DataTriggerAction | null): string[] => {
1025
1258
  };
1026
1259
 
1027
1260
  /**
1028
- * The triggers whose condition or {{field}} texts name a column. Dropping it
1029
- * is refused while any does, since they are only checked when saved and
1030
- * would quietly stop matching.
1261
+ * The triggers whose condition, {{field}} texts or time column name a
1262
+ * column. Dropping it is refused while any does, since they are only checked
1263
+ * when saved and would quietly stop matching.
1031
1264
  */
1032
1265
  export const triggersUsingColumn = (
1033
1266
  runner: SqlRunner,
@@ -1036,11 +1269,12 @@ export const triggersUsingColumn = (
1036
1269
  ): string[] =>
1037
1270
  runner
1038
1271
  .all(
1039
- 'SELECT name, condition, action FROM _ganju_triggers WHERE table_id = ? ORDER BY name',
1272
+ 'SELECT name, schedule, condition, action FROM _ganju_triggers WHERE table_id = ? ORDER BY name',
1040
1273
  [tableId]
1041
1274
  )
1042
1275
  .filter(
1043
1276
  row =>
1277
+ parse<DataTriggerSchedule>(row.schedule)?.column === column ||
1044
1278
  conditionColumns(parse(row.condition)).includes(column) ||
1045
1279
  actionTexts(parse<DataTriggerAction>(row.action)).some(text =>
1046
1280
  templateFields(text).includes(column)
@@ -1114,14 +1348,20 @@ export const renameColumnInTriggers = (
1114
1348
  const names = triggersUsingColumn(runner, tableId, from);
1115
1349
  for (const name of names) {
1116
1350
  const row = runner.all(
1117
- 'SELECT id, condition, action FROM _ganju_triggers WHERE name = ?',
1351
+ 'SELECT id, schedule, condition, action FROM _ganju_triggers WHERE name = ?',
1118
1352
  [name]
1119
1353
  )[0];
1354
+ const schedule = parse<DataTriggerSchedule>(row.schedule);
1120
1355
  const condition = parse(row.condition);
1121
1356
  const action = parse<DataTriggerAction>(row.action);
1122
1357
  runner.all(
1123
- `UPDATE _ganju_triggers SET condition = ?, action = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
1358
+ `UPDATE _ganju_triggers SET schedule = ?, condition = ?, action = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
1124
1359
  [
1360
+ schedule
1361
+ ? JSON.stringify(
1362
+ schedule.column === from ? { ...schedule, column: to } : schedule
1363
+ )
1364
+ : null,
1125
1365
  condition === null
1126
1366
  ? null
1127
1367
  : JSON.stringify(renameInCondition(condition, from, to)),
@@ -1132,3 +1372,342 @@ export const renameColumnInTriggers = (
1132
1372
  }
1133
1373
  return names;
1134
1374
  };
1375
+
1376
+ // Time triggers
1377
+
1378
+ // The time triggers counting from a column: it must stay a date or datetime.
1379
+ export const timeTriggersCountingFrom = (
1380
+ runner: SqlRunner,
1381
+ tableId: number,
1382
+ column: string
1383
+ ): string[] =>
1384
+ runner
1385
+ .all(
1386
+ "SELECT name, schedule FROM _ganju_triggers WHERE table_id = ? AND event = 'time' ORDER BY name",
1387
+ [tableId]
1388
+ )
1389
+ .filter(row => parse<DataTriggerSchedule>(row.schedule)?.column === column)
1390
+ .map(row => String(row.name));
1391
+
1392
+ // How far a time trigger has looked: due times up to `through` are done.
1393
+ // `resume` is the last row looked at when a scan stopped early, so the next
1394
+ // one carries on from it instead of reading the same rows again.
1395
+ interface TimeScanState {
1396
+ through: string;
1397
+ resume?: { value: string; id: number };
1398
+ }
1399
+
1400
+ const freshScan = () => JSON.stringify({ through: isoSeconds(new Date()) });
1401
+
1402
+ const shiftIso = (iso: string, minutes: number) =>
1403
+ isoSeconds(new Date(Date.parse(iso) + minutes * 60_000));
1404
+
1405
+ /**
1406
+ * The dates of a date column whose due time falls in (from, to]: midnight
1407
+ * in the database's zone plus `minutes`. Each date maps to its due time.
1408
+ */
1409
+ const dueDates = (
1410
+ from: string,
1411
+ to: string,
1412
+ minutes: number,
1413
+ timeZone: string | undefined
1414
+ ): Map<string, string> => {
1415
+ const out = new Map<string, string>();
1416
+ const day = 86_400_000;
1417
+ // A day either side covers any zone's offset.
1418
+ const start =
1419
+ Math.floor((Date.parse(from) - minutes * 60_000) / day) * day - day;
1420
+ const end = Date.parse(to) - minutes * 60_000 + day;
1421
+ for (let at = start; at <= end; at += day) {
1422
+ const date = new Date(at).toISOString().slice(0, 10);
1423
+ const midnight = localDatetime(`${date}T00:00:00`, timeZone);
1424
+ if (!midnight || Number.isNaN(midnight.getTime())) continue;
1425
+ const due = isoSeconds(new Date(midnight.getTime() + minutes * 60_000));
1426
+ if (due > from && due <= to) out.set(date, due);
1427
+ }
1428
+ return out;
1429
+ };
1430
+
1431
+ // A time trigger's own index on its column, named apart from the table's
1432
+ // (ix_ / ux_), which the table's own changes remake from its columns.
1433
+ const timeIndexName = (tableId: number, column: string | number) =>
1434
+ `it_${tableId}_${column}`;
1435
+
1436
+ /**
1437
+ * Indexes the columns time triggers count from, and drops the ones no time
1438
+ * trigger uses any more. A column the table already indexes (filterable or
1439
+ * unique) needs nothing more. Cheap, so every scan runs it too: a table
1440
+ * rebuilt for a column change loses the index and gets it back.
1441
+ */
1442
+ export const syncTimeIndexes = (runner: SqlRunner): void => {
1443
+ const wanted = new Map<string, string>();
1444
+ for (const row of runner.all(
1445
+ "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'"
1446
+ )) {
1447
+ const after = parse<DataTriggerSchedule>(row.schedule);
1448
+ if (!after) continue;
1449
+ const tableId = Number(row.table_id);
1450
+ let name: string;
1451
+ if (after.column === 'created_at' || after.column === 'updated_at') {
1452
+ name = timeIndexName(tableId, after.column);
1453
+ } else {
1454
+ const column = runner.all(
1455
+ 'SELECT id, filterable, is_unique FROM _ganju_columns WHERE table_id = ? AND name = ?',
1456
+ [tableId, after.column]
1457
+ )[0];
1458
+ if (!column || column.filterable === 1 || column.is_unique === 1)
1459
+ continue;
1460
+ name = timeIndexName(tableId, Number(column.id));
1461
+ }
1462
+ wanted.set(
1463
+ name,
1464
+ `CREATE INDEX IF NOT EXISTS "${name}" ON "${String(row.table_name)}"("${after.column}")`
1465
+ );
1466
+ }
1467
+ const existing = runner
1468
+ .all(
1469
+ "SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'it\\_%' ESCAPE '\\'"
1470
+ )
1471
+ .map(row => String(row.name));
1472
+ for (const name of existing) {
1473
+ if (!wanted.has(name)) runner.all(`DROP INDEX IF EXISTS "${name}"`);
1474
+ }
1475
+ for (const [name, sql] of wanted) {
1476
+ if (!existing.includes(name)) runner.all(sql);
1477
+ }
1478
+ };
1479
+
1480
+ export const hasTimeTriggers = (runner: SqlRunner): boolean =>
1481
+ runner.all(
1482
+ "SELECT 1 AS found FROM _ganju_triggers WHERE event = 'time' AND enabled = 1 LIMIT 1"
1483
+ ).length > 0;
1484
+
1485
+ export interface TimeScanResult {
1486
+ // Events stored in the outbox.
1487
+ events: number;
1488
+ // Triggers turned off, with why.
1489
+ disabled: { name: string; reason: string }[];
1490
+ }
1491
+
1492
+ const DAY_COUNT_KEY = 'time_events_day';
1493
+
1494
+ /**
1495
+ * One scan: for each enabled time trigger, the rows that came due since its
1496
+ * last scan, matching its `when`, not fired for that due time before, become
1497
+ * events in the outbox. One transaction, so a crash fires nothing twice.
1498
+ */
1499
+ export const scanTimeTriggers = (
1500
+ runner: SqlRunner,
1501
+ schema: DataSchema,
1502
+ now = new Date()
1503
+ ): TimeScanResult =>
1504
+ runner.transaction(() => {
1505
+ const result: TimeScanResult = { events: 0, disabled: [] };
1506
+ const rows = runner.all(
1507
+ "SELECT * FROM _ganju_triggers WHERE event = 'time' AND enabled = 1 ORDER BY created_at, id"
1508
+ );
1509
+ if (rows.length === 0) return result;
1510
+ syncTimeIndexes(runner);
1511
+ const to = isoSeconds(now);
1512
+ const floor = isoSeconds(new Date(now.getTime() - TIME_LOOKBACK_MS));
1513
+ runner.all('DELETE FROM _ganju_trigger_fired WHERE due_at < ?', [
1514
+ isoSeconds(new Date(now.getTime() - FIRED_KEEP_MS))
1515
+ ]);
1516
+ const today = to.slice(0, 10);
1517
+ const counted = parse<{ day: string; count: number }>(
1518
+ runner.all('SELECT value FROM _ganju_meta WHERE key = ?', [
1519
+ DAY_COUNT_KEY
1520
+ ])[0]?.value
1521
+ );
1522
+ let firedToday = counted?.day === today ? counted.count : 0;
1523
+ const artifactId =
1524
+ (runner.all("SELECT value FROM _ganju_meta WHERE key = 'artifact_id'")[0]
1525
+ ?.value as string | undefined) ?? '';
1526
+ const timeZone = schema.timeZone ?? undefined;
1527
+
1528
+ const turnOff = (row: SqlRow, reason: string) => {
1529
+ runner.all(
1530
+ `UPDATE _ganju_triggers SET enabled = 0, disabled_reason = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), updated_by = 'system:time' WHERE id = ?`,
1531
+ [reason, row.id]
1532
+ );
1533
+ result.disabled.push({ name: String(row.name), reason });
1534
+ };
1535
+
1536
+ for (const row of rows) {
1537
+ const trigger = toTrigger(schema, row);
1538
+ const table = schema.tables.find(t => t.id === Number(row.table_id));
1539
+ const after = trigger.after;
1540
+ if (!table || !after) continue;
1541
+ const kind = scheduleColumn(table, after.column)?.kind;
1542
+ if (!kind) {
1543
+ turnOff(
1544
+ row,
1545
+ `${table.name}.${after.column} is no longer a date or datetime column.`
1546
+ );
1547
+ continue;
1548
+ }
1549
+ const state = parse<TimeScanState>(row.scan_state) ?? {
1550
+ through: isoSeconds(new Date(trigger.createdAt))
1551
+ };
1552
+ const from = state.through > floor ? state.through : floor;
1553
+ if (from >= to) continue;
1554
+
1555
+ const column = `t."${after.column}"`;
1556
+ const params: SqlValue[] = [];
1557
+ let range: string;
1558
+ let due: string;
1559
+ if (kind === 'date') {
1560
+ const dates = dueDates(from, to, after.minutes, timeZone);
1561
+ if (dates.size === 0) {
1562
+ runner.all('UPDATE _ganju_triggers SET scan_state = ? WHERE id = ?', [
1563
+ JSON.stringify({ through: to }),
1564
+ row.id
1565
+ ]);
1566
+ continue;
1567
+ }
1568
+ due = `CASE ${column} ${[...dates].map(() => 'WHEN ? THEN ?').join(' ')} END`;
1569
+ for (const [date, at] of dates) params.push(date, at);
1570
+ range = `${column} IN (${[...dates.keys()].map(() => '?').join(', ')})`;
1571
+ params.push(...dates.keys());
1572
+ } else {
1573
+ const sign = after.minutes >= 0 ? '+' : '';
1574
+ due = `strftime('%Y-%m-%dT%H:%M:%SZ', ${column}, '${sign}${after.minutes} minutes')`;
1575
+ range = `${column} > ? AND ${column} <= ?`;
1576
+ params.push(
1577
+ shiftIso(from, -after.minutes),
1578
+ shiftIso(to, -after.minutes)
1579
+ );
1580
+ }
1581
+ if (state.resume) {
1582
+ range += ` AND (${column} > ? OR (${column} = ? AND t.id > ?))`;
1583
+ params.push(state.resume.value, state.resume.value, state.resume.id);
1584
+ }
1585
+ params.push(trigger.id);
1586
+
1587
+ // Read first, written after: nothing else runs while the read is open.
1588
+ let looked = 0;
1589
+ let more = false;
1590
+ let stoppedAt: { value: string; id: number } | null = null;
1591
+ // The last row this scan is done with: fired, or not matching.
1592
+ let done: { value: string; id: number } | null = null;
1593
+ let capped = false;
1594
+ const firing: DataTriggerEventMessage[] = [];
1595
+ for (const stored of runner.iterate(
1596
+ `SELECT x.* FROM (SELECT t.*, ${due} AS _ganju_due FROM "${table.name}" t WHERE ${range}) x
1597
+ 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)
1598
+ ORDER BY x."${after.column}", x.id`,
1599
+ params
1600
+ )) {
1601
+ if (
1602
+ looked >= DATA_TIME_ROWS_PER_SCAN ||
1603
+ firing.length >= DATA_TIME_EVENTS_PER_SCAN
1604
+ ) {
1605
+ more = true;
1606
+ break;
1607
+ }
1608
+ looked++;
1609
+ const at = {
1610
+ value: String(stored[after.column]),
1611
+ id: Number(stored.id)
1612
+ };
1613
+ const current = shape(table, JSON.stringify(stored));
1614
+ stoppedAt = at;
1615
+ if (!conditionMatches(trigger.when, current, null)) {
1616
+ done = at;
1617
+ continue;
1618
+ }
1619
+ if (firedToday + firing.length >= DATA_TIME_EVENTS_PER_DAY) {
1620
+ capped = true;
1621
+ break;
1622
+ }
1623
+ done = at;
1624
+ const customer = current?.participant_id;
1625
+ const message: DataTriggerEventMessage = {
1626
+ eventId: `evt_${crypto.randomUUID().replace(/-/g, '')}`,
1627
+ artifactId,
1628
+ triggerId: trigger.id,
1629
+ triggerName: trigger.name,
1630
+ table: table.name,
1631
+ event: 'time',
1632
+ rowId: at.id,
1633
+ row: current,
1634
+ previous: null,
1635
+ actor: null,
1636
+ depth: 1,
1637
+ at: now.toISOString(),
1638
+ dueAt: String(stored._ganju_due),
1639
+ ...(typeof customer === 'string' && customer
1640
+ ? { participantId: customer }
1641
+ : {})
1642
+ };
1643
+ if (utf8Bytes(current) > DATA_TRIGGER_MAX_ROW_BYTES) {
1644
+ message.row = null;
1645
+ message.truncated = true;
1646
+ }
1647
+ firing.push(message);
1648
+ }
1649
+ for (const message of firing) {
1650
+ runner.all(
1651
+ 'INSERT INTO _ganju_outbox (id, trigger_id, message) VALUES (?, ?, ?)',
1652
+ [message.eventId, trigger.id, JSON.stringify(message)]
1653
+ );
1654
+ runner.all(
1655
+ 'INSERT OR IGNORE INTO _ganju_trigger_fired (trigger_id, row_id, due_at) VALUES (?, ?, ?)',
1656
+ [trigger.id, message.rowId, message.dueAt ?? '']
1657
+ );
1658
+ }
1659
+ firedToday += firing.length;
1660
+ result.events += firing.length;
1661
+ // Events stored in this scan still run: the queue skips those of a
1662
+ // trigger that is off. So a trigger that fired some before the cap stays
1663
+ // on, carries on after them, and is turned off by the next scan.
1664
+ if (capped && firing.length > 0) {
1665
+ capped = false;
1666
+ more = true;
1667
+ stoppedAt = done;
1668
+ }
1669
+ if (capped) {
1670
+ const reason = `This database's time triggers fired ${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.`;
1671
+ turnOff(row, reason);
1672
+ // The queue records it on the runs list and tells the admins.
1673
+ const notice: DataTriggerEventMessage = {
1674
+ eventId: `evt_${crypto.randomUUID().replace(/-/g, '')}`,
1675
+ artifactId,
1676
+ triggerId: trigger.id,
1677
+ triggerName: trigger.name,
1678
+ table: table.name,
1679
+ event: 'time',
1680
+ rowId: stoppedAt?.id ?? 0,
1681
+ row: null,
1682
+ previous: null,
1683
+ actor: null,
1684
+ depth: 1,
1685
+ at: now.toISOString(),
1686
+ error: `Not run: ${reason}`,
1687
+ turnedOff: true
1688
+ };
1689
+ runner.all(
1690
+ 'INSERT INTO _ganju_outbox (id, trigger_id, message) VALUES (?, ?, ?)',
1691
+ [notice.eventId, trigger.id, JSON.stringify(notice)]
1692
+ );
1693
+ continue;
1694
+ }
1695
+ // Stopped early: the next scan carries on after the last row looked at,
1696
+ // within the same window. Otherwise the window is done.
1697
+ const next: TimeScanState =
1698
+ more && stoppedAt
1699
+ ? { through: from, resume: stoppedAt }
1700
+ : { through: to };
1701
+ runner.all('UPDATE _ganju_triggers SET scan_state = ? WHERE id = ?', [
1702
+ JSON.stringify(next),
1703
+ row.id
1704
+ ]);
1705
+ }
1706
+ if (result.events > 0) {
1707
+ runner.all(
1708
+ 'INSERT INTO _ganju_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
1709
+ [DAY_COUNT_KEY, JSON.stringify({ day: today, count: firedToday })]
1710
+ );
1711
+ }
1712
+ return result;
1713
+ });