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.
- package/SECURITY.md +2 -2
- package/db/migrations/109_builder_pins.sql +19 -0
- package/db/migrations/110_sql_charts.sql +23 -0
- package/db/migrations/111_sql_diagram.sql +24 -0
- package/db/migrations/113_rest_objects.sql +49 -0
- package/db/migrations/116_dump_jobs.sql +41 -0
- package/db/migrations/117_rest_handlers.sql +63 -0
- package/db/migrations/118_change_log.sql +54 -0
- package/docs/apex-feature-parity.md +11 -11
- package/docs/guide/01-installation.md +4 -0
- package/docs/guide/03-builder.md +267 -7
- package/docs/guide/08-security.md +29 -0
- package/docs/guide/12-development.md +70 -6
- package/docs/guide/13-rest-api.md +133 -10
- package/docs/guide/15-extensions.md +1 -1
- package/examples/hr/hr_62_rest_module.sql +39 -0
- package/package.json +1 -1
- package/public/app.css +1 -1
- package/public/builder-icons.svg +2 -0
- package/public/builder.css +442 -173
- package/public/builder.js +48 -0
- package/public/data-modeler.css +56 -0
- package/public/data-modeler.js +367 -0
- package/public/palette.js +133 -0
- package/public/sql-monitor.js +73 -0
- package/public/worksheet.css +49 -0
- package/public/worksheet.js +4 -0
- package/src/app.ts +10 -0
- package/src/automations.ts +6 -0
- package/src/binds.ts +2 -1
- package/src/builder/advisor.ts +24 -2
- package/src/builder/autorest.ts +363 -0
- package/src/builder/changelog.ts +240 -0
- package/src/builder/components.ts +1 -1
- package/src/builder/data-modeler.ts +382 -0
- package/src/builder/datapump.ts +364 -0
- package/src/builder/db-users.ts +472 -0
- package/src/builder/ddl-tools.ts +13 -4
- package/src/builder/find-workspace.ts +2 -2
- package/src/builder/launchpad.ts +140 -0
- package/src/builder/object-actions.ts +1 -0
- package/src/builder/object-browser.ts +7 -1
- package/src/builder/plan-view.ts +25 -0
- package/src/builder/rest-modules.ts +629 -0
- package/src/builder/rest.ts +1 -0
- package/src/builder/routes.ts +4 -2
- package/src/builder/scheduling.ts +495 -0
- package/src/builder/sql-charts.ts +203 -0
- package/src/builder/sql-monitor.ts +397 -0
- package/src/builder/sql-tools.ts +53 -0
- package/src/builder/sql-utilities.ts +7 -39
- package/src/builder/sql.ts +20 -9
- package/src/builder/table-editor.ts +4 -1
- package/src/builder/top-sql.ts +24 -6
- package/src/builder/ui.ts +25 -17
- package/src/builder/worksheet.ts +35 -28
- package/src/builder/workspaces.ts +4 -2
- package/src/changelog.ts +109 -0
- package/src/cli/replace.ts +3 -1
- package/src/cron-schedule.ts +282 -0
- package/src/datamodel.ts +324 -0
- package/src/datapump.ts +546 -0
- package/src/dbroles.ts +215 -0
- package/src/dumpscript.ts +407 -0
- package/src/pgcron.ts +225 -0
- package/src/runtime/autorest.ts +336 -0
- package/src/runtime/rest-handlers.ts +75 -0
- package/src/runtime/rest.ts +120 -26
- package/src/session.ts +2 -0
package/src/dbroles.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { createHash, createHmac, pbkdf2Sync, randomBytes } from 'node:crypto';
|
|
2
|
+
import pg from 'pg';
|
|
3
|
+
|
|
4
|
+
// (sprint 99, Database Actions DA-13) Database Users: the SQL that creates, changes and drops a PostgreSQL role,
|
|
5
|
+
// from what the form says and what the catalog says now. Pure functions: src/builder/db-users.ts reads the
|
|
6
|
+
// catalog and runs the statements; test/db-users.test.ts tests them.
|
|
7
|
+
//
|
|
8
|
+
// Passwords: the server never sends a password to PostgreSQL. scramVerifier() hashes it here (SCRAM-SHA-256,
|
|
9
|
+
// like psql's \password and libpq's PQencryptPasswordConn), and the statement carries the verifier as a literal
|
|
10
|
+
// (pg's escapeLiteral). Statements are built with PASSWORD_SLOT in its place: maskSql() shows them with
|
|
11
|
+
// '********' (the review, the activity log), withVerifier() fills in the verifier only for running them.
|
|
12
|
+
|
|
13
|
+
const ident = pg.escapeIdentifier;
|
|
14
|
+
// pg's escapeLiteral starts a literal with a backslash as " E'…'": without the space
|
|
15
|
+
const literal = (s: string) => pg.escapeLiteral(s).trim();
|
|
16
|
+
|
|
17
|
+
/** Where the password goes in a generated statement (never shown: see maskSql / withVerifier). */
|
|
18
|
+
export const PASSWORD_SLOT = '\u0000password\u0000';
|
|
19
|
+
export const MASK = `'********'`;
|
|
20
|
+
|
|
21
|
+
export type DefaultPriv = 'none' | 'read' | 'write' | 'custom';
|
|
22
|
+
export type ExistingPriv = '' | 'read' | 'write' | 'revoke';
|
|
23
|
+
|
|
24
|
+
export interface SchemaGrant {
|
|
25
|
+
schema: string;
|
|
26
|
+
usage: boolean;
|
|
27
|
+
create: boolean;
|
|
28
|
+
/** privileges on tables (and sequences) the creator makes in the schema from now on */
|
|
29
|
+
defaults: DefaultPriv;
|
|
30
|
+
/** a one-off grant on the tables there now ('' = no change) */
|
|
31
|
+
existing?: ExistingPriv;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RoleSpec {
|
|
35
|
+
name: string;
|
|
36
|
+
login: boolean;
|
|
37
|
+
/** keep the password, set a new one, or remove it (an existing role); none / set for a new one */
|
|
38
|
+
password: 'keep' | 'set' | 'remove';
|
|
39
|
+
/** YYYY-MM-DD, '' = no expiry */
|
|
40
|
+
validUntil: string;
|
|
41
|
+
/** -1 = no limit */
|
|
42
|
+
connLimit: number;
|
|
43
|
+
memberOf: string[];
|
|
44
|
+
/** the search path in this database, comma separated ('' = the server's default) */
|
|
45
|
+
searchPath: string;
|
|
46
|
+
comment: string;
|
|
47
|
+
schemas: SchemaGrant[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RoleContext {
|
|
51
|
+
/** the current database (ALTER ROLE … IN DATABASE) */
|
|
52
|
+
database: string;
|
|
53
|
+
/** the role whose new objects the default privileges cover (pgkiln's owner connection) */
|
|
54
|
+
creator: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const emptyRole = (name = ''): RoleSpec => ({ name, login: false, password: 'keep', validUntil: '', connLimit: -1, memberOf: [], searchPath: '', comment: '', schemas: [] });
|
|
58
|
+
|
|
59
|
+
/** A name for a new role: PostgreSQL reserves pg_, pgkiln reserves pgkiln. */
|
|
60
|
+
export const ROLE_NAME = /^[A-Za-z_][A-Za-z0-9_$]{0,62}$/;
|
|
61
|
+
export const reservedName = (name: string) => /^pg_/i.test(name) || /^pgkiln/i.test(name);
|
|
62
|
+
|
|
63
|
+
/** The schemas of a search path ($user stays $user), or null when one isn't a plain name. */
|
|
64
|
+
export function parseSearchPath(text: string): string[] | null {
|
|
65
|
+
const parts = text.split(',').map((p) => p.trim()).filter(Boolean).map((p) => (/^".*"$/.test(p) ? p.slice(1, -1).replace(/""/g, '"') : p));
|
|
66
|
+
return parts.every((p) => p === '$user' || /^[A-Za-z_][A-Za-z0-9_$]{0,62}$/.test(p)) ? parts : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const validDate = (d: string) => {
|
|
70
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) return false;
|
|
71
|
+
const t = new Date(`${d}T00:00:00Z`);
|
|
72
|
+
return !Number.isNaN(t.getTime()) && t.toISOString().slice(0, 10) === d;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export interface CheckOptions {
|
|
76
|
+
isNew: boolean;
|
|
77
|
+
/** roles a role may be made a member of (existing memberships may stay) */
|
|
78
|
+
grantable: string[];
|
|
79
|
+
/** schemas whose privileges the form may change */
|
|
80
|
+
schemas: string[];
|
|
81
|
+
before: RoleSpec | null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** What is wrong with a spec (empty: fine). */
|
|
85
|
+
export function checkRole(spec: RoleSpec, o: CheckOptions): string[] {
|
|
86
|
+
const errors: string[] = [];
|
|
87
|
+
if (o.isNew) {
|
|
88
|
+
if (!ROLE_NAME.test(spec.name)) errors.push('The name starts with a letter or _ and has only letters, digits, _ and $ (at most 63).');
|
|
89
|
+
else if (reservedName(spec.name)) errors.push('Names starting with pg_ or pgkiln are reserved (PostgreSQL, pgkiln).');
|
|
90
|
+
}
|
|
91
|
+
if (spec.validUntil && !validDate(spec.validUntil)) errors.push('Valid until is a date (YYYY-MM-DD).');
|
|
92
|
+
if (!Number.isInteger(spec.connLimit) || spec.connLimit < -1 || spec.connLimit > 100000) errors.push('The connection limit is a whole number from -1 (no limit) to 100000.');
|
|
93
|
+
if (spec.searchPath && !parseSearchPath(spec.searchPath)) errors.push('The search path is a comma-separated list of schema names ($user for the role\'s own).');
|
|
94
|
+
if (spec.comment.length > 1000) errors.push('The comment has at most 1000 characters.');
|
|
95
|
+
const kept = new Set(o.before?.memberOf ?? []);
|
|
96
|
+
for (const m of spec.memberOf) {
|
|
97
|
+
if (m === spec.name) errors.push('A role is not a member of itself.');
|
|
98
|
+
else if (!kept.has(m) && !o.grantable.includes(m)) errors.push(`The role ${m} can't be granted here (pgkiln's own roles, superusers and login roles can't).`);
|
|
99
|
+
}
|
|
100
|
+
for (const s of spec.schemas) {
|
|
101
|
+
if (!o.schemas.includes(s.schema)) errors.push(`Privileges on the schema ${s.schema} aren't changed here.`);
|
|
102
|
+
if (!['none', 'read', 'write', 'custom'].includes(s.defaults)) errors.push(`Unknown default privileges for ${s.schema}.`);
|
|
103
|
+
if (s.existing && !['read', 'write', 'revoke'].includes(s.existing)) errors.push(`Unknown privileges on the tables of ${s.schema}.`);
|
|
104
|
+
}
|
|
105
|
+
if (o.isNew && spec.password === 'remove') errors.push('A new role has no password to remove.');
|
|
106
|
+
return errors;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** What is wrong with a new password and its confirmation (empty: fine). */
|
|
110
|
+
export function checkPassword(password: string, confirm: string): string[] {
|
|
111
|
+
if (password.length < 8) return ['The password has at least 8 characters.'];
|
|
112
|
+
if (password.length > 1024) return ['The password has at most 1024 characters.'];
|
|
113
|
+
if (password.includes('\0')) return ['The password can\'t contain a NUL character.'];
|
|
114
|
+
if (password !== confirm) return ['The password and its confirmation differ.'];
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const privs: Record<Exclude<DefaultPriv, 'custom' | 'none'>, string> = { read: 'SELECT', write: 'SELECT, INSERT, UPDATE, DELETE' };
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The statements that make `before` (the catalog; null for a new role) into `after`, the password as
|
|
122
|
+
* PASSWORD_SLOT. Nothing that is already so is repeated.
|
|
123
|
+
*/
|
|
124
|
+
export function roleSql(before: RoleSpec | null, after: RoleSpec, ctx: RoleContext): string[] {
|
|
125
|
+
const out: string[] = [];
|
|
126
|
+
const r = ident(after.name);
|
|
127
|
+
const b = before ?? emptyRole(after.name);
|
|
128
|
+
const opts: string[] = [];
|
|
129
|
+
if (!before || before.login !== after.login) opts.push(after.login ? 'LOGIN' : 'NOLOGIN');
|
|
130
|
+
if (after.password === 'set') opts.push(`PASSWORD ${PASSWORD_SLOT}`);
|
|
131
|
+
else if (after.password === 'remove' && before) opts.push('PASSWORD NULL');
|
|
132
|
+
if (b.validUntil !== after.validUntil) opts.push(`VALID UNTIL ${literal(after.validUntil || 'infinity')}`);
|
|
133
|
+
if (b.connLimit !== after.connLimit) opts.push(`CONNECTION LIMIT ${after.connLimit}`);
|
|
134
|
+
if (!before) out.push(`CREATE ROLE ${r}${opts.length ? ` ${opts.join(' ')}` : ''};`);
|
|
135
|
+
else if (opts.length) out.push(`ALTER ROLE ${r} ${opts.join(' ')};`);
|
|
136
|
+
|
|
137
|
+
for (const g of after.memberOf.filter((m) => !b.memberOf.includes(m))) out.push(`GRANT ${ident(g)} TO ${r};`);
|
|
138
|
+
for (const g of b.memberOf.filter((m) => !after.memberOf.includes(m))) out.push(`REVOKE ${ident(g)} FROM ${r};`);
|
|
139
|
+
|
|
140
|
+
const path = (t: string) => (parseSearchPath(t) ?? []).join(',');
|
|
141
|
+
if (path(b.searchPath) !== path(after.searchPath)) {
|
|
142
|
+
const p = parseSearchPath(after.searchPath) ?? [];
|
|
143
|
+
out.push(p.length ? `ALTER ROLE ${r} IN DATABASE ${ident(ctx.database)} SET search_path TO ${p.map(ident).join(', ')};`
|
|
144
|
+
: `ALTER ROLE ${r} IN DATABASE ${ident(ctx.database)} RESET search_path;`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (const s of after.schemas) {
|
|
148
|
+
const was = b.schemas.find((x) => x.schema === s.schema) ?? { schema: s.schema, usage: false, create: false, defaults: 'none' as DefaultPriv };
|
|
149
|
+
const n = ident(s.schema);
|
|
150
|
+
const grant = [s.usage && !was.usage ? 'USAGE' : '', s.create && !was.create ? 'CREATE' : ''].filter(Boolean);
|
|
151
|
+
const revoke = [!s.usage && was.usage ? 'USAGE' : '', !s.create && was.create ? 'CREATE' : ''].filter(Boolean);
|
|
152
|
+
if (grant.length) out.push(`GRANT ${grant.join(', ')} ON SCHEMA ${n} TO ${r};`);
|
|
153
|
+
if (revoke.length) out.push(`REVOKE ${revoke.join(', ')} ON SCHEMA ${n} FROM ${r};`);
|
|
154
|
+
if (s.defaults !== 'custom' && s.defaults !== was.defaults) {
|
|
155
|
+
const adp = `ALTER DEFAULT PRIVILEGES FOR ROLE ${ident(ctx.creator)} IN SCHEMA ${n}`;
|
|
156
|
+
if (was.defaults !== 'none') out.push(`${adp} REVOKE ALL ON TABLES FROM ${r};`, `${adp} REVOKE ALL ON SEQUENCES FROM ${r};`);
|
|
157
|
+
if (s.defaults !== 'none') out.push(`${adp} GRANT ${privs[s.defaults]} ON TABLES TO ${r};`);
|
|
158
|
+
if (s.defaults === 'write') out.push(`${adp} GRANT USAGE, SELECT ON SEQUENCES TO ${r};`);
|
|
159
|
+
}
|
|
160
|
+
if (s.existing === 'revoke') out.push(`REVOKE ALL ON ALL TABLES IN SCHEMA ${n} FROM ${r};`, `REVOKE ALL ON ALL SEQUENCES IN SCHEMA ${n} FROM ${r};`);
|
|
161
|
+
else if (s.existing === 'read' || s.existing === 'write') {
|
|
162
|
+
out.push(`GRANT ${privs[s.existing]} ON ALL TABLES IN SCHEMA ${n} TO ${r};`);
|
|
163
|
+
if (s.existing === 'write') out.push(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${n} TO ${r};`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (b.comment !== after.comment) out.push(`COMMENT ON ROLE ${r} IS ${after.comment ? literal(after.comment) : 'NULL'};`);
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The statements that drop a role: its objects reassigned to another role (or, without one, dropped with it),
|
|
173
|
+
* its privileges and default privileges in this database revoked (DROP OWNED), then the role.
|
|
174
|
+
*/
|
|
175
|
+
export function dropSql(name: string, reassignTo: string | null): string[] {
|
|
176
|
+
const r = ident(name);
|
|
177
|
+
return [...(reassignTo ? [`REASSIGN OWNED BY ${r} TO ${ident(reassignTo)};`] : []), `DROP OWNED BY ${r};`, `DROP ROLE ${r};`];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The statements as the review and the activity log show them: the password masked. */
|
|
181
|
+
export const maskSql = (stmts: string[]) => stmts.map((s) => s.split(PASSWORD_SLOT).join(MASK)).join('\n');
|
|
182
|
+
|
|
183
|
+
/** The statements to run: the password slot filled with the SCRAM verifier (never shown or logged). */
|
|
184
|
+
export const withVerifier = (stmts: string[], verifier: string | null) =>
|
|
185
|
+
stmts.map((s) => {
|
|
186
|
+
if (!s.includes(PASSWORD_SLOT)) return s;
|
|
187
|
+
if (!verifier) throw new Error('A password is needed.');
|
|
188
|
+
return s.split(PASSWORD_SLOT).join(literal(verifier));
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
/** A digest of the reviewed (masked) statements: Apply runs only what the review showed. */
|
|
192
|
+
export const reviewDigest = (stmts: string[]) => createHash('sha256').update(maskSql(stmts)).digest('base64url');
|
|
193
|
+
|
|
194
|
+
// ------------------------------------------------------------------ SCRAM-SHA-256
|
|
195
|
+
|
|
196
|
+
// RFC 4013 SASLprep, as PostgreSQL applies it (src/common/saslprep.c): non-ASCII spaces become spaces,
|
|
197
|
+
// characters "mapped to nothing" go, NFKC; a password it can't prepare (control characters) is used as it is.
|
|
198
|
+
const NON_ASCII_SPACE = /[ - ]/g;
|
|
199
|
+
const TO_NOTHING = /[͏᠆᠋-᠍-︀-️]/g;
|
|
200
|
+
export function saslprep(password: string): string {
|
|
201
|
+
// eslint-disable-next-line no-control-regex
|
|
202
|
+
if (/^[\x20-\x7E]*$/.test(password)) return password;
|
|
203
|
+
const prepared = password.replace(NON_ASCII_SPACE, ' ').replace(TO_NOTHING, '').normalize('NFKC');
|
|
204
|
+
// eslint-disable-next-line no-control-regex
|
|
205
|
+
return /[\u0000-\u001F\u007F-\u009F]/.test(prepared) || !prepared ? password : prepared;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** A SCRAM-SHA-256 verifier for ALTER ROLE … PASSWORD (PostgreSQL stores a verifier as it is). */
|
|
209
|
+
export function scramVerifier(password: string, salt: Buffer = randomBytes(16), iterations = 4096): string {
|
|
210
|
+
const salted = pbkdf2Sync(Buffer.from(saslprep(password), 'utf8'), salt, iterations, 32, 'sha256');
|
|
211
|
+
const clientKey = createHmac('sha256', salted).update('Client Key').digest();
|
|
212
|
+
const storedKey = createHash('sha256').update(clientKey).digest();
|
|
213
|
+
const serverKey = createHmac('sha256', salted).update('Server Key').digest();
|
|
214
|
+
return `SCRAM-SHA-256$${iterations}:${salt.toString('base64')}$${storedKey.toString('base64')}:${serverKey.toString('base64')}`;
|
|
215
|
+
}
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import type pg from 'pg';
|
|
2
|
+
|
|
3
|
+
// (sprint 103, Database Actions DA-16) The SQL script pg_restore writes for one schema (`pg_restore -f -`), read line
|
|
4
|
+
// by line and run on one connection, inside the caller's transaction: Schema Import (src/datapump.ts) streams it here
|
|
5
|
+
// instead of to psql, so it can run in the same transaction as its schema renames, and so a dump can't reach further
|
|
6
|
+
// than its own schema.
|
|
7
|
+
//
|
|
8
|
+
// What runs:
|
|
9
|
+
// - statements, split like psql splits them: quotes, E'' strings, quoted identifiers, dollar quotes, comments, and
|
|
10
|
+
// SQL-standard function bodies (BEGIN ATOMIC … END) with their own semicolons
|
|
11
|
+
// - COPY … FROM stdin with the data lines after it up to "\.", sent with the COPY protocol in chunks (never the
|
|
12
|
+
// whole table in memory)
|
|
13
|
+
// - psql's \restrict and \unrestrict lines (PostgreSQL 17.6+) are skipped; any other psql meta-command is refused
|
|
14
|
+
//
|
|
15
|
+
// What is refused (checkStatement), before it reaches the server: statements pg_dump doesn't write for one schema
|
|
16
|
+
// (roles, databases, extensions, ALTER SYSTEM, DO, COPY to or from files and programs, SET ROLE, SET SESSION
|
|
17
|
+
// AUTHORIZATION, OWNER TO, SET SCHEMA, security labels), and objects created, changed or dropped outside the dump's
|
|
18
|
+
// own schema. A dump is still code: functions and triggers in it run as pgkiln's owner role. Import only dumps you
|
|
19
|
+
// trust.
|
|
20
|
+
|
|
21
|
+
export class ScriptError extends Error {
|
|
22
|
+
constructor(message: string, public line?: number) {
|
|
23
|
+
super(line ? `line ${line}: ${message}` : message);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type ScriptEvent =
|
|
28
|
+
| { type: 'sql'; sql: string; line: number }
|
|
29
|
+
| { type: 'copy'; sql: string; line: number }
|
|
30
|
+
| { type: 'data'; text: string }
|
|
31
|
+
| { type: 'copyEnd' };
|
|
32
|
+
|
|
33
|
+
type State = { q: 'none' } | { q: 's' } | { q: 'e' } | { q: 'd' } | { q: 'dollar'; tag: string } | { q: 'block'; depth: number };
|
|
34
|
+
|
|
35
|
+
const IDENT_CHAR = /[A-Za-z0-9_$\u0080-]/;
|
|
36
|
+
const DOLLAR = /^\$([A-Za-z_\u0080-][A-Za-z0-9_\u0080-]*)?\$/;
|
|
37
|
+
const ROUTINE = /^create\s+(or\s+replace\s+)?(function|procedure)\b/i;
|
|
38
|
+
|
|
39
|
+
/** Splits a psql script into statements and COPY data, a line at a time. */
|
|
40
|
+
export class ScriptLexer {
|
|
41
|
+
private buf = '';
|
|
42
|
+
private state: State = { q: 'none' };
|
|
43
|
+
private data = false;
|
|
44
|
+
/** BEGIN … END depth in a CREATE FUNCTION / PROCEDURE (SQL-standard bodies) */
|
|
45
|
+
private depth = 0;
|
|
46
|
+
private lineNo = 0;
|
|
47
|
+
private startLine = 0;
|
|
48
|
+
|
|
49
|
+
push(line: string): ScriptEvent[] {
|
|
50
|
+
this.lineNo++;
|
|
51
|
+
if (this.data) {
|
|
52
|
+
if (line === '\\.') {
|
|
53
|
+
this.data = false;
|
|
54
|
+
return [{ type: 'copyEnd' }];
|
|
55
|
+
}
|
|
56
|
+
return [{ type: 'data', text: `${line}\n` }];
|
|
57
|
+
}
|
|
58
|
+
const out: ScriptEvent[] = [];
|
|
59
|
+
let i = 0;
|
|
60
|
+
const n = line.length;
|
|
61
|
+
const emit = () => {
|
|
62
|
+
const sql = this.buf.trim();
|
|
63
|
+
this.buf = '';
|
|
64
|
+
this.depth = 0;
|
|
65
|
+
if (!sql || sql === ';') return;
|
|
66
|
+
if (/^copy\b/i.test(sql) && /\bfrom\s+stdin\s*;$/i.test(sql)) {
|
|
67
|
+
out.push({ type: 'copy', sql, line: this.startLine });
|
|
68
|
+
this.data = true;
|
|
69
|
+
} else out.push({ type: 'sql', sql, line: this.startLine });
|
|
70
|
+
};
|
|
71
|
+
while (i < n) {
|
|
72
|
+
const ch = line[i];
|
|
73
|
+
const st = this.state;
|
|
74
|
+
if (st.q === 'none') {
|
|
75
|
+
if (!this.buf.trim()) this.startLine = this.lineNo;
|
|
76
|
+
if (ch === '-' && line[i + 1] === '-') break; // a comment to the end of the line
|
|
77
|
+
if (ch === '/' && line[i + 1] === '*') {
|
|
78
|
+
this.state = { q: 'block', depth: 1 };
|
|
79
|
+
this.buf += ' ';
|
|
80
|
+
i += 2;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (ch === "'") {
|
|
84
|
+
const prev = this.buf.slice(-1);
|
|
85
|
+
const before = this.buf.slice(-2, -1);
|
|
86
|
+
this.state = /[eE]/.test(prev) && !IDENT_CHAR.test(before) ? { q: 'e' } : { q: 's' };
|
|
87
|
+
} else if (ch === '"') this.state = { q: 'd' };
|
|
88
|
+
else if (ch === '$' && !IDENT_CHAR.test(this.buf.slice(-1))) {
|
|
89
|
+
const m = DOLLAR.exec(line.slice(i));
|
|
90
|
+
if (m) {
|
|
91
|
+
this.state = { q: 'dollar', tag: m[0] };
|
|
92
|
+
this.buf += m[0];
|
|
93
|
+
i += m[0].length;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
} else if (ch === '\\') {
|
|
97
|
+
// a psql meta-command: \restrict / \unrestrict (pg_restore 17.6+) on a line of their own are skipped
|
|
98
|
+
if (!this.buf.trim() && /^\\(un)?restrict(\s+\S+)?\s*$/.test(line.slice(i))) return out;
|
|
99
|
+
throw new ScriptError(`psql meta-commands are not run here: ${line.slice(i, i + 20)}`, this.lineNo);
|
|
100
|
+
} else if (ch === ';' && this.depth === 0) {
|
|
101
|
+
this.buf += ';';
|
|
102
|
+
emit();
|
|
103
|
+
i++;
|
|
104
|
+
if (this.data) {
|
|
105
|
+
if (line.slice(i).trim()) throw new ScriptError('COPY data must start on the next line', this.lineNo);
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
continue;
|
|
109
|
+
} else if (/[A-Za-z_]/.test(ch) && !IDENT_CHAR.test(this.buf.slice(-1))) {
|
|
110
|
+
// a word: BEGIN / CASE / END count in a routine's SQL-standard body
|
|
111
|
+
let j = i;
|
|
112
|
+
while (j < n && IDENT_CHAR.test(line[j])) j++;
|
|
113
|
+
const word = line.slice(i, j).toLowerCase();
|
|
114
|
+
if ((word === 'begin' || word === 'case' || word === 'end') && ROUTINE.test(this.buf.trimStart())) {
|
|
115
|
+
if (word === 'begin') this.depth++;
|
|
116
|
+
else if (word === 'case' && this.depth > 0) this.depth++;
|
|
117
|
+
else if (word === 'end' && this.depth > 0) this.depth--;
|
|
118
|
+
}
|
|
119
|
+
this.buf += line.slice(i, j);
|
|
120
|
+
i = j;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
this.buf += ch;
|
|
124
|
+
i++;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (st.q === 'block') {
|
|
128
|
+
if (ch === '/' && line[i + 1] === '*') {
|
|
129
|
+
st.depth++;
|
|
130
|
+
i += 2;
|
|
131
|
+
} else if (ch === '*' && line[i + 1] === '/') {
|
|
132
|
+
st.depth--;
|
|
133
|
+
i += 2;
|
|
134
|
+
if (st.depth === 0) this.state = { q: 'none' };
|
|
135
|
+
} else i++;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (st.q === 'dollar') {
|
|
139
|
+
if (ch === '$' && line.startsWith(st.tag, i)) {
|
|
140
|
+
this.buf += st.tag;
|
|
141
|
+
i += st.tag.length;
|
|
142
|
+
this.state = { q: 'none' };
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
this.buf += ch;
|
|
146
|
+
i++;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
// quotes: '' and "" escape themselves; E'' strings also \x
|
|
150
|
+
if (st.q === 'e' && ch === '\\') {
|
|
151
|
+
this.buf += line.slice(i, i + 2);
|
|
152
|
+
i += 2;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const close = st.q === 'd' ? '"' : "'";
|
|
156
|
+
this.buf += ch;
|
|
157
|
+
i++;
|
|
158
|
+
if (ch === close) {
|
|
159
|
+
if (line[i] === close) {
|
|
160
|
+
this.buf += close;
|
|
161
|
+
i++;
|
|
162
|
+
} else this.state = { q: 'none' };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (this.state.q !== 'block') this.buf += '\n';
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The end of the script: what is left must be nothing but whitespace. */
|
|
170
|
+
end() {
|
|
171
|
+
if (this.data) throw new ScriptError('the script ends inside COPY data (no "\\." line)', this.lineNo);
|
|
172
|
+
if (this.state.q !== 'none' && this.state.q !== 'block') throw new ScriptError('the script ends inside a quoted string', this.lineNo);
|
|
173
|
+
if (this.buf.trim()) throw new ScriptError(`the script ends without a semicolon: ${this.buf.trim().slice(0, 60)}`, this.lineNo);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ------------------------------------------------------------------ checks
|
|
178
|
+
|
|
179
|
+
const ID = String.raw`(?:"(?:[^"]|"")+"|[A-Za-z_\u0080-][A-Za-z0-9_$\u0080-]*)`;
|
|
180
|
+
const QN = String.raw`(${ID})\s*\.\s*(${ID})`;
|
|
181
|
+
/** an identifier as PostgreSQL stores it */
|
|
182
|
+
export const ident = (s: string) => (s.startsWith('"') ? s.slice(1, -1).replace(/""/g, '"') : s.toLowerCase());
|
|
183
|
+
const re = (src: string) => new RegExp(src, 'is');
|
|
184
|
+
|
|
185
|
+
/** GUCs pg_dump and pg_restore set */
|
|
186
|
+
const SETTINGS = new Set([
|
|
187
|
+
'statement_timeout', 'lock_timeout', 'idle_in_transaction_session_timeout', 'transaction_timeout', 'client_encoding',
|
|
188
|
+
'standard_conforming_strings', 'check_function_bodies', 'xmloption', 'client_min_messages', 'row_security',
|
|
189
|
+
'default_tablespace', 'default_table_access_method', 'default_with_oids', 'search_path', 'escape_string_warning',
|
|
190
|
+
]);
|
|
191
|
+
|
|
192
|
+
const OBJECT = String.raw`(?:table|foreign\s+table|view|materialized\s+view|sequence|function|procedure|routine|aggregate|type|domain|index|statistics|collation|text\s+search\s+(?:configuration|dictionary|parser|template)|conversion)`;
|
|
193
|
+
|
|
194
|
+
const RULES: { re: RegExp; schemaGroup: number; qualified?: boolean }[] = [
|
|
195
|
+
// CREATE [OR REPLACE] [UNLOGGED|RECURSIVE] <object> [IF NOT EXISTS] schema.name
|
|
196
|
+
{ re: re(String.raw`^create\s+(?:or\s+replace\s+)?(?:(?:unlogged|recursive)\s+)?${OBJECT}\s+(?:if\s+not\s+exists\s+)?${QN}`), schemaGroup: 1 },
|
|
197
|
+
// CREATE [UNIQUE] INDEX [CONCURRENTLY] [IF NOT EXISTS] name ON [ONLY] schema.table
|
|
198
|
+
{ re: re(String.raw`^create\s+(?:unique\s+)?index\s+(?:concurrently\s+)?(?:if\s+not\s+exists\s+)?(?:${ID}\s+)?on\s+(?:only\s+)?${QN}`), schemaGroup: 1 },
|
|
199
|
+
// CREATE [OR REPLACE] [CONSTRAINT] TRIGGER name … ON schema.table
|
|
200
|
+
{ re: re(String.raw`^create\s+(?:or\s+replace\s+)?(?:constraint\s+)?trigger\s+${ID}\s.*?\son\s+${QN}`), schemaGroup: 1 },
|
|
201
|
+
// CREATE / ALTER POLICY name ON schema.table; CREATE RULE name AS ON event TO schema.table
|
|
202
|
+
{ re: re(String.raw`^(?:create|alter)\s+policy\s+${ID}\s+on\s+${QN}`), schemaGroup: 1 },
|
|
203
|
+
{ re: re(String.raw`^create\s+(?:or\s+replace\s+)?rule\s+${ID}\s+as\s+on\s+\w+\s+to\s+${QN}`), schemaGroup: 1 },
|
|
204
|
+
// ALTER <object> [IF EXISTS] [ONLY] schema.name …
|
|
205
|
+
{ re: re(String.raw`^alter\s+${OBJECT}\s+(?:if\s+exists\s+)?(?:only\s+)?${QN}`), schemaGroup: 1 },
|
|
206
|
+
// COMMENT ON CONSTRAINT|TRIGGER|POLICY|RULE name ON [DOMAIN] schema.table; COMMENT ON <kind> schema.name
|
|
207
|
+
{ re: re(String.raw`^comment\s+on\s+(?:constraint|trigger|policy|rule)\s+${ID}\s+on\s+(?:domain\s+)?${QN}`), schemaGroup: 1 },
|
|
208
|
+
{ re: re(String.raw`^comment\s+on\s+(?:column|${OBJECT})\s+${QN}`), schemaGroup: 1 },
|
|
209
|
+
// GRANT / REVOKE … ON [kind] schema.name
|
|
210
|
+
{ re: re(String.raw`^(?:grant|revoke)\s+.+?\s+on\s+(?:table|sequence|function|procedure|routine|type|domain|foreign\s+table)?\s*${QN}`), schemaGroup: 1 },
|
|
211
|
+
// DROP <object> [IF EXISTS] schema.name; DROP TRIGGER|POLICY|RULE [IF EXISTS] name ON schema.table (pg_restore --clean)
|
|
212
|
+
{ re: re(String.raw`^drop\s+${OBJECT}\s+(?:if\s+exists\s+)?${QN}`), schemaGroup: 1 },
|
|
213
|
+
{ re: re(String.raw`^drop\s+(?:trigger|policy|rule)\s+(?:if\s+exists\s+)?${ID}\s+on\s+${QN}`), schemaGroup: 1 },
|
|
214
|
+
// REFRESH MATERIALIZED VIEW schema.name
|
|
215
|
+
{ re: re(String.raw`^refresh\s+materialized\s+view\s+(?:concurrently\s+)?${QN}`), schemaGroup: 1 },
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
/** Why a statement of a dump of `schema` may not run here, or null when it may. */
|
|
219
|
+
export function checkStatement(sql: string, schema: string): string | null {
|
|
220
|
+
const s = sql.trim();
|
|
221
|
+
const outside = (name: string) => `it changes an object outside the dump's schema ${schema} (${name})`;
|
|
222
|
+
// settings
|
|
223
|
+
let m = re(String.raw`^set\s+(?:session\s+)?([a-z_]+)\s*(?:=|to)\s*(.*?)\s*;$`).exec(s);
|
|
224
|
+
if (m) {
|
|
225
|
+
const name = m[1].toLowerCase();
|
|
226
|
+
if (!SETTINGS.has(name)) return `SET ${name} is not something a dump sets`;
|
|
227
|
+
if (name === 'search_path' && !/^(''|"")$/.test(m[2])) return 'a dump runs with an empty search_path (names are schema-qualified)';
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
if (/^select\s+pg_catalog\.set_config\(\s*'search_path'\s*,\s*''\s*,\s*false\s*\)\s*;$/i.test(s)) return null;
|
|
231
|
+
m = /^select\s+pg_catalog\.setval\(\s*'((?:[^']|'')+)'\s*,\s*-?\d+\s*,\s*(?:true|false)\s*\)\s*;$/i.exec(s);
|
|
232
|
+
if (m) {
|
|
233
|
+
const n = re(String.raw`^${QN}$`).exec(m[1].replace(/''/g, "'"));
|
|
234
|
+
if (!n) return 'setval needs a schema-qualified sequence';
|
|
235
|
+
return ident(n[1]) === schema ? null : outside(m[1]);
|
|
236
|
+
}
|
|
237
|
+
// COPY schema.table [(columns)] FROM stdin
|
|
238
|
+
if (/^copy\b/i.test(s)) {
|
|
239
|
+
m = re(String.raw`^copy\s+${QN}\s*(?:\(\s*${ID}(?:\s*,\s*${ID})*\s*\))?\s+from\s+stdin\s*;$`).exec(s);
|
|
240
|
+
if (!m) return 'only COPY … FROM stdin of a schema-qualified table runs here';
|
|
241
|
+
return ident(m[1]) === schema ? null : outside(`${m[1]}.${m[2]}`);
|
|
242
|
+
}
|
|
243
|
+
// the schema itself
|
|
244
|
+
m = re(String.raw`^(?:create\s+schema|comment\s+on\s+schema|drop\s+schema\s+(?:if\s+exists\s+)?)\s*(${ID})`).exec(s);
|
|
245
|
+
if (m) return ident(m[1]) === schema ? null : outside(m[1]);
|
|
246
|
+
m = re(String.raw`^(?:grant|revoke)\s+.+?\s+on\s+schema\s+(${ID})`).exec(s);
|
|
247
|
+
if (m) return ident(m[1]) === schema ? null : outside(m[1]);
|
|
248
|
+
if (/^(grant|revoke)\b/i.test(s) && !/\son\s/i.test(s)) return 'role memberships are not granted from a dump';
|
|
249
|
+
// never: ownership, moving objects to another schema
|
|
250
|
+
if (/^alter\b/i.test(s) && /\bowner\s+to\b/i.test(s)) return 'OWNER TO is not run (pg_restore runs with --no-owner)';
|
|
251
|
+
if (/^alter\b/i.test(s) && /\bset\s+schema\b/i.test(s)) return 'SET SCHEMA would move an object out of the dump\'s schema';
|
|
252
|
+
if (/^create\s+(?:or\s+replace\s+)?(?:temp|temporary)\b/i.test(s)) return 'temporary objects are not part of a dump';
|
|
253
|
+
for (const r of RULES) {
|
|
254
|
+
m = r.re.exec(s);
|
|
255
|
+
if (m) return ident(m[r.schemaGroup]) === schema ? null : outside(`${m[r.schemaGroup]}.${m[r.schemaGroup + 1]}`);
|
|
256
|
+
}
|
|
257
|
+
// schema-level ALTER TABLE … ADD CONSTRAINT and others have matched above; anything else is not from pg_dump
|
|
258
|
+
const head = s.split(/\s+/).slice(0, 3).join(' ').replace(/[;(].*$/, '');
|
|
259
|
+
return `"${head}" is not something pg_dump writes for one schema`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ------------------------------------------------------------------ COPY FROM STDIN
|
|
263
|
+
|
|
264
|
+
interface Conn {
|
|
265
|
+
query(text: string): void;
|
|
266
|
+
sendCopyFromChunk(chunk: Buffer): void;
|
|
267
|
+
endCopyFrom(): void;
|
|
268
|
+
sendCopyFail(msg: string): void;
|
|
269
|
+
stream: NodeJS.WritableStream & { writableNeedDrain?: boolean };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** A COPY … FROM STDIN as a pg submittable: write() chunks, then end(). */
|
|
273
|
+
class CopyIn {
|
|
274
|
+
callback?: (err: Error | null) => void;
|
|
275
|
+
rows = 0;
|
|
276
|
+
private conn: Conn | null = null;
|
|
277
|
+
private error: Error | null = null;
|
|
278
|
+
private started!: Promise<void>;
|
|
279
|
+
private resolveStart!: () => void;
|
|
280
|
+
private rejectStart!: (e: Error) => void;
|
|
281
|
+
readonly done: Promise<number>;
|
|
282
|
+
|
|
283
|
+
constructor(public readonly text: string) {
|
|
284
|
+
this.started = new Promise((res, rej) => {
|
|
285
|
+
this.resolveStart = res;
|
|
286
|
+
this.rejectStart = rej;
|
|
287
|
+
});
|
|
288
|
+
this.started.catch(() => {});
|
|
289
|
+
this.done = new Promise((res, rej) => {
|
|
290
|
+
this.callback = (err) => (err ? rej(err) : res(this.rows));
|
|
291
|
+
});
|
|
292
|
+
this.done.catch(() => {});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
submit(connection: Conn) {
|
|
296
|
+
connection.query(this.text);
|
|
297
|
+
}
|
|
298
|
+
handleCopyInResponse(connection: Conn) {
|
|
299
|
+
this.conn = connection;
|
|
300
|
+
this.resolveStart();
|
|
301
|
+
}
|
|
302
|
+
handleCommandComplete(msg: { text?: string }) {
|
|
303
|
+
const m = /^COPY (\d+)/.exec(msg.text ?? '');
|
|
304
|
+
if (m) this.rows = Number(m[1]);
|
|
305
|
+
}
|
|
306
|
+
handleReadyForQuery() {
|
|
307
|
+
this.callback?.(this.error);
|
|
308
|
+
}
|
|
309
|
+
handleError(err: Error) {
|
|
310
|
+
// pg's client forgets the query on an error: its ReadyForQuery doesn't come here
|
|
311
|
+
this.error = err;
|
|
312
|
+
this.rejectStart(err);
|
|
313
|
+
this.callback?.(err);
|
|
314
|
+
}
|
|
315
|
+
handleRowDescription() {}
|
|
316
|
+
handleDataRow() {}
|
|
317
|
+
handlePortalSuspended() {}
|
|
318
|
+
handleEmptyQuery() {}
|
|
319
|
+
handleCopyData() {}
|
|
320
|
+
|
|
321
|
+
async write(chunk: string) {
|
|
322
|
+
await this.started;
|
|
323
|
+
if (this.error) return;
|
|
324
|
+
this.conn!.sendCopyFromChunk(Buffer.from(chunk, 'utf8'));
|
|
325
|
+
if (this.conn!.stream.writableNeedDrain) await new Promise((res) => this.conn!.stream.once('drain', res));
|
|
326
|
+
}
|
|
327
|
+
async end(failure?: string) {
|
|
328
|
+
await this.started.catch(() => {});
|
|
329
|
+
if (this.conn) {
|
|
330
|
+
if (failure && !this.error) this.conn.sendCopyFail(failure);
|
|
331
|
+
else this.conn.endCopyFrom();
|
|
332
|
+
}
|
|
333
|
+
return this.done;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export interface ScriptStats {
|
|
338
|
+
statements: number;
|
|
339
|
+
tables: number;
|
|
340
|
+
rows: number;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const CHUNK = 256 * 1024;
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Runs a pg_restore script on `client` (in the caller's transaction): every statement checked against `schema`, COPY
|
|
347
|
+
* data streamed. Throws a ScriptError (the statement's line) on a refused statement or the server's error.
|
|
348
|
+
*/
|
|
349
|
+
export async function runScript(client: pg.ClientBase, lines: AsyncIterable<string>, schema: string, log: (line: string) => void): Promise<ScriptStats> {
|
|
350
|
+
const lexer = new ScriptLexer();
|
|
351
|
+
const stats: ScriptStats = { statements: 0, tables: 0, rows: 0 };
|
|
352
|
+
let copy: { q: CopyIn; buf: string; sql: string; line: number } | null = null;
|
|
353
|
+
const flush = async () => {
|
|
354
|
+
if (copy && copy.buf) {
|
|
355
|
+
try {
|
|
356
|
+
await copy.q.write(copy.buf);
|
|
357
|
+
} catch (e) {
|
|
358
|
+
throw new ScriptError(`${(e as Error).message} (${copy.sql.slice(0, 80)})`, copy.line);
|
|
359
|
+
}
|
|
360
|
+
copy.buf = '';
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
try {
|
|
364
|
+
for await (const line of lines) {
|
|
365
|
+
for (const ev of lexer.push(line)) {
|
|
366
|
+
if (ev.type === 'data') {
|
|
367
|
+
copy!.buf += ev.text;
|
|
368
|
+
if (copy!.buf.length >= CHUNK) await flush();
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (ev.type === 'copyEnd') {
|
|
372
|
+
await flush();
|
|
373
|
+
const c = copy!;
|
|
374
|
+
copy = null;
|
|
375
|
+
try {
|
|
376
|
+
const rows = await c.q.end();
|
|
377
|
+
stats.rows += rows;
|
|
378
|
+
stats.tables++;
|
|
379
|
+
log(`${c.sql.replace(/\s+from\s+stdin\s*;$/i, '').replace(/^copy\s+/i, '')}: ${rows} row${rows === 1 ? '' : 's'}`);
|
|
380
|
+
} catch (e) {
|
|
381
|
+
throw new ScriptError(`${(e as Error).message} (${c.sql.slice(0, 80)})`, c.line);
|
|
382
|
+
}
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
const refusal = checkStatement(ev.sql, schema);
|
|
386
|
+
if (refusal) throw new ScriptError(`refused: ${refusal}`, ev.line);
|
|
387
|
+
stats.statements++;
|
|
388
|
+
if (ev.type === 'copy') {
|
|
389
|
+
const q = new CopyIn(ev.sql);
|
|
390
|
+
client.query(q as unknown as pg.QueryConfig);
|
|
391
|
+
copy = { q, buf: '', sql: ev.sql, line: ev.line };
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
try {
|
|
395
|
+
await client.query(ev.sql);
|
|
396
|
+
} catch (e) {
|
|
397
|
+
throw new ScriptError(`${(e as Error).message} (${ev.sql.slice(0, 80).replace(/\s+/g, ' ')})`, ev.line);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
lexer.end();
|
|
402
|
+
return stats;
|
|
403
|
+
} catch (e) {
|
|
404
|
+
if (copy) await copy.q.end(String((e as Error).message).slice(0, 200)).catch(() => {});
|
|
405
|
+
throw e;
|
|
406
|
+
}
|
|
407
|
+
}
|