pgkiln 0.47.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 (69) hide show
  1. package/SECURITY.md +2 -2
  2. package/db/migrations/109_builder_pins.sql +19 -0
  3. package/db/migrations/110_sql_charts.sql +23 -0
  4. package/db/migrations/111_sql_diagram.sql +24 -0
  5. package/db/migrations/113_rest_objects.sql +49 -0
  6. package/db/migrations/116_dump_jobs.sql +41 -0
  7. package/db/migrations/117_rest_handlers.sql +63 -0
  8. package/db/migrations/118_change_log.sql +54 -0
  9. package/docs/apex-feature-parity.md +11 -11
  10. package/docs/guide/01-installation.md +4 -0
  11. package/docs/guide/03-builder.md +267 -7
  12. package/docs/guide/08-security.md +29 -0
  13. package/docs/guide/12-development.md +70 -6
  14. package/docs/guide/13-rest-api.md +133 -10
  15. package/docs/guide/15-extensions.md +1 -1
  16. package/examples/hr/hr_62_rest_module.sql +39 -0
  17. package/package.json +1 -1
  18. package/public/app.css +1 -1
  19. package/public/builder-icons.svg +2 -0
  20. package/public/builder.css +442 -173
  21. package/public/builder.js +48 -0
  22. package/public/data-modeler.css +56 -0
  23. package/public/data-modeler.js +367 -0
  24. package/public/palette.js +133 -0
  25. package/public/sql-monitor.js +73 -0
  26. package/public/worksheet.css +49 -0
  27. package/public/worksheet.js +4 -0
  28. package/src/app.ts +10 -0
  29. package/src/automations.ts +6 -0
  30. package/src/binds.ts +2 -1
  31. package/src/builder/advisor.ts +24 -2
  32. package/src/builder/autorest.ts +363 -0
  33. package/src/builder/changelog.ts +240 -0
  34. package/src/builder/components.ts +1 -1
  35. package/src/builder/data-modeler.ts +382 -0
  36. package/src/builder/datapump.ts +364 -0
  37. package/src/builder/db-users.ts +472 -0
  38. package/src/builder/ddl-tools.ts +13 -4
  39. package/src/builder/find-workspace.ts +2 -2
  40. package/src/builder/launchpad.ts +140 -0
  41. package/src/builder/object-actions.ts +1 -0
  42. package/src/builder/object-browser.ts +7 -1
  43. package/src/builder/plan-view.ts +25 -0
  44. package/src/builder/rest-modules.ts +629 -0
  45. package/src/builder/rest.ts +1 -0
  46. package/src/builder/routes.ts +4 -2
  47. package/src/builder/scheduling.ts +495 -0
  48. package/src/builder/sql-charts.ts +203 -0
  49. package/src/builder/sql-monitor.ts +397 -0
  50. package/src/builder/sql-tools.ts +53 -0
  51. package/src/builder/sql-utilities.ts +7 -39
  52. package/src/builder/sql.ts +20 -9
  53. package/src/builder/table-editor.ts +4 -1
  54. package/src/builder/top-sql.ts +24 -6
  55. package/src/builder/ui.ts +25 -17
  56. package/src/builder/worksheet.ts +35 -28
  57. package/src/builder/workspaces.ts +4 -2
  58. package/src/changelog.ts +109 -0
  59. package/src/cli/replace.ts +3 -1
  60. package/src/cron-schedule.ts +282 -0
  61. package/src/datamodel.ts +324 -0
  62. package/src/datapump.ts +546 -0
  63. package/src/dbroles.ts +215 -0
  64. package/src/dumpscript.ts +407 -0
  65. package/src/pgcron.ts +225 -0
  66. package/src/runtime/autorest.ts +336 -0
  67. package/src/runtime/rest-handlers.ts +75 -0
  68. package/src/runtime/rest.ts +120 -26
  69. package/src/session.ts +2 -0
@@ -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`;
@@ -72,7 +72,9 @@ export const KEPT = ['app_access', 'api_client', 'session', 'sso_pending', 'save
72
72
  // (081) the mail service an application sends through, and its queued and sent mail
73
73
  'app_mail_service', 'mail_queue',
74
74
  // (086) users' preferences
75
- 'user_preference'];
75
+ 'user_preference',
76
+ // (113) AutoREST: the tables and views the application publishes (SQL Workshop › RESTful Services)
77
+ 'rest_object'];
76
78
  /** Children of pages (replaced with their page). */
77
79
  const PAGE_CHILDREN = ['region', 'item', 'button', 'dynamic_action', 'validation', 'process', 'computation', 'branch'];
78
80
  /** References into replaced tables from kept data, repointed below: "table.column". */
@@ -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
+ }