@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.
- package/dist/constants.d.ts +16 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +43 -0
- package/dist/data.d.ts +7 -5
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +27 -3
- package/dist/dataApi.d.ts +82 -6
- package/dist/dataApi.d.ts.map +1 -1
- package/dist/dataApi.js +47 -4
- package/dist/dataAttachment.d.ts +8 -0
- package/dist/dataAttachment.d.ts.map +1 -0
- package/dist/dataAttachment.js +19 -0
- package/dist/dataCompiler.d.ts +4 -0
- package/dist/dataCompiler.d.ts.map +1 -1
- package/dist/dataCompiler.js +27 -2
- package/dist/dataDdl.d.ts.map +1 -1
- package/dist/dataDdl.js +5 -0
- package/dist/dataEngine.d.ts +25 -3
- package/dist/dataEngine.d.ts.map +1 -1
- package/dist/dataEngine.js +508 -14
- package/dist/dataImport.d.ts +3 -0
- package/dist/dataImport.d.ts.map +1 -1
- package/dist/dataImport.js +12 -2
- package/dist/dataIndexAdvice.d.ts +36 -0
- package/dist/dataIndexAdvice.d.ts.map +1 -0
- package/dist/dataIndexAdvice.js +89 -0
- package/dist/dataRpc.d.ts +20 -1
- package/dist/dataRpc.d.ts.map +1 -1
- package/dist/dataSheet.d.ts +34 -0
- package/dist/dataSheet.d.ts.map +1 -0
- package/dist/dataSheet.js +100 -0
- package/dist/dataTools.js +3 -3
- package/dist/dataTriggers.d.ts +66 -4
- package/dist/dataTriggers.d.ts.map +1 -1
- package/dist/dataTriggers.js +446 -20
- package/dist/dataTypes.d.ts +69 -0
- package/dist/dataTypes.d.ts.map +1 -1
- package/dist/dataTypes.js +21 -1
- package/dist/index.d.ts +91 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/localizeDataError.d.ts.map +1 -1
- package/dist/localizeDataError.js +109 -3
- package/dist/schema.d.ts +67 -6
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +57 -2
- package/dist/untrustedContent.d.ts +7 -0
- package/dist/untrustedContent.d.ts.map +1 -1
- package/dist/untrustedContent.js +13 -1
- package/dist/whatsappWebhook.d.ts +28 -0
- package/dist/whatsappWebhook.d.ts.map +1 -0
- package/dist/whatsappWebhook.js +30 -0
- package/package.json +1 -1
- package/src/constants.ts +46 -0
- package/src/data.ts +33 -3
- package/src/dataApi.ts +54 -4
- package/src/dataAttachment.ts +23 -0
- package/src/dataCompiler.ts +41 -1
- package/src/dataDdl.ts +7 -0
- package/src/dataEngine.ts +680 -15
- package/src/dataImport.ts +16 -1
- package/src/dataIndexAdvice.ts +127 -0
- package/src/dataRpc.ts +27 -2
- package/src/dataSheet.ts +147 -0
- package/src/dataTools.ts +3 -3
- package/src/dataTriggers.ts +599 -20
- package/src/dataTypes.ts +108 -0
- package/src/index.ts +15 -0
- package/src/localizeDataError.ts +183 -4
- package/src/schema.ts +60 -2
- package/src/untrustedContent.ts +15 -0
- package/src/whatsappWebhook.ts +55 -0
package/src/dataTriggers.ts
CHANGED
|
@@ -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 = [
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1029
|
-
* is refused while any does, since they are only checked
|
|
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
|
+
});
|