@ganju/utils 0.0.21 → 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 (51) hide show
  1. package/dist/data.d.ts +4 -4
  2. package/dist/data.d.ts.map +1 -1
  3. package/dist/data.js +16 -2
  4. package/dist/dataApi.d.ts +32 -2
  5. package/dist/dataApi.d.ts.map +1 -1
  6. package/dist/dataApi.js +11 -1
  7. package/dist/dataCompiler.d.ts +1 -0
  8. package/dist/dataCompiler.d.ts.map +1 -1
  9. package/dist/dataCompiler.js +3 -2
  10. package/dist/dataEngine.d.ts +17 -1
  11. package/dist/dataEngine.d.ts.map +1 -1
  12. package/dist/dataEngine.js +368 -10
  13. package/dist/dataImport.d.ts +3 -0
  14. package/dist/dataImport.d.ts.map +1 -1
  15. package/dist/dataImport.js +12 -2
  16. package/dist/dataRpc.d.ts +9 -1
  17. package/dist/dataRpc.d.ts.map +1 -1
  18. package/dist/dataSheet.d.ts +34 -0
  19. package/dist/dataSheet.d.ts.map +1 -0
  20. package/dist/dataSheet.js +100 -0
  21. package/dist/dataTriggers.d.ts +43 -4
  22. package/dist/dataTriggers.d.ts.map +1 -1
  23. package/dist/dataTriggers.js +401 -19
  24. package/dist/dataTypes.d.ts +23 -0
  25. package/dist/dataTypes.d.ts.map +1 -1
  26. package/dist/dataTypes.js +15 -1
  27. package/dist/index.d.ts +35 -4
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2 -0
  30. package/dist/localizeDataError.d.ts.map +1 -1
  31. package/dist/localizeDataError.js +45 -1
  32. package/dist/schema.d.ts +33 -4
  33. package/dist/schema.d.ts.map +1 -1
  34. package/dist/schema.js +26 -3
  35. package/dist/untrustedContent.d.ts +7 -0
  36. package/dist/untrustedContent.d.ts.map +1 -1
  37. package/dist/untrustedContent.js +13 -1
  38. package/package.json +1 -1
  39. package/src/data.ts +19 -3
  40. package/src/dataApi.ts +14 -2
  41. package/src/dataCompiler.ts +4 -1
  42. package/src/dataEngine.ts +485 -10
  43. package/src/dataImport.ts +16 -1
  44. package/src/dataRpc.ts +10 -1
  45. package/src/dataSheet.ts +147 -0
  46. package/src/dataTriggers.ts +528 -19
  47. package/src/dataTypes.ts +46 -0
  48. package/src/index.ts +4 -0
  49. package/src/localizeDataError.ts +82 -1
  50. package/src/schema.ts +27 -3
  51. package/src/untrustedContent.ts +15 -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,12 +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;
149
192
  // The row's customer on a customer-scoped table, kept when the row is
150
193
  // left out for its size.
151
194
  participantId?: string;
152
195
  // Not run: the chain went too deep. Recorded as a failed run.
153
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;
154
200
  }
155
201
 
156
202
  const isPlainObject = (value: unknown): value is Record<string, unknown> =>
@@ -177,11 +223,14 @@ const isScalar = (value: unknown): value is Scalar =>
177
223
 
178
224
  const TEST_KEYS = ['changedTo', 'changedFrom', 'changed', 'equals'] as const;
179
225
 
226
+ // A time trigger reads the row as it is, with nothing changing, so only
227
+ // `equals` means anything there.
180
228
  const normaliseCondition = (
181
229
  table: DataTable,
182
230
  raw: unknown,
183
231
  depth = 0,
184
- count = { tests: 0 }
232
+ count = { tests: 0 },
233
+ equalsOnly = false
185
234
  ): DataTriggerCondition => {
186
235
  if (!isPlainObject(raw) || Object.keys(raw).length === 0) {
187
236
  throw new DataError(
@@ -205,7 +254,7 @@ const normaliseCondition = (
205
254
  );
206
255
  }
207
256
  const list = (raw[key] as unknown[]).map(item =>
208
- normaliseCondition(table, item, depth + 1, count)
257
+ normaliseCondition(table, item, depth + 1, count, equalsOnly)
209
258
  );
210
259
  return key === 'all' ? { all: list } : { any: list };
211
260
  }
@@ -231,6 +280,11 @@ const normaliseCondition = (
231
280
  `"when".${column}.${key}: a test is changedTo, changedFrom, changed or equals.`
232
281
  );
233
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
+ }
234
288
  if (key === 'changed') {
235
289
  if (value !== true)
236
290
  throw new DataError(`"when".${column}.changed is true.`);
@@ -508,6 +562,55 @@ export const normaliseCustomerAction = (
508
562
  >;
509
563
  };
510
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
+
511
614
  const defaultName = (table: string, on: string, action: DataTriggerAction) => {
512
615
  const target =
513
616
  action.type === 'function'
@@ -523,6 +626,7 @@ interface NormalisedTrigger {
523
626
  description: string | null;
524
627
  table: DataTable;
525
628
  on: DataTriggerEvent;
629
+ after: DataTriggerSchedule | null;
526
630
  when: DataTriggerCondition | null;
527
631
  action: DataTriggerAction;
528
632
  // null: not given, so a saved trigger stays on or off as it was.
@@ -547,7 +651,12 @@ export const normaliseTrigger = (
547
651
  }
548
652
  const on = input.on;
549
653
  if (!(DATA_TRIGGER_EVENTS as readonly unknown[]).includes(on)) {
550
- 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.');
551
660
  }
552
661
  const action = normaliseAction(table, input);
553
662
  const unnamed = input.name === undefined || input.name === null;
@@ -585,10 +694,11 @@ export const normaliseTrigger = (
585
694
  : null,
586
695
  table,
587
696
  on: on as DataTriggerEvent,
697
+ after: timed ? normaliseSchedule(table, input.after) : null,
588
698
  when:
589
699
  input.when === undefined || input.when === null
590
700
  ? null
591
- : normaliseCondition(table, input.when),
701
+ : normaliseCondition(table, input.when, 0, { tests: 0 }, timed),
592
702
  action,
593
703
  enabled: typeof input.enabled === 'boolean' ? input.enabled : null,
594
704
  replace: input.replace !== false,
@@ -625,6 +735,7 @@ const toTrigger = (schema: DataSchema, row: SqlRow): DataTrigger => ({
625
735
  description: (row.description as string | null) ?? null,
626
736
  table: schema.tables.find(t => t.id === Number(row.table_id))?.name ?? '',
627
737
  on: row.event as DataTriggerEvent,
738
+ after: parse<DataTriggerSchedule>(row.schedule),
628
739
  when: parse<DataTriggerCondition>(row.condition),
629
740
  action: parse<DataTriggerAction>(row.action) as DataTriggerAction,
630
741
  enabled: row.enabled === 1,
@@ -715,6 +826,19 @@ const upsert = (
715
826
  );
716
827
  }
717
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
+ }
718
842
  // A webhook keeps its secret while it stays a webhook.
719
843
  const keep =
720
844
  existing?.secret && trigger.action.type === 'webhook'
@@ -731,11 +855,25 @@ const upsert = (
731
855
  const reason = enabled
732
856
  ? null
733
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;
734
871
  runner.all(
735
- `INSERT INTO _ganju_triggers (id, name, description, table_id, event, condition, action, enabled, disabled_reason, managed, rate_limit, secret, updated_at, updated_by)
736
- 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'), ?)
737
874
  ON CONFLICT(id) DO UPDATE SET name = excluded.name, description = excluded.description,
738
- 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,
739
877
  action = excluded.action, enabled = excluded.enabled, disabled_reason = excluded.disabled_reason,
740
878
  managed = excluded.managed, rate_limit = excluded.rate_limit, secret = excluded.secret,
741
879
  updated_at = excluded.updated_at, updated_by = excluded.updated_by`,
@@ -745,6 +883,8 @@ const upsert = (
745
883
  trigger.description,
746
884
  trigger.table.id,
747
885
  trigger.on,
886
+ schedule,
887
+ scanState,
748
888
  trigger.when ? JSON.stringify(trigger.when) : null,
749
889
  JSON.stringify(trigger.action),
750
890
  enabled ? 1 : 0,
@@ -755,7 +895,8 @@ const upsert = (
755
895
  caller.actor
756
896
  ]
757
897
  );
758
- ensureChangeCapture(runner, trigger.table);
898
+ if (trigger.on === 'time' || wasTimed) syncTimeIndexes(runner);
899
+ else ensureChangeCapture(runner, trigger.table);
759
900
  const saved = toTrigger(schema, findRow(runner, id));
760
901
  return secret && secret !== keep
761
902
  ? { trigger: saved, secret }
@@ -780,6 +921,12 @@ export const dropTrigger = (
780
921
  ): { dropped: string } => {
781
922
  const row = findRow(runner, nameOrId);
782
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
+ }
783
930
  return { dropped: String(row.name) };
784
931
  };
785
932
 
@@ -797,7 +944,15 @@ export const setTriggerEnabled = (
797
944
  `UPDATE _ganju_triggers SET enabled = ?, disabled_reason = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), updated_by = ? WHERE id = ?`,
798
945
  [enabled ? 1 : 0, enabled ? null : reason, caller.actor, row.id]
799
946
  );
800
- 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') {
801
956
  const table = schema.tables.find(t => t.id === Number(row.table_id));
802
957
  if (table) ensureChangeCapture(runner, table);
803
958
  }
@@ -854,6 +1009,9 @@ export const replaceManagedTriggers = (
854
1009
  )) {
855
1010
  if (names.has(String(row.name))) continue;
856
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
+ ]);
857
1015
  removed.push(String(row.name));
858
1016
  }
859
1017
  const secrets: Record<string, string> = {};
@@ -862,6 +1020,7 @@ export const replaceManagedTriggers = (
862
1020
  if (saved.secret) secrets[saved.trigger.name] = saved.secret;
863
1021
  return saved.trigger;
864
1022
  });
1023
+ syncTimeIndexes(runner);
865
1024
  return { triggers, removed, secrets };
866
1025
  });
867
1026
  };
@@ -1060,7 +1219,8 @@ export const outboxSize = (runner: SqlRunner): number =>
1060
1219
 
1061
1220
  // After a restore: events pending at the restored moment were sent then.
1062
1221
  // Those stored before `before` came back with the restore; later ones are
1063
- // 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.
1064
1224
  export const clearOutbox = (runner: SqlRunner, before: string): number => {
1065
1225
  const n = Number(
1066
1226
  runner.all('SELECT count(*) AS n FROM _ganju_outbox WHERE created_at < ?', [
@@ -1069,6 +1229,9 @@ export const clearOutbox = (runner: SqlRunner, before: string): number => {
1069
1229
  );
1070
1230
  runner.all('DELETE FROM _ganju_outbox WHERE created_at < ?', [before]);
1071
1231
  runner.all('DELETE FROM _ganju_changes');
1232
+ runner.all("UPDATE _ganju_triggers SET scan_state = ? WHERE event = 'time'", [
1233
+ freshScan()
1234
+ ]);
1072
1235
  return n;
1073
1236
  };
1074
1237
 
@@ -1095,9 +1258,9 @@ const actionTexts = (action: DataTriggerAction | null): string[] => {
1095
1258
  };
1096
1259
 
1097
1260
  /**
1098
- * The triggers whose condition or {{field}} texts name a column. Dropping it
1099
- * is refused while any does, since they are only checked when saved and
1100
- * 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.
1101
1264
  */
1102
1265
  export const triggersUsingColumn = (
1103
1266
  runner: SqlRunner,
@@ -1106,11 +1269,12 @@ export const triggersUsingColumn = (
1106
1269
  ): string[] =>
1107
1270
  runner
1108
1271
  .all(
1109
- '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',
1110
1273
  [tableId]
1111
1274
  )
1112
1275
  .filter(
1113
1276
  row =>
1277
+ parse<DataTriggerSchedule>(row.schedule)?.column === column ||
1114
1278
  conditionColumns(parse(row.condition)).includes(column) ||
1115
1279
  actionTexts(parse<DataTriggerAction>(row.action)).some(text =>
1116
1280
  templateFields(text).includes(column)
@@ -1184,14 +1348,20 @@ export const renameColumnInTriggers = (
1184
1348
  const names = triggersUsingColumn(runner, tableId, from);
1185
1349
  for (const name of names) {
1186
1350
  const row = runner.all(
1187
- 'SELECT id, condition, action FROM _ganju_triggers WHERE name = ?',
1351
+ 'SELECT id, schedule, condition, action FROM _ganju_triggers WHERE name = ?',
1188
1352
  [name]
1189
1353
  )[0];
1354
+ const schedule = parse<DataTriggerSchedule>(row.schedule);
1190
1355
  const condition = parse(row.condition);
1191
1356
  const action = parse<DataTriggerAction>(row.action);
1192
1357
  runner.all(
1193
- `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 = ?`,
1194
1359
  [
1360
+ schedule
1361
+ ? JSON.stringify(
1362
+ schedule.column === from ? { ...schedule, column: to } : schedule
1363
+ )
1364
+ : null,
1195
1365
  condition === null
1196
1366
  ? null
1197
1367
  : JSON.stringify(renameInCondition(condition, from, to)),
@@ -1202,3 +1372,342 @@ export const renameColumnInTriggers = (
1202
1372
  }
1203
1373
  return names;
1204
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
+ });