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
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"><span aria-hidden="true">pg</span><span class="sr-only">pgkiln Builder home</span></a>
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>`;
@@ -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 type { Plan, PlanNode } from '../sqlplan.ts';
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
  }
@@ -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
+ }