@ganju/utils 0.0.17 → 0.0.18
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 +6 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +14 -1
- package/dist/customCodeToken.d.ts +4 -0
- package/dist/customCodeToken.d.ts.map +1 -1
- package/dist/customCodeToken.js +9 -1
- package/dist/data.d.ts +2 -0
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +28 -1
- package/dist/dataApi.d.ts +133 -1
- package/dist/dataApi.d.ts.map +1 -1
- package/dist/dataApi.js +62 -1
- package/dist/dataDdl.d.ts.map +1 -1
- package/dist/dataDdl.js +3 -1
- package/dist/dataEngine.d.ts.map +1 -1
- package/dist/dataEngine.js +62 -0
- package/dist/dataRpc.d.ts +25 -0
- package/dist/dataRpc.d.ts.map +1 -1
- package/dist/dataTriggers.d.ts +203 -0
- package/dist/dataTriggers.d.ts.map +1 -0
- package/dist/dataTriggers.js +760 -0
- package/dist/dataTypes.d.ts +1 -0
- package/dist/dataTypes.d.ts.map +1 -1
- package/dist/index.d.ts +115 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/localizeDataError.d.ts +18 -0
- package/dist/localizeDataError.d.ts.map +1 -0
- package/dist/localizeDataError.js +359 -0
- package/dist/schema.d.ts +110 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +84 -0
- package/dist/sdkConstants.d.ts +2 -0
- package/dist/sdkConstants.d.ts.map +1 -1
- package/dist/sdkConstants.js +3 -1
- package/package.json +1 -1
- package/src/constants.ts +17 -1
- package/src/customCodeToken.ts +14 -1
- package/src/data.ts +40 -0
- package/src/dataApi.ts +78 -2
- package/src/dataDdl.ts +3 -1
- package/src/dataEngine.ts +70 -0
- package/src/dataRpc.ts +15 -0
- package/src/dataTriggers.ts +1134 -0
- package/src/dataTypes.ts +3 -0
- package/src/index.ts +2 -0
- package/src/localizeDataError.ts +593 -0
- package/src/schema.ts +89 -0
- package/src/sdkConstants.ts +2 -0
|
@@ -0,0 +1,1134 @@
|
|
|
1
|
+
// Triggers: rules on a table that run something when its rows change. The
|
|
2
|
+
// object keeps them with the data, and every write goes through it, so no
|
|
3
|
+
// change is missed:
|
|
4
|
+
// - each table has SQLite triggers that copy every changed row, old and new,
|
|
5
|
+
// into _ganju_changes, but only while one of the owner's triggers listens
|
|
6
|
+
// on that table and event (so tables nobody watches write nothing extra);
|
|
7
|
+
// - before the write's transaction commits, the changes are matched against
|
|
8
|
+
// the owner's triggers and the matches stored in _ganju_outbox, so a write
|
|
9
|
+
// that commits always has its events and one that rolls back never does;
|
|
10
|
+
// - the object's alarm hands the outbox to a queue, and the queue's consumer
|
|
11
|
+
// runs the action.
|
|
12
|
+
// Conditions are data, never code, evaluated here against the old and new
|
|
13
|
+
// row.
|
|
14
|
+
import { DATA_IDENTIFIER_PATTERN } from './dataIdentifiers';
|
|
15
|
+
import { fromStored } from './dataCompiler';
|
|
16
|
+
import { DATA_MAX_DESCRIPTION, DataError } from './dataTypes';
|
|
17
|
+
import type { DataCaller, DataSchema, DataTable } from './dataTypes';
|
|
18
|
+
import type { SqlRow, SqlRunner } from './dataEngine';
|
|
19
|
+
|
|
20
|
+
export const DATA_TRIGGER_EVENTS = ['insert', 'update', 'delete'] as const;
|
|
21
|
+
export type DataTriggerEvent = (typeof DATA_TRIGGER_EVENTS)[number];
|
|
22
|
+
|
|
23
|
+
export const DATA_TRIGGER_ACTIONS = [
|
|
24
|
+
'function',
|
|
25
|
+
'webhook',
|
|
26
|
+
'chat',
|
|
27
|
+
'email',
|
|
28
|
+
'customer'
|
|
29
|
+
] as const;
|
|
30
|
+
export type DataTriggerActionType = (typeof DATA_TRIGGER_ACTIONS)[number];
|
|
31
|
+
|
|
32
|
+
export const DATA_MAX_TRIGGERS = 50;
|
|
33
|
+
// A chain of triggers (a triggered function's write firing another trigger)
|
|
34
|
+
// stops at this depth with a visible error.
|
|
35
|
+
export const DATA_TRIGGER_MAX_DEPTH = 3;
|
|
36
|
+
// Runs a minute per trigger; past it, events wait in the queue.
|
|
37
|
+
export const DATA_TRIGGER_DEFAULT_RATE = 60;
|
|
38
|
+
export const DATA_TRIGGER_MAX_RATE = 600;
|
|
39
|
+
export const DATA_TRIGGER_MAX_TEXT = 2000;
|
|
40
|
+
export const DATA_TRIGGER_MAX_EMAILS = 10;
|
|
41
|
+
// Conditions nest `all` / `any` this deep, with this many tests in all.
|
|
42
|
+
const MAX_CONDITION_DEPTH = 4;
|
|
43
|
+
const MAX_CONDITION_TESTS = 20;
|
|
44
|
+
// Events handed to the queue per alarm step, and the most of a row an event
|
|
45
|
+
// carries: a queue message holds 128 KB, so a larger row is left out and the
|
|
46
|
+
// receiver reads it by id. Sizes are UTF-8 bytes, not characters.
|
|
47
|
+
export const DATA_OUTBOX_BATCH = 100;
|
|
48
|
+
export const DATA_TRIGGER_MAX_ROW_BYTES = 48_000;
|
|
49
|
+
export const DATA_TRIGGER_MAX_MESSAGE_BYTES = 120_000;
|
|
50
|
+
|
|
51
|
+
const encoder = new TextEncoder();
|
|
52
|
+
export const utf8Bytes = (value: unknown): number =>
|
|
53
|
+
encoder.encode(typeof value === 'string' ? value : JSON.stringify(value))
|
|
54
|
+
.length;
|
|
55
|
+
|
|
56
|
+
type Scalar = string | number | boolean | null;
|
|
57
|
+
|
|
58
|
+
// One column's test. Several keys on one column must all hold.
|
|
59
|
+
export interface DataTriggerTest {
|
|
60
|
+
changedTo?: Scalar;
|
|
61
|
+
changedFrom?: Scalar;
|
|
62
|
+
changed?: true;
|
|
63
|
+
equals?: Scalar;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// `{ status: { changedTo: "READY" } }`; several columns must all hold;
|
|
67
|
+
// `{ all: [...] }` and `{ any: [...] }` combine.
|
|
68
|
+
export type DataTriggerCondition =
|
|
69
|
+
| { all: DataTriggerCondition[] }
|
|
70
|
+
| { any: DataTriggerCondition[] }
|
|
71
|
+
| { [column: string]: DataTriggerTest };
|
|
72
|
+
|
|
73
|
+
export interface DataTriggerTemplate {
|
|
74
|
+
name: string;
|
|
75
|
+
language: string;
|
|
76
|
+
// Each {{n}} of the template, in order: a row field, or fixed text.
|
|
77
|
+
params?: string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type DataTriggerAction =
|
|
81
|
+
| { type: 'function'; function: string }
|
|
82
|
+
| { type: 'webhook'; url: string }
|
|
83
|
+
| { type: 'chat'; channelId: string; chatId: string; text: string }
|
|
84
|
+
| { type: 'email'; to: string[]; subject: string; text: string }
|
|
85
|
+
| { type: 'customer'; text: string; template?: DataTriggerTemplate };
|
|
86
|
+
|
|
87
|
+
// A trigger as the owner writes it: the dashboard, the API and ganju.json's
|
|
88
|
+
// "triggers" use this shape, with exactly one action key.
|
|
89
|
+
export interface DataTriggerInput {
|
|
90
|
+
name?: string;
|
|
91
|
+
description?: string | null;
|
|
92
|
+
table: string;
|
|
93
|
+
on: DataTriggerEvent;
|
|
94
|
+
when?: DataTriggerCondition | null;
|
|
95
|
+
enabled?: boolean;
|
|
96
|
+
// false: refuse to save over a trigger that already has this name.
|
|
97
|
+
replace?: boolean;
|
|
98
|
+
rateLimit?: number;
|
|
99
|
+
function?: string;
|
|
100
|
+
webhook?: string;
|
|
101
|
+
chat?: { channelId: string; chatId: string; text: string };
|
|
102
|
+
email?: { to: string[] | string; subject?: string; text: string };
|
|
103
|
+
customer?: { text: string; template?: DataTriggerTemplate };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface DataTrigger {
|
|
107
|
+
id: string;
|
|
108
|
+
name: string;
|
|
109
|
+
description: string | null;
|
|
110
|
+
table: string;
|
|
111
|
+
on: DataTriggerEvent;
|
|
112
|
+
when: DataTriggerCondition | null;
|
|
113
|
+
action: DataTriggerAction;
|
|
114
|
+
enabled: boolean;
|
|
115
|
+
// Why it is off, when Ganju turned it off (failures in a row).
|
|
116
|
+
disabledReason: string | null;
|
|
117
|
+
// Deployed from ganju.json: the dashboard shows it but doesn't edit it.
|
|
118
|
+
managed: 'cli' | null;
|
|
119
|
+
rateLimit: number;
|
|
120
|
+
createdAt: string;
|
|
121
|
+
updatedAt: string;
|
|
122
|
+
updatedBy: string | null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// What the consumer reads to run one: the trigger with its webhook secret.
|
|
126
|
+
export interface DataTriggerWithSecret extends DataTrigger {
|
|
127
|
+
secret: string | null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// One event, as the queue carries it. `row` and `previous` are the row after
|
|
131
|
+
// and before the change (null on insert / delete); both are left out, with
|
|
132
|
+
// `truncated`, when the row is too large for a message.
|
|
133
|
+
export interface DataTriggerEventMessage {
|
|
134
|
+
eventId: string;
|
|
135
|
+
artifactId: string;
|
|
136
|
+
triggerId: string;
|
|
137
|
+
triggerName: string;
|
|
138
|
+
table: string;
|
|
139
|
+
event: DataTriggerEvent;
|
|
140
|
+
rowId: number;
|
|
141
|
+
row: Record<string, unknown> | null;
|
|
142
|
+
previous: Record<string, unknown> | null;
|
|
143
|
+
actor: string | null;
|
|
144
|
+
// 1 for a change made by anyone but a triggered function; a triggered
|
|
145
|
+
// function's writes fire at its depth + 1.
|
|
146
|
+
depth: number;
|
|
147
|
+
at: string;
|
|
148
|
+
truncated?: true;
|
|
149
|
+
// Not run: the chain went too deep. Recorded as a failed run.
|
|
150
|
+
error?: string;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
154
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
155
|
+
|
|
156
|
+
const NAME_PATTERN = /^[a-z][a-z0-9_-]{0,62}$/;
|
|
157
|
+
const SYSTEM_FIELDS = [
|
|
158
|
+
'id',
|
|
159
|
+
'created_at',
|
|
160
|
+
'updated_at',
|
|
161
|
+
'version',
|
|
162
|
+
'created_by',
|
|
163
|
+
'updated_by',
|
|
164
|
+
'participant_id'
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
const isScalar = (value: unknown): value is Scalar =>
|
|
168
|
+
value === null ||
|
|
169
|
+
typeof value === 'string' ||
|
|
170
|
+
typeof value === 'boolean' ||
|
|
171
|
+
(typeof value === 'number' && Number.isFinite(value));
|
|
172
|
+
|
|
173
|
+
// Conditions
|
|
174
|
+
|
|
175
|
+
const TEST_KEYS = ['changedTo', 'changedFrom', 'changed', 'equals'] as const;
|
|
176
|
+
|
|
177
|
+
const normaliseCondition = (
|
|
178
|
+
table: DataTable,
|
|
179
|
+
raw: unknown,
|
|
180
|
+
depth = 0,
|
|
181
|
+
count = { tests: 0 }
|
|
182
|
+
): DataTriggerCondition => {
|
|
183
|
+
if (!isPlainObject(raw) || Object.keys(raw).length === 0) {
|
|
184
|
+
throw new DataError(
|
|
185
|
+
'"when" is an object such as { "status": { "changedTo": "READY" } }.'
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if (depth >= MAX_CONDITION_DEPTH) {
|
|
189
|
+
throw new DataError(
|
|
190
|
+
`"when" nests all / any at most ${MAX_CONDITION_DEPTH} deep.`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if ('all' in raw || 'any' in raw) {
|
|
194
|
+
const key = 'all' in raw ? 'all' : 'any';
|
|
195
|
+
if (
|
|
196
|
+
Object.keys(raw).length !== 1 ||
|
|
197
|
+
!Array.isArray(raw[key]) ||
|
|
198
|
+
raw[key].length === 0
|
|
199
|
+
) {
|
|
200
|
+
throw new DataError(
|
|
201
|
+
`"${key}" is a non-empty list of conditions, alone in its object.`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
const list = (raw[key] as unknown[]).map(item =>
|
|
205
|
+
normaliseCondition(table, item, depth + 1, count)
|
|
206
|
+
);
|
|
207
|
+
return key === 'all' ? { all: list } : { any: list };
|
|
208
|
+
}
|
|
209
|
+
const out: Record<string, DataTriggerTest> = {};
|
|
210
|
+
for (const [column, test] of Object.entries(raw)) {
|
|
211
|
+
if (
|
|
212
|
+
!table.columns.some(c => c.name === column) &&
|
|
213
|
+
!SYSTEM_FIELDS.includes(column)
|
|
214
|
+
) {
|
|
215
|
+
throw new DataError(
|
|
216
|
+
`"when" names ${table.name}.${column}, which doesn't exist.`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
if (!isPlainObject(test) || Object.keys(test).length === 0) {
|
|
220
|
+
throw new DataError(
|
|
221
|
+
`"when".${column} is a test: changedTo, changedFrom, changed or equals.`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
const clean: DataTriggerTest = {};
|
|
225
|
+
for (const [key, value] of Object.entries(test)) {
|
|
226
|
+
if (!(TEST_KEYS as readonly string[]).includes(key)) {
|
|
227
|
+
throw new DataError(
|
|
228
|
+
`"when".${column}.${key}: a test is changedTo, changedFrom, changed or equals.`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (key === 'changed') {
|
|
232
|
+
if (value !== true)
|
|
233
|
+
throw new DataError(`"when".${column}.changed is true.`);
|
|
234
|
+
clean.changed = true;
|
|
235
|
+
} else {
|
|
236
|
+
if (!isScalar(value)) {
|
|
237
|
+
throw new DataError(
|
|
238
|
+
`"when".${column}.${key} is a text, number, true / false or null.`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
clean[key as 'changedTo' | 'changedFrom' | 'equals'] = value;
|
|
242
|
+
}
|
|
243
|
+
if (++count.tests > MAX_CONDITION_TESTS) {
|
|
244
|
+
throw new DataError(
|
|
245
|
+
`"when" holds at most ${MAX_CONDITION_TESTS} tests.`
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
out[column] = clean;
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// Values compare as JSON, so 3 and 3.0 match and "3" doesn't.
|
|
255
|
+
const same = (a: unknown, b: unknown) =>
|
|
256
|
+
JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Whether a change matches a condition. A missing side counts as all null:
|
|
260
|
+
* an insert "changed to" the values it was created with, and a delete
|
|
261
|
+
* "changed from" the values it had. `equals` tests the row after the change,
|
|
262
|
+
* or the deleted row.
|
|
263
|
+
*/
|
|
264
|
+
export const conditionMatches = (
|
|
265
|
+
when: DataTriggerCondition | null | undefined,
|
|
266
|
+
row: Record<string, unknown> | null,
|
|
267
|
+
previous: Record<string, unknown> | null
|
|
268
|
+
): boolean => {
|
|
269
|
+
if (!when) return true;
|
|
270
|
+
if ('all' in when && Array.isArray(when.all)) {
|
|
271
|
+
return (when.all as DataTriggerCondition[]).every(c =>
|
|
272
|
+
conditionMatches(c, row, previous)
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if ('any' in when && Array.isArray(when.any)) {
|
|
276
|
+
return (when.any as DataTriggerCondition[]).some(c =>
|
|
277
|
+
conditionMatches(c, row, previous)
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
const after = row ?? {};
|
|
281
|
+
const before = previous ?? {};
|
|
282
|
+
const current = row ?? previous ?? {};
|
|
283
|
+
return Object.entries(when as Record<string, DataTriggerTest>).every(
|
|
284
|
+
([column, test]) => {
|
|
285
|
+
const now = after[column] ?? null;
|
|
286
|
+
const then = before[column] ?? null;
|
|
287
|
+
if (test.changed && same(now, then)) return false;
|
|
288
|
+
if (
|
|
289
|
+
'changedTo' in test &&
|
|
290
|
+
!(same(now, test.changedTo) && !same(then, test.changedTo))
|
|
291
|
+
) {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
if (
|
|
295
|
+
'changedFrom' in test &&
|
|
296
|
+
!(same(then, test.changedFrom) && !same(now, test.changedFrom))
|
|
297
|
+
) {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
if ('equals' in test && !same(current[column] ?? null, test.equals))
|
|
301
|
+
return false;
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// Templates
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* A message built from a row: each {{field}} becomes that field's value as
|
|
311
|
+
* plain text. Never markup: the row is customer-written content, and the
|
|
312
|
+
* senders send it as text.
|
|
313
|
+
*/
|
|
314
|
+
export const renderTriggerText = (
|
|
315
|
+
text: string,
|
|
316
|
+
row: Record<string, unknown> | null
|
|
317
|
+
): string =>
|
|
318
|
+
text.replace(/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/g, (_, field: string) => {
|
|
319
|
+
const value = row?.[field];
|
|
320
|
+
if (value === null || value === undefined) return '';
|
|
321
|
+
return typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const templateFields = (text: string) =>
|
|
325
|
+
[...text.matchAll(/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/g)].map(m => m[1]);
|
|
326
|
+
|
|
327
|
+
// Definitions
|
|
328
|
+
|
|
329
|
+
const cleanText = (
|
|
330
|
+
value: unknown,
|
|
331
|
+
label: string,
|
|
332
|
+
max = DATA_TRIGGER_MAX_TEXT
|
|
333
|
+
) => {
|
|
334
|
+
if (typeof value !== 'string' || !value.trim())
|
|
335
|
+
throw new DataError(`${label} is required.`);
|
|
336
|
+
if (value.length > max)
|
|
337
|
+
throw new DataError(`${label} is at most ${max} characters.`);
|
|
338
|
+
return value.trim();
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const assertFields = (table: DataTable, text: string, label: string) => {
|
|
342
|
+
for (const field of templateFields(text)) {
|
|
343
|
+
if (
|
|
344
|
+
!table.columns.some(c => c.name === field) &&
|
|
345
|
+
!SYSTEM_FIELDS.includes(field)
|
|
346
|
+
) {
|
|
347
|
+
throw new DataError(
|
|
348
|
+
`${label} uses {{${field}}}, which isn't a column of ${table.name}.`
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const normaliseAction = (
|
|
355
|
+
table: DataTable,
|
|
356
|
+
input: Record<string, unknown>
|
|
357
|
+
): DataTriggerAction => {
|
|
358
|
+
const given = DATA_TRIGGER_ACTIONS.filter(
|
|
359
|
+
key => input[key] !== undefined && input[key] !== null
|
|
360
|
+
);
|
|
361
|
+
if (given.length !== 1) {
|
|
362
|
+
throw new DataError(
|
|
363
|
+
'A trigger runs one thing: "function", "webhook", "chat", "email" or "customer".'
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
const type = given[0];
|
|
367
|
+
const value = input[type];
|
|
368
|
+
switch (type) {
|
|
369
|
+
case 'function':
|
|
370
|
+
if (typeof value !== 'string' || !/^[a-zA-Z0-9_-]{1,64}$/.test(value)) {
|
|
371
|
+
throw new DataError(
|
|
372
|
+
'"function" is the name of one of this project\'s functions.'
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
return { type, function: value };
|
|
376
|
+
case 'webhook': {
|
|
377
|
+
if (typeof value !== 'string' || value.length > 2000) {
|
|
378
|
+
throw new DataError('"webhook" is an https URL.');
|
|
379
|
+
}
|
|
380
|
+
let url: URL;
|
|
381
|
+
try {
|
|
382
|
+
url = new URL(value);
|
|
383
|
+
} catch {
|
|
384
|
+
throw new DataError('"webhook" is an https URL.');
|
|
385
|
+
}
|
|
386
|
+
if (url.protocol !== 'https:')
|
|
387
|
+
throw new DataError('"webhook" must use https.');
|
|
388
|
+
return { type, url: url.toString() };
|
|
389
|
+
}
|
|
390
|
+
case 'chat': {
|
|
391
|
+
if (!isPlainObject(value)) {
|
|
392
|
+
throw new DataError('"chat" is { "channelId", "chatId", "text" }.');
|
|
393
|
+
}
|
|
394
|
+
const text = cleanText(value.text, '"chat.text"');
|
|
395
|
+
assertFields(table, text, '"chat.text"');
|
|
396
|
+
return {
|
|
397
|
+
type,
|
|
398
|
+
channelId: cleanText(value.channelId, '"chat.channelId"', 100),
|
|
399
|
+
chatId: cleanText(String(value.chatId ?? ''), '"chat.chatId"', 200),
|
|
400
|
+
text
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
case 'email': {
|
|
404
|
+
if (!isPlainObject(value))
|
|
405
|
+
throw new DataError('"email" is { "to", "subject", "text" }.');
|
|
406
|
+
const to = (Array.isArray(value.to) ? value.to : [value.to]).map(v =>
|
|
407
|
+
typeof v === 'string' ? v.trim().toLowerCase() : ''
|
|
408
|
+
);
|
|
409
|
+
if (
|
|
410
|
+
to.length === 0 ||
|
|
411
|
+
to.length > DATA_TRIGGER_MAX_EMAILS ||
|
|
412
|
+
to.some(v => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v))
|
|
413
|
+
) {
|
|
414
|
+
throw new DataError(
|
|
415
|
+
`"email.to" is 1 to ${DATA_TRIGGER_MAX_EMAILS} email addresses.`
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
const text = cleanText(value.text, '"email.text"');
|
|
419
|
+
const subject =
|
|
420
|
+
value.subject === undefined || value.subject === null
|
|
421
|
+
? `${table.name}: {{id}}`
|
|
422
|
+
: cleanText(value.subject, '"email.subject"', 200);
|
|
423
|
+
assertFields(table, text, '"email.text"');
|
|
424
|
+
assertFields(table, subject, '"email.subject"');
|
|
425
|
+
return { type, to: [...new Set(to)], subject, text };
|
|
426
|
+
}
|
|
427
|
+
case 'customer': {
|
|
428
|
+
if (!table.customerScoped) {
|
|
429
|
+
throw new DataError(
|
|
430
|
+
`"customer" messages the row's customer, so ${table.name} must be customer-scoped.`
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
if (!isPlainObject(value))
|
|
434
|
+
throw new DataError('"customer" is { "text", "template"? }.');
|
|
435
|
+
const text = cleanText(value.text, '"customer.text"');
|
|
436
|
+
assertFields(table, text, '"customer.text"');
|
|
437
|
+
if (value.template === undefined || value.template === null)
|
|
438
|
+
return { type, text };
|
|
439
|
+
const t = value.template;
|
|
440
|
+
if (!isPlainObject(t)) {
|
|
441
|
+
throw new DataError(
|
|
442
|
+
'"customer.template" is { "name", "language", "params"? }.'
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
const params = t.params === undefined ? [] : t.params;
|
|
446
|
+
if (
|
|
447
|
+
!Array.isArray(params) ||
|
|
448
|
+
params.length > 20 ||
|
|
449
|
+
!params.every(p => typeof p === 'string')
|
|
450
|
+
) {
|
|
451
|
+
throw new DataError('"customer.template.params" is a list of texts.');
|
|
452
|
+
}
|
|
453
|
+
for (const param of params as string[])
|
|
454
|
+
assertFields(table, param, '"customer.template.params"');
|
|
455
|
+
return {
|
|
456
|
+
type,
|
|
457
|
+
text,
|
|
458
|
+
template: {
|
|
459
|
+
name: cleanText(t.name, '"customer.template.name"', 512),
|
|
460
|
+
language: cleanText(t.language, '"customer.template.language"', 20),
|
|
461
|
+
params: params as string[]
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const defaultName = (table: string, on: string, action: DataTriggerAction) => {
|
|
469
|
+
const target =
|
|
470
|
+
action.type === 'function'
|
|
471
|
+
? action.function.toLowerCase().replace(/[^a-z0-9_-]/g, '-')
|
|
472
|
+
: action.type;
|
|
473
|
+
return `${table}-${on}-${target}`.slice(0, 63);
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
interface NormalisedTrigger {
|
|
477
|
+
name: string;
|
|
478
|
+
// No name was given: saving picks a free one from it.
|
|
479
|
+
defaultName: boolean;
|
|
480
|
+
description: string | null;
|
|
481
|
+
table: DataTable;
|
|
482
|
+
on: DataTriggerEvent;
|
|
483
|
+
when: DataTriggerCondition | null;
|
|
484
|
+
action: DataTriggerAction;
|
|
485
|
+
// null: not given, so a saved trigger stays on or off as it was.
|
|
486
|
+
enabled: boolean | null;
|
|
487
|
+
replace: boolean;
|
|
488
|
+
rateLimit: number;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export const normaliseTrigger = (
|
|
492
|
+
schema: DataSchema,
|
|
493
|
+
input: unknown
|
|
494
|
+
): NormalisedTrigger => {
|
|
495
|
+
if (!isPlainObject(input)) throw new DataError('A trigger is an object.');
|
|
496
|
+
const table = schema.tables.find(t => t.name === input.table);
|
|
497
|
+
if (!table) {
|
|
498
|
+
throw new DataError(
|
|
499
|
+
typeof input.table === 'string'
|
|
500
|
+
? `There is no table named ${input.table}.`
|
|
501
|
+
: 'A trigger needs "table".',
|
|
502
|
+
typeof input.table === 'string' ? 'not_found' : 'invalid'
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
const on = input.on;
|
|
506
|
+
if (!(DATA_TRIGGER_EVENTS as readonly unknown[]).includes(on)) {
|
|
507
|
+
throw new DataError('"on" is insert, update or delete.');
|
|
508
|
+
}
|
|
509
|
+
const action = normaliseAction(table, input);
|
|
510
|
+
const unnamed = input.name === undefined || input.name === null;
|
|
511
|
+
const name = unnamed
|
|
512
|
+
? defaultName(table.name, on as string, action)
|
|
513
|
+
: input.name;
|
|
514
|
+
if (typeof name !== 'string' || !NAME_PATTERN.test(name)) {
|
|
515
|
+
throw new DataError(
|
|
516
|
+
"A trigger's name is lowercase letters, digits, _ and -, starting with a letter."
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
const rate = input.rateLimit ?? DATA_TRIGGER_DEFAULT_RATE;
|
|
520
|
+
if (
|
|
521
|
+
typeof rate !== 'number' ||
|
|
522
|
+
!Number.isInteger(rate) ||
|
|
523
|
+
rate < 1 ||
|
|
524
|
+
rate > DATA_TRIGGER_MAX_RATE
|
|
525
|
+
) {
|
|
526
|
+
throw new DataError(
|
|
527
|
+
`"rateLimit" is 1 to ${DATA_TRIGGER_MAX_RATE} runs a minute.`
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
if (input.enabled !== undefined && typeof input.enabled !== 'boolean') {
|
|
531
|
+
throw new DataError('"enabled" is true or false.');
|
|
532
|
+
}
|
|
533
|
+
if (input.replace !== undefined && typeof input.replace !== 'boolean') {
|
|
534
|
+
throw new DataError('"replace" is true or false.');
|
|
535
|
+
}
|
|
536
|
+
return {
|
|
537
|
+
name,
|
|
538
|
+
defaultName: unnamed,
|
|
539
|
+
description:
|
|
540
|
+
typeof input.description === 'string' && input.description.trim()
|
|
541
|
+
? input.description.trim().slice(0, DATA_MAX_DESCRIPTION)
|
|
542
|
+
: null,
|
|
543
|
+
table,
|
|
544
|
+
on: on as DataTriggerEvent,
|
|
545
|
+
when:
|
|
546
|
+
input.when === undefined || input.when === null
|
|
547
|
+
? null
|
|
548
|
+
: normaliseCondition(table, input.when),
|
|
549
|
+
action,
|
|
550
|
+
enabled: typeof input.enabled === 'boolean' ? input.enabled : null,
|
|
551
|
+
replace: input.replace !== false,
|
|
552
|
+
rateLimit: rate
|
|
553
|
+
};
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
// The default name, numbered past the names already taken: two unnamed
|
|
557
|
+
// triggers on the same event never replace each other.
|
|
558
|
+
const freeName = (runner: SqlRunner, base: string) => {
|
|
559
|
+
const taken = new Set(
|
|
560
|
+
runner.all('SELECT name FROM _ganju_triggers').map(row => String(row.name))
|
|
561
|
+
);
|
|
562
|
+
if (!taken.has(base)) return base;
|
|
563
|
+
for (let n = 2; ; n++) {
|
|
564
|
+
const suffix = `-${n}`;
|
|
565
|
+
const name = `${base.slice(0, 63 - suffix.length)}${suffix}`;
|
|
566
|
+
if (!taken.has(name)) return name;
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
const parse = <T>(value: unknown): T | null => {
|
|
571
|
+
if (typeof value !== 'string') return null;
|
|
572
|
+
try {
|
|
573
|
+
return JSON.parse(value) as T;
|
|
574
|
+
} catch {
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
const toTrigger = (schema: DataSchema, row: SqlRow): DataTrigger => ({
|
|
580
|
+
id: String(row.id),
|
|
581
|
+
name: String(row.name),
|
|
582
|
+
description: (row.description as string | null) ?? null,
|
|
583
|
+
table: schema.tables.find(t => t.id === Number(row.table_id))?.name ?? '',
|
|
584
|
+
on: row.event as DataTriggerEvent,
|
|
585
|
+
when: parse<DataTriggerCondition>(row.condition),
|
|
586
|
+
action: parse<DataTriggerAction>(row.action) as DataTriggerAction,
|
|
587
|
+
enabled: row.enabled === 1,
|
|
588
|
+
disabledReason: (row.disabled_reason as string | null) ?? null,
|
|
589
|
+
managed: row.managed === 'cli' ? 'cli' : null,
|
|
590
|
+
rateLimit: Number(row.rate_limit),
|
|
591
|
+
createdAt: String(row.created_at),
|
|
592
|
+
updatedAt: String(row.updated_at),
|
|
593
|
+
updatedBy: (row.updated_by as string | null) ?? null
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
export const listTriggers = (
|
|
597
|
+
runner: SqlRunner,
|
|
598
|
+
schema: DataSchema
|
|
599
|
+
): DataTrigger[] =>
|
|
600
|
+
runner
|
|
601
|
+
.all('SELECT * FROM _ganju_triggers ORDER BY name')
|
|
602
|
+
.map(row => toTrigger(schema, row));
|
|
603
|
+
|
|
604
|
+
const findRow = (runner: SqlRunner, nameOrId: unknown): SqlRow => {
|
|
605
|
+
const row = runner.all(
|
|
606
|
+
'SELECT * FROM _ganju_triggers WHERE name = ? OR id = ?',
|
|
607
|
+
[String(nameOrId), String(nameOrId)]
|
|
608
|
+
)[0];
|
|
609
|
+
if (!row)
|
|
610
|
+
throw new DataError(
|
|
611
|
+
`There is no trigger named ${String(nameOrId)}.`,
|
|
612
|
+
'not_found'
|
|
613
|
+
);
|
|
614
|
+
return row;
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
export const getTrigger = (
|
|
618
|
+
runner: SqlRunner,
|
|
619
|
+
schema: DataSchema,
|
|
620
|
+
nameOrId: unknown
|
|
621
|
+
): DataTriggerWithSecret => {
|
|
622
|
+
const row = findRow(runner, nameOrId);
|
|
623
|
+
return {
|
|
624
|
+
...toTrigger(schema, row),
|
|
625
|
+
secret: (row.secret as string | null) ?? null
|
|
626
|
+
};
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
const newSecret = () => {
|
|
630
|
+
const bytes = new Uint8Array(24);
|
|
631
|
+
crypto.getRandomValues(bytes);
|
|
632
|
+
return `whsec_${Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')}`;
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
export interface SavedTrigger {
|
|
636
|
+
trigger: DataTrigger;
|
|
637
|
+
// A webhook's signing secret, the one time it is made: shown once.
|
|
638
|
+
secret?: string;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const upsert = (
|
|
642
|
+
runner: SqlRunner,
|
|
643
|
+
schema: DataSchema,
|
|
644
|
+
caller: DataCaller,
|
|
645
|
+
trigger: NormalisedTrigger,
|
|
646
|
+
managed: 'cli' | null
|
|
647
|
+
): SavedTrigger => {
|
|
648
|
+
const existing = runner.all('SELECT * FROM _ganju_triggers WHERE name = ?', [
|
|
649
|
+
trigger.name
|
|
650
|
+
])[0];
|
|
651
|
+
if (existing && !trigger.replace) {
|
|
652
|
+
throw new DataError(
|
|
653
|
+
`A trigger named ${trigger.name} already exists; give this one another name.`,
|
|
654
|
+
'conflict'
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
if (existing && (existing.managed === 'cli') !== (managed === 'cli')) {
|
|
658
|
+
throw new DataError(
|
|
659
|
+
existing.managed === 'cli'
|
|
660
|
+
? `${trigger.name} is deployed from ganju.json; change it there.`
|
|
661
|
+
: `A trigger named ${trigger.name} was made in the dashboard; give this one another name.`,
|
|
662
|
+
'conflict'
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
if (!existing) {
|
|
666
|
+
const count = Number(
|
|
667
|
+
runner.all('SELECT count(*) AS n FROM _ganju_triggers')[0]?.n ?? 0
|
|
668
|
+
);
|
|
669
|
+
if (count >= DATA_MAX_TRIGGERS) {
|
|
670
|
+
throw new DataError(
|
|
671
|
+
`A database holds up to ${DATA_MAX_TRIGGERS} triggers.`
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
// A webhook keeps its secret while it stays a webhook.
|
|
676
|
+
const keep =
|
|
677
|
+
existing?.secret && trigger.action.type === 'webhook'
|
|
678
|
+
? String(existing.secret)
|
|
679
|
+
: null;
|
|
680
|
+
const secret =
|
|
681
|
+
trigger.action.type === 'webhook' ? (keep ?? newSecret()) : null;
|
|
682
|
+
const id = existing
|
|
683
|
+
? String(existing.id)
|
|
684
|
+
: `trg_${crypto.randomUUID().replace(/-/g, '')}`;
|
|
685
|
+
// Left out, "enabled" keeps the trigger as it was, and why Ganju turned it
|
|
686
|
+
// off: a deploy doesn't turn back on what the dashboard or Ganju turned off.
|
|
687
|
+
const enabled = trigger.enabled ?? (existing ? existing.enabled === 1 : true);
|
|
688
|
+
const reason = enabled
|
|
689
|
+
? null
|
|
690
|
+
: ((existing?.disabled_reason as string | null | undefined) ?? null);
|
|
691
|
+
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'), ?)
|
|
694
|
+
ON CONFLICT(id) DO UPDATE SET name = excluded.name, description = excluded.description,
|
|
695
|
+
table_id = excluded.table_id, event = excluded.event, condition = excluded.condition,
|
|
696
|
+
action = excluded.action, enabled = excluded.enabled, disabled_reason = excluded.disabled_reason,
|
|
697
|
+
managed = excluded.managed, rate_limit = excluded.rate_limit, secret = excluded.secret,
|
|
698
|
+
updated_at = excluded.updated_at, updated_by = excluded.updated_by`,
|
|
699
|
+
[
|
|
700
|
+
id,
|
|
701
|
+
trigger.name,
|
|
702
|
+
trigger.description,
|
|
703
|
+
trigger.table.id,
|
|
704
|
+
trigger.on,
|
|
705
|
+
trigger.when ? JSON.stringify(trigger.when) : null,
|
|
706
|
+
JSON.stringify(trigger.action),
|
|
707
|
+
enabled ? 1 : 0,
|
|
708
|
+
reason,
|
|
709
|
+
managed,
|
|
710
|
+
trigger.rateLimit,
|
|
711
|
+
secret,
|
|
712
|
+
caller.actor
|
|
713
|
+
]
|
|
714
|
+
);
|
|
715
|
+
ensureChangeCapture(runner, trigger.table);
|
|
716
|
+
const saved = toTrigger(schema, findRow(runner, id));
|
|
717
|
+
return secret && secret !== keep
|
|
718
|
+
? { trigger: saved, secret }
|
|
719
|
+
: { trigger: saved };
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
export const saveTrigger = (
|
|
723
|
+
runner: SqlRunner,
|
|
724
|
+
schema: DataSchema,
|
|
725
|
+
caller: DataCaller,
|
|
726
|
+
input: unknown
|
|
727
|
+
): SavedTrigger =>
|
|
728
|
+
runner.transaction(() => {
|
|
729
|
+
const trigger = normaliseTrigger(schema, input);
|
|
730
|
+
if (trigger.defaultName) trigger.name = freeName(runner, trigger.name);
|
|
731
|
+
return upsert(runner, schema, caller, trigger, null);
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
export const dropTrigger = (
|
|
735
|
+
runner: SqlRunner,
|
|
736
|
+
nameOrId: unknown
|
|
737
|
+
): { dropped: string } => {
|
|
738
|
+
const row = findRow(runner, nameOrId);
|
|
739
|
+
runner.all('DELETE FROM _ganju_triggers WHERE id = ?', [row.id]);
|
|
740
|
+
return { dropped: String(row.name) };
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
// On / off, by the owner or by Ganju after failures in a row (with why).
|
|
744
|
+
export const setTriggerEnabled = (
|
|
745
|
+
runner: SqlRunner,
|
|
746
|
+
schema: DataSchema,
|
|
747
|
+
caller: DataCaller,
|
|
748
|
+
nameOrId: unknown,
|
|
749
|
+
enabled: boolean,
|
|
750
|
+
reason: string | null = null
|
|
751
|
+
): DataTrigger => {
|
|
752
|
+
const row = findRow(runner, nameOrId);
|
|
753
|
+
runner.all(
|
|
754
|
+
`UPDATE _ganju_triggers SET enabled = ?, disabled_reason = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), updated_by = ? WHERE id = ?`,
|
|
755
|
+
[enabled ? 1 : 0, enabled ? null : reason, caller.actor, row.id]
|
|
756
|
+
);
|
|
757
|
+
if (enabled) {
|
|
758
|
+
const table = schema.tables.find(t => t.id === Number(row.table_id));
|
|
759
|
+
if (table) ensureChangeCapture(runner, table);
|
|
760
|
+
}
|
|
761
|
+
return toTrigger(schema, findRow(runner, row.id));
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* The triggers ganju.json deploys: each one saved, and every other trigger
|
|
766
|
+
* the CLI deployed before removed. Those made in the dashboard stay. One
|
|
767
|
+
* transaction, so a bad entry changes nothing.
|
|
768
|
+
*/
|
|
769
|
+
export const replaceManagedTriggers = (
|
|
770
|
+
runner: SqlRunner,
|
|
771
|
+
schema: DataSchema,
|
|
772
|
+
caller: DataCaller,
|
|
773
|
+
inputs: unknown
|
|
774
|
+
): {
|
|
775
|
+
triggers: DataTrigger[];
|
|
776
|
+
removed: string[];
|
|
777
|
+
secrets: Record<string, string>;
|
|
778
|
+
} => {
|
|
779
|
+
if (!Array.isArray(inputs) || inputs.length > DATA_MAX_TRIGGERS) {
|
|
780
|
+
throw new DataError(
|
|
781
|
+
`"triggers" is a list of up to ${DATA_MAX_TRIGGERS} triggers.`
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
return runner.transaction(() => {
|
|
785
|
+
const normalised = inputs.map((input, i) => {
|
|
786
|
+
try {
|
|
787
|
+
return normaliseTrigger(schema, input);
|
|
788
|
+
} catch (error) {
|
|
789
|
+
if (error instanceof DataError) {
|
|
790
|
+
throw new DataError(
|
|
791
|
+
`Trigger ${i}: ${error.message}`,
|
|
792
|
+
error.code,
|
|
793
|
+
error.details
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
throw error;
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
const names = new Set<string>();
|
|
800
|
+
for (const trigger of normalised) {
|
|
801
|
+
if (names.has(trigger.name)) {
|
|
802
|
+
throw new DataError(
|
|
803
|
+
`Two triggers are named ${trigger.name}; give each a "name" of its own.`
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
names.add(trigger.name);
|
|
807
|
+
}
|
|
808
|
+
const removed: string[] = [];
|
|
809
|
+
for (const row of runner.all(
|
|
810
|
+
"SELECT id, name FROM _ganju_triggers WHERE managed = 'cli'"
|
|
811
|
+
)) {
|
|
812
|
+
if (names.has(String(row.name))) continue;
|
|
813
|
+
runner.all('DELETE FROM _ganju_triggers WHERE id = ?', [row.id]);
|
|
814
|
+
removed.push(String(row.name));
|
|
815
|
+
}
|
|
816
|
+
const secrets: Record<string, string> = {};
|
|
817
|
+
const triggers = normalised.map(trigger => {
|
|
818
|
+
const saved = upsert(runner, schema, caller, trigger, 'cli');
|
|
819
|
+
if (saved.secret) secrets[saved.trigger.name] = saved.secret;
|
|
820
|
+
return saved.trigger;
|
|
821
|
+
});
|
|
822
|
+
return { triggers, removed, secrets };
|
|
823
|
+
});
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
// Change capture
|
|
827
|
+
|
|
828
|
+
const jsonRowSql = (table: DataTable, side: 'new' | 'old') => {
|
|
829
|
+
const fields = [...SYSTEM_FIELDS, ...table.columns.map(c => c.name)];
|
|
830
|
+
// json_set takes its arguments in pairs, and SQLite caps a function at 127
|
|
831
|
+
// arguments, so wide tables are built in pieces.
|
|
832
|
+
let out = "'{}'";
|
|
833
|
+
for (let i = 0; i < fields.length; i += 50) {
|
|
834
|
+
const pairs = fields
|
|
835
|
+
.slice(i, i + 50)
|
|
836
|
+
.map(f => `'$."${f}"', ${side}."${f}"`)
|
|
837
|
+
.join(', ');
|
|
838
|
+
out = `json_set(${out}, ${pairs})`;
|
|
839
|
+
}
|
|
840
|
+
return out;
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* The SQLite triggers that copy a table's changed rows into _ganju_changes.
|
|
845
|
+
* Each runs only while an enabled trigger of the owner's listens for that
|
|
846
|
+
* event on the table. Named like the table's other triggers, so they are
|
|
847
|
+
* dropped and remade with them when its columns change.
|
|
848
|
+
*/
|
|
849
|
+
export const changeCaptureTriggers = (
|
|
850
|
+
table: Pick<DataTable, 'id' | 'name' | 'columns'>
|
|
851
|
+
) => {
|
|
852
|
+
const t = `"${table.name}"`;
|
|
853
|
+
const prefix = `tr_${table.id}_`;
|
|
854
|
+
const listening = (event: DataTriggerEvent) =>
|
|
855
|
+
`EXISTS (SELECT 1 FROM _ganju_triggers WHERE table_id = ${table.id} AND event = '${event}' AND enabled = 1)`;
|
|
856
|
+
const capture = (
|
|
857
|
+
event: DataTriggerEvent,
|
|
858
|
+
rowId: string,
|
|
859
|
+
oldJson: string,
|
|
860
|
+
newJson: string
|
|
861
|
+
) =>
|
|
862
|
+
`INSERT INTO _ganju_changes (table_id, event, row_id, actor, old, new) VALUES (${table.id}, '${event}', ${rowId}, (SELECT actor FROM _ganju_context WHERE id = 1), ${oldJson}, ${newJson})`;
|
|
863
|
+
const full = table as DataTable;
|
|
864
|
+
return [
|
|
865
|
+
{
|
|
866
|
+
name: `${prefix}ev_ai`,
|
|
867
|
+
sql: `CREATE TRIGGER IF NOT EXISTS "${prefix}ev_ai" AFTER INSERT ON ${t} WHEN ${listening('insert')} BEGIN ${capture('insert', 'new.id', 'NULL', jsonRowSql(full, 'new'))}; END`
|
|
868
|
+
},
|
|
869
|
+
{
|
|
870
|
+
name: `${prefix}ev_au`,
|
|
871
|
+
sql: `CREATE TRIGGER IF NOT EXISTS "${prefix}ev_au" AFTER UPDATE ON ${t} WHEN ${listening('update')} BEGIN ${capture('update', 'new.id', jsonRowSql(full, 'old'), jsonRowSql(full, 'new'))}; END`
|
|
872
|
+
},
|
|
873
|
+
{
|
|
874
|
+
name: `${prefix}ev_ad`,
|
|
875
|
+
sql: `CREATE TRIGGER IF NOT EXISTS "${prefix}ev_ad" AFTER DELETE ON ${t} WHEN ${listening('delete')} BEGIN ${capture('delete', 'old.id', jsonRowSql(full, 'old'), 'NULL')}; END`
|
|
876
|
+
}
|
|
877
|
+
];
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
// Tables made before triggers existed have no capture triggers until their
|
|
881
|
+
// columns next change; the first trigger on one adds them.
|
|
882
|
+
export const ensureChangeCapture = (runner: SqlRunner, table: DataTable) => {
|
|
883
|
+
for (const trigger of changeCaptureTriggers(table)) runner.all(trigger.sql);
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
const shape = (
|
|
887
|
+
table: DataTable,
|
|
888
|
+
raw: unknown
|
|
889
|
+
): Record<string, unknown> | null => {
|
|
890
|
+
const stored = parse<Record<string, unknown>>(raw);
|
|
891
|
+
if (!stored) return null;
|
|
892
|
+
const out: Record<string, unknown> = {};
|
|
893
|
+
for (const field of SYSTEM_FIELDS) out[field] = stored[field] ?? null;
|
|
894
|
+
for (const column of table.columns) {
|
|
895
|
+
out[column.name] = fromStored(column, stored[column.name] ?? null);
|
|
896
|
+
}
|
|
897
|
+
if (!table.customerScoped) delete out.participant_id;
|
|
898
|
+
return out;
|
|
899
|
+
};
|
|
900
|
+
|
|
901
|
+
const DEPTH_PATTERN = /^trigger:([^\s]+)$/;
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Turns the changes a write captured into events, inside the write's
|
|
905
|
+
* transaction. A trigger never matches the writes of a run it started
|
|
906
|
+
* (actor `trigger:<id>`), and a chain stops at DATA_TRIGGER_MAX_DEPTH with
|
|
907
|
+
* an event that only records why. Returns how many events were stored.
|
|
908
|
+
*/
|
|
909
|
+
export const collectTriggerEvents = (
|
|
910
|
+
runner: SqlRunner,
|
|
911
|
+
schema: DataSchema,
|
|
912
|
+
caller: DataCaller
|
|
913
|
+
): number => {
|
|
914
|
+
const changes = runner.all('SELECT * FROM _ganju_changes ORDER BY id');
|
|
915
|
+
if (changes.length === 0) return 0;
|
|
916
|
+
runner.all('DELETE FROM _ganju_changes');
|
|
917
|
+
const artifactId =
|
|
918
|
+
(runner.all("SELECT value FROM _ganju_meta WHERE key = 'artifact_id'")[0]
|
|
919
|
+
?.value as string | undefined) ?? null;
|
|
920
|
+
const triggers = runner
|
|
921
|
+
.all('SELECT * FROM _ganju_triggers WHERE enabled = 1')
|
|
922
|
+
.map(row => toTrigger(schema, row));
|
|
923
|
+
const ownTrigger = DEPTH_PATTERN.exec(caller.actor)?.[1] ?? null;
|
|
924
|
+
const depth = (caller.triggerDepth ?? 0) + 1;
|
|
925
|
+
let stored = 0;
|
|
926
|
+
for (const change of changes) {
|
|
927
|
+
const table = schema.tables.find(t => t.id === Number(change.table_id));
|
|
928
|
+
if (!table) continue;
|
|
929
|
+
const event = change.event as DataTriggerEvent;
|
|
930
|
+
const listening = triggers.filter(
|
|
931
|
+
t => t.table === table.name && t.on === event
|
|
932
|
+
);
|
|
933
|
+
if (listening.length === 0) continue;
|
|
934
|
+
const row = shape(table, change.new);
|
|
935
|
+
const previous = shape(table, change.old);
|
|
936
|
+
for (const trigger of listening) {
|
|
937
|
+
if (trigger.id === ownTrigger) continue;
|
|
938
|
+
if (!conditionMatches(trigger.when, row, previous)) continue;
|
|
939
|
+
const message: DataTriggerEventMessage = {
|
|
940
|
+
eventId: `evt_${crypto.randomUUID().replace(/-/g, '')}`,
|
|
941
|
+
artifactId: artifactId ?? '',
|
|
942
|
+
triggerId: trigger.id,
|
|
943
|
+
triggerName: trigger.name,
|
|
944
|
+
table: table.name,
|
|
945
|
+
event,
|
|
946
|
+
rowId: Number(change.row_id),
|
|
947
|
+
row,
|
|
948
|
+
previous,
|
|
949
|
+
actor: (change.actor as string | null) ?? null,
|
|
950
|
+
depth,
|
|
951
|
+
at: new Date().toISOString()
|
|
952
|
+
};
|
|
953
|
+
if (depth > DATA_TRIGGER_MAX_DEPTH) {
|
|
954
|
+
message.row = null;
|
|
955
|
+
message.previous = null;
|
|
956
|
+
message.error = `Not run: a chain of triggers stops at depth ${DATA_TRIGGER_MAX_DEPTH} (this change was made by ${caller.actor}).`;
|
|
957
|
+
} else if (utf8Bytes([row, previous]) > DATA_TRIGGER_MAX_ROW_BYTES) {
|
|
958
|
+
message.row = null;
|
|
959
|
+
message.previous = null;
|
|
960
|
+
message.truncated = true;
|
|
961
|
+
}
|
|
962
|
+
runner.all(
|
|
963
|
+
'INSERT INTO _ganju_outbox (id, trigger_id, message) VALUES (?, ?, ?)',
|
|
964
|
+
[message.eventId, trigger.id, JSON.stringify(message)]
|
|
965
|
+
);
|
|
966
|
+
stored++;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
return stored;
|
|
970
|
+
};
|
|
971
|
+
|
|
972
|
+
// The outbox, oldest first, for the alarm to hand to the queue.
|
|
973
|
+
export const readOutbox = (
|
|
974
|
+
runner: SqlRunner,
|
|
975
|
+
limit = DATA_OUTBOX_BATCH
|
|
976
|
+
): { seq: number; message: DataTriggerEventMessage }[] =>
|
|
977
|
+
runner
|
|
978
|
+
.all('SELECT seq, message FROM _ganju_outbox ORDER BY seq LIMIT ?', [limit])
|
|
979
|
+
.map(row => ({
|
|
980
|
+
seq: Number(row.seq),
|
|
981
|
+
message: JSON.parse(String(row.message)) as DataTriggerEventMessage
|
|
982
|
+
}));
|
|
983
|
+
|
|
984
|
+
export const deleteOutbox = (runner: SqlRunner, throughSeq: number) => {
|
|
985
|
+
runner.all('DELETE FROM _ganju_outbox WHERE seq <= ?', [throughSeq]);
|
|
986
|
+
};
|
|
987
|
+
|
|
988
|
+
export const outboxSize = (runner: SqlRunner): number =>
|
|
989
|
+
Number(runner.all('SELECT count(*) AS n FROM _ganju_outbox')[0]?.n ?? 0);
|
|
990
|
+
|
|
991
|
+
// After a restore: events pending at the restored moment were sent then.
|
|
992
|
+
// Those stored before `before` came back with the restore; later ones are
|
|
993
|
+
// writes made since, and stay.
|
|
994
|
+
export const clearOutbox = (runner: SqlRunner, before: string): number => {
|
|
995
|
+
const n = Number(
|
|
996
|
+
runner.all('SELECT count(*) AS n FROM _ganju_outbox WHERE created_at < ?', [
|
|
997
|
+
before
|
|
998
|
+
])[0]?.n ?? 0
|
|
999
|
+
);
|
|
1000
|
+
runner.all('DELETE FROM _ganju_outbox WHERE created_at < ?', [before]);
|
|
1001
|
+
runner.all('DELETE FROM _ganju_changes');
|
|
1002
|
+
return n;
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
const conditionColumns = (condition: unknown): string[] => {
|
|
1006
|
+
if (!isPlainObject(condition)) return [];
|
|
1007
|
+
if (Array.isArray(condition.all))
|
|
1008
|
+
return condition.all.flatMap(conditionColumns);
|
|
1009
|
+
if (Array.isArray(condition.any))
|
|
1010
|
+
return condition.any.flatMap(conditionColumns);
|
|
1011
|
+
return Object.keys(condition);
|
|
1012
|
+
};
|
|
1013
|
+
|
|
1014
|
+
const actionTexts = (action: DataTriggerAction | null): string[] => {
|
|
1015
|
+
switch (action?.type) {
|
|
1016
|
+
case 'chat':
|
|
1017
|
+
return [action.text];
|
|
1018
|
+
case 'email':
|
|
1019
|
+
return [action.subject, action.text];
|
|
1020
|
+
case 'customer':
|
|
1021
|
+
return [action.text, ...(action.template?.params ?? [])];
|
|
1022
|
+
default:
|
|
1023
|
+
return [];
|
|
1024
|
+
}
|
|
1025
|
+
};
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* The triggers whose condition or {{field}} texts name a column. Dropping it
|
|
1029
|
+
* is refused while any does, since they are only checked when saved and
|
|
1030
|
+
* would quietly stop matching.
|
|
1031
|
+
*/
|
|
1032
|
+
export const triggersUsingColumn = (
|
|
1033
|
+
runner: SqlRunner,
|
|
1034
|
+
tableId: number,
|
|
1035
|
+
column: string
|
|
1036
|
+
): string[] =>
|
|
1037
|
+
runner
|
|
1038
|
+
.all(
|
|
1039
|
+
'SELECT name, condition, action FROM _ganju_triggers WHERE table_id = ? ORDER BY name',
|
|
1040
|
+
[tableId]
|
|
1041
|
+
)
|
|
1042
|
+
.filter(
|
|
1043
|
+
row =>
|
|
1044
|
+
conditionColumns(parse(row.condition)).includes(column) ||
|
|
1045
|
+
actionTexts(parse<DataTriggerAction>(row.action)).some(text =>
|
|
1046
|
+
templateFields(text).includes(column)
|
|
1047
|
+
)
|
|
1048
|
+
)
|
|
1049
|
+
.map(row => String(row.name));
|
|
1050
|
+
|
|
1051
|
+
const renameInCondition = (
|
|
1052
|
+
condition: unknown,
|
|
1053
|
+
from: string,
|
|
1054
|
+
to: string
|
|
1055
|
+
): unknown => {
|
|
1056
|
+
if (!isPlainObject(condition)) return condition;
|
|
1057
|
+
if (Array.isArray(condition.all))
|
|
1058
|
+
return { all: condition.all.map(c => renameInCondition(c, from, to)) };
|
|
1059
|
+
if (Array.isArray(condition.any))
|
|
1060
|
+
return { any: condition.any.map(c => renameInCondition(c, from, to)) };
|
|
1061
|
+
return Object.fromEntries(
|
|
1062
|
+
Object.entries(condition).map(([key, test]) => [
|
|
1063
|
+
key === from ? to : key,
|
|
1064
|
+
test
|
|
1065
|
+
])
|
|
1066
|
+
);
|
|
1067
|
+
};
|
|
1068
|
+
|
|
1069
|
+
const renameInText = (text: string, from: string, to: string) =>
|
|
1070
|
+
text.replace(/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/g, (match, field: string) =>
|
|
1071
|
+
field === from ? `{{${to}}}` : match
|
|
1072
|
+
);
|
|
1073
|
+
|
|
1074
|
+
const renameInAction = (
|
|
1075
|
+
action: DataTriggerAction,
|
|
1076
|
+
from: string,
|
|
1077
|
+
to: string
|
|
1078
|
+
): DataTriggerAction => {
|
|
1079
|
+
const r = (text: string) => renameInText(text, from, to);
|
|
1080
|
+
switch (action.type) {
|
|
1081
|
+
case 'chat':
|
|
1082
|
+
return { ...action, text: r(action.text) };
|
|
1083
|
+
case 'email':
|
|
1084
|
+
return { ...action, subject: r(action.subject), text: r(action.text) };
|
|
1085
|
+
case 'customer':
|
|
1086
|
+
return {
|
|
1087
|
+
...action,
|
|
1088
|
+
text: r(action.text),
|
|
1089
|
+
...(action.template
|
|
1090
|
+
? {
|
|
1091
|
+
template: {
|
|
1092
|
+
...action.template,
|
|
1093
|
+
params: action.template.params?.map(r)
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
: {})
|
|
1097
|
+
};
|
|
1098
|
+
default:
|
|
1099
|
+
return action;
|
|
1100
|
+
}
|
|
1101
|
+
};
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* A renamed column, renamed in the triggers that use it too, inside the
|
|
1105
|
+
* rename's transaction. Those deployed from ganju.json then differ from the
|
|
1106
|
+
* file until it is changed to match. Returns the triggers changed.
|
|
1107
|
+
*/
|
|
1108
|
+
export const renameColumnInTriggers = (
|
|
1109
|
+
runner: SqlRunner,
|
|
1110
|
+
tableId: number,
|
|
1111
|
+
from: string,
|
|
1112
|
+
to: string
|
|
1113
|
+
): string[] => {
|
|
1114
|
+
const names = triggersUsingColumn(runner, tableId, from);
|
|
1115
|
+
for (const name of names) {
|
|
1116
|
+
const row = runner.all(
|
|
1117
|
+
'SELECT id, condition, action FROM _ganju_triggers WHERE name = ?',
|
|
1118
|
+
[name]
|
|
1119
|
+
)[0];
|
|
1120
|
+
const condition = parse(row.condition);
|
|
1121
|
+
const action = parse<DataTriggerAction>(row.action);
|
|
1122
|
+
runner.all(
|
|
1123
|
+
`UPDATE _ganju_triggers SET condition = ?, action = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
|
|
1124
|
+
[
|
|
1125
|
+
condition === null
|
|
1126
|
+
? null
|
|
1127
|
+
: JSON.stringify(renameInCondition(condition, from, to)),
|
|
1128
|
+
action ? JSON.stringify(renameInAction(action, from, to)) : row.action,
|
|
1129
|
+
row.id
|
|
1130
|
+
]
|
|
1131
|
+
);
|
|
1132
|
+
}
|
|
1133
|
+
return names;
|
|
1134
|
+
};
|