@ganju/utils 0.0.16 → 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 +9 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +26 -2
- package/dist/customCodeToken.d.ts +15 -0
- package/dist/customCodeToken.d.ts.map +1 -1
- package/dist/customCodeToken.js +49 -1
- package/dist/data.d.ts +6 -0
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +53 -1
- package/dist/dataApi.d.ts +200 -1
- package/dist/dataApi.d.ts.map +1 -1
- package/dist/dataApi.js +106 -1
- package/dist/dataCompiler.d.ts.map +1 -1
- package/dist/dataCompiler.js +5 -1
- package/dist/dataCsv.d.ts +20 -0
- package/dist/dataCsv.d.ts.map +1 -0
- package/dist/dataCsv.js +102 -0
- 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 +86 -4
- package/dist/dataImport.d.ts +2 -0
- package/dist/dataImport.d.ts.map +1 -1
- package/dist/dataImport.js +4 -1
- package/dist/dataQueries.d.ts +73 -0
- package/dist/dataQueries.d.ts.map +1 -0
- package/dist/dataQueries.js +513 -0
- package/dist/dataRpc.d.ts +44 -0
- package/dist/dataRpc.d.ts.map +1 -1
- package/dist/dataTools.d.ts +40 -0
- package/dist/dataTools.d.ts.map +1 -0
- package/dist/dataTools.js +280 -0
- 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 +3 -0
- package/dist/dataTypes.d.ts.map +1 -1
- package/dist/index.d.ts +201 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -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 +201 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +149 -1
- package/dist/sdkConstants.d.ts +3 -0
- package/dist/sdkConstants.d.ts.map +1 -1
- package/dist/sdkConstants.js +4 -1
- package/package.json +1 -1
- package/src/constants.ts +30 -2
- package/src/customCodeToken.ts +87 -0
- package/src/data.ts +74 -0
- package/src/dataApi.ts +129 -2
- package/src/dataCompiler.ts +5 -1
- package/src/dataCsv.ts +99 -0
- package/src/dataDdl.ts +3 -1
- package/src/dataEngine.ts +94 -4
- package/src/dataImport.ts +3 -0
- package/src/dataQueries.ts +730 -0
- package/src/dataRpc.ts +26 -0
- package/src/dataTools.ts +338 -0
- package/src/dataTriggers.ts +1134 -0
- package/src/dataTypes.ts +9 -0
- package/src/index.ts +11 -1
- package/src/localizeDataError.ts +593 -0
- package/src/schema.ts +158 -1
- package/src/sdkConstants.ts +3 -0
package/src/dataApi.ts
CHANGED
|
@@ -16,7 +16,13 @@ export interface DataApiRoute {
|
|
|
16
16
|
path: string;
|
|
17
17
|
summary: string;
|
|
18
18
|
description?: string;
|
|
19
|
-
tag:
|
|
19
|
+
tag:
|
|
20
|
+
| 'Database'
|
|
21
|
+
| 'Tables'
|
|
22
|
+
| 'Rows'
|
|
23
|
+
| 'Queries'
|
|
24
|
+
| 'Triggers'
|
|
25
|
+
| 'History and export';
|
|
20
26
|
body?: z.ZodType;
|
|
21
27
|
query?: z.ZodObject;
|
|
22
28
|
// What a 200/201 answers with, in a sentence.
|
|
@@ -29,6 +35,7 @@ export interface DataApiRoute {
|
|
|
29
35
|
const {
|
|
30
36
|
DATA_DATABASE_CREATE,
|
|
31
37
|
DATA_DATABASE_DELETE,
|
|
38
|
+
DATA_DATABASE_PATCH,
|
|
32
39
|
DATA_TABLE_INPUT,
|
|
33
40
|
DATA_TABLE_PATCH,
|
|
34
41
|
DATA_COLUMN_INPUT,
|
|
@@ -46,7 +53,13 @@ const {
|
|
|
46
53
|
DATA_EXPORT_BODY,
|
|
47
54
|
DATA_EXPORT_QUERY,
|
|
48
55
|
DATA_EXPORT_FILE_QUERY,
|
|
49
|
-
DATA_CHANGE_LIST_QUERY
|
|
56
|
+
DATA_CHANGE_LIST_QUERY,
|
|
57
|
+
DATA_QUERY_INPUT,
|
|
58
|
+
DATA_QUERY_RUN_BODY,
|
|
59
|
+
DATA_TRIGGER_INPUT,
|
|
60
|
+
DATA_TRIGGER_PATCH,
|
|
61
|
+
DATA_TRIGGER_DEPLOY_BODY,
|
|
62
|
+
DATA_TRIGGER_RUN_LIST_QUERY
|
|
50
63
|
} = Schema;
|
|
51
64
|
|
|
52
65
|
export const DATA_API_ROUTES = {
|
|
@@ -70,6 +83,16 @@ export const DATA_API_ROUTES = {
|
|
|
70
83
|
returns: '{ database }',
|
|
71
84
|
status: 201
|
|
72
85
|
},
|
|
86
|
+
updateDatabase: {
|
|
87
|
+
method: 'patch',
|
|
88
|
+
path: '',
|
|
89
|
+
tag: 'Database',
|
|
90
|
+
summary: "Change the database's settings",
|
|
91
|
+
description:
|
|
92
|
+
'`timeZone`, an IANA zone such as `America/Bogota`, or null for UTC. Datetimes come back in it for callers that send no `timeZone` of their own: the assistant, functions and scripts. Datetimes without an offset are read in it too.',
|
|
93
|
+
body: DATA_DATABASE_PATCH,
|
|
94
|
+
returns: '{ database }'
|
|
95
|
+
},
|
|
73
96
|
remove: {
|
|
74
97
|
method: 'delete',
|
|
75
98
|
path: '',
|
|
@@ -253,6 +276,108 @@ export const DATA_API_ROUTES = {
|
|
|
253
276
|
body: DATA_WRITE_BODY,
|
|
254
277
|
returns: '{ results }'
|
|
255
278
|
},
|
|
279
|
+
listQueries: {
|
|
280
|
+
method: 'get',
|
|
281
|
+
path: '/query',
|
|
282
|
+
tag: 'Queries',
|
|
283
|
+
summary: 'Named queries',
|
|
284
|
+
description:
|
|
285
|
+
'Each with its SQL, parameters, the tables it reads and its plan (`SCAN orders` means it reads the whole table).',
|
|
286
|
+
returns: '{ queries }'
|
|
287
|
+
},
|
|
288
|
+
saveQuery: {
|
|
289
|
+
method: 'post',
|
|
290
|
+
path: '/query',
|
|
291
|
+
tag: 'Queries',
|
|
292
|
+
summary: 'Save a named query',
|
|
293
|
+
description:
|
|
294
|
+
"One `SELECT` (or `WITH … SELECT`) with named parameters such as `:from`, each declared in `params` with its type. It is compiled, never run, and refused unless it only reads this database's tables. Saving under an existing name replaces it. `assistant: true` lets channel bots run it from data-query.",
|
|
295
|
+
body: DATA_QUERY_INPUT,
|
|
296
|
+
returns: '{ query }',
|
|
297
|
+
status: 201
|
|
298
|
+
},
|
|
299
|
+
dropQuery: {
|
|
300
|
+
method: 'delete',
|
|
301
|
+
path: '/query/:name',
|
|
302
|
+
tag: 'Queries',
|
|
303
|
+
summary: 'Delete a named query',
|
|
304
|
+
returns: '{ dropped }'
|
|
305
|
+
},
|
|
306
|
+
runQuery: {
|
|
307
|
+
method: 'post',
|
|
308
|
+
path: '/query/:name/run',
|
|
309
|
+
tag: 'Queries',
|
|
310
|
+
summary: 'Run a named query',
|
|
311
|
+
description:
|
|
312
|
+
'With its `params`, up to `limit` rows (100 by default, 500 at most) and 1 MB; `truncated` says it stopped early. A query that would scan a table over 200,000 rows is refused.',
|
|
313
|
+
body: DATA_QUERY_RUN_BODY,
|
|
314
|
+
returns: '{ query, rows, truncated? }'
|
|
315
|
+
},
|
|
316
|
+
listTriggers: {
|
|
317
|
+
method: 'get',
|
|
318
|
+
path: '/trigger',
|
|
319
|
+
tag: 'Triggers',
|
|
320
|
+
summary: 'Triggers',
|
|
321
|
+
description:
|
|
322
|
+
'Each with its table, event, condition and action. `pending` counts events not yet handed to the queue. Webhook secrets are never listed.',
|
|
323
|
+
returns: '{ triggers, pending }'
|
|
324
|
+
},
|
|
325
|
+
saveTrigger: {
|
|
326
|
+
method: 'post',
|
|
327
|
+
path: '/trigger',
|
|
328
|
+
tag: 'Triggers',
|
|
329
|
+
summary: 'Save a trigger',
|
|
330
|
+
description:
|
|
331
|
+
'Runs one action when rows of `table` are inserted, updated or deleted, and `when` holds: `{ "status": { "changedTo": "READY" } }` fires on the transition only; `changedFrom`, `changed: true` and `equals` test a column too, and `all` / `any` combine them. The action is one of `function` (a function of this project, given `{ event, table, row, previous, actor, eventId, triggerId }`), `webhook` (an https URL, sent a POST signed in `x-ganju-signature`), `chat` (`{ channelId, chatId, text }`), `email` (`{ to, subject, text }`) or `customer` (`{ text, template? }`, to the row\'s customer on a customer-scoped table). Texts take `{{field}}` from the row, inserted as plain text. Saving under an existing name replaces it, unless `replace` is false; with no `name`, it always creates a new trigger, named from the table, event and action and numbered past those taken. Left out, `enabled` keeps a saved trigger on or off as it was. A new webhook answers with its signing `secret`, shown this once. Delivery is at least once: every event carries an `eventId`.',
|
|
332
|
+
body: DATA_TRIGGER_INPUT,
|
|
333
|
+
returns: '{ trigger, secret? }',
|
|
334
|
+
status: 201
|
|
335
|
+
},
|
|
336
|
+
updateTrigger: {
|
|
337
|
+
method: 'patch',
|
|
338
|
+
path: '/trigger/:name',
|
|
339
|
+
tag: 'Triggers',
|
|
340
|
+
summary: 'Turn a trigger on or off',
|
|
341
|
+
description:
|
|
342
|
+
'Turning one back on clears why Ganju turned it off (20 failed runs in a row).',
|
|
343
|
+
body: DATA_TRIGGER_PATCH,
|
|
344
|
+
returns: '{ trigger }'
|
|
345
|
+
},
|
|
346
|
+
dropTrigger: {
|
|
347
|
+
method: 'delete',
|
|
348
|
+
path: '/trigger/:name',
|
|
349
|
+
tag: 'Triggers',
|
|
350
|
+
summary: 'Delete a trigger',
|
|
351
|
+
returns: '{ dropped }'
|
|
352
|
+
},
|
|
353
|
+
deployTriggers: {
|
|
354
|
+
method: 'post',
|
|
355
|
+
path: '/trigger/deploy',
|
|
356
|
+
tag: 'Triggers',
|
|
357
|
+
summary: 'Deploy the triggers of ganju.json',
|
|
358
|
+
description:
|
|
359
|
+
"What `ganju deploy` sends: every trigger listed is saved as managed by the CLI, and the CLI's earlier ones not listed are deleted. Triggers made in the dashboard are left alone. All or nothing. New webhooks' secrets come back once, by name.",
|
|
360
|
+
body: DATA_TRIGGER_DEPLOY_BODY,
|
|
361
|
+
returns: '{ triggers, removed, secrets }'
|
|
362
|
+
},
|
|
363
|
+
listTriggerRuns: {
|
|
364
|
+
method: 'get',
|
|
365
|
+
path: '/trigger/:name/run',
|
|
366
|
+
tag: 'Triggers',
|
|
367
|
+
summary: "A trigger's recent runs",
|
|
368
|
+
description:
|
|
369
|
+
'Newest first, up to 50: `ok`, `failed` with its error, `skipped`, or `running`.',
|
|
370
|
+
query: DATA_TRIGGER_RUN_LIST_QUERY,
|
|
371
|
+
returns: '{ runs }'
|
|
372
|
+
},
|
|
373
|
+
retryTriggerRun: {
|
|
374
|
+
method: 'post',
|
|
375
|
+
path: '/trigger/:name/run/:runId/retry',
|
|
376
|
+
tag: 'Triggers',
|
|
377
|
+
summary: 'Run a failed event again',
|
|
378
|
+
description: 'The same event, with the same `eventId`.',
|
|
379
|
+
returns: '{ run }'
|
|
380
|
+
},
|
|
256
381
|
startExport: {
|
|
257
382
|
method: 'post',
|
|
258
383
|
path: '/export',
|
|
@@ -397,6 +522,8 @@ export const dataOpenApi = (serverUrl: string) => {
|
|
|
397
522
|
{ name: 'Database' },
|
|
398
523
|
{ name: 'Tables' },
|
|
399
524
|
{ name: 'Rows' },
|
|
525
|
+
{ name: 'Queries' },
|
|
526
|
+
{ name: 'Triggers' },
|
|
400
527
|
{ name: 'History and export' }
|
|
401
528
|
],
|
|
402
529
|
paths,
|
package/src/dataCompiler.ts
CHANGED
|
@@ -83,7 +83,11 @@ export const unknownColumn = (table: DataTable, name: string) =>
|
|
|
83
83
|
// Access
|
|
84
84
|
|
|
85
85
|
export const accessOf = (caller: DataCaller, table: DataTable): DataAccess =>
|
|
86
|
-
caller.
|
|
86
|
+
caller.tables && !caller.tables.includes(table.name)
|
|
87
|
+
? 'none'
|
|
88
|
+
: caller.restricted
|
|
89
|
+
? table.access
|
|
90
|
+
: 'full';
|
|
87
91
|
|
|
88
92
|
export const visibleTables = (schema: DataSchema, caller: DataCaller) =>
|
|
89
93
|
schema.tables.filter(table => DATA_ACCESS_RANK[accessOf(caller, table)] >= 1);
|
package/src/dataCsv.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a CSV file into rows of text, with no parser package behind it:
|
|
3
|
+
* the CLI's import and the owner's data-admin tool read files the same way.
|
|
4
|
+
*
|
|
5
|
+
* RFC 4180 plus the two things spreadsheets actually write: a byte-order mark
|
|
6
|
+
* in front, and `;` between fields where the decimal mark is a comma (Excel in
|
|
7
|
+
* Spanish). The separator is read from the header line, so the same file
|
|
8
|
+
* exported from either locale imports the same way.
|
|
9
|
+
*
|
|
10
|
+
* Every cell comes back as text. Deciding what "28.000" means is the shared
|
|
11
|
+
* import code's job, so the CLI and the dashboard decide it the same way.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface ParsedCsv {
|
|
15
|
+
headers: string[];
|
|
16
|
+
rows: string[][];
|
|
17
|
+
// The file line each row starts on, counting the header as line 1, since a
|
|
18
|
+
// quoted cell can span lines and blank lines are skipped.
|
|
19
|
+
lines: number[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SEPARATORS = [',', ';', '\t'] as const;
|
|
23
|
+
|
|
24
|
+
// The separator that appears most in the first line, outside quotes.
|
|
25
|
+
export const detectSeparator = (text: string): string => {
|
|
26
|
+
const counts = new Map<string, number>(SEPARATORS.map(s => [s, 0]));
|
|
27
|
+
let quoted = false;
|
|
28
|
+
for (const char of text) {
|
|
29
|
+
if (char === '"') quoted = !quoted;
|
|
30
|
+
else if (!quoted && (char === '\n' || char === '\r')) break;
|
|
31
|
+
else if (!quoted && counts.has(char))
|
|
32
|
+
counts.set(char, counts.get(char)! + 1);
|
|
33
|
+
}
|
|
34
|
+
let best = ',';
|
|
35
|
+
for (const [separator, count] of counts) {
|
|
36
|
+
if (count > counts.get(best)!) best = separator;
|
|
37
|
+
}
|
|
38
|
+
return best;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const parseCsv = (input: string, separator?: string): ParsedCsv => {
|
|
42
|
+
const text = input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;
|
|
43
|
+
const sep = separator ?? detectSeparator(text);
|
|
44
|
+
const records: { cells: string[]; line: number }[] = [];
|
|
45
|
+
let cells: string[] = [];
|
|
46
|
+
let cell = '';
|
|
47
|
+
let quoted = false;
|
|
48
|
+
let line = 1;
|
|
49
|
+
let startLine = 1;
|
|
50
|
+
|
|
51
|
+
const endCell = () => {
|
|
52
|
+
cells.push(cell);
|
|
53
|
+
cell = '';
|
|
54
|
+
};
|
|
55
|
+
const endRecord = () => {
|
|
56
|
+
endCell();
|
|
57
|
+
// A blank line, or one of nothing but separators, is not a row.
|
|
58
|
+
if (cells.some(c => c.trim() !== '')) {
|
|
59
|
+
records.push({ cells, line: startLine });
|
|
60
|
+
}
|
|
61
|
+
cells = [];
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
for (let i = 0; i < text.length; i++) {
|
|
65
|
+
const char = text[i];
|
|
66
|
+
if (quoted) {
|
|
67
|
+
if (char === '"') {
|
|
68
|
+
if (text[i + 1] === '"') {
|
|
69
|
+
cell += '"';
|
|
70
|
+
i++;
|
|
71
|
+
} else quoted = false;
|
|
72
|
+
} else {
|
|
73
|
+
if (char === '\n') line++;
|
|
74
|
+
cell += char;
|
|
75
|
+
}
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (char === '"' && cell === '') {
|
|
79
|
+
quoted = true;
|
|
80
|
+
} else if (char === sep) {
|
|
81
|
+
endCell();
|
|
82
|
+
} else if (char === '\n' || char === '\r') {
|
|
83
|
+
if (char === '\r' && text[i + 1] === '\n') i++;
|
|
84
|
+
endRecord();
|
|
85
|
+
line++;
|
|
86
|
+
startLine = line;
|
|
87
|
+
} else {
|
|
88
|
+
cell += char;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (cell !== '' || cells.length) endRecord();
|
|
92
|
+
|
|
93
|
+
const [header, ...body] = records;
|
|
94
|
+
return {
|
|
95
|
+
headers: (header?.cells ?? []).map(h => h.trim()),
|
|
96
|
+
rows: body.map(r => r.cells),
|
|
97
|
+
lines: body.map(r => r.line)
|
|
98
|
+
};
|
|
99
|
+
};
|
package/src/dataDdl.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The SQL a project's database runs to build and change its tables. Pure
|
|
2
2
|
// string building: every name in here passed identifierIssue or is one of
|
|
3
3
|
// ours, and every owner-supplied value is a literal escaped by sqlLiteral.
|
|
4
|
+
import { changeCaptureTriggers } from './dataTriggers';
|
|
4
5
|
import { quoteIdentifier as q } from './dataIdentifiers';
|
|
5
6
|
import type {
|
|
6
7
|
DataColumn,
|
|
@@ -339,7 +340,8 @@ const historyTriggers = (table: DataTable): NamedStatement[] => {
|
|
|
339
340
|
|
|
340
341
|
export const desiredTriggers = (table: DataTable): NamedStatement[] => [
|
|
341
342
|
...ftsTriggers(table),
|
|
342
|
-
...historyTriggers(table)
|
|
343
|
+
...historyTriggers(table),
|
|
344
|
+
...changeCaptureTriggers(table)
|
|
343
345
|
];
|
|
344
346
|
|
|
345
347
|
// Type changes
|
package/src/dataEngine.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// the tests and the tool-design eval. The runner executes; everything about
|
|
4
4
|
// which SQL is allowed and how results look is decided here.
|
|
5
5
|
import { identifierIssue, quoteIdentifier as q } from './dataIdentifiers';
|
|
6
|
+
import { collectTriggerEvents, renameColumnInTriggers, triggersUsingColumn } from './dataTriggers';
|
|
6
7
|
import {
|
|
7
8
|
DATA_NOW_SQL,
|
|
8
9
|
NUMERIC_TYPES,
|
|
@@ -172,6 +173,60 @@ export const INTERNAL_MIGRATIONS: readonly (readonly string[])[] = [
|
|
|
172
173
|
WHERE c.table_id = _ganju_tables.id AND c.type = 'text'
|
|
173
174
|
ORDER BY c.position, c.id LIMIT 1
|
|
174
175
|
) WHERE display_column_id IS NULL AND created_at < '2026-10-24T00:00:00Z'`
|
|
176
|
+
],
|
|
177
|
+
// Named queries: the owner's SELECTs, run by name. `tables` and `plan` are
|
|
178
|
+
// what the check found when each was saved.
|
|
179
|
+
[
|
|
180
|
+
`CREATE TABLE IF NOT EXISTS _ganju_queries (
|
|
181
|
+
name TEXT PRIMARY KEY,
|
|
182
|
+
description TEXT,
|
|
183
|
+
sql TEXT NOT NULL,
|
|
184
|
+
params TEXT NOT NULL DEFAULT '[]',
|
|
185
|
+
assistant INTEGER NOT NULL DEFAULT 0,
|
|
186
|
+
tables TEXT NOT NULL DEFAULT '[]',
|
|
187
|
+
plan TEXT NOT NULL DEFAULT '[]',
|
|
188
|
+
created_at TEXT NOT NULL DEFAULT (${DATA_NOW_SQL}),
|
|
189
|
+
updated_at TEXT NOT NULL DEFAULT (${DATA_NOW_SQL}),
|
|
190
|
+
updated_by TEXT
|
|
191
|
+
) WITHOUT ROWID`
|
|
192
|
+
],
|
|
193
|
+
// Triggers: the owner's rules, the rows a write changed while one listens
|
|
194
|
+
// (emptied before the write commits), and the events waiting for the queue.
|
|
195
|
+
[
|
|
196
|
+
`CREATE TABLE IF NOT EXISTS _ganju_triggers (
|
|
197
|
+
id TEXT PRIMARY KEY,
|
|
198
|
+
name TEXT NOT NULL UNIQUE,
|
|
199
|
+
description TEXT,
|
|
200
|
+
table_id INTEGER NOT NULL,
|
|
201
|
+
event TEXT NOT NULL,
|
|
202
|
+
condition TEXT,
|
|
203
|
+
action TEXT NOT NULL,
|
|
204
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
205
|
+
disabled_reason TEXT,
|
|
206
|
+
managed TEXT,
|
|
207
|
+
rate_limit INTEGER NOT NULL DEFAULT 60,
|
|
208
|
+
secret TEXT,
|
|
209
|
+
created_at TEXT NOT NULL DEFAULT (${DATA_NOW_SQL}),
|
|
210
|
+
updated_at TEXT NOT NULL DEFAULT (${DATA_NOW_SQL}),
|
|
211
|
+
updated_by TEXT
|
|
212
|
+
) WITHOUT ROWID`,
|
|
213
|
+
'CREATE INDEX IF NOT EXISTS _ganju_triggers_table ON _ganju_triggers(table_id, event)',
|
|
214
|
+
`CREATE TABLE IF NOT EXISTS _ganju_changes (
|
|
215
|
+
id INTEGER PRIMARY KEY,
|
|
216
|
+
table_id INTEGER NOT NULL,
|
|
217
|
+
event TEXT NOT NULL,
|
|
218
|
+
row_id INTEGER NOT NULL,
|
|
219
|
+
actor TEXT,
|
|
220
|
+
old TEXT,
|
|
221
|
+
new TEXT
|
|
222
|
+
)`,
|
|
223
|
+
`CREATE TABLE IF NOT EXISTS _ganju_outbox (
|
|
224
|
+
seq INTEGER PRIMARY KEY,
|
|
225
|
+
id TEXT NOT NULL UNIQUE,
|
|
226
|
+
trigger_id TEXT NOT NULL,
|
|
227
|
+
message TEXT NOT NULL,
|
|
228
|
+
created_at TEXT NOT NULL DEFAULT (${DATA_NOW_SQL})
|
|
229
|
+
)`
|
|
175
230
|
]
|
|
176
231
|
];
|
|
177
232
|
|
|
@@ -271,7 +326,8 @@ export const loadSchema = (runner: SqlRunner): DataSchema => {
|
|
|
271
326
|
position: Number(c.position)
|
|
272
327
|
};
|
|
273
328
|
})
|
|
274
|
-
}))
|
|
329
|
+
})),
|
|
330
|
+
timeZone: getMeta(runner, 'time_zone')
|
|
275
331
|
};
|
|
276
332
|
};
|
|
277
333
|
|
|
@@ -1356,7 +1412,8 @@ const insertValues = (
|
|
|
1356
1412
|
// A bot may only point a relation at a row it could read: none in a table
|
|
1357
1413
|
// hidden from it, and only its own customer's in a scoped one. Otherwise it
|
|
1358
1414
|
// could link an order to another customer and read their name back as the
|
|
1359
|
-
// label.
|
|
1415
|
+
// label. A caller held to some tables (a function's `data.tables`) may not
|
|
1416
|
+
// point outside them either.
|
|
1360
1417
|
const assertRelationsVisible = (
|
|
1361
1418
|
runner: SqlRunner,
|
|
1362
1419
|
schema: DataSchema,
|
|
@@ -1364,7 +1421,7 @@ const assertRelationsVisible = (
|
|
|
1364
1421
|
table: DataTable,
|
|
1365
1422
|
values: Map<string, SqlValue>
|
|
1366
1423
|
) => {
|
|
1367
|
-
if (!caller.restricted) return;
|
|
1424
|
+
if (!caller.restricted && !caller.tables) return;
|
|
1368
1425
|
for (const [name, value] of values) {
|
|
1369
1426
|
if (value === null) continue;
|
|
1370
1427
|
const column = table.columns.find(c => c.name === name);
|
|
@@ -1373,7 +1430,9 @@ const assertRelationsVisible = (
|
|
|
1373
1430
|
if (!target) continue;
|
|
1374
1431
|
if (!canRead(caller, target)) {
|
|
1375
1432
|
throw new DataError(
|
|
1376
|
-
|
|
1433
|
+
caller.restricted
|
|
1434
|
+
? `${table.name}.${name} points at a table the assistant can't use.`
|
|
1435
|
+
: `${table.name}.${name} points at ${target.name}, which isn't one of this caller's tables.`,
|
|
1377
1436
|
'forbidden'
|
|
1378
1437
|
);
|
|
1379
1438
|
}
|
|
@@ -1776,6 +1835,8 @@ export const write = (
|
|
|
1776
1835
|
assertWritable(runner, caller, addsData);
|
|
1777
1836
|
return runner.transaction(() => {
|
|
1778
1837
|
setContextActor(runner, caller.actor);
|
|
1838
|
+
// Rows left by a change that wasn't a write (a schema change) fire nothing.
|
|
1839
|
+
runner.all('DELETE FROM _ganju_changes');
|
|
1779
1840
|
const results: WriteResult[] = [];
|
|
1780
1841
|
operations.forEach((op, index) => {
|
|
1781
1842
|
try {
|
|
@@ -1792,6 +1853,7 @@ export const write = (
|
|
|
1792
1853
|
throw error;
|
|
1793
1854
|
}
|
|
1794
1855
|
});
|
|
1856
|
+
collectTriggerEvents(runner, schema, caller);
|
|
1795
1857
|
return { ok: true as const, results };
|
|
1796
1858
|
});
|
|
1797
1859
|
};
|
|
@@ -2268,6 +2330,8 @@ export const dropTable = (
|
|
|
2268
2330
|
dropTriggers(runner, table);
|
|
2269
2331
|
runner.all(`DROP TABLE IF EXISTS ${q(ftsTableName(table))}`);
|
|
2270
2332
|
runner.all(`DROP TABLE ${q(table.name)}`);
|
|
2333
|
+
// Its triggers go with it; a later table of the same name starts with none.
|
|
2334
|
+
runner.all('DELETE FROM _ganju_triggers WHERE table_id = ?', [table.id]);
|
|
2271
2335
|
runner.all('UPDATE _ganju_tables SET display_column_id = NULL WHERE id = ?', [table.id]);
|
|
2272
2336
|
runner.all('DELETE FROM _ganju_columns WHERE table_id = ?', [table.id]);
|
|
2273
2337
|
runner.all('DELETE FROM _ganju_tables WHERE id = ?', [table.id]);
|
|
@@ -2642,6 +2706,20 @@ export const copyTableStep = (
|
|
|
2642
2706
|
}
|
|
2643
2707
|
};
|
|
2644
2708
|
|
|
2709
|
+
// Triggers name columns in their conditions and texts, checked only when
|
|
2710
|
+
// saved: a column one of them uses can't be dropped under it.
|
|
2711
|
+
const assertNoTriggerUses = (runner: SqlRunner, table: DataTable, column: string) => {
|
|
2712
|
+
const triggers = triggersUsingColumn(runner, table.id, column);
|
|
2713
|
+
if (triggers.length === 0) return;
|
|
2714
|
+
throw new DataError(
|
|
2715
|
+
triggers.length === 1
|
|
2716
|
+
? `${table.name}.${column} can't be dropped while the trigger ${triggers[0]} uses it; change or delete that trigger first.`
|
|
2717
|
+
: `${table.name}.${column} can't be dropped while the triggers ${triggers.join(', ')} use it; change or delete those triggers first.`,
|
|
2718
|
+
'constraint',
|
|
2719
|
+
{ triggers }
|
|
2720
|
+
);
|
|
2721
|
+
};
|
|
2722
|
+
|
|
2645
2723
|
export const updateColumn = (
|
|
2646
2724
|
runner: SqlRunner,
|
|
2647
2725
|
schema: DataSchema,
|
|
@@ -2670,6 +2748,16 @@ export const updateColumn = (
|
|
|
2670
2748
|
}
|
|
2671
2749
|
|
|
2672
2750
|
if (columnChangeNeedsRebuild(schema, table.name, column.name, patch)) {
|
|
2751
|
+
// A rename renames the column in its triggers too; a copy can't, so the
|
|
2752
|
+
// two go one after the other.
|
|
2753
|
+
const triggers = draft.name !== column.name ? triggersUsingColumn(runner, table.id, column.name) : [];
|
|
2754
|
+
if (triggers.length) {
|
|
2755
|
+
throw new DataError(
|
|
2756
|
+
`${table.name}.${column.name} is used by triggers (${triggers.join(', ')}): rename it on its own, then make the other changes.`,
|
|
2757
|
+
'constraint',
|
|
2758
|
+
{ triggers }
|
|
2759
|
+
);
|
|
2760
|
+
}
|
|
2673
2761
|
return rebuildTable(
|
|
2674
2762
|
runner,
|
|
2675
2763
|
schema,
|
|
@@ -2687,6 +2775,7 @@ export const updateColumn = (
|
|
|
2687
2775
|
runner.all(
|
|
2688
2776
|
`ALTER TABLE ${q(table.name)} RENAME COLUMN ${q(column.name)} TO ${q(draft.name)}`
|
|
2689
2777
|
);
|
|
2778
|
+
renameColumnInTriggers(runner, table.id, column.name, draft.name);
|
|
2690
2779
|
}
|
|
2691
2780
|
updateColumnMeta(runner, schema, table, column.id, draft);
|
|
2692
2781
|
touchTable(runner, table.id);
|
|
@@ -2709,6 +2798,7 @@ export const dropColumn = (
|
|
|
2709
2798
|
if (table.columns.length === 1) {
|
|
2710
2799
|
throw new DataError(`${table.name} needs at least one column; drop the table instead.`);
|
|
2711
2800
|
}
|
|
2801
|
+
assertNoTriggerUses(runner, table, column.name);
|
|
2712
2802
|
if (dropColumnNeedsRebuild(schema, table.name, column.name)) {
|
|
2713
2803
|
return rebuildTable(
|
|
2714
2804
|
runner,
|
package/src/dataImport.ts
CHANGED