@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.
- package/dist/data.d.ts +4 -4
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +16 -2
- package/dist/dataApi.d.ts +32 -2
- package/dist/dataApi.d.ts.map +1 -1
- package/dist/dataApi.js +11 -1
- package/dist/dataCompiler.d.ts +1 -0
- package/dist/dataCompiler.d.ts.map +1 -1
- package/dist/dataCompiler.js +3 -2
- package/dist/dataEngine.d.ts +17 -1
- package/dist/dataEngine.d.ts.map +1 -1
- package/dist/dataEngine.js +368 -10
- package/dist/dataImport.d.ts +3 -0
- package/dist/dataImport.d.ts.map +1 -1
- package/dist/dataImport.js +12 -2
- package/dist/dataRpc.d.ts +9 -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/dataTriggers.d.ts +43 -4
- package/dist/dataTriggers.d.ts.map +1 -1
- package/dist/dataTriggers.js +401 -19
- package/dist/dataTypes.d.ts +23 -0
- package/dist/dataTypes.d.ts.map +1 -1
- package/dist/dataTypes.js +15 -1
- package/dist/index.d.ts +35 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/localizeDataError.d.ts.map +1 -1
- package/dist/localizeDataError.js +45 -1
- package/dist/schema.d.ts +33 -4
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +26 -3
- package/dist/untrustedContent.d.ts +7 -0
- package/dist/untrustedContent.d.ts.map +1 -1
- package/dist/untrustedContent.js +13 -1
- package/package.json +1 -1
- package/src/data.ts +19 -3
- package/src/dataApi.ts +14 -2
- package/src/dataCompiler.ts +4 -1
- package/src/dataEngine.ts +485 -10
- package/src/dataImport.ts +16 -1
- package/src/dataRpc.ts +10 -1
- package/src/dataSheet.ts +147 -0
- package/src/dataTriggers.ts +528 -19
- package/src/dataTypes.ts +46 -0
- package/src/index.ts +4 -0
- package/src/localizeDataError.ts +82 -1
- package/src/schema.ts +27 -3
- package/src/untrustedContent.ts +15 -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,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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1099
|
-
* is refused while any does, since they are only checked
|
|
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
|
+
});
|