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.
- package/SECURITY.md +2 -2
- package/db/migrations/116_dump_jobs.sql +41 -0
- package/db/migrations/117_rest_handlers.sql +63 -0
- package/db/migrations/118_change_log.sql +54 -0
- package/docs/apex-feature-parity.md +7 -7
- package/docs/guide/01-installation.md +4 -0
- package/docs/guide/03-builder.md +179 -5
- package/docs/guide/08-security.md +11 -0
- package/docs/guide/12-development.md +44 -6
- package/docs/guide/13-rest-api.md +59 -1
- package/docs/guide/15-extensions.md +1 -1
- package/examples/hr/hr_62_rest_module.sql +39 -0
- package/package.json +1 -1
- package/public/app.css +1 -1
- package/public/builder-icons.svg +2 -0
- package/public/builder.css +420 -173
- package/public/builder.js +48 -0
- package/public/sql-monitor.js +73 -0
- package/src/app.ts +6 -0
- package/src/automations.ts +6 -0
- package/src/binds.ts +2 -1
- package/src/builder/advisor.ts +24 -2
- package/src/builder/autorest.ts +3 -2
- package/src/builder/changelog.ts +240 -0
- package/src/builder/components.ts +1 -1
- package/src/builder/datapump.ts +364 -0
- package/src/builder/ddl-tools.ts +13 -4
- package/src/builder/find-workspace.ts +2 -2
- package/src/builder/plan-view.ts +25 -0
- package/src/builder/rest-modules.ts +629 -0
- package/src/builder/rest.ts +1 -0
- package/src/builder/routes.ts +4 -2
- package/src/builder/scheduling.ts +495 -0
- package/src/builder/sql-monitor.ts +397 -0
- package/src/builder/sql-tools.ts +6 -2
- package/src/builder/sql-utilities.ts +7 -39
- package/src/builder/sql.ts +6 -0
- package/src/builder/table-editor.ts +4 -1
- package/src/builder/top-sql.ts +24 -6
- package/src/builder/ui.ts +6 -4
- package/src/builder/worksheet.ts +5 -21
- package/src/changelog.ts +109 -0
- package/src/cron-schedule.ts +282 -0
- package/src/datapump.ts +546 -0
- package/src/dumpscript.ts +407 -0
- package/src/pgcron.ts +225 -0
- package/src/runtime/rest-handlers.ts +75 -0
- package/src/runtime/rest.ts +116 -22
package/src/builder/ui.ts
CHANGED
|
@@ -53,7 +53,7 @@ export type Section = 'apps' | 'users' | 'sql' | 'developers' | 'gallery';
|
|
|
53
53
|
// app.css / app.js (the runtime never loads them). Add new builder assets
|
|
54
54
|
// here, and only here.
|
|
55
55
|
export const BUILDER_STYLES = ['/static/builder.css', '/static/code-editor.css', '/static/worksheet.css', '/static/data-modeler.css'];
|
|
56
|
-
export const BUILDER_SCRIPTS = ['/static/builder.js', '/static/code-editor.js', '/static/worksheet.js', '/static/palette.js', '/static/data-modeler.js'];
|
|
56
|
+
export const BUILDER_SCRIPTS = ['/static/builder.js', '/static/code-editor.js', '/static/worksheet.js', '/static/palette.js', '/static/data-modeler.js', '/static/sql-monitor.js'];
|
|
57
57
|
export const builderHead = () =>
|
|
58
58
|
html`${BUILDER_STYLES.map((href) => html`<link rel="stylesheet" href="${href}">\n`)}${BUILDER_SCRIPTS.map((src) => html`<script src="${src}" defer></script>\n`)}`;
|
|
59
59
|
|
|
@@ -61,12 +61,14 @@ export const builderHead = () =>
|
|
|
61
61
|
export const BUILDER_ICONS = [
|
|
62
62
|
'builder', 'back', 'down', 'up', 'left', 'right', 'undo', 'redo', 'zoom-in', 'zoom-out', 'expand', 'help', 'wrench',
|
|
63
63
|
'region', 'item', 'button', 'grip', 'arrow-up', 'arrow-down', 'narrow', 'wide', 'sun', 'moon', 'monitor',
|
|
64
|
-
'rendering', 'processing', 'shapes', 'save', 'dots', 'pages',
|
|
64
|
+
'rendering', 'processing', 'shapes', 'save', 'dots', 'pages', 'kiln',
|
|
65
65
|
] as const;
|
|
66
66
|
export function bicon(name: string, cls = 'icon') {
|
|
67
67
|
if (!(BUILDER_ICONS as readonly string[]).includes(name)) return icon(name, cls);
|
|
68
68
|
return html`<svg class="${cls}" aria-hidden="true" focusable="false"><use href="/static/builder-icons.svg#b-${name}"></use></svg>`;
|
|
69
69
|
}
|
|
70
|
+
/** (sprint 105) the pgkiln brand mark (an ember flame) for the rail and the sign-in pages. */
|
|
71
|
+
export const brandMark = () => bicon('kiln');
|
|
70
72
|
|
|
71
73
|
// ------------------------------------------------------------------ theme
|
|
72
74
|
// The builder's own light/dark choice (separate from the runtime's): kept in
|
|
@@ -113,7 +115,7 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
|
|
|
113
115
|
`${title} · Builder`,
|
|
114
116
|
html`<a class="skip-link" href="#main">Skip to content</a>
|
|
115
117
|
<nav class="ide-rail" aria-label="Builder">
|
|
116
|
-
<a class="rail-logo" href="${BASE}" title="pgkiln Builder"
|
|
118
|
+
<a class="rail-logo" href="${BASE}" title="pgkiln Builder">${brandMark()}<span class="sr-only">pgkiln Builder home</span></a>
|
|
117
119
|
<ul class="rail-list">
|
|
118
120
|
${rail('apps', BASE, 'builder', 'App Builder')}
|
|
119
121
|
${s.workspace?.sql_workshop === false ? '' : rail('sql', `${BASE}/sql`, 'database', 'SQL Workshop')}
|
|
@@ -252,7 +254,7 @@ export const appHeader = (a: any, active: 'pages' | 'shared' | 'settings' | 'act
|
|
|
252
254
|
};
|
|
253
255
|
|
|
254
256
|
/** Tabs of the SQL Workshop. */
|
|
255
|
-
export type WorkshopTab = 'home' | 'sql' | 'scripts' | 'quicksql' | 'query' | 'objects' | 'load' | 'unload' | 'sample' | 'ai' | 'ddl' | 'compare' | 'utilities' | 'charts' | 'modeler' | 'rest';
|
|
257
|
+
export type WorkshopTab = 'home' | 'sql' | 'scripts' | 'quicksql' | 'query' | 'objects' | 'load' | 'unload' | 'sample' | 'ai' | 'ddl' | 'compare' | 'utilities' | 'charts' | 'modeler' | 'rest' | 'scheduling' | 'datapump' | 'monitor' | 'changes';
|
|
256
258
|
export const workshopTabs = (active: WorkshopTab) => {
|
|
257
259
|
const tab = (key: string, href: string, ic: string, label: string) =>
|
|
258
260
|
html`<a class="ide-tab" href="${href}"${active === key ? raw(' aria-current="page"') : ''}>${icon(ic)}<span>${label}</span></a>`;
|
package/src/builder/worksheet.ts
CHANGED
|
@@ -14,8 +14,9 @@ import { currentWorkspace, inWorkspaceSql, sessionSchema, usableSchemas, worksho
|
|
|
14
14
|
import { applySubstitutions, explainStatement, pickStatements, runStatements, substitutionNames, textTable, type RunMode, type WorksheetRun } from '../sqlworksheet.ts';
|
|
15
15
|
import { applyBinds, bindNames } from '../binds.ts';
|
|
16
16
|
import { formatSql } from '../sqlformat.ts';
|
|
17
|
-
import
|
|
17
|
+
import { planView } from './plan-view.ts';
|
|
18
18
|
import { unloadStatement } from '../unload.ts';
|
|
19
|
+
import { recordChanges } from '../changelog.ts';
|
|
19
20
|
|
|
20
21
|
// (sprint 91, Database Actions DA-1/DA-2) The SQL Worksheet: one screen with a navigator (schema, object
|
|
21
22
|
// type, filter; tables and views open to their columns and give SELECT / INSERT / UPDATE / DELETE
|
|
@@ -68,26 +69,6 @@ const HISTORY_SHOWN = 200;
|
|
|
68
69
|
|
|
69
70
|
interface HistoryRow { id: string; sql: string; status: 'ok' | 'error'; rows: string | null; ms: number | null; error: string | null; runs: number; at: string }
|
|
70
71
|
|
|
71
|
-
const fmtMs = (n: number) => (n >= 100 ? n.toFixed(0) : n >= 1 ? n.toFixed(1) : n.toFixed(3));
|
|
72
|
-
|
|
73
|
-
/** (sprint 92, DA-4) A plan as a tree of steps (open), with the text plan behind a toggle. */
|
|
74
|
-
function planView(plan: Plan, text: string): Raw {
|
|
75
|
-
const step = (n: PlanNode): Raw => html`<li><details open${n.slowest ? raw(' class="is-slowest"') : ''}>
|
|
76
|
-
<summary><span class="ws-plan-op">${n.op}</span>${n.target ? html` <span class="ws-plan-target">${n.target}</span>` : ''}
|
|
77
|
-
<span class="ws-plan-nums">cost ${n.startupCost.toFixed(2)}..${n.totalCost.toFixed(2)} · rows ${n.planRows} · width ${n.width}${plan.analyze
|
|
78
|
-
? n.neverRun ? html` · <em>never executed</em>` : html` · <strong>${fmtMs(n.actualTotal ?? 0)} ms</strong> × ${n.loops} · ${n.actualRows} row${n.actualRows === 1 ? '' : 's'}${n.selfMs !== undefined ? ` · self ${fmtMs(n.selfMs)} ms` : ''}${n.sharedHit || n.sharedRead ? ` · buffers ${n.sharedHit ?? 0} hit${n.sharedRead ? `, ${n.sharedRead} read` : ''}` : ''}`
|
|
79
|
-
: ''}</span>
|
|
80
|
-
${n.slowest ? html`<span class="tag tag-warn">slowest step</span>` : ''}
|
|
81
|
-
${n.misestimate ? html`<span class="tag tag-warn" title="Estimated ${n.planRows} rows, got ${n.actualRows}: ANALYZE the table, or look at the statistics">estimate ${n.misestimate >= 1 ? `${Math.round(n.misestimate)}× too high` : `${Math.round(1 / n.misestimate)}× too low`}</span>` : ''}
|
|
82
|
-
</summary>
|
|
83
|
-
${n.details.length ? html`<dl class="ws-plan-details">${n.details.map(([k, v]) => html`<dt>${k}</dt><dd><code>${v}</code></dd>`)}</dl>` : ''}
|
|
84
|
-
${n.children.length ? html`<ul class="ws-plan-tree">${n.children.map(step)}</ul>` : ''}
|
|
85
|
-
</details></li>`;
|
|
86
|
-
return html`${plan.planningMs !== undefined || plan.executionMs !== undefined ? html`<p class="muted small ws-info">${plan.planningMs !== undefined ? `Planning ${fmtMs(plan.planningMs)} ms` : ''}${plan.executionMs !== undefined ? ` · execution ${fmtMs(plan.executionMs)} ms (rolled back)` : ''}</p>` : ''}
|
|
87
|
-
<ul class="ws-plan-tree ws-plan-root">${step(plan.root)}</ul>
|
|
88
|
-
<details class="ws-plan-text"><summary>Text plan</summary><pre class="ws-log ws-plan">${text}</pre></details>`;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
72
|
/** (sprint 92, DA-5) Keep the statements of a run in SQL History: the same statement again right after counts on its row. */
|
|
92
73
|
async function recordHistory(s: Session, run: WorksheetRun) {
|
|
93
74
|
const ws = currentWorkspace(s);
|
|
@@ -406,6 +387,9 @@ async function worksheet(req: Req, reply: FastifyReply) {
|
|
|
406
387
|
if (mode === 'statement' || mode === 'script') {
|
|
407
388
|
clearCompletions(); // the statements may have changed tables or grants
|
|
408
389
|
if (run) await recordHistory(s, run);
|
|
390
|
+
// (sprint 106, DA-17) the DDL that succeeded goes into the change log
|
|
391
|
+
if (run) await recordChanges({ workspaceId: currentWorkspace(s), author: s.username ?? '?', source: 'worksheet', defaultSchema: searchSchema ?? 'public',
|
|
392
|
+
statements: run.results.filter((r) => r.status === 'ok').map((r) => r.sql) });
|
|
409
393
|
const detail = picked.map((x) => x.sql).join(';\n');
|
|
410
394
|
await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), elapsedMs: Math.round(performance.now() - started), detail: detail.length > 2000 ? `${detail.slice(0, 2000)}…` : detail });
|
|
411
395
|
}
|
package/src/changelog.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { owner } from './db.ts';
|
|
2
|
+
|
|
3
|
+
// (sprint 106, Database Actions DA-17; migration 118) A Liquibase-style change log: the DDL a developer runs from
|
|
4
|
+
// the SQL Worksheet, Table Properties and Schema Comparison is recorded as change sets (meta.change_set: author,
|
|
5
|
+
// time, SQL, schema, where it came from, a number per schema). SQL Workshop › Change Log (builder/changelog.ts)
|
|
6
|
+
// lists them per schema and writes them as migration files: plain SQL, a Liquibase formatted SQL changelog
|
|
7
|
+
// (--liquibase formatted sql, --changeset author:id) or Flyway versioned migrations (V0001__add_bonus.sql).
|
|
8
|
+
|
|
9
|
+
export type ChangeSource = 'worksheet' | 'table' | 'compare';
|
|
10
|
+
|
|
11
|
+
export interface ChangeSet {
|
|
12
|
+
id: number;
|
|
13
|
+
workspace_id: number;
|
|
14
|
+
schema_name: string;
|
|
15
|
+
seq: number;
|
|
16
|
+
name: string;
|
|
17
|
+
author: string;
|
|
18
|
+
source: ChangeSource;
|
|
19
|
+
sql: string;
|
|
20
|
+
object_name: string | null;
|
|
21
|
+
created_at: string;
|
|
22
|
+
exported_at: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Statements that change a schema's definition (as their first keyword says). */
|
|
26
|
+
const DDL = /^(create|alter|drop|comment|grant|revoke)\b/i;
|
|
27
|
+
/** DDL that is not about a schema: roles, databases, tablespaces, the server's settings, temporary objects. */
|
|
28
|
+
const NOT_SCHEMA = /^(create|alter|drop)\s+(or\s+replace\s+)?(role|user|group|database|tablespace|system|server|user\s+mapping|subscription|publication|event\s+trigger)\b|^(create|alter)\s+(global\s+|local\s+)?(temp|temporary|unlogged\s+temp)\b|^alter\s+(system|default\s+privileges\s+for\s+role)\b|^(grant|revoke)\s+(?![\s\S]*\bon\b)/i;
|
|
29
|
+
const LEAD = /^(\s*(--[^\n]*(\n|$)|\/\*[\s\S]*?\*\/))*\s*/;
|
|
30
|
+
|
|
31
|
+
const IDENT = String.raw`(?:"(?:[^"]|"")+"|[A-Za-z_][A-Za-z0-9_$]*)`;
|
|
32
|
+
const unquote = (s: string) => (s.startsWith('"') ? s.slice(1, -1).replace(/""/g, '"') : s.toLowerCase());
|
|
33
|
+
|
|
34
|
+
/** Whether a statement is schema DDL a change log keeps. */
|
|
35
|
+
export function isSchemaDdl(sql: string): boolean {
|
|
36
|
+
const s = sql.replace(LEAD, '');
|
|
37
|
+
return DDL.test(s) && !NOT_SCHEMA.test(s);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The schema and object a DDL statement is about: the first qualified name in it, else the default schema. */
|
|
41
|
+
export function ddlTarget(sql: string, defaultSchema: string): { schema: string; object: string | null } {
|
|
42
|
+
const s = sql.replace(LEAD, '');
|
|
43
|
+
const m = new RegExp(`(${IDENT})\\s*\\.\\s*(${IDENT})`).exec(s.replace(/'(?:[^']|'')*'/g, "''"));
|
|
44
|
+
if (m && !/^\d/.test(m[1])) return { schema: unquote(m[1]), object: unquote(m[2]) };
|
|
45
|
+
const bare = new RegExp(`^(?:create|alter|drop)\\s+(?:or\\s+replace\\s+)?(?:unique\\s+)?(?:materialized\\s+)?(?:table|view|index|sequence|function|procedure|type|domain|trigger|policy)\\s+(?:concurrently\\s+)?(?:if\\s+(?:not\\s+)?exists\\s+)?(${IDENT})`, 'i').exec(s);
|
|
46
|
+
return { schema: defaultSchema, object: bare ? unquote(bare[1]) : null };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A change set's name from its first statement: add_bonus, drop_bonus, create_orders, create_index_orders_ix, … */
|
|
50
|
+
export function changeName(sql: string): string {
|
|
51
|
+
const s = sql.replace(LEAD, '').replace(/\s+/g, ' ');
|
|
52
|
+
const clean = (x: string) => x.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 60) || 'change';
|
|
53
|
+
const col = new RegExp(`^alter table (?:if exists )?(?:only )?${IDENT}(?:\\.${IDENT})? (add|drop) (?:column )?(?:if (?:not )?exists )?(${IDENT})`, 'i').exec(s);
|
|
54
|
+
if (col && !/^(constraint|primary|unique|foreign|check)$/i.test(col[2])) return clean(`${col[1]}_${unquote(col[2])}`);
|
|
55
|
+
const kind = new RegExp(`^(create|alter|drop)(?: or replace)?(?: unique)?(?: materialized)? (table|view|index|sequence|function|procedure|type|domain|trigger|policy|schema|extension)(?: concurrently)?(?: if (?:not )?exists)? (${IDENT})(?:\\s*\\.\\s*(${IDENT}))?`, 'i').exec(s);
|
|
56
|
+
const { object } = ddlTarget(sql, '');
|
|
57
|
+
if (kind) return clean(`${kind[1]}_${kind[2].toLowerCase() === 'table' ? '' : `${kind[2]}_`}${unquote(kind[4] ?? kind[3])}`);
|
|
58
|
+
const verb = /^(comment|grant|revoke)\b/i.exec(s);
|
|
59
|
+
return clean(`${verb ? verb[1] : 'change'}_${object ?? ''}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The SQL of statements as one script, each ending with a semicolon. */
|
|
63
|
+
export const joinStatements = (statements: string[]) => statements.map((x) => x.trim().replace(/;*\s*$/, ';')).join('\n\n');
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Record statements that ran as change sets: those that are schema DDL, grouped by schema (one change set per
|
|
67
|
+
* schema, in the order they ran). Never fails the caller: a recording error is logged.
|
|
68
|
+
*/
|
|
69
|
+
export async function recordChanges(opts: { workspaceId: number; author: string; source: ChangeSource; statements: string[]; defaultSchema: string; name?: string }) {
|
|
70
|
+
const groups = new Map<string, { sql: string[]; object: string | null }>();
|
|
71
|
+
for (const st of opts.statements) {
|
|
72
|
+
if (!isSchemaDdl(st)) continue;
|
|
73
|
+
const t = ddlTarget(st, opts.defaultSchema);
|
|
74
|
+
const g = groups.get(t.schema) ?? { sql: [], object: t.object };
|
|
75
|
+
g.sql.push(st);
|
|
76
|
+
groups.set(t.schema, g);
|
|
77
|
+
}
|
|
78
|
+
const ids: number[] = [];
|
|
79
|
+
try {
|
|
80
|
+
for (const [schema, g] of groups) {
|
|
81
|
+
const sql = joinStatements(g.sql);
|
|
82
|
+
const r = await owner.one<{ id: number }>(
|
|
83
|
+
`insert into meta.change_set (workspace_id, schema_name, name, author, source, sql, object_name) values ($1, $2, $3, $4, $5, $6, $7) returning id`,
|
|
84
|
+
[opts.workspaceId, schema.slice(0, 63), opts.name ?? changeName(g.sql[0]), opts.author, opts.source, sql.slice(0, 1_000_000), g.object?.slice(0, 63) ?? null]);
|
|
85
|
+
ids.push(r!.id);
|
|
86
|
+
}
|
|
87
|
+
} catch (e) {
|
|
88
|
+
console.error('change log: could not record a change set:', (e as Error).message);
|
|
89
|
+
}
|
|
90
|
+
return ids;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Flyway's file name of a change set: V0001__add_bonus.sql. */
|
|
94
|
+
export const flywayName = (c: Pick<ChangeSet, 'seq' | 'name'>) => `V${String(c.seq).padStart(4, '0')}__${c.name}.sql`;
|
|
95
|
+
|
|
96
|
+
const header = (c: ChangeSet) => `-- ${c.schema_name} #${c.seq} ${c.name}: ${c.author}, ${c.created_at.slice(0, 19).replace('T', ' ')} (${c.source})`;
|
|
97
|
+
|
|
98
|
+
/** Change sets as plain SQL: one script, a comment before each. */
|
|
99
|
+
export const plainScript = (list: ChangeSet[]) => `${list.map((c) => `${header(c)}\n${c.sql.trim()}\n`).join('\n')}`;
|
|
100
|
+
|
|
101
|
+
/** Change sets as a Liquibase formatted SQL changelog. */
|
|
102
|
+
export function liquibaseChangelog(list: ChangeSet[]): string {
|
|
103
|
+
const author = (a: string) => a.replace(/[\s:]+/g, '_') || 'pgkiln';
|
|
104
|
+
return `--liquibase formatted sql\n\n${list.map((c) =>
|
|
105
|
+
`--changeset ${author(c.author)}:${String(c.seq).padStart(4, '0')}-${c.name}\n--comment: ${c.schema_name}, ${c.source}, ${c.created_at.slice(0, 19).replace('T', ' ')}\n${c.sql.trim()}\n`).join('\n')}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A Flyway migration file of one change set. */
|
|
109
|
+
export const flywayFile = (c: ChangeSet) => `${header(c)}\n${c.sql.trim()}\n`;
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// (sprint 101, Database Actions DA-12) pg_cron schedules, without a database: parse, explain and compute the
|
|
2
|
+
// next runs of a schedule as pg_cron understands it. SQL Workshop › Scheduling (src/builder/scheduling.ts) uses it
|
|
3
|
+
// for the list's "next run" column and the schedule builder's preview; it never decides what pg_cron accepts
|
|
4
|
+
// (cron.schedule does), it only explains.
|
|
5
|
+
//
|
|
6
|
+
// Supported, as in pg_cron (its parser comes from Vixie cron):
|
|
7
|
+
// - five fields "minute hour day-of-month month day-of-week": *, lists (1,15), ranges (1-5), steps (*/15, 0-30/10,
|
|
8
|
+
// 5/20 = 5 to the end by 20), month names (jan … dec) and day names (sun … sat), 0 or 7 for Sunday
|
|
9
|
+
// - "$" in day-of-month: the last day of the month (also in a list: "15,$")
|
|
10
|
+
// - day-of-month and day-of-week both restricted (neither starts with "*"): a day matching either runs
|
|
11
|
+
// - "n seconds" (1 to 59): an interval rather than a time of day
|
|
12
|
+
// - macros @yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly, and @reboot (no next runs)
|
|
13
|
+
// pg_cron reads schedules in its cron.timezone (GMT unless set), so the next runs take a time zone.
|
|
14
|
+
|
|
15
|
+
export interface CronFields {
|
|
16
|
+
minute: string;
|
|
17
|
+
hour: string;
|
|
18
|
+
day: string;
|
|
19
|
+
month: string;
|
|
20
|
+
weekday: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type Schedule =
|
|
24
|
+
| { kind: 'cron'; minutes: Set<number>; hours: Set<number>; days: Set<number>; lastDay: boolean; months: Set<number>; weekdays: Set<number>; either: boolean; fields: CronFields }
|
|
25
|
+
| { kind: 'seconds'; seconds: number }
|
|
26
|
+
| { kind: 'reboot' };
|
|
27
|
+
|
|
28
|
+
export class ScheduleError extends Error {}
|
|
29
|
+
|
|
30
|
+
const MACROS: Record<string, string> = {
|
|
31
|
+
'@yearly': '0 0 1 1 *',
|
|
32
|
+
'@annually': '0 0 1 1 *',
|
|
33
|
+
'@monthly': '0 0 1 * *',
|
|
34
|
+
'@weekly': '0 0 * * 0',
|
|
35
|
+
'@daily': '0 0 * * *',
|
|
36
|
+
'@midnight': '0 0 * * *',
|
|
37
|
+
'@hourly': '0 * * * *',
|
|
38
|
+
};
|
|
39
|
+
const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
|
40
|
+
const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
|
41
|
+
const MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
42
|
+
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
43
|
+
|
|
44
|
+
/** Ready-made schedules for the builder (label, expression). */
|
|
45
|
+
export const PRESETS: [string, string][] = [
|
|
46
|
+
['Every minute', '* * * * *'],
|
|
47
|
+
['Every 30 seconds', '30 seconds'],
|
|
48
|
+
['Every 5 minutes', '*/5 * * * *'],
|
|
49
|
+
['Every 15 minutes', '*/15 * * * *'],
|
|
50
|
+
['Every hour', '0 * * * *'],
|
|
51
|
+
['Every day at 02:00', '0 2 * * *'],
|
|
52
|
+
['Every day at midnight', '0 0 * * *'],
|
|
53
|
+
['Weekdays at 07:00', '0 7 * * 1-5'],
|
|
54
|
+
['Every Sunday at 03:00', '0 3 * * 0'],
|
|
55
|
+
['First day of the month at 01:00', '0 1 1 * *'],
|
|
56
|
+
['Last day of the month at 23:00', '0 23 $ * *'],
|
|
57
|
+
['Every year on 1 January', '0 0 1 1 *'],
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const MAX_LENGTH = 200;
|
|
61
|
+
|
|
62
|
+
function field(text: string, min: number, max: number, label: string, names?: string[], allowLast = false): { set: Set<number>; last: boolean } {
|
|
63
|
+
const set = new Set<number>();
|
|
64
|
+
let last = false;
|
|
65
|
+
if (text === '') throw new ScheduleError(`${label}: empty`);
|
|
66
|
+
const num = (s: string) => {
|
|
67
|
+
const lower = s.toLowerCase();
|
|
68
|
+
const named = names ? names.indexOf(lower) : -1;
|
|
69
|
+
const n = named >= 0 ? named + (min === 1 ? 1 : 0) : /^\d{1,2}$/.test(s) ? Number(s) : NaN;
|
|
70
|
+
if (!Number.isInteger(n) || n < min || n > max) throw new ScheduleError(`${label}: "${s}" is not between ${min} and ${max}`);
|
|
71
|
+
return n;
|
|
72
|
+
};
|
|
73
|
+
for (const part of text.split(',')) {
|
|
74
|
+
if (allowLast && part === '$') {
|
|
75
|
+
last = true;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const pieces = part.split('/');
|
|
79
|
+
if (pieces.length > 2) throw new ScheduleError(`${label}: bad step in "${part}"`);
|
|
80
|
+
const [range, stepText] = pieces;
|
|
81
|
+
if (stepText !== undefined && !/^\d{1,2}$/.test(stepText)) throw new ScheduleError(`${label}: bad step in "${part}"`);
|
|
82
|
+
const step = stepText === undefined ? 1 : Number(stepText);
|
|
83
|
+
if (step < 1) throw new ScheduleError(`${label}: bad step in "${part}"`);
|
|
84
|
+
let [from, to] = [min, max];
|
|
85
|
+
if (range !== '*') {
|
|
86
|
+
const ends = range.split('-');
|
|
87
|
+
if (ends.length > 2 || ends.some((e) => e === '')) throw new ScheduleError(`${label}: "${part}" is not a number, range or *`);
|
|
88
|
+
from = num(ends[0]);
|
|
89
|
+
to = ends.length === 1 ? (stepText === undefined ? from : max) : num(ends[1]);
|
|
90
|
+
if (to < from) throw new ScheduleError(`${label}: "${range}" runs backwards`);
|
|
91
|
+
}
|
|
92
|
+
for (let n = from; n <= to; n += step) set.add(n);
|
|
93
|
+
}
|
|
94
|
+
return { set, last };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Parse a pg_cron schedule; throws ScheduleError with a message for people. */
|
|
98
|
+
export function parseSchedule(input: string): Schedule {
|
|
99
|
+
const text = String(input ?? '').trim();
|
|
100
|
+
if (!text) throw new ScheduleError('Enter a schedule, for example "0 2 * * *" (every day at 02:00).');
|
|
101
|
+
if (text.length > MAX_LENGTH) throw new ScheduleError(`A schedule is at most ${MAX_LENGTH} characters.`);
|
|
102
|
+
const secs = /^(\d+)\s*seconds?$/i.exec(text);
|
|
103
|
+
if (secs) {
|
|
104
|
+
const n = Number(secs[1]);
|
|
105
|
+
if (n < 1 || n > 59) throw new ScheduleError('An interval in seconds is 1 to 59 seconds; use a cron schedule for longer intervals.');
|
|
106
|
+
return { kind: 'seconds', seconds: n };
|
|
107
|
+
}
|
|
108
|
+
if (text.toLowerCase() === '@reboot') return { kind: 'reboot' };
|
|
109
|
+
const expanded = MACROS[text.toLowerCase()];
|
|
110
|
+
if (!expanded && text.startsWith('@')) throw new ScheduleError(`Unknown shortcut "${text}" (use @hourly, @daily, @weekly, @monthly, @yearly or @reboot).`);
|
|
111
|
+
const parts = (expanded ?? text).split(/\s+/);
|
|
112
|
+
if (parts.length !== 5) throw new ScheduleError('A schedule has five fields: minute hour day-of-month month day-of-week (or "n seconds", or @daily, …).');
|
|
113
|
+
const [minute, hour, day, month, weekday] = parts;
|
|
114
|
+
const days = field(day, 1, 31, 'Day of month', undefined, true);
|
|
115
|
+
const weekdays = field(weekday, 0, 7, 'Day of week', DAYS).set;
|
|
116
|
+
if (weekdays.delete(7)) weekdays.add(0); // 7 is Sunday too
|
|
117
|
+
return {
|
|
118
|
+
kind: 'cron',
|
|
119
|
+
minutes: field(minute, 0, 59, 'Minute').set,
|
|
120
|
+
hours: field(hour, 0, 23, 'Hour').set,
|
|
121
|
+
days: days.set,
|
|
122
|
+
lastDay: days.last,
|
|
123
|
+
months: field(month, 1, 12, 'Month', MONTHS).set,
|
|
124
|
+
weekdays,
|
|
125
|
+
// Vixie cron: a field starting with * counts as unrestricted
|
|
126
|
+
either: !day.startsWith('*') && !weekday.startsWith('*'),
|
|
127
|
+
fields: { minute, hour, day, month, weekday },
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Why a schedule is not usable, or null. */
|
|
132
|
+
export function scheduleProblem(text: string): string | null {
|
|
133
|
+
try {
|
|
134
|
+
const s = parseSchedule(text);
|
|
135
|
+
if (s.kind === 'cron') nextRuns(s, new Date(), 1);
|
|
136
|
+
return null;
|
|
137
|
+
} catch (e) {
|
|
138
|
+
if (e instanceof ScheduleError) return e.message;
|
|
139
|
+
throw e;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The five fields of a cron schedule (macros expanded), or null for "n seconds", @reboot and bad input. */
|
|
144
|
+
export function splitSchedule(text: string): CronFields | null {
|
|
145
|
+
try {
|
|
146
|
+
const s = parseSchedule(text);
|
|
147
|
+
return s.kind === 'cron' ? s.fields : null;
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** A schedule from the builder's five fields (blank fields are *). */
|
|
154
|
+
export function joinSchedule(f: Partial<CronFields>): string {
|
|
155
|
+
const v = (x: string | undefined) => String(x ?? '').trim() || '*';
|
|
156
|
+
return [v(f.minute), v(f.hour), v(f.day), v(f.month), v(f.weekday)].join(' ');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function validTimeZone(tz: string) {
|
|
160
|
+
try {
|
|
161
|
+
new Intl.DateTimeFormat('en', { timeZone: tz });
|
|
162
|
+
return true;
|
|
163
|
+
} catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** pg_cron's time zone names (GMT, UTC, IANA names); unknown ones fall back to UTC. */
|
|
169
|
+
export const cronTimeZone = (tz: string | null | undefined) => (tz && validTimeZone(tz) ? tz : 'UTC');
|
|
170
|
+
|
|
171
|
+
const WEEKDAY: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
172
|
+
|
|
173
|
+
const formatter = (timeZone: string) =>
|
|
174
|
+
new Intl.DateTimeFormat('en-US', { timeZone, hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', weekday: 'short' });
|
|
175
|
+
|
|
176
|
+
function wallClock(d: Date, fmt: Intl.DateTimeFormat) {
|
|
177
|
+
const p = Object.fromEntries(fmt.formatToParts(d).map((x) => [x.type, x.value]));
|
|
178
|
+
return { year: +p.year, month: +p.month, day: +p.day, hour: +p.hour % 24, minute: +p.minute, weekday: WEEKDAY[p.weekday] };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const daysInMonth = (year: number, month: number) => new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The next `count` runs after `after` (exclusive), in the time zone. "n seconds" counts on from `after`;
|
|
185
|
+
* @reboot has none. A schedule that never matches within five years (30 February) throws ScheduleError.
|
|
186
|
+
*/
|
|
187
|
+
export function nextRuns(schedule: Schedule, after: Date, count = 5, timeZone = 'UTC'): Date[] {
|
|
188
|
+
if (schedule.kind === 'reboot') return [];
|
|
189
|
+
if (schedule.kind === 'seconds') {
|
|
190
|
+
const start = Math.floor(after.getTime() / 1000) * 1000;
|
|
191
|
+
return Array.from({ length: count }, (_, i) => new Date(start + (i + 1) * schedule.seconds * 1000));
|
|
192
|
+
}
|
|
193
|
+
const fmt = formatter(timeZone);
|
|
194
|
+
const out: Date[] = [];
|
|
195
|
+
let lastKey = ''; // a wall-clock minute that comes twice (end of summer time) runs once
|
|
196
|
+
let t = Math.floor(after.getTime() / 60_000) * 60_000 + 60_000;
|
|
197
|
+
const limit = t + 5 * 366 * 86_400_000;
|
|
198
|
+
while (out.length < count && t < limit) {
|
|
199
|
+
const w = wallClock(new Date(t), fmt);
|
|
200
|
+
const domOk = schedule.days.has(w.day) || (schedule.lastDay && w.day === daysInMonth(w.year, w.month));
|
|
201
|
+
const dowOk = schedule.weekdays.has(w.weekday);
|
|
202
|
+
const dayOk = schedule.either ? domOk || dowOk : domOk && dowOk;
|
|
203
|
+
if (!schedule.months.has(w.month) || !dayOk) {
|
|
204
|
+
// to (nearly) the end of the wall-clock day: an hour short, for a day that daylight saving time shortens;
|
|
205
|
+
// then by quarters of an hour, which are the same instants in every time zone
|
|
206
|
+
const left = (24 * 60 - (w.hour * 60 + w.minute)) - 60;
|
|
207
|
+
t += left > 15 ? Math.floor(left / 15) * 15 * 60_000 : (15 - (w.minute % 15)) * 60_000;
|
|
208
|
+
} else if (!schedule.hours.has(w.hour)) t += (15 - (w.minute % 15)) * 60_000;
|
|
209
|
+
else if (schedule.minutes.has(w.minute)) {
|
|
210
|
+
const key = `${w.year}-${w.month}-${w.day} ${w.hour}:${w.minute}`;
|
|
211
|
+
if (key !== lastKey) out.push(new Date(t));
|
|
212
|
+
lastKey = key;
|
|
213
|
+
t += 60_000;
|
|
214
|
+
} else t += 60_000;
|
|
215
|
+
}
|
|
216
|
+
if (!out.length) throw new ScheduleError('The schedule never matches (for example 30 February).');
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** A run time as "2026-10-17 02:00" (with seconds for interval schedules) in the time zone. */
|
|
221
|
+
export function formatRun(d: Date, timeZone = 'UTC', seconds = false): string {
|
|
222
|
+
const p = Object.fromEntries(new Intl.DateTimeFormat('en-CA', {
|
|
223
|
+
timeZone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
224
|
+
}).formatToParts(d).map((x) => [x.type, x.value]));
|
|
225
|
+
return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}${seconds ? `:${p.second}` : ''}`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ------------------------------------------------------------------ in words
|
|
229
|
+
|
|
230
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
231
|
+
const sorted = (s: Set<number>) => [...s].sort((a, b) => a - b);
|
|
232
|
+
const isAll = (s: Set<number>, min: number, max: number) => s.size === max - min + 1;
|
|
233
|
+
/** a list of numbers with runs as ranges: 1-5, 7 */
|
|
234
|
+
function compact(values: number[], name: (n: number) => string = String) {
|
|
235
|
+
const out: string[] = [];
|
|
236
|
+
for (let i = 0; i < values.length; i++) {
|
|
237
|
+
let j = i;
|
|
238
|
+
while (j + 1 < values.length && values[j + 1] === values[j] + 1) j++;
|
|
239
|
+
out.push(j - i >= 2 ? `${name(values[i])} to ${name(values[j])}` : j > i ? `${name(values[i])}, ${name(values[j])}` : name(values[i]));
|
|
240
|
+
i = j;
|
|
241
|
+
}
|
|
242
|
+
return out.join(', ');
|
|
243
|
+
}
|
|
244
|
+
/** an evenly spaced set from 0: its step, else 0 */
|
|
245
|
+
function stepOf(s: Set<number>, min: number, max: number) {
|
|
246
|
+
const v = sorted(s);
|
|
247
|
+
if (v.length < 2 || v[0] !== min) return 0;
|
|
248
|
+
const step = v[1] - v[0];
|
|
249
|
+
return v.every((x, i) => x === min + i * step) && v[v.length - 1] + step > max ? step : 0;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The schedule in words ("At 02:00, every day"). */
|
|
253
|
+
export function describeSchedule(s: Schedule): string {
|
|
254
|
+
if (s.kind === 'reboot') return 'When the database server starts';
|
|
255
|
+
if (s.kind === 'seconds') return s.seconds === 1 ? 'Every second' : `Every ${s.seconds} seconds`;
|
|
256
|
+
const minutes = sorted(s.minutes);
|
|
257
|
+
const hours = sorted(s.hours);
|
|
258
|
+
let time: string;
|
|
259
|
+
if (isAll(s.minutes, 0, 59) && isAll(s.hours, 0, 23)) time = 'Every minute';
|
|
260
|
+
else if (isAll(s.hours, 0, 23)) {
|
|
261
|
+
const step = stepOf(s.minutes, 0, 59);
|
|
262
|
+
time = step ? `Every ${step} minutes` : minutes.length === 1 ? (minutes[0] === 0 ? 'Every hour' : `At minute ${minutes[0]} of every hour`) : `At minutes ${compact(minutes)} of every hour`;
|
|
263
|
+
} else if (hours.length * minutes.length <= 6) time = `At ${hours.flatMap((h) => minutes.map((m) => `${pad(h)}:${pad(m)}`)).join(', ')}`;
|
|
264
|
+
else {
|
|
265
|
+
const step = stepOf(s.hours, 0, 23);
|
|
266
|
+
const mins = isAll(s.minutes, 0, 59) ? 'Every minute' : minutes.length === 1 ? `At minute ${minutes[0]}` : `At minutes ${compact(minutes)}`;
|
|
267
|
+
time = `${mins} ${step ? `of every ${step} hours` : `past hours ${compact(hours)}`}`;
|
|
268
|
+
}
|
|
269
|
+
const parts = [time];
|
|
270
|
+
const domAll = isAll(s.days, 1, 31) && !s.lastDay;
|
|
271
|
+
const dowAll = isAll(s.weekdays, 0, 6);
|
|
272
|
+
const dom = s.lastDay ? (s.days.size ? `on day ${compact(sorted(s.days))} and the last day of the month` : 'on the last day of the month') : `on day ${compact(sorted(s.days))} of the month`;
|
|
273
|
+
const weekdays = sorted(s.weekdays);
|
|
274
|
+
const dow = weekdays.join() === '1,2,3,4,5' ? 'on weekdays' : `on ${compact(weekdays, (d) => DAY_NAMES[d])}`;
|
|
275
|
+
if (s.either) parts.push(`${dom} or ${dow}`);
|
|
276
|
+
else if (!domAll && !dowAll) parts.push(`${dom} if it is ${dow.replace(/^on /, 'a ')}`);
|
|
277
|
+
else if (!domAll) parts.push(dom);
|
|
278
|
+
else if (!dowAll) parts.push(dow);
|
|
279
|
+
else if (time !== 'Every minute' && !/^Every \d+ minutes$|hour$/.test(time)) parts.push('every day');
|
|
280
|
+
if (!isAll(s.months, 1, 12)) parts.push(`in ${compact(sorted(s.months), (m) => MONTH_NAMES[m - 1])}`);
|
|
281
|
+
return parts.join(', ');
|
|
282
|
+
}
|