neonctl 2.22.2 → 2.23.1
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/README.md +84 -0
- package/analytics.js +5 -2
- package/commands/branches.js +9 -1
- package/commands/connection_string.js +9 -1
- package/commands/functions.js +268 -0
- package/commands/index.js +4 -0
- package/commands/neon_auth.js +1013 -0
- package/commands/projects.js +9 -1
- package/commands/psql.js +6 -1
- package/functions_api.js +43 -0
- package/package.json +15 -5
- package/psql/cli.js +51 -0
- package/psql/command/cmd_cond.js +437 -0
- package/psql/command/cmd_connect.js +815 -0
- package/psql/command/cmd_copy.js +1025 -0
- package/psql/command/cmd_describe.js +1810 -0
- package/psql/command/cmd_format.js +909 -0
- package/psql/command/cmd_io.js +2187 -0
- package/psql/command/cmd_lo.js +385 -0
- package/psql/command/cmd_meta.js +970 -0
- package/psql/command/cmd_misc.js +187 -0
- package/psql/command/cmd_pipeline.js +1141 -0
- package/psql/command/cmd_restrict.js +171 -0
- package/psql/command/cmd_show.js +751 -0
- package/psql/command/dispatch.js +343 -0
- package/psql/command/inputQueue.js +42 -0
- package/psql/command/shared.js +71 -0
- package/psql/complete/filenames.js +139 -0
- package/psql/complete/index.js +104 -0
- package/psql/complete/matcher.js +314 -0
- package/psql/complete/psqlVars.js +247 -0
- package/psql/complete/queries.js +491 -0
- package/psql/complete/rules.js +2387 -0
- package/psql/core/common.js +1250 -0
- package/psql/core/help.js +576 -0
- package/psql/core/mainloop.js +1353 -0
- package/psql/core/prompt.js +437 -0
- package/psql/core/settings.js +684 -0
- package/psql/core/sqlHelp.js +1066 -0
- package/psql/core/startup.js +840 -0
- package/psql/core/syncVars.js +116 -0
- package/psql/core/variables.js +287 -0
- package/psql/describe/formatters.js +1277 -0
- package/psql/describe/processNamePattern.js +270 -0
- package/psql/describe/queries.js +2373 -0
- package/psql/describe/versionGate.js +43 -0
- package/psql/index.js +2005 -0
- package/psql/io/history.js +299 -0
- package/psql/io/input.js +120 -0
- package/psql/io/lineEditor/buffer.js +323 -0
- package/psql/io/lineEditor/complete.js +227 -0
- package/psql/io/lineEditor/filename.js +159 -0
- package/psql/io/lineEditor/index.js +891 -0
- package/psql/io/lineEditor/keymap.js +738 -0
- package/psql/io/lineEditor/vt100.js +363 -0
- package/psql/io/pgpass.js +202 -0
- package/psql/io/pgservice.js +194 -0
- package/psql/io/psqlrc.js +422 -0
- package/psql/print/aligned.js +1756 -0
- package/psql/print/asciidoc.js +248 -0
- package/psql/print/crosstab.js +460 -0
- package/psql/print/csv.js +92 -0
- package/psql/print/html.js +258 -0
- package/psql/print/json.js +96 -0
- package/psql/print/latex.js +396 -0
- package/psql/print/pager.js +265 -0
- package/psql/print/troff.js +258 -0
- package/psql/print/unaligned.js +118 -0
- package/psql/print/units.js +135 -0
- package/psql/scanner/slash.js +513 -0
- package/psql/scanner/sql.js +910 -0
- package/psql/scanner/stringutils.js +390 -0
- package/psql/types/backslash.js +1 -0
- package/psql/types/connection.js +1 -0
- package/psql/types/index.js +7 -0
- package/psql/types/printer.js +1 -0
- package/psql/types/repl.js +1 -0
- package/psql/types/scanner.js +24 -0
- package/psql/types/settings.js +1 -0
- package/psql/types/variables.js +1 -0
- package/psql/wire/connection.js +2844 -0
- package/psql/wire/copy.js +108 -0
- package/psql/wire/notify.js +59 -0
- package/psql/wire/pipeline.js +519 -0
- package/psql/wire/protocol.js +466 -0
- package/psql/wire/sasl.js +296 -0
- package/psql/wire/tls.js +596 -0
- package/test_utils/fixtures.js +1 -0
- package/utils/esbuild.js +147 -0
- package/utils/psql.js +107 -11
- package/utils/zip.js +4 -0
- package/writer.js +1 -1
- package/commands/auth.test.js +0 -211
- package/commands/branches.test.js +0 -460
- package/commands/checkout.test.js +0 -170
- package/commands/connection_string.test.js +0 -196
- package/commands/data_api.test.js +0 -169
- package/commands/databases.test.js +0 -39
- package/commands/help.test.js +0 -9
- package/commands/init.test.js +0 -56
- package/commands/ip_allow.test.js +0 -59
- package/commands/link.test.js +0 -381
- package/commands/operations.test.js +0 -7
- package/commands/orgs.test.js +0 -7
- package/commands/projects.test.js +0 -144
- package/commands/psql.test.js +0 -49
- package/commands/roles.test.js +0 -37
- package/commands/set_context.test.js +0 -159
- package/commands/vpc_endpoints.test.js +0 -69
- package/context.test.js +0 -119
- package/env.test.js +0 -55
- package/utils/formats.test.js +0 -32
- package/writer.test.js +0 -104
|
@@ -0,0 +1,1025 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* psql `\copy` backslash command (WP-16).
|
|
3
|
+
*
|
|
4
|
+
* Port of `parse_slash_copy()` + `do_copy()` from upstream `src/bin/psql/copy.c`.
|
|
5
|
+
* The wire-level protocol (CopyData/CopyDone/CopyFail framing and the
|
|
6
|
+
* in-copy-in/in-copy-out state machine) lives in `../wire/connection.ts`; here
|
|
7
|
+
* we own:
|
|
8
|
+
*
|
|
9
|
+
* 1. Lexing the user-supplied tail of `\copy …`. Mirrors the upstream
|
|
10
|
+
* `strtokx()`-driven tokeniser: we ratchet through the input with the
|
|
11
|
+
* same whitespace/delim/quote rules so the grammar matches psql.
|
|
12
|
+
* 2. Building the COPY SQL the server sees. The client side does
|
|
13
|
+
* file/program plumbing; the server always sees `... FROM STDIN ...` /
|
|
14
|
+
* `... TO STDOUT ...` so the COPY data flows over the protocol stream.
|
|
15
|
+
* 3. Driving the protocol: open the file (or spawn `PROGRAM 'cmd'`), then
|
|
16
|
+
* `startCopyIn(sql)` / `startCopyOut(sql)`, push/pull bytes, and print
|
|
17
|
+
* the upstream-style `COPY <N>` summary on success.
|
|
18
|
+
*
|
|
19
|
+
* Grammar accepted (matching upstream documentation):
|
|
20
|
+
*
|
|
21
|
+
* \copy [BINARY] tablename [(columnlist)] FROM
|
|
22
|
+
* ( 'file' | PROGRAM 'cmd' | STDIN | PSTDIN ) [options]
|
|
23
|
+
* \copy [BINARY] tablename [(columnlist)] TO
|
|
24
|
+
* ( 'file' | PROGRAM 'cmd' | STDOUT | PSTDOUT ) [options]
|
|
25
|
+
* \copy (subquery) TO ( 'file' | PROGRAM 'cmd' | STDOUT | PSTDOUT ) [options]
|
|
26
|
+
*
|
|
27
|
+
* `\copy (subquery) FROM ...` is rejected — COPY FROM requires a real
|
|
28
|
+
* destination table, so the subquery form only makes sense with `TO`.
|
|
29
|
+
*
|
|
30
|
+
* Limitations vs upstream:
|
|
31
|
+
* - Binary COPY (server-side `WITH (FORMAT BINARY)` option) is byte-for-byte
|
|
32
|
+
* transparent: bytes captured by `COPY ... TO STDOUT WITH BINARY` are
|
|
33
|
+
* piped straight to the destination, and on `COPY ... FROM STDIN WITH
|
|
34
|
+
* BINARY` we relay the source bytes verbatim. We do NOT parse tuples;
|
|
35
|
+
* `validateCopyBinarySignature` is offered for callers that want to
|
|
36
|
+
* sniff the 11-byte file header, but the wire path itself is format-
|
|
37
|
+
* agnostic. The legacy `BINARY <table> FROM …` keyword syntax is parsed
|
|
38
|
+
* and re-emitted verbatim; we don't try to interpret the options blob.
|
|
39
|
+
* - The literal `\.` end-of-data marker is honoured when (and only when):
|
|
40
|
+
* the source is STDIN, AND the COPY format is text (not csv, not binary).
|
|
41
|
+
* A line matching exactly `\.` terminates the stream client-side via
|
|
42
|
+
* CopyDone; subsequent input bytes go back to the SQL stream. Matches
|
|
43
|
+
* upstream's stricter behaviour: csv/binary COPY treats `\.` as data.
|
|
44
|
+
* - PSTDIN/PSTDOUT are treated as STDIN/STDOUT (no separate "psql stdin
|
|
45
|
+
* vs current input source" distinction — REPL plumbing isn't wired yet).
|
|
46
|
+
*/
|
|
47
|
+
import { spawn } from 'node:child_process';
|
|
48
|
+
import { createReadStream, createWriteStream, promises as fsPromises, } from 'node:fs';
|
|
49
|
+
import { Buffer } from 'node:buffer';
|
|
50
|
+
import { pumpReadable } from '../wire/copy.js';
|
|
51
|
+
import { getPipelineState } from './cmd_pipeline.js';
|
|
52
|
+
import { writeErr, writeOut } from './shared.js';
|
|
53
|
+
/**
|
|
54
|
+
* Diagnostic emitted when the user tries to run `\copy` (or a raw COPY
|
|
55
|
+
* statement) inside an active `\startpipeline` ... `\endpipeline` block.
|
|
56
|
+
* Matches upstream libpq's wording so conformance tests grepping stderr
|
|
57
|
+
* (e.g. tap/001_basic.pl lines 490-531) pick it up unchanged. Exported so
|
|
58
|
+
* the wire-layer abort path can reuse the same string and tests can match
|
|
59
|
+
* via a single source of truth.
|
|
60
|
+
*/
|
|
61
|
+
export const COPY_IN_PIPELINE_MSG = 'COPY in a pipeline is not supported, aborting connection';
|
|
62
|
+
const WHITESPACE = ' \t\n\r';
|
|
63
|
+
/**
|
|
64
|
+
* Tokenise the next term of the `\copy` tail. Mirrors upstream's `strtokx`
|
|
65
|
+
* call sites: each call passes a different combination of (delim chars,
|
|
66
|
+
* quote chars, allow-doubled-quotes, allow-E-strings). We faithfully replay
|
|
67
|
+
* those: this isn't a general lexer, it's a state machine indexed by the
|
|
68
|
+
* caller's intent.
|
|
69
|
+
*
|
|
70
|
+
* Returns `{ token, rest }`. `token === null` ⇒ end-of-input.
|
|
71
|
+
*
|
|
72
|
+
* Key differences from upstream `strtokx`:
|
|
73
|
+
* - We return tokens WITH outer quotes intact when the caller asked for
|
|
74
|
+
* them. `dequote` handles strip if desired. Upstream stores quotes
|
|
75
|
+
* in-place and optionally strips via `strip_quotes`.
|
|
76
|
+
* - Delimiter characters in `delim` are returned as single-char tokens
|
|
77
|
+
* when they're the first non-whitespace byte.
|
|
78
|
+
*/
|
|
79
|
+
const tokenize = (input, delim, quote, doubleQuoteEscape) => {
|
|
80
|
+
let i = 0;
|
|
81
|
+
const n = input.length;
|
|
82
|
+
// 1. Skip leading whitespace.
|
|
83
|
+
while (i < n && WHITESPACE.includes(input[i]))
|
|
84
|
+
i++;
|
|
85
|
+
if (i >= n)
|
|
86
|
+
return { token: null, rest: '' };
|
|
87
|
+
// 2. Delimiter character returned as single-char token.
|
|
88
|
+
if (delim.length > 0 && delim.includes(input[i])) {
|
|
89
|
+
const token = input[i];
|
|
90
|
+
i++;
|
|
91
|
+
while (i < n && WHITESPACE.includes(input[i]))
|
|
92
|
+
i++;
|
|
93
|
+
return { token, rest: input.slice(i) };
|
|
94
|
+
}
|
|
95
|
+
// 3. Quoted token. Upstream allows backslash-escape inside `(query)` forms
|
|
96
|
+
// when standard_conforming_strings is off; we model that with the
|
|
97
|
+
// `doubleQuoteEscape` flag (true ⇒ backslash escapes any next char).
|
|
98
|
+
if (quote.length > 0 && quote.includes(input[i])) {
|
|
99
|
+
const thisQuote = input[i];
|
|
100
|
+
const start = i;
|
|
101
|
+
i++;
|
|
102
|
+
while (i < n) {
|
|
103
|
+
const c = input[i];
|
|
104
|
+
if (doubleQuoteEscape && c === '\\' && i + 1 < n) {
|
|
105
|
+
i += 2;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (c === thisQuote && input[i + 1] === thisQuote) {
|
|
109
|
+
// Doubled quote — stays in token; caller dequotes if needed.
|
|
110
|
+
i += 2;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (c === thisQuote) {
|
|
114
|
+
i++;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
i++;
|
|
118
|
+
}
|
|
119
|
+
const token = input.slice(start, i);
|
|
120
|
+
while (i < n && WHITESPACE.includes(input[i]))
|
|
121
|
+
i++;
|
|
122
|
+
return { token, rest: input.slice(i) };
|
|
123
|
+
}
|
|
124
|
+
// 4. Bareword: scan to next whitespace, delim, or quote.
|
|
125
|
+
const start = i;
|
|
126
|
+
while (i < n) {
|
|
127
|
+
const c = input[i];
|
|
128
|
+
if (WHITESPACE.includes(c))
|
|
129
|
+
break;
|
|
130
|
+
if (delim.length > 0 && delim.includes(c))
|
|
131
|
+
break;
|
|
132
|
+
if (quote.length > 0 && quote.includes(c))
|
|
133
|
+
break;
|
|
134
|
+
i++;
|
|
135
|
+
}
|
|
136
|
+
const token = input.slice(start, i);
|
|
137
|
+
while (i < n && WHITESPACE.includes(input[i]))
|
|
138
|
+
i++;
|
|
139
|
+
return { token, rest: input.slice(i) };
|
|
140
|
+
};
|
|
141
|
+
/**
|
|
142
|
+
* Strip surrounding single quotes from a filename / program argument and
|
|
143
|
+
* undouble any embedded quotes. Mirrors upstream's `strip_quotes(token, '\'', 0)`.
|
|
144
|
+
*/
|
|
145
|
+
const stripSingleQuotes = (token) => {
|
|
146
|
+
if (token.length < 2 || !token.startsWith("'") || !token.endsWith("'")) {
|
|
147
|
+
return token;
|
|
148
|
+
}
|
|
149
|
+
let out = '';
|
|
150
|
+
let i = 1;
|
|
151
|
+
const end = token.length - 1;
|
|
152
|
+
while (i < end) {
|
|
153
|
+
if (token[i] === "'" && token[i + 1] === "'") {
|
|
154
|
+
out += "'";
|
|
155
|
+
i += 2;
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
out += token[i];
|
|
159
|
+
i++;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
};
|
|
164
|
+
/**
|
|
165
|
+
* Expand a leading `~/` in filename arguments. Upstream `expand_tilde` only
|
|
166
|
+
* touches the very first character; we do the same (no `~user/` form, since
|
|
167
|
+
* Node doesn't expose `getpwnam` cleanly).
|
|
168
|
+
*/
|
|
169
|
+
const expandTilde = (filePath) => {
|
|
170
|
+
if (!filePath.startsWith('~'))
|
|
171
|
+
return filePath;
|
|
172
|
+
if (filePath === '~' || filePath.startsWith('~/')) {
|
|
173
|
+
const home = process.env.HOME ?? process.env.USERPROFILE;
|
|
174
|
+
if (home === undefined)
|
|
175
|
+
return filePath;
|
|
176
|
+
return home + filePath.slice(1);
|
|
177
|
+
}
|
|
178
|
+
return filePath;
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
181
|
+
* Parse the tail of a `\copy ...` line. Returns a {@link ParsedCopy} on
|
|
182
|
+
* success or an error message on syntax failure (mirroring upstream's
|
|
183
|
+
* `pg_log_error("\\copy: parse error at \"%s\"")`).
|
|
184
|
+
*
|
|
185
|
+
* The input is everything after `\copy` (the command name itself is stripped
|
|
186
|
+
* by the dispatcher's `BackslashContext.rawArgs`).
|
|
187
|
+
*/
|
|
188
|
+
export const parseSlashCopy = (input) => {
|
|
189
|
+
let beforeToFrom = '';
|
|
190
|
+
let rest = input;
|
|
191
|
+
let token;
|
|
192
|
+
// Helper to keep the failure messages consistent with upstream.
|
|
193
|
+
const errAt = (tok) => ({
|
|
194
|
+
ok: false,
|
|
195
|
+
error: tok !== null && tok.length > 0
|
|
196
|
+
? `parse error at "${tok}"`
|
|
197
|
+
: 'parse error at end of line',
|
|
198
|
+
});
|
|
199
|
+
// First token: optional BINARY, or table-name / "(" for subquery.
|
|
200
|
+
let r1 = tokenize(rest, '.,()', '"', false);
|
|
201
|
+
token = r1.token;
|
|
202
|
+
rest = r1.rest;
|
|
203
|
+
if (token === null)
|
|
204
|
+
return errAt(null);
|
|
205
|
+
// Optional legacy BINARY keyword (pre-7.3 syntax). Re-emit then read next.
|
|
206
|
+
if (token.toLowerCase() === 'binary') {
|
|
207
|
+
beforeToFrom += token;
|
|
208
|
+
r1 = tokenize(rest, '.,()', '"', false);
|
|
209
|
+
token = r1.token;
|
|
210
|
+
rest = r1.rest;
|
|
211
|
+
if (token === null)
|
|
212
|
+
return errAt(null);
|
|
213
|
+
}
|
|
214
|
+
// `(query)` subquery form? Re-emit balanced-paren contents verbatim.
|
|
215
|
+
let isSubquery = false;
|
|
216
|
+
if (token === '(') {
|
|
217
|
+
isSubquery = true;
|
|
218
|
+
let parens = 1;
|
|
219
|
+
while (parens > 0) {
|
|
220
|
+
beforeToFrom += ' ';
|
|
221
|
+
beforeToFrom += token;
|
|
222
|
+
const r = tokenize(rest, '()', '"\'', true);
|
|
223
|
+
token = r.token;
|
|
224
|
+
rest = r.rest;
|
|
225
|
+
if (token === null)
|
|
226
|
+
return errAt(null);
|
|
227
|
+
if (token === '(')
|
|
228
|
+
parens++;
|
|
229
|
+
else if (token === ')')
|
|
230
|
+
parens--;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
beforeToFrom += beforeToFrom.length > 0 ? ' ' : '';
|
|
234
|
+
beforeToFrom += token;
|
|
235
|
+
// Next token: schema-separator `.`, column-list opener `(`, or FROM/TO.
|
|
236
|
+
let r2 = tokenize(rest, '.,()', '"', false);
|
|
237
|
+
token = r2.token;
|
|
238
|
+
rest = r2.rest;
|
|
239
|
+
if (token === null)
|
|
240
|
+
return errAt(null);
|
|
241
|
+
// Schema-qualified `schema.table` — upstream just re-emits all three tokens.
|
|
242
|
+
if (token === '.') {
|
|
243
|
+
beforeToFrom += token;
|
|
244
|
+
r2 = tokenize(rest, '.,()', '"', false);
|
|
245
|
+
token = r2.token;
|
|
246
|
+
rest = r2.rest;
|
|
247
|
+
if (token === null)
|
|
248
|
+
return errAt(null);
|
|
249
|
+
beforeToFrom += token;
|
|
250
|
+
r2 = tokenize(rest, '.,()', '"', false);
|
|
251
|
+
token = r2.token;
|
|
252
|
+
rest = r2.rest;
|
|
253
|
+
if (token === null)
|
|
254
|
+
return errAt(null);
|
|
255
|
+
}
|
|
256
|
+
// Parenthesised column list `(col1, col2, …)`.
|
|
257
|
+
if (token === '(') {
|
|
258
|
+
for (;;) {
|
|
259
|
+
beforeToFrom += ' ';
|
|
260
|
+
beforeToFrom += token;
|
|
261
|
+
const r = tokenize(rest, '()', '"', false);
|
|
262
|
+
token = r.token;
|
|
263
|
+
rest = r.rest;
|
|
264
|
+
if (token === null)
|
|
265
|
+
return errAt(null);
|
|
266
|
+
if (token === ')')
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
beforeToFrom += ' ';
|
|
270
|
+
beforeToFrom += token;
|
|
271
|
+
r2 = tokenize(rest, '.,()', '"', false);
|
|
272
|
+
token = r2.token;
|
|
273
|
+
rest = r2.rest;
|
|
274
|
+
if (token === null)
|
|
275
|
+
return errAt(null);
|
|
276
|
+
}
|
|
277
|
+
// FROM / TO keyword.
|
|
278
|
+
let direction;
|
|
279
|
+
if (token.toLowerCase() === 'from') {
|
|
280
|
+
direction = 'from';
|
|
281
|
+
}
|
|
282
|
+
else if (token.toLowerCase() === 'to') {
|
|
283
|
+
direction = 'to';
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
return errAt(token);
|
|
287
|
+
}
|
|
288
|
+
// \copy (subquery) FROM is invalid — subqueries only make sense with TO.
|
|
289
|
+
if (isSubquery && direction === 'from') {
|
|
290
|
+
return {
|
|
291
|
+
ok: false,
|
|
292
|
+
error: 'cannot use COPY FROM with a (subquery) source',
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
// Filename / PROGRAM / STDIN / STDOUT / PSTDIN / PSTDOUT.
|
|
296
|
+
let r3 = tokenize(rest, ';', "'", false);
|
|
297
|
+
token = r3.token;
|
|
298
|
+
rest = r3.rest;
|
|
299
|
+
if (token === null)
|
|
300
|
+
return errAt(null);
|
|
301
|
+
let file = null;
|
|
302
|
+
let program = false;
|
|
303
|
+
let psqlInOut = false;
|
|
304
|
+
const lower = token.toLowerCase();
|
|
305
|
+
if (lower === 'program') {
|
|
306
|
+
r3 = tokenize(rest, ';', "'", false);
|
|
307
|
+
token = r3.token;
|
|
308
|
+
rest = r3.rest;
|
|
309
|
+
if (token === null)
|
|
310
|
+
return errAt(null);
|
|
311
|
+
if (!token.startsWith("'") || !token.endsWith("'") || token.length < 2) {
|
|
312
|
+
return errAt(token);
|
|
313
|
+
}
|
|
314
|
+
file = stripSingleQuotes(token);
|
|
315
|
+
program = true;
|
|
316
|
+
}
|
|
317
|
+
else if (lower === 'stdin' || lower === 'stdout') {
|
|
318
|
+
file = null;
|
|
319
|
+
}
|
|
320
|
+
else if (lower === 'pstdin' || lower === 'pstdout') {
|
|
321
|
+
file = null;
|
|
322
|
+
psqlInOut = true;
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
file = expandTilde(stripSingleQuotes(token));
|
|
326
|
+
}
|
|
327
|
+
// Collect the rest as the post-filename options blob (verbatim).
|
|
328
|
+
let afterToFrom = null;
|
|
329
|
+
rest = rest.trim();
|
|
330
|
+
if (rest.length > 0) {
|
|
331
|
+
afterToFrom = rest;
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
ok: true,
|
|
335
|
+
value: {
|
|
336
|
+
beforeToFrom,
|
|
337
|
+
afterToFrom,
|
|
338
|
+
file,
|
|
339
|
+
program,
|
|
340
|
+
psqlInOut,
|
|
341
|
+
direction,
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
};
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// do_copy
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
/**
|
|
349
|
+
* Build the SQL string sent to the backend. The server always sees
|
|
350
|
+
* `STDIN`/`STDOUT` here — client-side `'file'` / `PROGRAM 'cmd'` plumbing is
|
|
351
|
+
* invisible to the server because that's what frontend-driven COPY is for.
|
|
352
|
+
*/
|
|
353
|
+
const buildCopySql = (opts) => {
|
|
354
|
+
const tail = opts.direction === 'from' ? ' FROM STDIN ' : ' TO STDOUT ';
|
|
355
|
+
const after = opts.afterToFrom !== null ? opts.afterToFrom : '';
|
|
356
|
+
return `COPY ${opts.beforeToFrom}${tail}${after}`.trimEnd();
|
|
357
|
+
};
|
|
358
|
+
/**
|
|
359
|
+
* Strip single-quoted SQL string literals from a fragment so a keyword scan
|
|
360
|
+
* over the result can't false-trigger on a payload character. Handles both
|
|
361
|
+
* the standard `'…''…'` form (doubled-quote escape) and the escape-string
|
|
362
|
+
* `E'…\…'` form (backslash-escape). Each match collapses to `''` so token
|
|
363
|
+
* boundaries around the literal are preserved.
|
|
364
|
+
*
|
|
365
|
+
* This is lenient on the `E` prefix recognition: we don't enforce that the
|
|
366
|
+
* `E` is unescaped (e.g. we'd also strip `xE'…'`). False-positive stripping
|
|
367
|
+
* is safe — we only ever miss a `csv` / `binary` / `format` mention that was
|
|
368
|
+
* intended as a data payload, which is exactly the case we want to skip.
|
|
369
|
+
*/
|
|
370
|
+
const stripCopyOptionsStrings = (s) => {
|
|
371
|
+
return s.replace(/E'(?:\\.|[^'])*'|'(?:''|[^'])*'/g, "''");
|
|
372
|
+
};
|
|
373
|
+
/**
|
|
374
|
+
* Detect whether the COPY uses the (default) text format. Upstream psql only
|
|
375
|
+
* honours the `\.` end-of-data marker for text-format COPY; csv/binary treat
|
|
376
|
+
* the bytes as data.
|
|
377
|
+
*
|
|
378
|
+
* The check is a coarse keyword scan of the options string: if any of `csv`,
|
|
379
|
+
* `binary`, or `format <something>` appears (case-insensitive), we assume the
|
|
380
|
+
* user has explicitly selected a non-text format and disable EOF-marker
|
|
381
|
+
* handling. Quoted literals (including `E'…'` escape strings) are stripped
|
|
382
|
+
* first so a column-named "binary" or a `DELIMITER E'\\tbinary'` payload
|
|
383
|
+
* doesn't false-trigger.
|
|
384
|
+
*
|
|
385
|
+
* The `FORMAT` value itself may be optionally single-quoted in the new
|
|
386
|
+
* parenthesised-options syntax (e.g. `WITH (FORMAT 'csv')`); we accept either
|
|
387
|
+
* a bareword or a `'…'` literal there to match upstream's option grammar.
|
|
388
|
+
*/
|
|
389
|
+
export const isCopyTextFormat = (afterToFrom) => {
|
|
390
|
+
if (afterToFrom === null)
|
|
391
|
+
return true;
|
|
392
|
+
// Strip quoted literals so `DELIMITER 'binary'` and `DELIMITER E'\\tcsv'`
|
|
393
|
+
// don't false-trigger the format-detection regexes below.
|
|
394
|
+
const stripped = stripCopyOptionsStrings(afterToFrom);
|
|
395
|
+
if (/\bcsv\b/i.test(stripped))
|
|
396
|
+
return false;
|
|
397
|
+
if (/\bbinary\b/i.test(stripped))
|
|
398
|
+
return false;
|
|
399
|
+
// The newer WITH (FORMAT <fmt>) form — if `format` appears followed by a
|
|
400
|
+
// non-text token, assume non-text. We don't try to parse the value because
|
|
401
|
+
// anything other than `text` is non-default; treat any FORMAT mention as
|
|
402
|
+
// "user said something explicit" and only allow the marker for `format text`.
|
|
403
|
+
// Match against the ORIGINAL string for the value extraction since the
|
|
404
|
+
// stripped form will have collapsed quoted values to `''`.
|
|
405
|
+
const m = /\bformat\s+(?:'([A-Za-z_]+)'|([A-Za-z_]+))/i.exec(afterToFrom);
|
|
406
|
+
if (m) {
|
|
407
|
+
return (m[1] ?? m[2]).toLowerCase() === 'text';
|
|
408
|
+
}
|
|
409
|
+
return true;
|
|
410
|
+
};
|
|
411
|
+
/**
|
|
412
|
+
* Mirror of `isCopyTextFormat`'s scan, but returns `true` only when the COPY
|
|
413
|
+
* was explicitly opted into binary format. Used by the `\copy` driver to gate
|
|
414
|
+
* the BINARY-signature byte-for-byte transparency check (we don't want to
|
|
415
|
+
* touch text/csv streams).
|
|
416
|
+
*
|
|
417
|
+
* Matches `WITH BINARY`, `WITH (FORMAT binary)` (with or without quotes around
|
|
418
|
+
* the value), the legacy psql `BINARY t FROM …` keyword (which the parser
|
|
419
|
+
* folds into `beforeToFrom`), and mixed-case variants.
|
|
420
|
+
*/
|
|
421
|
+
export const isCopyBinaryFormat = (beforeToFrom, afterToFrom) => {
|
|
422
|
+
// Legacy syntax: the BINARY keyword sits between `\copy` and the table name,
|
|
423
|
+
// which our parser preserves as the leading token of `beforeToFrom`.
|
|
424
|
+
if (/^\s*binary\b/i.test(beforeToFrom))
|
|
425
|
+
return true;
|
|
426
|
+
if (afterToFrom === null)
|
|
427
|
+
return false;
|
|
428
|
+
// Strip quoted literals (including `E'…'` escape strings) so a column-named
|
|
429
|
+
// `binary` or a payload literal doesn't trigger.
|
|
430
|
+
const stripped = stripCopyOptionsStrings(afterToFrom);
|
|
431
|
+
// Plain `WITH BINARY` (or the bare options token).
|
|
432
|
+
if (/(^|\W)binary(\W|$)/i.test(stripped)) {
|
|
433
|
+
// But only when it isn't part of a `format binary` form (already covered
|
|
434
|
+
// by the regex below — keep both paths so `WITH BINARY` alone still wins).
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
// FORMAT value may be optionally single-quoted in WITH (FORMAT 'binary').
|
|
438
|
+
const m = /\bformat\s+(?:'([A-Za-z_]+)'|([A-Za-z_]+))/i.exec(afterToFrom);
|
|
439
|
+
if (m) {
|
|
440
|
+
return (m[1] ?? m[2]).toLowerCase() === 'binary';
|
|
441
|
+
}
|
|
442
|
+
return false;
|
|
443
|
+
};
|
|
444
|
+
/**
|
|
445
|
+
* PostgreSQL COPY binary-format file header signature.
|
|
446
|
+
*
|
|
447
|
+
* Per the docs[1]: every binary COPY stream begins with an 11-byte signature
|
|
448
|
+
* (`PGCOPY\n\xff\r\n\0`), followed by a 4-byte flags field and a 4-byte
|
|
449
|
+
* header-extension-area length. After that come zero-or-more tuples, then a
|
|
450
|
+
* 2-byte file trailer of `0xFFFF` (Int16 `-1`).
|
|
451
|
+
*
|
|
452
|
+
* We expose the signature bytes (not the full 19-byte fixed prefix) so callers
|
|
453
|
+
* can sniff incoming streams or assert outgoing streams without depending on
|
|
454
|
+
* server-version-specific flags / extension data.
|
|
455
|
+
*
|
|
456
|
+
* [1] https://www.postgresql.org/docs/current/sql-copy.html#id-1.9.3.55.9.4
|
|
457
|
+
*/
|
|
458
|
+
export const COPY_BINARY_SIGNATURE = Buffer.from([
|
|
459
|
+
0x50, 0x47, 0x43, 0x4f, 0x50, 0x59, 0x0a, 0xff, 0x0d, 0x0a, 0x00,
|
|
460
|
+
]);
|
|
461
|
+
/**
|
|
462
|
+
* Validate that a buffer starts with the COPY binary signature.
|
|
463
|
+
*
|
|
464
|
+
* Used to assert round-trip transparency: bytes captured by `COPY ... TO
|
|
465
|
+
* STDOUT WITH BINARY` should be byte-for-byte acceptable to `COPY ... FROM
|
|
466
|
+
* STDIN WITH BINARY` on another instance. We don't try to parse tuples —
|
|
467
|
+
* that requires per-type binary decoders the printer doesn't otherwise need.
|
|
468
|
+
*
|
|
469
|
+
* Returns `null` on success or a short diagnostic string on failure (matching
|
|
470
|
+
* the upstream wording style: "missing signature" / "wrong signature").
|
|
471
|
+
*/
|
|
472
|
+
export const validateCopyBinarySignature = (buf) => {
|
|
473
|
+
if (buf.length < COPY_BINARY_SIGNATURE.length) {
|
|
474
|
+
return 'missing COPY binary signature (input too short)';
|
|
475
|
+
}
|
|
476
|
+
for (let i = 0; i < COPY_BINARY_SIGNATURE.length; i++) {
|
|
477
|
+
if (buf[i] !== COPY_BINARY_SIGNATURE[i]) {
|
|
478
|
+
return 'COPY binary signature mismatch';
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return null;
|
|
482
|
+
};
|
|
483
|
+
/**
|
|
484
|
+
* Parse a CommandComplete tag like `"COPY 17"` into its numeric row count.
|
|
485
|
+
* Returns `null` when the tag is unparseable; callers print it verbatim then.
|
|
486
|
+
*/
|
|
487
|
+
const parseCopyTagRows = (tag) => {
|
|
488
|
+
if (tag === null)
|
|
489
|
+
return null;
|
|
490
|
+
const m = /^COPY (\d+)$/.exec(tag.trim());
|
|
491
|
+
if (!m)
|
|
492
|
+
return null;
|
|
493
|
+
return parseInt(m[1], 10);
|
|
494
|
+
};
|
|
495
|
+
const spawnProgram = (cmd, direction) => {
|
|
496
|
+
const child = spawn('sh', ['-c', cmd], {
|
|
497
|
+
stdio: [
|
|
498
|
+
direction === 'to' ? 'pipe' : 'inherit',
|
|
499
|
+
direction === 'from' ? 'pipe' : 'inherit',
|
|
500
|
+
'inherit',
|
|
501
|
+
],
|
|
502
|
+
});
|
|
503
|
+
// Capture the program's terminal status so the caller can surface a nonzero
|
|
504
|
+
// exit / signal as a COPY failure rather than silently reporting success.
|
|
505
|
+
// `close` carries (code, signal); `error` fires when the spawn itself
|
|
506
|
+
// failed (e.g. sh missing).
|
|
507
|
+
const closed = new Promise((resolve) => {
|
|
508
|
+
child.once('close', (code, signal) => {
|
|
509
|
+
resolve({ code, signal, error: null });
|
|
510
|
+
});
|
|
511
|
+
child.once('error', (error) => {
|
|
512
|
+
resolve({ code: null, signal: null, error });
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
return {
|
|
516
|
+
child,
|
|
517
|
+
readable: direction === 'from' ? child.stdout : null,
|
|
518
|
+
writable: direction === 'to' ? child.stdin : null,
|
|
519
|
+
closed,
|
|
520
|
+
};
|
|
521
|
+
};
|
|
522
|
+
/**
|
|
523
|
+
* Turn a {@link ProgramExit} into psql-style diagnostic text, or `null` when
|
|
524
|
+
* the program succeeded (exit 0, no signal, no spawn error).
|
|
525
|
+
*/
|
|
526
|
+
const describeProgramExit = (cmd, exit) => {
|
|
527
|
+
if (exit.error !== null) {
|
|
528
|
+
return `could not execute command "${cmd}": ${exit.error.message}`;
|
|
529
|
+
}
|
|
530
|
+
if (exit.signal !== null) {
|
|
531
|
+
return `program "${cmd}" was terminated by signal ${exit.signal}`;
|
|
532
|
+
}
|
|
533
|
+
if (exit.code !== null && exit.code !== 0) {
|
|
534
|
+
return `program "${cmd}" failed with exit code ${exit.code}`;
|
|
535
|
+
}
|
|
536
|
+
return null;
|
|
537
|
+
};
|
|
538
|
+
/**
|
|
539
|
+
* Drain a `CopyOutStream` (AsyncIterable<Buffer>) into a Node Writable. We
|
|
540
|
+
* await each write to honour backpressure. Mirrors upstream's `handleCopyOut`
|
|
541
|
+
* inner loop.
|
|
542
|
+
*/
|
|
543
|
+
const drainCopyTo = async (conn, sql, out) => {
|
|
544
|
+
const copyOut = await conn.startCopyOut(sql);
|
|
545
|
+
for await (const chunk of copyOut) {
|
|
546
|
+
if (chunk.length === 0)
|
|
547
|
+
continue;
|
|
548
|
+
await new Promise((resolve, reject) => {
|
|
549
|
+
out.write(chunk, (err) => {
|
|
550
|
+
if (err !== null && err !== undefined)
|
|
551
|
+
reject(err);
|
|
552
|
+
else
|
|
553
|
+
resolve();
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
/**
|
|
559
|
+
* Pump a Readable into a CopyInStream, honouring the upstream `\.` text-mode
|
|
560
|
+
* EOF marker. A line consisting EXACTLY of `\.` (LF- or CRLF-terminated) ends
|
|
561
|
+
* the COPY via `copyIn.end()`; everything after the marker is left on the
|
|
562
|
+
* Readable for the caller (the REPL goes back to SQL mode and reads it as
|
|
563
|
+
* the next statement).
|
|
564
|
+
*
|
|
565
|
+
* The marker is detected by accumulating a tail buffer until we see a newline,
|
|
566
|
+
* then comparing the line to `\.`. We DO NOT mutate or strip data already
|
|
567
|
+
* flushed — once a chunk has been forwarded as CopyData, it's gone. The
|
|
568
|
+
* implementation reads chunks, splits on newlines, and forwards complete
|
|
569
|
+
* lines individually so the marker can short-circuit the stream cleanly.
|
|
570
|
+
*
|
|
571
|
+
* We DO NOT use `for await (const chunk of readable)` because Node destroys
|
|
572
|
+
* the underlying stream when the async-iterator wrapper exits (even cleanly
|
|
573
|
+
* via `break`), which would prevent the caller from resuming reads after the
|
|
574
|
+
* marker. Instead we drive the readable with explicit data/end event
|
|
575
|
+
* listeners, paused/resumed via `pause()`/`resume()`, and remove them once
|
|
576
|
+
* the marker fires — leaving the source intact for subsequent consumption.
|
|
577
|
+
*
|
|
578
|
+
* Returns true if the marker was hit (caller closed the stream), false on
|
|
579
|
+
* normal EOF.
|
|
580
|
+
*/
|
|
581
|
+
const pumpStdinWithEofMarker = async (readable, copyIn) => {
|
|
582
|
+
return new Promise((resolve, reject) => {
|
|
583
|
+
let tail = Buffer.alloc(0);
|
|
584
|
+
let markerHit = false;
|
|
585
|
+
let settled = false;
|
|
586
|
+
/** In-flight `copyIn.write` chain; we serialize writes for backpressure. */
|
|
587
|
+
let writeChain = Promise.resolve();
|
|
588
|
+
const settle = (run) => {
|
|
589
|
+
if (settled)
|
|
590
|
+
return;
|
|
591
|
+
settled = true;
|
|
592
|
+
readable.removeListener('data', onData);
|
|
593
|
+
readable.removeListener('end', onEnd);
|
|
594
|
+
readable.removeListener('error', onError);
|
|
595
|
+
run().then(() => {
|
|
596
|
+
resolve(markerHit);
|
|
597
|
+
}, (err) => {
|
|
598
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
599
|
+
});
|
|
600
|
+
};
|
|
601
|
+
const writeBytes = (bytes) => {
|
|
602
|
+
if (bytes.length === 0)
|
|
603
|
+
return;
|
|
604
|
+
// Copy the slice: `subarray` views share memory with `tail`, which is
|
|
605
|
+
// reassigned (and replaced by Buffer.concat) as more chunks arrive. A
|
|
606
|
+
// copy keeps the queued write independent of that churn.
|
|
607
|
+
const owned = Buffer.from(bytes);
|
|
608
|
+
writeChain = writeChain.then(() => copyIn.write(owned));
|
|
609
|
+
};
|
|
610
|
+
const handleChunk = (chunk) => {
|
|
611
|
+
if (settled)
|
|
612
|
+
return;
|
|
613
|
+
// Operate in the BYTE domain — never decode to a JS string. A
|
|
614
|
+
// Buffer -> string -> Buffer round-trip mangles a multibyte char split
|
|
615
|
+
// across chunk boundaries and any non-UTF-8 client_encoding byte
|
|
616
|
+
// (LATIN1/SJIS) into U+FFFD. stdin yields Buffers;
|
|
617
|
+
// guard the rare string case without assuming a lossy re-encode.
|
|
618
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, 'utf8');
|
|
619
|
+
tail = tail.length === 0 ? buf : Buffer.concat([tail, buf]);
|
|
620
|
+
let nl = tail.indexOf(0x0a); // '\n'
|
|
621
|
+
while (nl !== -1) {
|
|
622
|
+
const line = tail.subarray(0, nl + 1); // includes the trailing \n
|
|
623
|
+
// Match exactly `\.\n` or `\.\r\n` (0x5c 0x2e [0x0d] 0x0a). Upstream
|
|
624
|
+
// rejects trailing whitespace, so the line length must be exact.
|
|
625
|
+
const isMarker = (line.length === 3 &&
|
|
626
|
+
line[0] === 0x5c &&
|
|
627
|
+
line[1] === 0x2e &&
|
|
628
|
+
line[2] === 0x0a) ||
|
|
629
|
+
(line.length === 4 &&
|
|
630
|
+
line[0] === 0x5c &&
|
|
631
|
+
line[1] === 0x2e &&
|
|
632
|
+
line[2] === 0x0d &&
|
|
633
|
+
line[3] === 0x0a);
|
|
634
|
+
if (isMarker) {
|
|
635
|
+
markerHit = true;
|
|
636
|
+
const leftover = Buffer.from(tail.subarray(nl + 1)); // copy out
|
|
637
|
+
tail = Buffer.alloc(0);
|
|
638
|
+
// Pause + remove listeners BEFORE unshifting so the post-marker
|
|
639
|
+
// bytes aren't re-emitted into our own data handler.
|
|
640
|
+
readable.pause();
|
|
641
|
+
readable.removeListener('data', onData);
|
|
642
|
+
readable.removeListener('end', onEnd);
|
|
643
|
+
readable.removeListener('error', onError);
|
|
644
|
+
if (leftover.length > 0) {
|
|
645
|
+
readable.unshift(leftover);
|
|
646
|
+
}
|
|
647
|
+
settled = true;
|
|
648
|
+
writeChain
|
|
649
|
+
.then(() => copyIn.end())
|
|
650
|
+
.then(() => {
|
|
651
|
+
resolve(true);
|
|
652
|
+
}, (err) => {
|
|
653
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
654
|
+
});
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
writeBytes(line);
|
|
658
|
+
tail = tail.subarray(nl + 1);
|
|
659
|
+
nl = tail.indexOf(0x0a);
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
const onData = (chunk) => {
|
|
663
|
+
try {
|
|
664
|
+
handleChunk(chunk);
|
|
665
|
+
}
|
|
666
|
+
catch (err) {
|
|
667
|
+
settle(async () => {
|
|
668
|
+
try {
|
|
669
|
+
await copyIn.fail(err instanceof Error ? err.message : String(err));
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
// best-effort
|
|
673
|
+
}
|
|
674
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
const onEnd = () => {
|
|
679
|
+
if (settled)
|
|
680
|
+
return;
|
|
681
|
+
const trailing = tail;
|
|
682
|
+
tail = Buffer.alloc(0);
|
|
683
|
+
settle(async () => {
|
|
684
|
+
if (trailing.length > 0) {
|
|
685
|
+
writeBytes(trailing);
|
|
686
|
+
}
|
|
687
|
+
await writeChain;
|
|
688
|
+
await copyIn.end();
|
|
689
|
+
});
|
|
690
|
+
};
|
|
691
|
+
const onError = (err) => {
|
|
692
|
+
if (settled)
|
|
693
|
+
return;
|
|
694
|
+
settle(async () => {
|
|
695
|
+
try {
|
|
696
|
+
await copyIn.fail(err.message);
|
|
697
|
+
}
|
|
698
|
+
catch {
|
|
699
|
+
// best-effort
|
|
700
|
+
}
|
|
701
|
+
throw err;
|
|
702
|
+
});
|
|
703
|
+
};
|
|
704
|
+
readable.on('data', onData);
|
|
705
|
+
readable.once('end', onEnd);
|
|
706
|
+
readable.once('error', onError);
|
|
707
|
+
// Trigger flowing mode in case the readable is paused.
|
|
708
|
+
readable.resume();
|
|
709
|
+
});
|
|
710
|
+
};
|
|
711
|
+
/**
|
|
712
|
+
* Execute a parsed `\copy`. Opens the file (or spawns the program), wires the
|
|
713
|
+
* stream into `startCopyIn` / `startCopyOut`, and returns the resulting
|
|
714
|
+
* CommandComplete tag (e.g. `"COPY 17"`) on success.
|
|
715
|
+
*/
|
|
716
|
+
export const doCopy = async (conn, opts) => {
|
|
717
|
+
const sql = buildCopySql(opts);
|
|
718
|
+
// Helper to surface a uniform error shape. We deliberately keep the upstream
|
|
719
|
+
// wording for the common "could not execute command" / "<file>: <reason>"
|
|
720
|
+
// variants so tests / users that grep stderr keep working.
|
|
721
|
+
const failWith = (msg) => ({ ok: false, error: msg });
|
|
722
|
+
// Resolve file path / program command into a Readable/Writable.
|
|
723
|
+
let readable = null;
|
|
724
|
+
let writable = null;
|
|
725
|
+
let program = null;
|
|
726
|
+
// Captures an async write-stream error for the COPY TO <file> path. Without
|
|
727
|
+
// a listener, an open() failure (EACCES/ENOTDIR on an unwritable path) emits
|
|
728
|
+
// 'error' with nothing attached and aborts the whole process. An array (vs a
|
|
729
|
+
// nullable let) keeps the captured value visible to control-flow narrowing
|
|
730
|
+
// even though it's only assigned inside the async listener.
|
|
731
|
+
const fileWriteErrors = [];
|
|
732
|
+
/**
|
|
733
|
+
* True iff the data path is "psql stdin" — i.e. the user typed
|
|
734
|
+
* `\copy t FROM STDIN`. Only this path honours the `\.` text-mode EOF
|
|
735
|
+
* marker; file and PROGRAM sources stream verbatim to match upstream.
|
|
736
|
+
*/
|
|
737
|
+
let fromStdin = false;
|
|
738
|
+
/** Cleanup callbacks run in `finally`. */
|
|
739
|
+
const cleanups = [];
|
|
740
|
+
if (opts.direction === 'from') {
|
|
741
|
+
if (opts.file !== null) {
|
|
742
|
+
if (opts.program) {
|
|
743
|
+
try {
|
|
744
|
+
program = spawnProgram(opts.file, 'from');
|
|
745
|
+
}
|
|
746
|
+
catch (err) {
|
|
747
|
+
return failWith(`could not execute command "${opts.file}": ${err instanceof Error ? err.message : String(err)}`);
|
|
748
|
+
}
|
|
749
|
+
readable = program.readable;
|
|
750
|
+
const p = program;
|
|
751
|
+
cleanups.push(async () => {
|
|
752
|
+
try {
|
|
753
|
+
p.child.stdout?.destroy();
|
|
754
|
+
}
|
|
755
|
+
catch {
|
|
756
|
+
// ignore
|
|
757
|
+
}
|
|
758
|
+
await p.closed;
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
else {
|
|
762
|
+
try {
|
|
763
|
+
// fstat the path to reject directories before we open a stream.
|
|
764
|
+
const stat = await fsPromises.stat(opts.file);
|
|
765
|
+
if (stat.isDirectory()) {
|
|
766
|
+
return failWith(`${opts.file}: cannot copy from/to a directory`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
catch (err) {
|
|
770
|
+
return failWith(`${opts.file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
771
|
+
}
|
|
772
|
+
const stream = createReadStream(opts.file);
|
|
773
|
+
readable = stream;
|
|
774
|
+
cleanups.push(() => new Promise((resolve) => {
|
|
775
|
+
if (stream.destroyed) {
|
|
776
|
+
resolve();
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
stream.once('close', () => {
|
|
780
|
+
resolve();
|
|
781
|
+
});
|
|
782
|
+
stream.destroy();
|
|
783
|
+
}));
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
else {
|
|
787
|
+
// STDIN form — read from process.stdin. We don't differentiate
|
|
788
|
+
// PSTDIN/STDIN here (see file header limitations).
|
|
789
|
+
readable = process.stdin;
|
|
790
|
+
fromStdin = true;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
else {
|
|
794
|
+
// direction === 'to'
|
|
795
|
+
if (opts.file !== null) {
|
|
796
|
+
if (opts.program) {
|
|
797
|
+
try {
|
|
798
|
+
program = spawnProgram(opts.file, 'to');
|
|
799
|
+
}
|
|
800
|
+
catch (err) {
|
|
801
|
+
return failWith(`could not execute command "${opts.file}": ${err instanceof Error ? err.message : String(err)}`);
|
|
802
|
+
}
|
|
803
|
+
writable = program.writable;
|
|
804
|
+
const p = program;
|
|
805
|
+
cleanups.push(async () => {
|
|
806
|
+
try {
|
|
807
|
+
p.child.stdin?.end();
|
|
808
|
+
}
|
|
809
|
+
catch {
|
|
810
|
+
// ignore
|
|
811
|
+
}
|
|
812
|
+
await p.closed;
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
try {
|
|
817
|
+
// Reject if the path exists and is a directory.
|
|
818
|
+
const stat = await fsPromises.stat(opts.file).catch(() => null);
|
|
819
|
+
if (stat?.isDirectory()) {
|
|
820
|
+
return failWith(`${opts.file}: cannot copy from/to a directory`);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
catch {
|
|
824
|
+
// ENOENT is fine for write — createWriteStream will create it.
|
|
825
|
+
}
|
|
826
|
+
const stream = createWriteStream(opts.file);
|
|
827
|
+
// Trap the async open/write error synchronously so it can't crash the
|
|
828
|
+
// process; surfaced as a COPY failure after the drive.
|
|
829
|
+
stream.once('error', (e) => {
|
|
830
|
+
fileWriteErrors.push(e);
|
|
831
|
+
});
|
|
832
|
+
writable = stream;
|
|
833
|
+
cleanups.push(() => new Promise((resolve, reject) => {
|
|
834
|
+
stream.end((err) => {
|
|
835
|
+
if (err)
|
|
836
|
+
reject(err);
|
|
837
|
+
else
|
|
838
|
+
resolve();
|
|
839
|
+
});
|
|
840
|
+
}));
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
else {
|
|
844
|
+
// STDOUT form. Cast through unknown because process.stdout's `Writable`
|
|
845
|
+
// type isn't strictly compatible with the generic interface.
|
|
846
|
+
writable = process.stdout;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
// Drive the COPY.
|
|
850
|
+
let tag = null;
|
|
851
|
+
try {
|
|
852
|
+
if (opts.direction === 'from') {
|
|
853
|
+
if (readable === null) {
|
|
854
|
+
return failWith('no input stream for COPY FROM');
|
|
855
|
+
}
|
|
856
|
+
const copyIn = await conn.startCopyIn(sql);
|
|
857
|
+
// STDIN honours the `\.` EOF marker for BOTH text and CSV (psql treats
|
|
858
|
+
// `\.` on its own line as end-of-data in either) — only binary STDIN and
|
|
859
|
+
// file/PROGRAM sources stream bytes verbatim. Gating on text-only made a
|
|
860
|
+
// CSV `\copy … FROM STDIN` swallow the `\.` line as a data row and the
|
|
861
|
+
// following SQL into the copy stream.
|
|
862
|
+
if (fromStdin &&
|
|
863
|
+
!isCopyBinaryFormat(opts.beforeToFrom, opts.afterToFrom)) {
|
|
864
|
+
await pumpStdinWithEofMarker(readable, copyIn);
|
|
865
|
+
}
|
|
866
|
+
else {
|
|
867
|
+
await pumpReadable(conn, readable, copyIn);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
else {
|
|
871
|
+
if (writable === null) {
|
|
872
|
+
return failWith('no output stream for COPY TO');
|
|
873
|
+
}
|
|
874
|
+
await drainCopyTo(conn, sql, writable);
|
|
875
|
+
// A deferred open()/write() failure on the output file: report it as a
|
|
876
|
+
// COPY failure instead of letting the unhandled 'error' abort the
|
|
877
|
+
// process. (`finally` below still runs the stream cleanups.)
|
|
878
|
+
if (fileWriteErrors.length > 0)
|
|
879
|
+
return failWith(fileWriteErrors[0].message);
|
|
880
|
+
}
|
|
881
|
+
// For `PROGRAM '...'` sources/sinks, wait for the child to exit and fold a
|
|
882
|
+
// nonzero exit / signal / spawn error into the COPY result. Without this a
|
|
883
|
+
// failing program (e.g. `\copy t TO PROGRAM 'false'`) reported success.
|
|
884
|
+
// Close the program's stdin first so a TO PROGRAM child that
|
|
885
|
+
// reads to EOF can finish.
|
|
886
|
+
if (program !== null) {
|
|
887
|
+
program.writable?.end();
|
|
888
|
+
const exit = await program.closed;
|
|
889
|
+
// A program is only spawned when opts.file holds the command string.
|
|
890
|
+
const progErr = describeProgramExit(opts.file ?? '', exit);
|
|
891
|
+
if (progErr !== null)
|
|
892
|
+
throw new Error(progErr);
|
|
893
|
+
}
|
|
894
|
+
// The connection records the trailing CommandComplete tag for us. We
|
|
895
|
+
// narrow via a duck-type check so we don't tighten the Connection type.
|
|
896
|
+
tag = readLastCopyTag(conn);
|
|
897
|
+
}
|
|
898
|
+
catch (err) {
|
|
899
|
+
return failWith(err instanceof Error ? err.message : String(err));
|
|
900
|
+
}
|
|
901
|
+
finally {
|
|
902
|
+
for (const c of cleanups) {
|
|
903
|
+
try {
|
|
904
|
+
await c();
|
|
905
|
+
}
|
|
906
|
+
catch {
|
|
907
|
+
// best-effort cleanup
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
return { ok: true, tag };
|
|
912
|
+
};
|
|
913
|
+
/**
|
|
914
|
+
* Read the connection's `lastCopyTag` if the implementation exposes it.
|
|
915
|
+
* PgConnection sets this property after each COPY; mock connections in tests
|
|
916
|
+
* may not, in which case we return null and the caller prints just `COPY`.
|
|
917
|
+
*/
|
|
918
|
+
const readLastCopyTag = (conn) => {
|
|
919
|
+
const maybe = conn.lastCopyTag;
|
|
920
|
+
if (typeof maybe === 'string')
|
|
921
|
+
return maybe;
|
|
922
|
+
return null;
|
|
923
|
+
};
|
|
924
|
+
// ---------------------------------------------------------------------------
|
|
925
|
+
// Backslash command registration
|
|
926
|
+
// ---------------------------------------------------------------------------
|
|
927
|
+
/**
|
|
928
|
+
* `\copy` command spec. Mirrors upstream's `exec_command_a_or_copy` path
|
|
929
|
+
* (well, just the copy half). On success we print the trailing `COPY <N>`
|
|
930
|
+
* footer to stdout, matching `do_copy`'s expectation that SendQuery's normal
|
|
931
|
+
* result-printing pipeline emits the tag.
|
|
932
|
+
*/
|
|
933
|
+
export const cmdCopy = {
|
|
934
|
+
name: 'copy',
|
|
935
|
+
helpKey: 'copy',
|
|
936
|
+
async run(ctx) {
|
|
937
|
+
if (!ctx.settings.db) {
|
|
938
|
+
ctx.settings.lastErrorResult = { message: 'no connection to the server' };
|
|
939
|
+
writeErr('\\copy: no connection to the server\n');
|
|
940
|
+
return { status: 'error' };
|
|
941
|
+
}
|
|
942
|
+
// COPY is not supported inside a \startpipeline ... \endpipeline block:
|
|
943
|
+
// upstream libpq aborts the connection with this exact diagnostic and
|
|
944
|
+
// psql exits non-zero. Detect at the command layer so we don't even
|
|
945
|
+
// send the Query — that lets us short-circuit before the protocol
|
|
946
|
+
// switches into the COPY data phase (which would otherwise hang).
|
|
947
|
+
//
|
|
948
|
+
// We close (but do NOT null) the connection on `ctx.settings.db` so the
|
|
949
|
+
// mainloop's `checkConnectionLost` polls `db.isClosed()` and surfaces
|
|
950
|
+
// the standard "connection to server was lost" diagnostic + EXIT_BADCONN.
|
|
951
|
+
// That matches libpq's "aborting connection" promise — the script halts
|
|
952
|
+
// after this command rather than appearing to recover.
|
|
953
|
+
if (getPipelineState(ctx.settings) !== null) {
|
|
954
|
+
ctx.settings.lastErrorResult = { message: COPY_IN_PIPELINE_MSG };
|
|
955
|
+
writeErr(`\\copy: ${COPY_IN_PIPELINE_MSG}\n`);
|
|
956
|
+
try {
|
|
957
|
+
await ctx.settings.db.close();
|
|
958
|
+
}
|
|
959
|
+
catch {
|
|
960
|
+
// best-effort; the connection may already be dead
|
|
961
|
+
}
|
|
962
|
+
return { status: 'error' };
|
|
963
|
+
}
|
|
964
|
+
const raw = ctx.restOfLine();
|
|
965
|
+
if (raw.trim().length === 0) {
|
|
966
|
+
ctx.settings.lastErrorResult = { message: 'arguments required' };
|
|
967
|
+
writeErr('\\copy: arguments required\n');
|
|
968
|
+
return { status: 'error' };
|
|
969
|
+
}
|
|
970
|
+
const parsed = parseSlashCopy(raw);
|
|
971
|
+
if (!parsed.ok) {
|
|
972
|
+
ctx.settings.lastErrorResult = { message: parsed.error };
|
|
973
|
+
writeErr(`\\copy: ${parsed.error}\n`);
|
|
974
|
+
return { status: 'error' };
|
|
975
|
+
}
|
|
976
|
+
const result = await doCopy(ctx.settings.db, parsed.value);
|
|
977
|
+
if (!result.ok) {
|
|
978
|
+
ctx.settings.lastErrorResult = { message: result.error };
|
|
979
|
+
writeErr(`\\copy: ${result.error}\n`);
|
|
980
|
+
return { status: 'error' };
|
|
981
|
+
}
|
|
982
|
+
// Print the upstream-style command tag (e.g. "COPY 17") so users see the
|
|
983
|
+
// same summary as `psql`. If the connection didn't surface a tag, just
|
|
984
|
+
// print `COPY` — the operation still succeeded.
|
|
985
|
+
//
|
|
986
|
+
// BUT: when the COPY destination is psql's own stdout (i.e. `\copy ...
|
|
987
|
+
// TO STDOUT` / `TO PSTDOUT`), emitting the tag would mix it into the
|
|
988
|
+
// user's data stream. Upstream `do_copy()` suppresses the tag in this
|
|
989
|
+
// case — `pset.queryFout` is shared between the data stream and the tag
|
|
990
|
+
// print path, so the tag has nowhere to land. Mirror that here: only
|
|
991
|
+
// print when the destination is a file, a program, or when the COPY is
|
|
992
|
+
// a FROM (where the data flowed *into* the server, not out to stdout).
|
|
993
|
+
const suppressTag = parsed.value.direction === 'to' &&
|
|
994
|
+
parsed.value.file === null &&
|
|
995
|
+
!parsed.value.program;
|
|
996
|
+
if (!suppressTag) {
|
|
997
|
+
const rows = parseCopyTagRows(result.tag);
|
|
998
|
+
if (result.tag !== null && rows !== null) {
|
|
999
|
+
writeOut(`COPY ${String(rows)}\n`);
|
|
1000
|
+
}
|
|
1001
|
+
else if (result.tag !== null) {
|
|
1002
|
+
writeOut(`${result.tag}\n`);
|
|
1003
|
+
}
|
|
1004
|
+
else {
|
|
1005
|
+
writeOut('COPY\n');
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
return { status: 'ok' };
|
|
1009
|
+
},
|
|
1010
|
+
};
|
|
1011
|
+
/**
|
|
1012
|
+
* Register the `\copy` command on the supplied registry. Called from
|
|
1013
|
+
* `dispatch.ts::defaultRegistry()` (one new line).
|
|
1014
|
+
*/
|
|
1015
|
+
export const registerCopyCommands = (registry) => {
|
|
1016
|
+
registry.register(cmdCopy);
|
|
1017
|
+
};
|
|
1018
|
+
// Re-export for direct callers that want to bypass the dispatcher (tests).
|
|
1019
|
+
export { buildCopySql, pumpStdinWithEofMarker };
|
|
1020
|
+
/**
|
|
1021
|
+
* Convenience: encode a JS string as UTF-8 bytes for COPY FROM. Exposed so
|
|
1022
|
+
* tests can feed a `Buffer` to {@link doCopy} without re-implementing the
|
|
1023
|
+
* Readable shim.
|
|
1024
|
+
*/
|
|
1025
|
+
export const toBuffer = (s) => Buffer.from(s, 'utf8');
|