neonctl 2.22.0 → 2.23.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 (116) hide show
  1. package/README.md +242 -16
  2. package/analytics.js +5 -2
  3. package/commands/branches.js +9 -1
  4. package/commands/checkout.js +249 -0
  5. package/commands/connection_string.js +15 -2
  6. package/commands/data_api.js +286 -0
  7. package/commands/functions.js +277 -0
  8. package/commands/index.js +12 -0
  9. package/commands/link.js +667 -0
  10. package/commands/neon_auth.js +1013 -0
  11. package/commands/projects.js +9 -1
  12. package/commands/psql.js +62 -0
  13. package/commands/set_context.js +7 -2
  14. package/context.js +86 -14
  15. package/functions_api.js +44 -0
  16. package/index.js +3 -0
  17. package/package.json +60 -51
  18. package/psql/cli.js +51 -0
  19. package/psql/command/cmd_cond.js +437 -0
  20. package/psql/command/cmd_connect.js +815 -0
  21. package/psql/command/cmd_copy.js +1025 -0
  22. package/psql/command/cmd_describe.js +1810 -0
  23. package/psql/command/cmd_format.js +909 -0
  24. package/psql/command/cmd_io.js +2187 -0
  25. package/psql/command/cmd_lo.js +385 -0
  26. package/psql/command/cmd_meta.js +970 -0
  27. package/psql/command/cmd_misc.js +187 -0
  28. package/psql/command/cmd_pipeline.js +1141 -0
  29. package/psql/command/cmd_restrict.js +171 -0
  30. package/psql/command/cmd_show.js +751 -0
  31. package/psql/command/dispatch.js +343 -0
  32. package/psql/command/inputQueue.js +42 -0
  33. package/psql/command/shared.js +71 -0
  34. package/psql/complete/filenames.js +139 -0
  35. package/psql/complete/index.js +104 -0
  36. package/psql/complete/matcher.js +314 -0
  37. package/psql/complete/psqlVars.js +247 -0
  38. package/psql/complete/queries.js +491 -0
  39. package/psql/complete/rules.js +2387 -0
  40. package/psql/core/common.js +1250 -0
  41. package/psql/core/help.js +576 -0
  42. package/psql/core/mainloop.js +1353 -0
  43. package/psql/core/prompt.js +437 -0
  44. package/psql/core/settings.js +684 -0
  45. package/psql/core/sqlHelp.js +1066 -0
  46. package/psql/core/startup.js +840 -0
  47. package/psql/core/syncVars.js +116 -0
  48. package/psql/core/variables.js +287 -0
  49. package/psql/describe/formatters.js +1277 -0
  50. package/psql/describe/processNamePattern.js +270 -0
  51. package/psql/describe/queries.js +2373 -0
  52. package/psql/describe/versionGate.js +43 -0
  53. package/psql/index.js +2005 -0
  54. package/psql/io/history.js +299 -0
  55. package/psql/io/input.js +120 -0
  56. package/psql/io/lineEditor/buffer.js +323 -0
  57. package/psql/io/lineEditor/complete.js +227 -0
  58. package/psql/io/lineEditor/filename.js +159 -0
  59. package/psql/io/lineEditor/index.js +891 -0
  60. package/psql/io/lineEditor/keymap.js +738 -0
  61. package/psql/io/lineEditor/vt100.js +363 -0
  62. package/psql/io/pgpass.js +202 -0
  63. package/psql/io/pgservice.js +194 -0
  64. package/psql/io/psqlrc.js +422 -0
  65. package/psql/print/aligned.js +1756 -0
  66. package/psql/print/asciidoc.js +248 -0
  67. package/psql/print/crosstab.js +460 -0
  68. package/psql/print/csv.js +92 -0
  69. package/psql/print/html.js +258 -0
  70. package/psql/print/json.js +96 -0
  71. package/psql/print/latex.js +396 -0
  72. package/psql/print/pager.js +265 -0
  73. package/psql/print/troff.js +258 -0
  74. package/psql/print/unaligned.js +118 -0
  75. package/psql/print/units.js +135 -0
  76. package/psql/scanner/slash.js +513 -0
  77. package/psql/scanner/sql.js +910 -0
  78. package/psql/scanner/stringutils.js +390 -0
  79. package/psql/types/backslash.js +1 -0
  80. package/psql/types/connection.js +1 -0
  81. package/psql/types/index.js +7 -0
  82. package/psql/types/printer.js +1 -0
  83. package/psql/types/repl.js +1 -0
  84. package/psql/types/scanner.js +24 -0
  85. package/psql/types/settings.js +1 -0
  86. package/psql/types/variables.js +1 -0
  87. package/psql/wire/connection.js +2844 -0
  88. package/psql/wire/copy.js +108 -0
  89. package/psql/wire/notify.js +59 -0
  90. package/psql/wire/pipeline.js +519 -0
  91. package/psql/wire/protocol.js +466 -0
  92. package/psql/wire/sasl.js +296 -0
  93. package/psql/wire/tls.js +596 -0
  94. package/test_utils/fixtures.js +1 -0
  95. package/utils/enrichers.js +18 -1
  96. package/utils/esbuild.js +147 -0
  97. package/utils/middlewares.js +1 -1
  98. package/utils/psql.js +107 -11
  99. package/utils/zip.js +4 -0
  100. package/writer.js +1 -1
  101. package/commands/auth.test.js +0 -211
  102. package/commands/branches.test.js +0 -460
  103. package/commands/connection_string.test.js +0 -196
  104. package/commands/databases.test.js +0 -39
  105. package/commands/help.test.js +0 -9
  106. package/commands/init.test.js +0 -56
  107. package/commands/ip_allow.test.js +0 -59
  108. package/commands/operations.test.js +0 -7
  109. package/commands/orgs.test.js +0 -7
  110. package/commands/projects.test.js +0 -144
  111. package/commands/roles.test.js +0 -37
  112. package/commands/set_context.test.js +0 -159
  113. package/commands/vpc_endpoints.test.js +0 -69
  114. package/env.test.js +0 -55
  115. package/utils/formats.test.js +0 -32
  116. 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');