pgkiln 0.48.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/SECURITY.md +2 -2
  2. package/db/migrations/116_dump_jobs.sql +41 -0
  3. package/db/migrations/117_rest_handlers.sql +63 -0
  4. package/db/migrations/118_change_log.sql +54 -0
  5. package/docs/apex-feature-parity.md +7 -7
  6. package/docs/guide/01-installation.md +4 -0
  7. package/docs/guide/03-builder.md +179 -5
  8. package/docs/guide/08-security.md +11 -0
  9. package/docs/guide/12-development.md +44 -6
  10. package/docs/guide/13-rest-api.md +59 -1
  11. package/docs/guide/15-extensions.md +1 -1
  12. package/examples/hr/hr_62_rest_module.sql +39 -0
  13. package/package.json +1 -1
  14. package/public/app.css +1 -1
  15. package/public/builder-icons.svg +2 -0
  16. package/public/builder.css +420 -173
  17. package/public/builder.js +48 -0
  18. package/public/sql-monitor.js +73 -0
  19. package/src/app.ts +6 -0
  20. package/src/automations.ts +6 -0
  21. package/src/binds.ts +2 -1
  22. package/src/builder/advisor.ts +24 -2
  23. package/src/builder/autorest.ts +3 -2
  24. package/src/builder/changelog.ts +240 -0
  25. package/src/builder/components.ts +1 -1
  26. package/src/builder/datapump.ts +364 -0
  27. package/src/builder/ddl-tools.ts +13 -4
  28. package/src/builder/find-workspace.ts +2 -2
  29. package/src/builder/plan-view.ts +25 -0
  30. package/src/builder/rest-modules.ts +629 -0
  31. package/src/builder/rest.ts +1 -0
  32. package/src/builder/routes.ts +4 -2
  33. package/src/builder/scheduling.ts +495 -0
  34. package/src/builder/sql-monitor.ts +397 -0
  35. package/src/builder/sql-tools.ts +6 -2
  36. package/src/builder/sql-utilities.ts +7 -39
  37. package/src/builder/sql.ts +6 -0
  38. package/src/builder/table-editor.ts +4 -1
  39. package/src/builder/top-sql.ts +24 -6
  40. package/src/builder/ui.ts +6 -4
  41. package/src/builder/worksheet.ts +5 -21
  42. package/src/changelog.ts +109 -0
  43. package/src/cron-schedule.ts +282 -0
  44. package/src/datapump.ts +546 -0
  45. package/src/dumpscript.ts +407 -0
  46. package/src/pgcron.ts +225 -0
  47. package/src/runtime/rest-handlers.ts +75 -0
  48. package/src/runtime/rest.ts +116 -22
@@ -0,0 +1,546 @@
1
+ import { execFile, spawn, type ChildProcess } from 'node:child_process';
2
+ import { createReadStream } from 'node:fs';
3
+ import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, resolve } from 'node:path';
6
+ import { createInterface } from 'node:readline';
7
+ import pg from 'pg';
8
+ import { owner, ownerUrl } from './db.ts';
9
+ import { runScript } from './dumpscript.ts';
10
+
11
+ // (sprint 103, Database Actions DA-16: Data Pump) Schema Export / Import with pg_dump and pg_restore, as background
12
+ // jobs (meta.dump_job, migration 116). Pages: src/builder/datapump.ts.
13
+ //
14
+ // Export: pg_dump of one schema (or some of its tables), custom or plain format, definitions and/or data, run with an
15
+ // argument array (never a shell) and the owner connection's password in PGPASSWORD (never on the command line), to a
16
+ // file in the dump directory; the job keeps pg_dump's log, the size and the timing.
17
+ //
18
+ // Import: a custom or tar archive (an export's file or an upload) of one schema S into a schema T, new or existing.
19
+ // pg_restore writes the script (`pg_restore -f -`, no connection of its own) and pgkiln runs it on one connection in
20
+ // one transaction (src/dumpscript.ts), between schema renames that make the dump's names point at T:
21
+ //
22
+ // begin
23
+ // alter schema S rename to pgkiln_dump_<job> (when T ≠ S and S exists: the original steps aside)
24
+ // alter schema T rename to S (when T exists: the dump's names now mean T)
25
+ // … the script (CREATE SCHEMA S is skipped when T exists; created when the dump has no schema entry) …
26
+ // alter schema S rename to T (when T ≠ S)
27
+ // alter schema pgkiln_dump_<job> rename to S
28
+ // commit
29
+ //
30
+ // Other sessions keep seeing the original S until the commit. Limits: names inside PL/pgSQL bodies and other text
31
+ // (dynamic SQL, a function's SET search_path) still say S after a rename; views, defaults, triggers and policies
32
+ // refer to objects, not names, so they follow. Files stay in PGKILN_DUMP_DIR for PGKILN_DUMP_KEEP_DAYS days.
33
+
34
+ export const DUMP_DIR = resolve(process.env.PGKILN_DUMP_DIR || join(tmpdir(), 'pgkiln-dumps'));
35
+ export const KEEP_DAYS = Math.max(1, Number(process.env.PGKILN_DUMP_KEEP_DAYS) || 7);
36
+ /** the largest upload, in MB (read at every upload) */
37
+ export const uploadMaxMb = () => Math.max(1, Number(process.env.PGKILN_DUMP_MAX_MB) || 2048);
38
+ const LOG_MAX = 256 * 1024;
39
+ const HEARTBEAT_MS = 5000;
40
+ /** a running job whose server has not written for this long was interrupted */
41
+ const STALE_MS = 2 * 60 * 1000;
42
+
43
+ export type Format = 'custom' | 'plain';
44
+ export type Content = 'all' | 'definitions' | 'data';
45
+
46
+ export interface ExportOptions {
47
+ format: Format;
48
+ content: Content;
49
+ /** only these tables (and views, sequences) of the schema; empty: the whole schema */
50
+ tables: string[];
51
+ /** leave these tables out (with the whole schema) */
52
+ exclude: string[];
53
+ /** keep GRANTs (plain format: also owners are left out either way) */
54
+ privileges: boolean;
55
+ }
56
+
57
+ export interface ImportOptions {
58
+ target: string;
59
+ content: Content;
60
+ /** drop the dump's objects in the target first (pg_restore --clean --if-exists) */
61
+ clean: boolean;
62
+ privileges: boolean;
63
+ }
64
+
65
+ export interface Job {
66
+ id: number;
67
+ kind: 'export' | 'import';
68
+ status: 'uploaded' | 'running' | 'succeeded' | 'failed' | 'cancelled';
69
+ username: string;
70
+ schema_name: string | null;
71
+ target_schema: string | null;
72
+ options: Record<string, unknown>;
73
+ file_name: string | null;
74
+ original_name: string | null;
75
+ source_job: number | null;
76
+ bytes: number | null;
77
+ log: string;
78
+ error: string | null;
79
+ created_at: string;
80
+ started_at: string | null;
81
+ finished_at: string | null;
82
+ seconds: number | null;
83
+ expires_at: string | null;
84
+ file_live: boolean;
85
+ }
86
+
87
+ export const JOB_COLUMNS = `id::int, kind, status, username, schema_name, target_schema, options, file_name, original_name, source_job::int,
88
+ bytes::float8 as bytes, log, error, to_char(created_at, 'YYYY-MM-DD HH24:MI:SS') as created_at, to_char(started_at, 'YYYY-MM-DD HH24:MI:SS') as started_at,
89
+ to_char(finished_at, 'YYYY-MM-DD HH24:MI:SS') as finished_at, round(extract(epoch from coalesce(finished_at, now()) - started_at)::numeric, 1)::float8 as seconds,
90
+ to_char(expires_at, 'YYYY-MM-DD') as expires_at, (file_name is not null and (expires_at is null or expires_at > now())) as file_live`;
91
+
92
+ export const getJob = async (id: number | string) =>
93
+ /^\d{1,15}$/.test(String(id)) ? ((await owner.one<Job>(`select ${JOB_COLUMNS} from meta.dump_job where id = $1`, [id])) ?? null) : null;
94
+ export const listJobs = async (limit = 200) => (await owner.query<Job>(`select ${JOB_COLUMNS} from meta.dump_job order by id desc limit $1`, [limit])).rows;
95
+
96
+ /** The schemas pgkiln keeps for itself: never exported, imported or replaced here. */
97
+ export const reservedSchema = (name: string) => /^(pg_|pgkiln)/i.test(name) || ['meta', 'information_schema', 'pg_catalog', 'pg_toast'].includes(name.toLowerCase());
98
+ /** A schema name typed in a form: an identifier PostgreSQL keeps as typed (quoted when it needs to be). */
99
+ export const SCHEMA_NAME = /^[A-Za-z_][A-Za-z0-9_$]{0,62}$/;
100
+
101
+ export const filePath = (name: string) => {
102
+ if (!/^[A-Za-z0-9_.-]{1,120}$/.test(name) || name.startsWith('.')) throw new Error('bad file name');
103
+ return join(DUMP_DIR, name);
104
+ };
105
+ export const jobFile = (j: Pick<Job, 'file_name'>) => (j.file_name ? filePath(j.file_name) : null);
106
+ export const readJobFile = (j: Pick<Job, 'file_name'>) => createReadStream(jobFile(j)!);
107
+
108
+ // ------------------------------------------------------------------ the client programs
109
+
110
+ export interface Tools {
111
+ dump: string;
112
+ restore: string;
113
+ major: number;
114
+ version: string;
115
+ }
116
+ export type ToolState = { ok: true; tools: Tools; server: number } | { ok: false; server: number; reason: string; found: string[] };
117
+
118
+ const run = (file: string, args: string[], timeout = 10_000) =>
119
+ new Promise<string>((res, rej) => execFile(file, args, { timeout, env: childEnv() }, (err, stdout) => (err ? rej(err) : res(String(stdout)))));
120
+
121
+ /** The environment of pg_dump and pg_restore: no other secrets of this process. */
122
+ function childEnv(password?: string) {
123
+ const env: NodeJS.ProcessEnv = { PATH: process.env.PATH ?? '/usr/bin:/bin', LANG: 'C.UTF-8', PGAPPNAME: 'pgkiln schema export', PGCONNECT_TIMEOUT: '15' };
124
+ if (process.env.HOME) env.HOME = process.env.HOME;
125
+ if (password) env.PGPASSWORD = password;
126
+ return env;
127
+ }
128
+
129
+ async function candidateDirs() {
130
+ if (process.env.PGKILN_PG_BIN) return [process.env.PGKILN_PG_BIN];
131
+ const dirs: string[] = [];
132
+ const versions = async (base: string, pattern: RegExp, bin: (d: string) => string) => {
133
+ try {
134
+ for (const d of await readdir(base)) if (pattern.test(d)) dirs.push(bin(join(base, d)));
135
+ } catch {
136
+ // not this platform
137
+ }
138
+ };
139
+ await versions('/usr/lib/postgresql', /^\d+$/, (d) => join(d, 'bin')); // Debian, Ubuntu
140
+ await versions('/usr', /^pgsql-\d+$/, (d) => join(d, 'bin')); // RHEL (PGDG)
141
+ await versions('/opt/homebrew/opt', /^postgresql@\d+$/, (d) => join(d, 'bin')); // macOS
142
+ await versions('/usr/local/opt', /^postgresql@\d+$/, (d) => join(d, 'bin'));
143
+ dirs.push(''); // PATH
144
+ return dirs;
145
+ }
146
+
147
+ let cached: { at: number; state: ToolState } | null = null;
148
+
149
+ /** pg_dump and pg_restore of a major version at least the server's (pg_dump refuses newer servers), the newest found. */
150
+ export async function findTools(fresh = false): Promise<ToolState> {
151
+ if (!fresh && cached && Date.now() - cached.at < 60_000) return cached.state;
152
+ const server = Number((await owner.one<{ v: number }>(`select current_setting('server_version_num')::int / 10000 as v`))!.v);
153
+ const found: { dir: string; major: number; version: string }[] = [];
154
+ for (const dir of await candidateDirs()) {
155
+ const bin = (name: string) => (dir ? join(dir, name) : name);
156
+ try {
157
+ const out = await run(bin('pg_dump'), ['--version']);
158
+ await run(bin('pg_restore'), ['--version']);
159
+ const m = /\)\s*(\d+)(\.\d+)?/.exec(out);
160
+ if (m) found.push({ dir, major: Number(m[1]), version: out.trim() });
161
+ } catch {
162
+ // not there
163
+ }
164
+ }
165
+ found.sort((a, b) => b.major - a.major);
166
+ const best = found[0];
167
+ const state: ToolState = best && best.major >= server
168
+ ? { ok: true, server, tools: { dump: best.dir ? join(best.dir, 'pg_dump') : 'pg_dump', restore: best.dir ? join(best.dir, 'pg_restore') : 'pg_restore', major: best.major, version: best.version } }
169
+ : {
170
+ ok: false, server, found: found.map((f) => `${f.version}${f.dir ? ` (${f.dir})` : ''}`),
171
+ reason: best
172
+ ? `pg_dump ${best.major} can't dump a PostgreSQL ${server} server: install the PostgreSQL ${server} client programs (postgresql-client-${server}) or set PGKILN_PG_BIN to their directory.`
173
+ : `pg_dump and pg_restore were not found: install the PostgreSQL ${server} client programs (postgresql-client-${server}) or set PGKILN_PG_BIN to their directory.`,
174
+ };
175
+ cached = { at: Date.now(), state };
176
+ return state;
177
+ }
178
+
179
+ /** The owner connection for pg_dump: the URL without its password, and the password apart. */
180
+ function connection() {
181
+ const u = new URL(ownerUrl);
182
+ const password = decodeURIComponent(u.password);
183
+ u.password = '';
184
+ return { dbname: u.toString(), password };
185
+ }
186
+
187
+ /** A name as a pg_dump pattern that matches only itself. */
188
+ const pattern = (name: string) => `"${name.replace(/"/g, '""')}"`;
189
+ const q = (name: string) => pg.escapeIdentifier(name);
190
+
191
+ // ------------------------------------------------------------------ jobs
192
+
193
+ const running = new Map<number, { child?: ChildProcess; cancel?: () => void }>();
194
+
195
+ /** A job's log, kept short (the start and the end of a long one). */
196
+ class Log {
197
+ text = '';
198
+ add(line: string) {
199
+ this.text += `${line.replace(/\s+$/, '')}\n`;
200
+ if (this.text.length > LOG_MAX) this.text = `${this.text.slice(0, LOG_MAX / 4)}\n… (log shortened) …\n${this.text.slice(-LOG_MAX / 2)}`;
201
+ }
202
+ }
203
+
204
+ async function track<T>(id: number, log: Log, work: () => Promise<T>) {
205
+ const beat = setInterval(() => void owner.query('update meta.dump_job set heartbeat_at = now(), log = $2 where id = $1', [id, log.text]).catch(() => {}), HEARTBEAT_MS);
206
+ try {
207
+ return await work();
208
+ } finally {
209
+ clearInterval(beat);
210
+ }
211
+ }
212
+
213
+ async function finish(id: number, log: Log, error: Error | null, bytes?: number | null) {
214
+ const cancelled = error?.message === 'cancelled';
215
+ if (error && !(error as { logged?: boolean }).logged) log.add(cancelled ? 'Cancelled.' : `Failed: ${error.message}`);
216
+ await owner.query(
217
+ `update meta.dump_job set status = $2, error = $3, log = $4, bytes = coalesce($5, bytes), finished_at = now(), heartbeat_at = null where id = $1`,
218
+ [id, cancelled ? 'cancelled' : error ? 'failed' : 'succeeded', cancelled ? null : error?.message ?? null, log.text, bytes ?? null],
219
+ );
220
+ running.delete(id);
221
+ }
222
+
223
+ /** Waits for a program; its stderr goes to the log. */
224
+ function wait(child: ChildProcess, log: Log, name: string) {
225
+ const lines = createInterface({ input: child.stderr! });
226
+ lines.on('line', (l) => log.add(l));
227
+ return new Promise<void>((res, rej) => {
228
+ child.on('error', rej);
229
+ child.on('close', (code, signal) => (code === 0 ? res() : rej(new Error(signal === 'SIGTERM' ? 'cancelled' : `${name} ended with exit code ${code ?? signal}`))));
230
+ });
231
+ }
232
+
233
+ export class DumpError extends Error {}
234
+
235
+ const schemaTables = async (schema: string) =>
236
+ (await owner.query<{ n: string }>(`select c.relname as n from pg_class c join pg_namespace s on s.oid = c.relnamespace
237
+ where s.nspname = $1 and c.relkind in ('r', 'p', 'v', 'm', 'S', 'f') order by 1`, [schema])).rows.map((r) => r.n);
238
+
239
+ /** The schemas an administrator may export: not pgkiln's own or PostgreSQL's, not an extension's. */
240
+ export const exportableSchemas = async () =>
241
+ (await owner.query<{ n: string }>(
242
+ `select nspname as n from pg_namespace n
243
+ where not exists (select 1 from pg_depend d where d.classid = 'pg_namespace'::regclass and d.objid = n.oid and d.deptype = 'e')
244
+ order by 1`)).rows.map((r) => r.n).filter((n) => !reservedSchema(n));
245
+
246
+ export { schemaTables };
247
+
248
+ /** Starts pg_dump of a schema in the background; the job's id. */
249
+ export async function startExport(username: string, schema: string, o: ExportOptions): Promise<number> {
250
+ const tools = await findTools();
251
+ if (!tools.ok) throw new DumpError(tools.reason);
252
+ if (!(await exportableSchemas()).includes(schema)) throw new DumpError(`There is no schema "${schema}" to export.`);
253
+ const tables = await schemaTables(schema);
254
+ const unknown = [...o.tables, ...o.exclude].filter((t) => !tables.includes(t));
255
+ if (unknown.length) throw new DumpError(`Not in ${schema}: ${unknown.join(', ')}`);
256
+ if (o.tables.length && o.exclude.length) throw new DumpError('Choose tables to include or to leave out, not both.');
257
+ await mkdir(DUMP_DIR, { recursive: true, mode: 0o700 });
258
+ const id = Number((await owner.one<{ id: number }>(
259
+ `insert into meta.dump_job (kind, status, username, schema_name, options, started_at, heartbeat_at, expires_at)
260
+ values ('export', 'running', $1, $2, $3, now(), now(), now() + make_interval(days => $4)) returning id`,
261
+ [username, schema, JSON.stringify(o), KEEP_DAYS],
262
+ ))!.id);
263
+ const file = `export-${id}-${schema.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 60)}.${o.format === 'plain' ? 'sql' : 'dump'}`;
264
+ await owner.query('update meta.dump_job set file_name = $2 where id = $1', [id, file]);
265
+ const { dbname, password } = connection();
266
+ const args = [
267
+ `--format=${o.format === 'plain' ? 'p' : 'c'}`, `--file=${filePath(file)}`, '--verbose',
268
+ ...(o.tables.length ? o.tables.map((t) => `--table=${pattern(schema)}.${pattern(t)}`) : [`--schema=${pattern(schema)}`]),
269
+ ...o.exclude.map((t) => `--exclude-table=${pattern(schema)}.${pattern(t)}`),
270
+ ...(o.content === 'definitions' ? ['--schema-only'] : o.content === 'data' ? ['--data-only'] : []),
271
+ ...(o.format === 'plain' ? ['--no-owner'] : []),
272
+ ...(o.privileges ? [] : ['--no-privileges']),
273
+ `--dbname=${dbname}`,
274
+ ];
275
+ const log = new Log();
276
+ log.add(`pg_dump ${tools.tools.version.replace(/^pg_dump\s*/, '')}: schema ${schema}${o.tables.length ? `, tables ${o.tables.join(', ')}` : ''}${o.exclude.length ? `, without ${o.exclude.join(', ')}` : ''}; ${o.format} format, ${o.content === 'all' ? 'definitions and data' : o.content}${o.privileges ? '' : ', no grants'}`);
277
+ const child = spawn(tools.tools.dump, args, { env: childEnv(password), stdio: ['ignore', 'ignore', 'pipe'] });
278
+ running.set(id, { child });
279
+ void track(id, log, () => wait(child, log, 'pg_dump'))
280
+ .then(async () => finish(id, log, null, (await stat(filePath(file))).size))
281
+ .catch(async (e: Error) => {
282
+ await rm(filePath(file), { force: true }).catch(() => {});
283
+ await owner.query('update meta.dump_job set file_name = null where id = $1', [id]).catch(() => {});
284
+ await finish(id, log, e).catch((x) => console.error('schema export:', x.message));
285
+ });
286
+ return id;
287
+ }
288
+
289
+ // ------------------------------------------------------------------ archives
290
+
291
+ export interface TocEntry {
292
+ id: number;
293
+ desc: string;
294
+ schema: string | null;
295
+ name: string;
296
+ }
297
+ export interface Toc {
298
+ header: [string, string][];
299
+ entries: TocEntry[];
300
+ schemas: string[];
301
+ /** the archive creates its schema (a dump of a whole schema; not of some tables) */
302
+ schemaEntry: boolean;
303
+ counts: [string, number][];
304
+ list: string;
305
+ }
306
+
307
+ // pg_restore -l: "215; 1259 16390 TABLE hr employees owner" (desc can have spaces, the owner can be empty)
308
+ const DESCS = [
309
+ 'TABLE DATA', 'SEQUENCE SET', 'SEQUENCE OWNED BY', 'FK CONSTRAINT', 'CHECK CONSTRAINT', 'MATERIALIZED VIEW DATA', 'MATERIALIZED VIEW', 'DEFAULT ACL',
310
+ 'ROW SECURITY', 'TEXT SEARCH CONFIGURATION', 'TEXT SEARCH DICTIONARY', 'FOREIGN TABLE', 'INDEX ATTACH', 'TABLE ATTACH', 'LARGE OBJECT', 'BLOB DATA', 'BLOBS',
311
+ 'SCHEMA', 'TABLE', 'VIEW', 'SEQUENCE', 'FUNCTION', 'PROCEDURE', 'AGGREGATE', 'TYPE', 'DOMAIN', 'CONSTRAINT', 'INDEX', 'TRIGGER', 'POLICY', 'RULE',
312
+ 'COMMENT', 'ACL', 'DEFAULT', 'EXTENSION', 'COLLATION', 'STATISTICS', 'OPERATOR', 'CAST', 'PUBLICATION', 'SUBSCRIPTION', 'EVENT TRIGGER', 'SERVER',
313
+ 'USER MAPPING', 'FOREIGN DATA WRAPPER', 'CONVERSION', 'ENCODING', 'STDSTRINGS', 'SEARCHPATH', 'DATABASE', 'DATABASE PROPERTIES',
314
+ ];
315
+
316
+ export function parseToc(text: string): Toc {
317
+ const header: [string, string][] = [];
318
+ const entries: TocEntry[] = [];
319
+ for (const line of text.split('\n')) {
320
+ const h = /^;\s+([A-Za-z][A-Za-z ]+?):\s+(.*)$/.exec(line);
321
+ if (h) {
322
+ header.push([h[1].trim(), h[2].trim()]);
323
+ continue;
324
+ }
325
+ const m = /^(\d+);\s+\d+\s+\d+\s+(.*)$/.exec(line);
326
+ if (!m) continue;
327
+ const desc = DESCS.find((d) => m[2].startsWith(`${d} `));
328
+ if (!desc) continue;
329
+ const rest = m[2].slice(desc.length + 1).split(' ');
330
+ const schema = rest[0] === '-' ? null : rest[0];
331
+ entries.push({ id: Number(m[1]), desc, schema, name: rest.slice(1, -1).join(' ') || rest.slice(1).join(' ') });
332
+ }
333
+ const schemas = [...new Set(entries.flatMap((e) => (e.desc === 'SCHEMA' ? [e.name] : e.schema ? [e.schema] : [])))];
334
+ const counts = new Map<string, number>();
335
+ for (const e of entries) counts.set(e.desc, (counts.get(e.desc) ?? 0) + 1);
336
+ return { header, entries, schemas, schemaEntry: entries.some((e) => e.desc === 'SCHEMA'), counts: [...counts], list: text };
337
+ }
338
+
339
+ /** The table of contents of an archive (pg_restore -l; no connection). */
340
+ export async function readToc(file: string): Promise<Toc> {
341
+ const tools = await findTools();
342
+ if (!tools.ok) throw new DumpError(tools.reason);
343
+ try {
344
+ return parseToc(await run(tools.tools.restore, ['--list', file], 60_000));
345
+ } catch (e) {
346
+ const msg = String((e as { stderr?: string }).stderr || (e as Error).message);
347
+ throw new DumpError(/text format|psql/i.test(msg)
348
+ ? 'This is a plain SQL dump: run it in SQL Scripts, or export the schema again in custom format.'
349
+ : `pg_restore can't read this file as a custom or tar archive. ${msg.replace(/^.*?pg_restore: (error: )?/s, '')}`.trim());
350
+ }
351
+ }
352
+
353
+ /** The one schema an archive may be imported from, or why not. */
354
+ export function archiveSchema(toc: Toc): string {
355
+ if (toc.schemas.length !== 1) throw new DumpError(toc.schemas.length ? `The archive holds ${toc.schemas.length} schemas (${toc.schemas.join(', ')}): import a dump of one schema.` : 'The archive holds no schema objects.');
356
+ const s = toc.schemas[0];
357
+ if (reservedSchema(s)) throw new DumpError(`The archive is a dump of ${s}, which pgkiln keeps for itself.`);
358
+ if (toc.entries.some((e) => ['EXTENSION', 'DATABASE', 'PUBLICATION', 'SUBSCRIPTION', 'EVENT TRIGGER', 'SERVER', 'USER MAPPING', 'FOREIGN DATA WRAPPER', 'LARGE OBJECT', 'BLOBS', 'BLOB DATA'].includes(e.desc)))
359
+ throw new DumpError('The archive holds objects outside a schema (extensions, large objects, servers…): pgkiln imports one schema\'s tables, views, functions and data.');
360
+ return s;
361
+ }
362
+
363
+ /** Imports an archive (an upload's job, or a new job for an export's file) in the background; the job's id. */
364
+ export async function startImport(username: string, source: Job, o: ImportOptions): Promise<number> {
365
+ const tools = await findTools();
366
+ if (!tools.ok) throw new DumpError(tools.reason);
367
+ if (!source.file_live) throw new DumpError('The file of this job is no longer kept.');
368
+ if (!SCHEMA_NAME.test(o.target)) throw new DumpError('The target schema: letters, digits, _ and $, up to 63, not starting with a digit.');
369
+ if (reservedSchema(o.target)) throw new DumpError(`${o.target} is pgkiln's or PostgreSQL's own schema.`);
370
+ const file = jobFile(source)!;
371
+ const toc = await readToc(file);
372
+ const schema = archiveSchema(toc);
373
+ let id: number;
374
+ if (source.kind === 'import' && source.status === 'uploaded') {
375
+ id = source.id;
376
+ const r = await owner.query(
377
+ `update meta.dump_job set status = 'running', schema_name = $2, target_schema = $3, options = $4, started_at = now(), heartbeat_at = now()
378
+ where id = $1 and status = 'uploaded'`, [id, schema, o.target, JSON.stringify(o)]);
379
+ if (!r.rowCount) throw new DumpError('This upload is already being imported.');
380
+ } else {
381
+ id = Number((await owner.one<{ id: number }>(
382
+ `insert into meta.dump_job (kind, status, username, schema_name, target_schema, options, source_job, started_at, heartbeat_at)
383
+ values ('import', 'running', $1, $2, $3, $4, $5, now(), now()) returning id`,
384
+ [username, schema, o.target, JSON.stringify(o), source.id]))!.id);
385
+ }
386
+ const log = new Log();
387
+ log.add(`pg_restore ${tools.tools.version.replace(/^pg_dump\s*/, '')}: ${source.original_name ?? source.file_name} (schema ${schema}) into ${o.target}; ${o.content === 'all' ? 'definitions and data' : o.content}${o.clean ? ', existing objects dropped first' : ''}${o.privileges ? '' : ', no grants'}`);
388
+ const state: { cancelled: boolean; child?: ChildProcess; client?: pg.Client } = { cancelled: false };
389
+ running.set(id, {
390
+ cancel: () => {
391
+ state.cancelled = true;
392
+ state.child?.kill('SIGTERM');
393
+ // the server stops what it runs; the transaction rolls back
394
+ if (state.client) void owner.query('select pg_cancel_backend($1)', [(state.client as unknown as { processID: number }).processID]).catch(() => {});
395
+ },
396
+ });
397
+ void track(id, log, () => restore(id, tools.tools, file, toc, schema, o, log, state))
398
+ .then(() => finish(id, log, null))
399
+ .catch((e: Error) => finish(id, log, state.cancelled ? new Error('cancelled') : e).catch((x) => console.error('schema import:', x.message)));
400
+ return id;
401
+ }
402
+
403
+ async function restore(id: number, tools: Tools, file: string, toc: Toc, schema: string, o: ImportOptions, log: Log, state: { cancelled: boolean; child?: ChildProcess; client?: pg.Client }) {
404
+ const client = new pg.Client({ connectionString: ownerUrl, application_name: 'pgkiln schema import' });
405
+ state.client = client;
406
+ client.on('error', () => {});
407
+ await client.connect();
408
+ let listFile: string | null = null;
409
+ try {
410
+ await client.query('begin');
411
+ const exists = async (name: string) => !!(await client.query('select 1 from pg_namespace where nspname = $1', [name])).rowCount;
412
+ const targetExists = await exists(o.target);
413
+ const aside = `pgkiln_dump_${id}`;
414
+ const moveAside = o.target !== schema && (await exists(schema));
415
+ if (o.content === 'data' && !targetExists) throw new DumpError(`${o.target} doesn't exist: import the definitions too, or choose an existing schema.`);
416
+ if (moveAside) {
417
+ await client.query(`alter schema ${q(schema)} rename to ${q(aside)}`);
418
+ log.add(`Schema ${schema} renamed to ${aside} for the import (other sessions still see ${schema}).`);
419
+ }
420
+ if (targetExists && o.target !== schema) await client.query(`alter schema ${q(o.target)} rename to ${q(schema)}`);
421
+ if (!targetExists && (!toc.schemaEntry || o.content === 'data')) await client.query(`create schema ${q(schema)}`);
422
+ const args = ['--file=-', '--no-owner', '--no-security-labels', '--no-tablespaces', '--no-publications', '--no-subscriptions'];
423
+ if (o.content === 'definitions') args.push('--schema-only');
424
+ if (o.content === 'data') args.push('--data-only');
425
+ if (o.clean && o.content !== 'data') args.push('--clean', '--if-exists');
426
+ if (!o.privileges) args.push('--no-privileges');
427
+ let lines = toc.list.split('\n');
428
+ // the schema is there: leave out its CREATE (and DROP) SCHEMA
429
+ if (targetExists && toc.schemaEntry) lines = lines.map((l) => (/^\d+;\s+\d+\s+\d+\s+SCHEMA - /.test(l) ? `;${l}` : l));
430
+ // data only: the tables' foreign keys are there, so referenced tables load first (pg_dump orders data by name)
431
+ if (o.content === 'data') lines = await dataOrder(client, schema, toc, lines);
432
+ if (lines.join('\n') !== toc.list) {
433
+ listFile = `${file}.${id}.list`;
434
+ await writeFile(listFile, lines.join('\n'), { mode: 0o600 });
435
+ args.push(`--use-list=${listFile}`);
436
+ }
437
+ args.push(file);
438
+ const child = spawn(tools.restore, args, { env: childEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
439
+ state.child = child;
440
+ const exited = wait(child, log, 'pg_restore');
441
+ exited.catch(() => {});
442
+ let stats;
443
+ try {
444
+ stats = await runScript(client, createInterface({ input: child.stdout!, crlfDelay: Infinity }), schema, (l) => log.add(l));
445
+ } catch (e) {
446
+ child.kill('SIGTERM');
447
+ throw e;
448
+ }
449
+ await exited;
450
+ if (state.cancelled) throw new Error('cancelled');
451
+ if (o.target !== schema) await client.query(`alter schema ${q(schema)} rename to ${q(o.target)}`);
452
+ if (moveAside) await client.query(`alter schema ${q(aside)} rename to ${q(schema)}`);
453
+ await client.query('commit');
454
+ log.add(`Done: ${stats.statements} statements, ${stats.tables} table${stats.tables === 1 ? '' : 's'} with ${stats.rows} row${stats.rows === 1 ? '' : 's'} into ${o.target}.`);
455
+ } catch (e) {
456
+ await client.query('rollback').catch(() => {});
457
+ if (!state.cancelled) log.add(`Failed: ${(e as Error).message}`);
458
+ log.add('Rolled back: nothing was changed.');
459
+ (e as { logged?: boolean }).logged = true;
460
+ throw e;
461
+ } finally {
462
+ if (listFile) await rm(listFile, { force: true }).catch(() => {});
463
+ await client.end().catch(() => {});
464
+ }
465
+ }
466
+
467
+ /** The archive's list with its TABLE DATA entries in foreign key order of the schema's tables (parents first). */
468
+ async function dataOrder(client: pg.ClientBase, schema: string, toc: Toc, lines: string[]) {
469
+ const deps = new Map((await client.query<{ t: string; deps: string[] }>(
470
+ `select c.relname as t, array(select distinct p.relname::text from pg_constraint k join pg_class p on p.oid = k.confrelid
471
+ where k.conrelid = c.oid and k.contype = 'f' and p.relnamespace = c.relnamespace and p.oid <> c.oid) as deps
472
+ from pg_class c where c.relnamespace = (select oid from pg_namespace where nspname = $1) and c.relkind in ('r', 'p')`, [schema])).rows.map((r) => [r.t, r.deps]));
473
+ const rank = new Map<string, number>();
474
+ const visit = (t: string, seen: Set<string>): number => {
475
+ if (rank.has(t)) return rank.get(t)!;
476
+ if (seen.has(t)) return 0; // a cycle: as listed
477
+ seen.add(t);
478
+ const r = Math.max(0, ...(deps.get(t) ?? []).map((d) => visit(d, seen) + 1));
479
+ rank.set(t, r);
480
+ return r;
481
+ };
482
+ const data = new Map(toc.entries.filter((e) => e.desc === 'TABLE DATA').map((e) => [e.id, e.name]));
483
+ const at: number[] = [];
484
+ lines.forEach((l, i) => {
485
+ const m = /^(\d+);/.exec(l);
486
+ if (m && data.has(Number(m[1]))) at.push(i);
487
+ });
488
+ const sorted = at.map((i) => lines[i]).sort((a, b) => visit(data.get(Number(a.split(';')[0]))!, new Set()) - visit(data.get(Number(b.split(';')[0]))!, new Set()));
489
+ const out = [...lines];
490
+ at.forEach((i, k) => (out[i] = sorted[k]));
491
+ return out;
492
+ }
493
+
494
+ /** Cancels a running job of this server. */
495
+ export function cancelJob(id: number) {
496
+ const r = running.get(id);
497
+ if (!r) return false;
498
+ if (r.child && !r.cancel) r.child.kill('SIGTERM');
499
+ r.cancel?.();
500
+ return true;
501
+ }
502
+
503
+ /** Removes a finished job and its file (an export's file stays while an import of it runs). */
504
+ export async function deleteJob(j: Job) {
505
+ if (j.status === 'running') throw new DumpError('The job is running: cancel it first.');
506
+ if (j.file_name && !(await owner.one(`select 1 as x from meta.dump_job where source_job = $1 and status = 'running'`, [j.id])))
507
+ await rm(filePath(j.file_name), { force: true });
508
+ await owner.query('delete from meta.dump_job where id = $1', [j.id]);
509
+ }
510
+
511
+ /** An uploaded file's job: the file is already in the dump directory. */
512
+ export async function uploadJob(username: string, fileName: string, originalName: string, bytes: number) {
513
+ return Number((await owner.one<{ id: number }>(
514
+ `insert into meta.dump_job (kind, status, username, file_name, original_name, bytes, expires_at)
515
+ values ('import', 'uploaded', $1, $2, $3, $4, now() + make_interval(days => $5)) returning id`,
516
+ [username, fileName, originalName.slice(0, 255), bytes, KEEP_DAYS]))!.id);
517
+ }
518
+
519
+ /**
520
+ * The scheduler's chore (src/automations.ts): jobs whose server stopped are marked failed; files past their days are
521
+ * removed (the job stays in the list); files no job knows (an upload cut off) after a day.
522
+ */
523
+ export async function dumpTick() {
524
+ await owner.query(
525
+ `update meta.dump_job set status = 'failed', error = 'interrupted: the server stopped while the job ran', finished_at = now(), heartbeat_at = null
526
+ where status = 'running' and (heartbeat_at is null or heartbeat_at < now() - make_interval(secs => $1))`, [STALE_MS / 1000]);
527
+ const expired = (await owner.query<{ id: number; file_name: string }>(
528
+ `select id::int, file_name from meta.dump_job where file_name is not null and expires_at < now()
529
+ and not exists (select 1 from meta.dump_job i where i.source_job = dump_job.id and i.status = 'running')`)).rows;
530
+ for (const j of expired) {
531
+ await rm(filePath(j.file_name), { force: true }).catch(() => {});
532
+ await owner.query('update meta.dump_job set file_name = null where id = $1', [j.id]);
533
+ }
534
+ let names: string[];
535
+ try {
536
+ names = await readdir(DUMP_DIR);
537
+ } catch {
538
+ return;
539
+ }
540
+ const known = new Set((await owner.query<{ f: string }>('select file_name as f from meta.dump_job where file_name is not null')).rows.map((r) => r.f));
541
+ for (const n of names) {
542
+ if (known.has(n) || !/^(export|upload)-/.test(n)) continue;
543
+ const st = await stat(join(DUMP_DIR, n)).catch(() => null);
544
+ if (st && Date.now() - st.mtimeMs > 86_400_000) await rm(join(DUMP_DIR, n), { force: true }).catch(() => {});
545
+ }
546
+ }