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