pgkiln 0.48.0 → 0.49.0

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.
Files changed (48) hide show
  1. package/SECURITY.md +2 -2
  2. package/db/migrations/116_dump_jobs.sql +41 -0
  3. package/db/migrations/117_rest_handlers.sql +63 -0
  4. package/db/migrations/118_change_log.sql +54 -0
  5. package/docs/apex-feature-parity.md +7 -7
  6. package/docs/guide/01-installation.md +4 -0
  7. package/docs/guide/03-builder.md +179 -5
  8. package/docs/guide/08-security.md +11 -0
  9. package/docs/guide/12-development.md +44 -6
  10. package/docs/guide/13-rest-api.md +59 -1
  11. package/docs/guide/15-extensions.md +1 -1
  12. package/examples/hr/hr_62_rest_module.sql +39 -0
  13. package/package.json +1 -1
  14. package/public/app.css +1 -1
  15. package/public/builder-icons.svg +2 -0
  16. package/public/builder.css +420 -173
  17. package/public/builder.js +48 -0
  18. package/public/sql-monitor.js +73 -0
  19. package/src/app.ts +6 -0
  20. package/src/automations.ts +6 -0
  21. package/src/binds.ts +2 -1
  22. package/src/builder/advisor.ts +24 -2
  23. package/src/builder/autorest.ts +3 -2
  24. package/src/builder/changelog.ts +240 -0
  25. package/src/builder/components.ts +1 -1
  26. package/src/builder/datapump.ts +364 -0
  27. package/src/builder/ddl-tools.ts +13 -4
  28. package/src/builder/find-workspace.ts +2 -2
  29. package/src/builder/plan-view.ts +25 -0
  30. package/src/builder/rest-modules.ts +629 -0
  31. package/src/builder/rest.ts +1 -0
  32. package/src/builder/routes.ts +4 -2
  33. package/src/builder/scheduling.ts +495 -0
  34. package/src/builder/sql-monitor.ts +397 -0
  35. package/src/builder/sql-tools.ts +6 -2
  36. package/src/builder/sql-utilities.ts +7 -39
  37. package/src/builder/sql.ts +6 -0
  38. package/src/builder/table-editor.ts +4 -1
  39. package/src/builder/top-sql.ts +24 -6
  40. package/src/builder/ui.ts +6 -4
  41. package/src/builder/worksheet.ts +5 -21
  42. package/src/changelog.ts +109 -0
  43. package/src/cron-schedule.ts +282 -0
  44. package/src/datapump.ts +546 -0
  45. package/src/dumpscript.ts +407 -0
  46. package/src/pgcron.ts +225 -0
  47. package/src/runtime/rest-handlers.ts +75 -0
  48. package/src/runtime/rest.ts +116 -22
@@ -0,0 +1,407 @@
1
+ import type pg from 'pg';
2
+
3
+ // (sprint 103, Database Actions DA-16) The SQL script pg_restore writes for one schema (`pg_restore -f -`), read line
4
+ // by line and run on one connection, inside the caller's transaction: Schema Import (src/datapump.ts) streams it here
5
+ // instead of to psql, so it can run in the same transaction as its schema renames, and so a dump can't reach further
6
+ // than its own schema.
7
+ //
8
+ // What runs:
9
+ // - statements, split like psql splits them: quotes, E'' strings, quoted identifiers, dollar quotes, comments, and
10
+ // SQL-standard function bodies (BEGIN ATOMIC … END) with their own semicolons
11
+ // - COPY … FROM stdin with the data lines after it up to "\.", sent with the COPY protocol in chunks (never the
12
+ // whole table in memory)
13
+ // - psql's \restrict and \unrestrict lines (PostgreSQL 17.6+) are skipped; any other psql meta-command is refused
14
+ //
15
+ // What is refused (checkStatement), before it reaches the server: statements pg_dump doesn't write for one schema
16
+ // (roles, databases, extensions, ALTER SYSTEM, DO, COPY to or from files and programs, SET ROLE, SET SESSION
17
+ // AUTHORIZATION, OWNER TO, SET SCHEMA, security labels), and objects created, changed or dropped outside the dump's
18
+ // own schema. A dump is still code: functions and triggers in it run as pgkiln's owner role. Import only dumps you
19
+ // trust.
20
+
21
+ export class ScriptError extends Error {
22
+ constructor(message: string, public line?: number) {
23
+ super(line ? `line ${line}: ${message}` : message);
24
+ }
25
+ }
26
+
27
+ export type ScriptEvent =
28
+ | { type: 'sql'; sql: string; line: number }
29
+ | { type: 'copy'; sql: string; line: number }
30
+ | { type: 'data'; text: string }
31
+ | { type: 'copyEnd' };
32
+
33
+ type State = { q: 'none' } | { q: 's' } | { q: 'e' } | { q: 'd' } | { q: 'dollar'; tag: string } | { q: 'block'; depth: number };
34
+
35
+ const IDENT_CHAR = /[A-Za-z0-9_$\u0080-￿]/;
36
+ const DOLLAR = /^\$([A-Za-z_\u0080-￿][A-Za-z0-9_\u0080-￿]*)?\$/;
37
+ const ROUTINE = /^create\s+(or\s+replace\s+)?(function|procedure)\b/i;
38
+
39
+ /** Splits a psql script into statements and COPY data, a line at a time. */
40
+ export class ScriptLexer {
41
+ private buf = '';
42
+ private state: State = { q: 'none' };
43
+ private data = false;
44
+ /** BEGIN … END depth in a CREATE FUNCTION / PROCEDURE (SQL-standard bodies) */
45
+ private depth = 0;
46
+ private lineNo = 0;
47
+ private startLine = 0;
48
+
49
+ push(line: string): ScriptEvent[] {
50
+ this.lineNo++;
51
+ if (this.data) {
52
+ if (line === '\\.') {
53
+ this.data = false;
54
+ return [{ type: 'copyEnd' }];
55
+ }
56
+ return [{ type: 'data', text: `${line}\n` }];
57
+ }
58
+ const out: ScriptEvent[] = [];
59
+ let i = 0;
60
+ const n = line.length;
61
+ const emit = () => {
62
+ const sql = this.buf.trim();
63
+ this.buf = '';
64
+ this.depth = 0;
65
+ if (!sql || sql === ';') return;
66
+ if (/^copy\b/i.test(sql) && /\bfrom\s+stdin\s*;$/i.test(sql)) {
67
+ out.push({ type: 'copy', sql, line: this.startLine });
68
+ this.data = true;
69
+ } else out.push({ type: 'sql', sql, line: this.startLine });
70
+ };
71
+ while (i < n) {
72
+ const ch = line[i];
73
+ const st = this.state;
74
+ if (st.q === 'none') {
75
+ if (!this.buf.trim()) this.startLine = this.lineNo;
76
+ if (ch === '-' && line[i + 1] === '-') break; // a comment to the end of the line
77
+ if (ch === '/' && line[i + 1] === '*') {
78
+ this.state = { q: 'block', depth: 1 };
79
+ this.buf += ' ';
80
+ i += 2;
81
+ continue;
82
+ }
83
+ if (ch === "'") {
84
+ const prev = this.buf.slice(-1);
85
+ const before = this.buf.slice(-2, -1);
86
+ this.state = /[eE]/.test(prev) && !IDENT_CHAR.test(before) ? { q: 'e' } : { q: 's' };
87
+ } else if (ch === '"') this.state = { q: 'd' };
88
+ else if (ch === '$' && !IDENT_CHAR.test(this.buf.slice(-1))) {
89
+ const m = DOLLAR.exec(line.slice(i));
90
+ if (m) {
91
+ this.state = { q: 'dollar', tag: m[0] };
92
+ this.buf += m[0];
93
+ i += m[0].length;
94
+ continue;
95
+ }
96
+ } else if (ch === '\\') {
97
+ // a psql meta-command: \restrict / \unrestrict (pg_restore 17.6+) on a line of their own are skipped
98
+ if (!this.buf.trim() && /^\\(un)?restrict(\s+\S+)?\s*$/.test(line.slice(i))) return out;
99
+ throw new ScriptError(`psql meta-commands are not run here: ${line.slice(i, i + 20)}`, this.lineNo);
100
+ } else if (ch === ';' && this.depth === 0) {
101
+ this.buf += ';';
102
+ emit();
103
+ i++;
104
+ if (this.data) {
105
+ if (line.slice(i).trim()) throw new ScriptError('COPY data must start on the next line', this.lineNo);
106
+ return out;
107
+ }
108
+ continue;
109
+ } else if (/[A-Za-z_]/.test(ch) && !IDENT_CHAR.test(this.buf.slice(-1))) {
110
+ // a word: BEGIN / CASE / END count in a routine's SQL-standard body
111
+ let j = i;
112
+ while (j < n && IDENT_CHAR.test(line[j])) j++;
113
+ const word = line.slice(i, j).toLowerCase();
114
+ if ((word === 'begin' || word === 'case' || word === 'end') && ROUTINE.test(this.buf.trimStart())) {
115
+ if (word === 'begin') this.depth++;
116
+ else if (word === 'case' && this.depth > 0) this.depth++;
117
+ else if (word === 'end' && this.depth > 0) this.depth--;
118
+ }
119
+ this.buf += line.slice(i, j);
120
+ i = j;
121
+ continue;
122
+ }
123
+ this.buf += ch;
124
+ i++;
125
+ continue;
126
+ }
127
+ if (st.q === 'block') {
128
+ if (ch === '/' && line[i + 1] === '*') {
129
+ st.depth++;
130
+ i += 2;
131
+ } else if (ch === '*' && line[i + 1] === '/') {
132
+ st.depth--;
133
+ i += 2;
134
+ if (st.depth === 0) this.state = { q: 'none' };
135
+ } else i++;
136
+ continue;
137
+ }
138
+ if (st.q === 'dollar') {
139
+ if (ch === '$' && line.startsWith(st.tag, i)) {
140
+ this.buf += st.tag;
141
+ i += st.tag.length;
142
+ this.state = { q: 'none' };
143
+ continue;
144
+ }
145
+ this.buf += ch;
146
+ i++;
147
+ continue;
148
+ }
149
+ // quotes: '' and "" escape themselves; E'' strings also \x
150
+ if (st.q === 'e' && ch === '\\') {
151
+ this.buf += line.slice(i, i + 2);
152
+ i += 2;
153
+ continue;
154
+ }
155
+ const close = st.q === 'd' ? '"' : "'";
156
+ this.buf += ch;
157
+ i++;
158
+ if (ch === close) {
159
+ if (line[i] === close) {
160
+ this.buf += close;
161
+ i++;
162
+ } else this.state = { q: 'none' };
163
+ }
164
+ }
165
+ if (this.state.q !== 'block') this.buf += '\n';
166
+ return out;
167
+ }
168
+
169
+ /** The end of the script: what is left must be nothing but whitespace. */
170
+ end() {
171
+ if (this.data) throw new ScriptError('the script ends inside COPY data (no "\\." line)', this.lineNo);
172
+ if (this.state.q !== 'none' && this.state.q !== 'block') throw new ScriptError('the script ends inside a quoted string', this.lineNo);
173
+ if (this.buf.trim()) throw new ScriptError(`the script ends without a semicolon: ${this.buf.trim().slice(0, 60)}`, this.lineNo);
174
+ }
175
+ }
176
+
177
+ // ------------------------------------------------------------------ checks
178
+
179
+ const ID = String.raw`(?:"(?:[^"]|"")+"|[A-Za-z_\u0080-￿][A-Za-z0-9_$\u0080-￿]*)`;
180
+ const QN = String.raw`(${ID})\s*\.\s*(${ID})`;
181
+ /** an identifier as PostgreSQL stores it */
182
+ export const ident = (s: string) => (s.startsWith('"') ? s.slice(1, -1).replace(/""/g, '"') : s.toLowerCase());
183
+ const re = (src: string) => new RegExp(src, 'is');
184
+
185
+ /** GUCs pg_dump and pg_restore set */
186
+ const SETTINGS = new Set([
187
+ 'statement_timeout', 'lock_timeout', 'idle_in_transaction_session_timeout', 'transaction_timeout', 'client_encoding',
188
+ 'standard_conforming_strings', 'check_function_bodies', 'xmloption', 'client_min_messages', 'row_security',
189
+ 'default_tablespace', 'default_table_access_method', 'default_with_oids', 'search_path', 'escape_string_warning',
190
+ ]);
191
+
192
+ const OBJECT = String.raw`(?:table|foreign\s+table|view|materialized\s+view|sequence|function|procedure|routine|aggregate|type|domain|index|statistics|collation|text\s+search\s+(?:configuration|dictionary|parser|template)|conversion)`;
193
+
194
+ const RULES: { re: RegExp; schemaGroup: number; qualified?: boolean }[] = [
195
+ // CREATE [OR REPLACE] [UNLOGGED|RECURSIVE] <object> [IF NOT EXISTS] schema.name
196
+ { re: re(String.raw`^create\s+(?:or\s+replace\s+)?(?:(?:unlogged|recursive)\s+)?${OBJECT}\s+(?:if\s+not\s+exists\s+)?${QN}`), schemaGroup: 1 },
197
+ // CREATE [UNIQUE] INDEX [CONCURRENTLY] [IF NOT EXISTS] name ON [ONLY] schema.table
198
+ { re: re(String.raw`^create\s+(?:unique\s+)?index\s+(?:concurrently\s+)?(?:if\s+not\s+exists\s+)?(?:${ID}\s+)?on\s+(?:only\s+)?${QN}`), schemaGroup: 1 },
199
+ // CREATE [OR REPLACE] [CONSTRAINT] TRIGGER name … ON schema.table
200
+ { re: re(String.raw`^create\s+(?:or\s+replace\s+)?(?:constraint\s+)?trigger\s+${ID}\s.*?\son\s+${QN}`), schemaGroup: 1 },
201
+ // CREATE / ALTER POLICY name ON schema.table; CREATE RULE name AS ON event TO schema.table
202
+ { re: re(String.raw`^(?:create|alter)\s+policy\s+${ID}\s+on\s+${QN}`), schemaGroup: 1 },
203
+ { re: re(String.raw`^create\s+(?:or\s+replace\s+)?rule\s+${ID}\s+as\s+on\s+\w+\s+to\s+${QN}`), schemaGroup: 1 },
204
+ // ALTER <object> [IF EXISTS] [ONLY] schema.name …
205
+ { re: re(String.raw`^alter\s+${OBJECT}\s+(?:if\s+exists\s+)?(?:only\s+)?${QN}`), schemaGroup: 1 },
206
+ // COMMENT ON CONSTRAINT|TRIGGER|POLICY|RULE name ON [DOMAIN] schema.table; COMMENT ON <kind> schema.name
207
+ { re: re(String.raw`^comment\s+on\s+(?:constraint|trigger|policy|rule)\s+${ID}\s+on\s+(?:domain\s+)?${QN}`), schemaGroup: 1 },
208
+ { re: re(String.raw`^comment\s+on\s+(?:column|${OBJECT})\s+${QN}`), schemaGroup: 1 },
209
+ // GRANT / REVOKE … ON [kind] schema.name
210
+ { re: re(String.raw`^(?:grant|revoke)\s+.+?\s+on\s+(?:table|sequence|function|procedure|routine|type|domain|foreign\s+table)?\s*${QN}`), schemaGroup: 1 },
211
+ // DROP <object> [IF EXISTS] schema.name; DROP TRIGGER|POLICY|RULE [IF EXISTS] name ON schema.table (pg_restore --clean)
212
+ { re: re(String.raw`^drop\s+${OBJECT}\s+(?:if\s+exists\s+)?${QN}`), schemaGroup: 1 },
213
+ { re: re(String.raw`^drop\s+(?:trigger|policy|rule)\s+(?:if\s+exists\s+)?${ID}\s+on\s+${QN}`), schemaGroup: 1 },
214
+ // REFRESH MATERIALIZED VIEW schema.name
215
+ { re: re(String.raw`^refresh\s+materialized\s+view\s+(?:concurrently\s+)?${QN}`), schemaGroup: 1 },
216
+ ];
217
+
218
+ /** Why a statement of a dump of `schema` may not run here, or null when it may. */
219
+ export function checkStatement(sql: string, schema: string): string | null {
220
+ const s = sql.trim();
221
+ const outside = (name: string) => `it changes an object outside the dump's schema ${schema} (${name})`;
222
+ // settings
223
+ let m = re(String.raw`^set\s+(?:session\s+)?([a-z_]+)\s*(?:=|to)\s*(.*?)\s*;$`).exec(s);
224
+ if (m) {
225
+ const name = m[1].toLowerCase();
226
+ if (!SETTINGS.has(name)) return `SET ${name} is not something a dump sets`;
227
+ if (name === 'search_path' && !/^(''|"")$/.test(m[2])) return 'a dump runs with an empty search_path (names are schema-qualified)';
228
+ return null;
229
+ }
230
+ if (/^select\s+pg_catalog\.set_config\(\s*'search_path'\s*,\s*''\s*,\s*false\s*\)\s*;$/i.test(s)) return null;
231
+ m = /^select\s+pg_catalog\.setval\(\s*'((?:[^']|'')+)'\s*,\s*-?\d+\s*,\s*(?:true|false)\s*\)\s*;$/i.exec(s);
232
+ if (m) {
233
+ const n = re(String.raw`^${QN}$`).exec(m[1].replace(/''/g, "'"));
234
+ if (!n) return 'setval needs a schema-qualified sequence';
235
+ return ident(n[1]) === schema ? null : outside(m[1]);
236
+ }
237
+ // COPY schema.table [(columns)] FROM stdin
238
+ if (/^copy\b/i.test(s)) {
239
+ m = re(String.raw`^copy\s+${QN}\s*(?:\(\s*${ID}(?:\s*,\s*${ID})*\s*\))?\s+from\s+stdin\s*;$`).exec(s);
240
+ if (!m) return 'only COPY … FROM stdin of a schema-qualified table runs here';
241
+ return ident(m[1]) === schema ? null : outside(`${m[1]}.${m[2]}`);
242
+ }
243
+ // the schema itself
244
+ m = re(String.raw`^(?:create\s+schema|comment\s+on\s+schema|drop\s+schema\s+(?:if\s+exists\s+)?)\s*(${ID})`).exec(s);
245
+ if (m) return ident(m[1]) === schema ? null : outside(m[1]);
246
+ m = re(String.raw`^(?:grant|revoke)\s+.+?\s+on\s+schema\s+(${ID})`).exec(s);
247
+ if (m) return ident(m[1]) === schema ? null : outside(m[1]);
248
+ if (/^(grant|revoke)\b/i.test(s) && !/\son\s/i.test(s)) return 'role memberships are not granted from a dump';
249
+ // never: ownership, moving objects to another schema
250
+ if (/^alter\b/i.test(s) && /\bowner\s+to\b/i.test(s)) return 'OWNER TO is not run (pg_restore runs with --no-owner)';
251
+ if (/^alter\b/i.test(s) && /\bset\s+schema\b/i.test(s)) return 'SET SCHEMA would move an object out of the dump\'s schema';
252
+ if (/^create\s+(?:or\s+replace\s+)?(?:temp|temporary)\b/i.test(s)) return 'temporary objects are not part of a dump';
253
+ for (const r of RULES) {
254
+ m = r.re.exec(s);
255
+ if (m) return ident(m[r.schemaGroup]) === schema ? null : outside(`${m[r.schemaGroup]}.${m[r.schemaGroup + 1]}`);
256
+ }
257
+ // schema-level ALTER TABLE … ADD CONSTRAINT and others have matched above; anything else is not from pg_dump
258
+ const head = s.split(/\s+/).slice(0, 3).join(' ').replace(/[;(].*$/, '');
259
+ return `"${head}" is not something pg_dump writes for one schema`;
260
+ }
261
+
262
+ // ------------------------------------------------------------------ COPY FROM STDIN
263
+
264
+ interface Conn {
265
+ query(text: string): void;
266
+ sendCopyFromChunk(chunk: Buffer): void;
267
+ endCopyFrom(): void;
268
+ sendCopyFail(msg: string): void;
269
+ stream: NodeJS.WritableStream & { writableNeedDrain?: boolean };
270
+ }
271
+
272
+ /** A COPY … FROM STDIN as a pg submittable: write() chunks, then end(). */
273
+ class CopyIn {
274
+ callback?: (err: Error | null) => void;
275
+ rows = 0;
276
+ private conn: Conn | null = null;
277
+ private error: Error | null = null;
278
+ private started!: Promise<void>;
279
+ private resolveStart!: () => void;
280
+ private rejectStart!: (e: Error) => void;
281
+ readonly done: Promise<number>;
282
+
283
+ constructor(public readonly text: string) {
284
+ this.started = new Promise((res, rej) => {
285
+ this.resolveStart = res;
286
+ this.rejectStart = rej;
287
+ });
288
+ this.started.catch(() => {});
289
+ this.done = new Promise((res, rej) => {
290
+ this.callback = (err) => (err ? rej(err) : res(this.rows));
291
+ });
292
+ this.done.catch(() => {});
293
+ }
294
+
295
+ submit(connection: Conn) {
296
+ connection.query(this.text);
297
+ }
298
+ handleCopyInResponse(connection: Conn) {
299
+ this.conn = connection;
300
+ this.resolveStart();
301
+ }
302
+ handleCommandComplete(msg: { text?: string }) {
303
+ const m = /^COPY (\d+)/.exec(msg.text ?? '');
304
+ if (m) this.rows = Number(m[1]);
305
+ }
306
+ handleReadyForQuery() {
307
+ this.callback?.(this.error);
308
+ }
309
+ handleError(err: Error) {
310
+ // pg's client forgets the query on an error: its ReadyForQuery doesn't come here
311
+ this.error = err;
312
+ this.rejectStart(err);
313
+ this.callback?.(err);
314
+ }
315
+ handleRowDescription() {}
316
+ handleDataRow() {}
317
+ handlePortalSuspended() {}
318
+ handleEmptyQuery() {}
319
+ handleCopyData() {}
320
+
321
+ async write(chunk: string) {
322
+ await this.started;
323
+ if (this.error) return;
324
+ this.conn!.sendCopyFromChunk(Buffer.from(chunk, 'utf8'));
325
+ if (this.conn!.stream.writableNeedDrain) await new Promise((res) => this.conn!.stream.once('drain', res));
326
+ }
327
+ async end(failure?: string) {
328
+ await this.started.catch(() => {});
329
+ if (this.conn) {
330
+ if (failure && !this.error) this.conn.sendCopyFail(failure);
331
+ else this.conn.endCopyFrom();
332
+ }
333
+ return this.done;
334
+ }
335
+ }
336
+
337
+ export interface ScriptStats {
338
+ statements: number;
339
+ tables: number;
340
+ rows: number;
341
+ }
342
+
343
+ const CHUNK = 256 * 1024;
344
+
345
+ /**
346
+ * Runs a pg_restore script on `client` (in the caller's transaction): every statement checked against `schema`, COPY
347
+ * data streamed. Throws a ScriptError (the statement's line) on a refused statement or the server's error.
348
+ */
349
+ export async function runScript(client: pg.ClientBase, lines: AsyncIterable<string>, schema: string, log: (line: string) => void): Promise<ScriptStats> {
350
+ const lexer = new ScriptLexer();
351
+ const stats: ScriptStats = { statements: 0, tables: 0, rows: 0 };
352
+ let copy: { q: CopyIn; buf: string; sql: string; line: number } | null = null;
353
+ const flush = async () => {
354
+ if (copy && copy.buf) {
355
+ try {
356
+ await copy.q.write(copy.buf);
357
+ } catch (e) {
358
+ throw new ScriptError(`${(e as Error).message} (${copy.sql.slice(0, 80)})`, copy.line);
359
+ }
360
+ copy.buf = '';
361
+ }
362
+ };
363
+ try {
364
+ for await (const line of lines) {
365
+ for (const ev of lexer.push(line)) {
366
+ if (ev.type === 'data') {
367
+ copy!.buf += ev.text;
368
+ if (copy!.buf.length >= CHUNK) await flush();
369
+ continue;
370
+ }
371
+ if (ev.type === 'copyEnd') {
372
+ await flush();
373
+ const c = copy!;
374
+ copy = null;
375
+ try {
376
+ const rows = await c.q.end();
377
+ stats.rows += rows;
378
+ stats.tables++;
379
+ log(`${c.sql.replace(/\s+from\s+stdin\s*;$/i, '').replace(/^copy\s+/i, '')}: ${rows} row${rows === 1 ? '' : 's'}`);
380
+ } catch (e) {
381
+ throw new ScriptError(`${(e as Error).message} (${c.sql.slice(0, 80)})`, c.line);
382
+ }
383
+ continue;
384
+ }
385
+ const refusal = checkStatement(ev.sql, schema);
386
+ if (refusal) throw new ScriptError(`refused: ${refusal}`, ev.line);
387
+ stats.statements++;
388
+ if (ev.type === 'copy') {
389
+ const q = new CopyIn(ev.sql);
390
+ client.query(q as unknown as pg.QueryConfig);
391
+ copy = { q, buf: '', sql: ev.sql, line: ev.line };
392
+ continue;
393
+ }
394
+ try {
395
+ await client.query(ev.sql);
396
+ } catch (e) {
397
+ throw new ScriptError(`${(e as Error).message} (${ev.sql.slice(0, 80).replace(/\s+/g, ' ')})`, ev.line);
398
+ }
399
+ }
400
+ }
401
+ lexer.end();
402
+ return stats;
403
+ } catch (e) {
404
+ if (copy) await copy.q.end(String((e as Error).message).slice(0, 200)).catch(() => {});
405
+ throw e;
406
+ }
407
+ }
package/src/pgcron.ts ADDED
@@ -0,0 +1,225 @@
1
+ import pg from 'pg';
2
+ import { createHash } from 'node:crypto';
3
+ import { owner, ownerUrl } from './db.ts';
4
+ import { parseSchedule, ScheduleError } from './cron-schedule.ts';
5
+
6
+ // (sprint 101, Database Actions DA-12) pg_cron for SQL Workshop › Scheduling (src/builder/scheduling.ts): whether it
7
+ // can be used here, its jobs and runs, the SQL that changes them (shown to the administrator before it runs) and
8
+ // Run now. Schedules are parsed and explained by src/cron-schedule.ts; pg_cron itself decides what it accepts.
9
+
10
+ export type CronState =
11
+ | { ok: true; version: string; timeZone: string; user: string; database: string; seesAll: boolean }
12
+ | { ok: false; reason: 'not_available' | 'not_loaded' | 'other_database' | 'not_created' | 'no_access' | 'old'; database: string; cronDatabase: string | null; message?: string };
13
+
14
+ const FUNCTIONS = ['cron.schedule_in_database(text,text,text,text,text,boolean)', 'cron.alter_job(bigint,text,text,text,text,boolean)', 'cron.unschedule(bigint)'];
15
+
16
+ /** Can pg_cron be used from pgkiln's owner connection, and if not, why. */
17
+ export async function cronState(): Promise<CronState> {
18
+ const info = (await owner.one<{ version: string | null; available: boolean; preload: string; cron_db: string | null; database: string; user: string; tz: string | null; job: boolean; funcs: boolean; sees_all: boolean }>(
19
+ `select (select extversion from pg_extension where extname = 'pg_cron') as version,
20
+ exists (select 1 from pg_available_extensions where name = 'pg_cron') as available,
21
+ current_setting('shared_preload_libraries') as preload,
22
+ current_setting('cron.database_name', true) as cron_db,
23
+ current_database() as database, current_user as "user",
24
+ current_setting('cron.timezone', true) as tz,
25
+ to_regclass('cron.job') is not null and to_regclass('cron.job_run_details') is not null as job,
26
+ (select bool_and(to_regprocedure(f) is not null) from unnest($1::text[]) f) as funcs,
27
+ (select rolsuper or rolbypassrls from pg_roles where rolname = current_user) as sees_all`, [FUNCTIONS]))!;
28
+ const base = { database: info.database, cronDatabase: info.cron_db };
29
+ // a cron schema with pg_cron's tables and functions counts as installed (tests stand in for pg_cron with one)
30
+ if (!info.version && !info.job) {
31
+ if (!info.available) return { ok: false, reason: 'not_available', ...base };
32
+ if (!/(^|[\s,])"?pg_cron"?($|[\s,])/.test(info.preload)) return { ok: false, reason: 'not_loaded', ...base };
33
+ if ((info.cron_db ?? 'postgres') !== info.database) return { ok: false, reason: 'other_database', ...base, cronDatabase: info.cron_db ?? 'postgres' };
34
+ return { ok: false, reason: 'not_created', ...base };
35
+ }
36
+ if (!info.funcs) return { ok: false, reason: 'old', ...base, message: info.version ?? undefined };
37
+ try {
38
+ await owner.query('select 1 from cron.job limit 1');
39
+ await owner.query('select 1 from cron.job_run_details limit 1');
40
+ } catch (e) {
41
+ return { ok: false, reason: 'no_access', ...base, message: (e as Error).message };
42
+ }
43
+ return { ok: true, version: info.version ?? '(no extension)', timeZone: info.tz || 'GMT', user: info.user, database: info.database, seesAll: info.sees_all };
44
+ }
45
+
46
+ export interface Job {
47
+ jobid: string;
48
+ jobname: string | null;
49
+ schedule: string;
50
+ command: string;
51
+ database: string;
52
+ username: string;
53
+ active: boolean;
54
+ last_status: string | null;
55
+ last_start: string | null;
56
+ last_secs: number | null;
57
+ last_message: string | null;
58
+ failures: number;
59
+ }
60
+
61
+ const JOBS_SQL = `
62
+ select j.jobid::text as jobid, j.jobname, j.schedule, j.command, j.database, j.username, j.active,
63
+ r.status as last_status, to_char(r.start_time at time zone $1, 'YYYY-MM-DD HH24:MI:SS') as last_start,
64
+ round(extract(epoch from r.end_time - r.start_time)::numeric, 3)::float8 as last_secs, r.return_message as last_message,
65
+ (select count(*)::int from (select status from cron.job_run_details f where f.jobid = j.jobid order by f.runid desc limit 10) x where x.status = 'failed') as failures
66
+ from cron.job j
67
+ left join lateral (select d.status, d.start_time, d.end_time, d.return_message from cron.job_run_details d
68
+ where d.jobid = j.jobid order by d.runid desc limit 1) r on true`;
69
+
70
+ export const listJobs = async (timeZone: string) => (await owner.query<Job>(`${JOBS_SQL} order by lower(coalesce(j.jobname, '')), j.jobid`, [timeZone])).rows;
71
+ export const oneJob = async (timeZone: string, id: string) =>
72
+ /^\d{1,18}$/.test(id) ? ((await owner.one<Job>(`${JOBS_SQL} where j.jobid = $2::bigint`, [timeZone, id])) ?? null) : null;
73
+
74
+ export interface Run {
75
+ runid: string;
76
+ jobid: string | null;
77
+ jobname: string | null;
78
+ database: string | null;
79
+ username: string | null;
80
+ command: string | null;
81
+ status: string | null;
82
+ return_message: string | null;
83
+ start: string | null;
84
+ secs: number | null;
85
+ }
86
+
87
+ export const RUN_STATUSES = ['starting', 'running', 'sending', 'connecting', 'succeeded', 'failed'];
88
+
89
+ /** Runs, newest first: of one job, with a status, or whose message or command contains a text. */
90
+ export async function listRuns(timeZone: string, f: { job?: string; status?: string; q?: string; limit?: number }) {
91
+ const where: string[] = [];
92
+ const params: unknown[] = [timeZone];
93
+ if (f.job && /^\d{1,18}$/.test(f.job)) where.push(`d.jobid = $${params.push(f.job)}::bigint`);
94
+ if (f.status && RUN_STATUSES.includes(f.status)) where.push(`d.status = $${params.push(f.status)}`);
95
+ if (f.q) where.push(`(strpos(lower(d.return_message), lower($${params.push(f.q)})) > 0 or strpos(lower(d.command), lower($${params.length})) > 0)`);
96
+ return (await owner.query<Run>(
97
+ `select d.runid::text as runid, d.jobid::text as jobid, j.jobname, d.database, d.username, d.command, d.status, d.return_message,
98
+ to_char(d.start_time at time zone $1, 'YYYY-MM-DD HH24:MI:SS') as start,
99
+ round(extract(epoch from d.end_time - d.start_time)::numeric, 3)::float8 as secs
100
+ from cron.job_run_details d left join cron.job j on j.jobid = d.jobid
101
+ ${where.length ? `where ${where.join(' and ')}` : ''} order by d.runid desc limit ${Math.min(Math.max(f.limit ?? 200, 1), 1000)}`, params)).rows;
102
+ }
103
+
104
+ /** Runs older than a number of days (or all of one job's), as the purge would delete them. */
105
+ export async function countPurge(days: number | null, job: string | null) {
106
+ const p = purgeWhere(days, job);
107
+ return (await owner.one<{ n: number }>(`select count(*)::int as n from cron.job_run_details ${p}`))!.n;
108
+ }
109
+
110
+ // ------------------------------------------------------------------ SQL
111
+
112
+ export interface JobSpec {
113
+ name: string;
114
+ schedule: string;
115
+ command: string;
116
+ database: string;
117
+ username: string;
118
+ active: boolean;
119
+ }
120
+
121
+ export const lit = (v: string) => pg.escapeLiteral(v);
122
+
123
+ /** The command as a dollar-quoted string (readable when it has quotes and lines), with a tag it doesn't contain. */
124
+ export function dollar(v: string) {
125
+ let tag = '$cmd$';
126
+ for (let i = 1; v.includes(tag) || v.endsWith(tag.slice(0, -1)); i++) tag = `$cmd${i}$`;
127
+ return `${tag}${v}${tag}`;
128
+ }
129
+
130
+ /** What is wrong with a job (empty: nothing). */
131
+ export function checkJob(spec: JobSpec, o: { databases: string[]; users: string[] }) {
132
+ const errors: string[] = [];
133
+ if (!spec.name) errors.push('Give the job a name.');
134
+ else if (spec.name.length > 100) errors.push('A name is at most 100 characters.');
135
+ else if (/[\x00-\x1f\x7f]/.test(spec.name)) errors.push('A name has no control characters.');
136
+ try {
137
+ parseSchedule(spec.schedule);
138
+ } catch (e) {
139
+ if (!(e instanceof ScheduleError)) throw e;
140
+ errors.push(`Schedule: ${e.message}`);
141
+ }
142
+ if (!spec.command.trim()) errors.push('Enter the SQL the job runs.');
143
+ else if (spec.command.length > 100_000) errors.push('The command is at most 100,000 characters.');
144
+ else if (spec.command.includes('\0')) errors.push('The command has a NUL character.');
145
+ if (!o.databases.includes(spec.database)) errors.push('Choose a database that accepts connections.');
146
+ if (!o.users.includes(spec.username)) errors.push('Choose a role that can sign in to run the job as.');
147
+ return errors;
148
+ }
149
+
150
+ /** The statements that create (before null) or change a job. */
151
+ export function jobSql(before: (JobSpec & { jobid: string }) | null, spec: JobSpec): string[] {
152
+ if (!before) {
153
+ return [`select cron.schedule_in_database(\n job_name := ${lit(spec.name)},\n schedule := ${lit(spec.schedule)},\n command := ${dollar(spec.command)},\n database := ${lit(spec.database)},\n username := ${lit(spec.username)},\n active := ${spec.active}\n);`];
154
+ }
155
+ const set: string[] = [];
156
+ if (spec.schedule !== before.schedule) set.push(`schedule := ${lit(spec.schedule)}`);
157
+ if (spec.command !== before.command) set.push(`command := ${dollar(spec.command)}`);
158
+ if (spec.database !== before.database) set.push(`database := ${lit(spec.database)}`);
159
+ if (spec.username !== before.username) set.push(`username := ${lit(spec.username)}`);
160
+ if (spec.active !== before.active) set.push(`active := ${spec.active}`);
161
+ const out = set.length ? [`select cron.alter_job(\n job_id := ${BigInt(before.jobid)},\n ${set.join(',\n ')}\n);`] : [];
162
+ // pg_cron's alter_job has no name: the name is cron.job's own column (unique per user)
163
+ if (spec.name !== before.name) out.push(`update cron.job set jobname = ${lit(spec.name)} where jobid = ${BigInt(before.jobid)};`);
164
+ return out;
165
+ }
166
+
167
+ export const activeSql = (jobid: string, active: boolean) => [`select cron.alter_job(job_id := ${BigInt(jobid)}, active := ${active});`];
168
+ export const unscheduleSql = (jobid: string) => [`select cron.unschedule(job_id := ${BigInt(jobid)});`];
169
+
170
+ function purgeWhere(days: number | null, job: string | null) {
171
+ const w: string[] = [];
172
+ if (job !== null) w.push(`jobid = ${BigInt(job)}`);
173
+ if (days !== null) w.push(`end_time < now() - interval '${Math.trunc(days)} days'`);
174
+ return w.length ? `where ${w.join(' and ')}` : '';
175
+ }
176
+ export const purgeSql = (days: number | null, job: string | null) => [`delete from cron.job_run_details ${purgeWhere(days, job)}`.trim() + ';'];
177
+
178
+ /** A digest of reviewed statements: Apply runs only what the review showed. */
179
+ export const reviewDigest = (stmts: string[]) => createHash('sha256').update(stmts.join('\n')).digest('base64url');
180
+
181
+ /** Run reviewed statements in one transaction; the error message when they fail (nothing changed). */
182
+ export async function applySql(stmts: string[]): Promise<string> {
183
+ try {
184
+ await owner.tx(async (c) => {
185
+ await c.query(`set local lock_timeout = '10s'`);
186
+ for (const x of stmts) await c.query(x);
187
+ });
188
+ return '';
189
+ } catch (e) {
190
+ return (e as Error).message;
191
+ }
192
+ }
193
+
194
+ // ------------------------------------------------------------------ Run now
195
+
196
+ export interface RunNow { ok: boolean; ms: number; message: string; notices: string[] }
197
+
198
+ /**
199
+ * Run a job's command once, now, as pg_cron would: in the job's database (a new connection with the owner's login
200
+ * to it), as the job's user (SET ROLE when it isn't the owner), outside a transaction block so VACUUM works, each
201
+ * statement in its own transaction unless the command says otherwise. pg_cron doesn't record the run (it has no
202
+ * run-now): the caller logs it.
203
+ */
204
+ export async function runNow(job: Pick<Job, 'command' | 'database' | 'username'>, timeoutMs = 600_000): Promise<RunNow> {
205
+ const url = new URL(ownerUrl);
206
+ url.pathname = `/${encodeURIComponent(job.database)}`;
207
+ const client = new pg.Client({ connectionString: url.toString(), application_name: 'pgkiln-cron-run-now', statement_timeout: timeoutMs, connectionTimeoutMillis: 10_000 });
208
+ const notices: string[] = [];
209
+ client.on('notice', (n) => notices.length < 100 && notices.push(`${n.severity ?? 'NOTICE'}: ${n.message}`));
210
+ const t0 = performance.now();
211
+ try {
212
+ await client.connect();
213
+ const me = (await client.query<{ u: string }>('select current_user as u')).rows[0].u;
214
+ if (job.username !== me) await client.query(`set role ${pg.escapeIdentifier(job.username)}`);
215
+ // the simple query protocol: several statements, and no transaction block around them (as pg_cron runs them)
216
+ const res = await client.query(job.command);
217
+ const last = Array.isArray(res) ? res[res.length - 1] : res;
218
+ const tag = last ? `${last.command ?? ''}${last.rowCount !== null && last.rowCount !== undefined ? ` ${last.rowCount}` : ''}`.trim() : '';
219
+ return { ok: true, ms: performance.now() - t0, message: tag || 'done', notices };
220
+ } catch (e) {
221
+ return { ok: false, ms: performance.now() - t0, message: (e as Error).message, notices };
222
+ } finally {
223
+ await client.end().catch(() => undefined);
224
+ }
225
+ }