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.
Files changed (113) hide show
  1. package/README.md +84 -0
  2. package/analytics.js +5 -2
  3. package/commands/branches.js +9 -1
  4. package/commands/connection_string.js +9 -1
  5. package/commands/functions.js +268 -0
  6. package/commands/index.js +4 -0
  7. package/commands/neon_auth.js +1013 -0
  8. package/commands/projects.js +9 -1
  9. package/commands/psql.js +6 -1
  10. package/functions_api.js +43 -0
  11. package/package.json +15 -5
  12. package/psql/cli.js +51 -0
  13. package/psql/command/cmd_cond.js +437 -0
  14. package/psql/command/cmd_connect.js +815 -0
  15. package/psql/command/cmd_copy.js +1025 -0
  16. package/psql/command/cmd_describe.js +1810 -0
  17. package/psql/command/cmd_format.js +909 -0
  18. package/psql/command/cmd_io.js +2187 -0
  19. package/psql/command/cmd_lo.js +385 -0
  20. package/psql/command/cmd_meta.js +970 -0
  21. package/psql/command/cmd_misc.js +187 -0
  22. package/psql/command/cmd_pipeline.js +1141 -0
  23. package/psql/command/cmd_restrict.js +171 -0
  24. package/psql/command/cmd_show.js +751 -0
  25. package/psql/command/dispatch.js +343 -0
  26. package/psql/command/inputQueue.js +42 -0
  27. package/psql/command/shared.js +71 -0
  28. package/psql/complete/filenames.js +139 -0
  29. package/psql/complete/index.js +104 -0
  30. package/psql/complete/matcher.js +314 -0
  31. package/psql/complete/psqlVars.js +247 -0
  32. package/psql/complete/queries.js +491 -0
  33. package/psql/complete/rules.js +2387 -0
  34. package/psql/core/common.js +1250 -0
  35. package/psql/core/help.js +576 -0
  36. package/psql/core/mainloop.js +1353 -0
  37. package/psql/core/prompt.js +437 -0
  38. package/psql/core/settings.js +684 -0
  39. package/psql/core/sqlHelp.js +1066 -0
  40. package/psql/core/startup.js +840 -0
  41. package/psql/core/syncVars.js +116 -0
  42. package/psql/core/variables.js +287 -0
  43. package/psql/describe/formatters.js +1277 -0
  44. package/psql/describe/processNamePattern.js +270 -0
  45. package/psql/describe/queries.js +2373 -0
  46. package/psql/describe/versionGate.js +43 -0
  47. package/psql/index.js +2005 -0
  48. package/psql/io/history.js +299 -0
  49. package/psql/io/input.js +120 -0
  50. package/psql/io/lineEditor/buffer.js +323 -0
  51. package/psql/io/lineEditor/complete.js +227 -0
  52. package/psql/io/lineEditor/filename.js +159 -0
  53. package/psql/io/lineEditor/index.js +891 -0
  54. package/psql/io/lineEditor/keymap.js +738 -0
  55. package/psql/io/lineEditor/vt100.js +363 -0
  56. package/psql/io/pgpass.js +202 -0
  57. package/psql/io/pgservice.js +194 -0
  58. package/psql/io/psqlrc.js +422 -0
  59. package/psql/print/aligned.js +1756 -0
  60. package/psql/print/asciidoc.js +248 -0
  61. package/psql/print/crosstab.js +460 -0
  62. package/psql/print/csv.js +92 -0
  63. package/psql/print/html.js +258 -0
  64. package/psql/print/json.js +96 -0
  65. package/psql/print/latex.js +396 -0
  66. package/psql/print/pager.js +265 -0
  67. package/psql/print/troff.js +258 -0
  68. package/psql/print/unaligned.js +118 -0
  69. package/psql/print/units.js +135 -0
  70. package/psql/scanner/slash.js +513 -0
  71. package/psql/scanner/sql.js +910 -0
  72. package/psql/scanner/stringutils.js +390 -0
  73. package/psql/types/backslash.js +1 -0
  74. package/psql/types/connection.js +1 -0
  75. package/psql/types/index.js +7 -0
  76. package/psql/types/printer.js +1 -0
  77. package/psql/types/repl.js +1 -0
  78. package/psql/types/scanner.js +24 -0
  79. package/psql/types/settings.js +1 -0
  80. package/psql/types/variables.js +1 -0
  81. package/psql/wire/connection.js +2844 -0
  82. package/psql/wire/copy.js +108 -0
  83. package/psql/wire/notify.js +59 -0
  84. package/psql/wire/pipeline.js +519 -0
  85. package/psql/wire/protocol.js +466 -0
  86. package/psql/wire/sasl.js +296 -0
  87. package/psql/wire/tls.js +596 -0
  88. package/test_utils/fixtures.js +1 -0
  89. package/utils/esbuild.js +147 -0
  90. package/utils/psql.js +107 -11
  91. package/utils/zip.js +4 -0
  92. package/writer.js +1 -1
  93. package/commands/auth.test.js +0 -211
  94. package/commands/branches.test.js +0 -460
  95. package/commands/checkout.test.js +0 -170
  96. package/commands/connection_string.test.js +0 -196
  97. package/commands/data_api.test.js +0 -169
  98. package/commands/databases.test.js +0 -39
  99. package/commands/help.test.js +0 -9
  100. package/commands/init.test.js +0 -56
  101. package/commands/ip_allow.test.js +0 -59
  102. package/commands/link.test.js +0 -381
  103. package/commands/operations.test.js +0 -7
  104. package/commands/orgs.test.js +0 -7
  105. package/commands/projects.test.js +0 -144
  106. package/commands/psql.test.js +0 -49
  107. package/commands/roles.test.js +0 -37
  108. package/commands/set_context.test.js +0 -159
  109. package/commands/vpc_endpoints.test.js +0 -69
  110. package/context.test.js +0 -119
  111. package/env.test.js +0 -55
  112. package/utils/formats.test.js +0 -32
  113. package/writer.test.js +0 -104
@@ -0,0 +1,1353 @@
1
+ /**
2
+ * psql REPL main loop.
3
+ *
4
+ * TypeScript port of `MainLoop()` in `src/bin/psql/mainloop.c`. Drives the
5
+ * read-eval-print cycle: read a line, feed it to the SQL scanner, dispatch
6
+ * SQL or backslash commands as boundaries appear, print results, and loop.
7
+ *
8
+ * Simplifications vs upstream (each tracked against the WP plan):
9
+ *
10
+ * - Line editing is delegated to `node:readline` for now. Upstream uses
11
+ * GNU readline / libedit for history and completion. The proper raw-mode
12
+ * line editor is owned by WP-24; until then we get sane prompt rendering
13
+ * and Ctrl-C handling for free from the standard library.
14
+ * - History accumulation is omitted (`pg_append_history` / `pg_send_history`
15
+ * in upstream). The history sink lives in WP-25.
16
+ * - `\COPY FROM STDIN` raw-data lines are not wired (WP-16). When that lands,
17
+ * the mainloop will switch to PROMPT3 and forward lines to a CopyInStream.
18
+ * - `\if`/`\elif`/`\else`/`\endif` dispatch is wired directly here so the
19
+ * cmd_cond module can stay decoupled from the dispatch registry. Other
20
+ * backslash commands go through the registry interface that WP-13 owns.
21
+ * - The transaction-state poll (`pset.statusF`/`PQtransactionStatus`) is
22
+ * represented by an optional `txStatus` field on Connection; if the
23
+ * Connection doesn't expose one we treat the state as `unknown` for
24
+ * prompt rendering.
25
+ *
26
+ * Tracked TODOs:
27
+ *
28
+ * - PSQLRC startup script (WP-22).
29
+ * - Encoding / multibyte handling beyond UTF-8 (handled implicitly by JS).
30
+ * - `\watch` continuous-execution mode.
31
+ */
32
+ import * as readline from 'node:readline';
33
+ import { initialScanState } from '../types/scanner.js';
34
+ import { scanSql } from '../scanner/sql.js';
35
+ import { scanSlashArgs } from '../scanner/slash.js';
36
+ import { renderPromptByName } from './prompt.js';
37
+ import { captureLastError, pickOut, refreshErrorVars, renderResultSet, sendQuery, writeQueryError, } from './common.js';
38
+ import { formatDurationMs } from '../print/units.js';
39
+ import { COND_COMMAND_NAMES, attachCondStack, cmdElif, cmdElse, cmdEndif, cmdIf, } from '../command/cmd_cond.js';
40
+ import { consumeNext as consumeQueuedInput } from '../command/inputQueue.js';
41
+ import { consumeBindState, getPipelineState } from '../command/cmd_pipeline.js';
42
+ import { appendHistory, defaultHistoryPath, loadHistory, resolveHistSize, truncateHistory, } from '../io/history.js';
43
+ import { LineEditor } from '../io/lineEditor/index.js';
44
+ import { psqlCompleter } from '../complete/index.js';
45
+ // ---------------------------------------------------------------------------
46
+ // Exit codes — mirror psql's `EXIT_*` constants.
47
+ // ---------------------------------------------------------------------------
48
+ export const EXIT_SUCCESS = 0;
49
+ export const EXIT_FAILURE = 1;
50
+ export const EXIT_BADCONN = 2;
51
+ export const EXIT_USER = 3;
52
+ // ---------------------------------------------------------------------------
53
+ // Built-in cond command map — these are dispatched directly, before the
54
+ // registry lookup, because they must run even inside an inactive branch.
55
+ // ---------------------------------------------------------------------------
56
+ const COND_COMMANDS = new Map([
57
+ ['if', cmdIf],
58
+ ['elif', cmdElif],
59
+ ['else', cmdElse],
60
+ ['endif', cmdEndif],
61
+ ]);
62
+ const makeStreamLineReader = (input, out) => {
63
+ const rl = readline.createInterface({
64
+ input,
65
+ crlfDelay: Infinity,
66
+ terminal: false,
67
+ });
68
+ // We deliberately do NOT use rl[Symbol.asyncIterator]() here. Node's
69
+ // events.on()-based readline iterator races its own close: when the input
70
+ // stream EOFs after pushing many buffered lines, the iterator drains most
71
+ // of them correctly but on the boundary call (when the internal queue
72
+ // empties on the same tick the close completes) it calls
73
+ // Interface.resume() AFTER close has been applied, which throws
74
+ // `ERR_USE_AFTER_CLOSE: readline was closed` instead of returning
75
+ // `{ done: true }`. The previous, *successfully* buffered last `'line'`
76
+ // event ends up dropped. Reproduces in a standalone Node v24 program with
77
+ // ~2000 pushed lines followed by `push(null)`. The 'line' / 'close'
78
+ // event model below is queue-based so we never lose a line on close.
79
+ const lineQueue = [];
80
+ let waiter = null;
81
+ let ended = false;
82
+ rl.on('line', (line) => {
83
+ if (waiter) {
84
+ const w = waiter;
85
+ waiter = null;
86
+ w(line);
87
+ }
88
+ else {
89
+ lineQueue.push(line);
90
+ }
91
+ });
92
+ rl.on('close', () => {
93
+ ended = true;
94
+ if (waiter) {
95
+ const w = waiter;
96
+ waiter = null;
97
+ w(null);
98
+ }
99
+ });
100
+ return {
101
+ readLine: () => {
102
+ if (lineQueue.length > 0) {
103
+ return Promise.resolve(lineQueue.shift());
104
+ }
105
+ if (ended)
106
+ return Promise.resolve(null);
107
+ return new Promise((resolve) => {
108
+ waiter = resolve;
109
+ });
110
+ },
111
+ pushHistory: () => undefined,
112
+ // No prompt to garble; just write straight to stdout.
113
+ interject: (text) => {
114
+ out.write(text);
115
+ },
116
+ close: () => {
117
+ rl.close();
118
+ return Promise.resolve();
119
+ },
120
+ };
121
+ };
122
+ /**
123
+ * Parse a psql VI_MODE-style boolean. Mirrors `ParseVariableBool` for the
124
+ * common spellings: `on` / `true` / `yes` / `1` are truthy; `off` / `false` /
125
+ * `no` / `0` are falsy; everything else returns `null` so the caller can
126
+ * surface the upstream "invalid value" diagnostic.
127
+ */
128
+ const parseBoolVar = (raw) => {
129
+ const v = raw.toLowerCase().trim();
130
+ if (v === '' || v === 'on' || v === 'true' || v === 'yes' || v === '1') {
131
+ return true;
132
+ }
133
+ if (v === 'off' || v === 'false' || v === 'no' || v === '0') {
134
+ return false;
135
+ }
136
+ return null;
137
+ };
138
+ /** Translate a psql VI_MODE var value into the LineEditor mode. */
139
+ const viModeOption = (raw) => {
140
+ if (raw === undefined)
141
+ return 'emacs';
142
+ return parseBoolVar(raw) === true ? 'vi' : 'emacs';
143
+ };
144
+ const makeEditorLineReader = async (ctx, opts = {}) => {
145
+ const env = process.env;
146
+ const histPath = defaultHistoryPath(env);
147
+ const histSize = resolveHistSize(env);
148
+ const histControl = ctx.settings.vars.get('HISTCONTROL') ??
149
+ ctx.settings.histControl;
150
+ let history = [];
151
+ try {
152
+ history = await loadHistory(histPath);
153
+ }
154
+ catch {
155
+ // Missing or unreadable history file — start fresh.
156
+ history = [];
157
+ }
158
+ // VI_MODE: upstream readline's `set editing-mode {emacs|vi}`. We read once
159
+ // here for the initial mode, and below we install a VarStore hook so a
160
+ // subsequent `\set VI_MODE on` switches the editor at the next prompt.
161
+ const initialMode = viModeOption(ctx.settings.vars.get('VI_MODE'));
162
+ const editor = new LineEditor({
163
+ stdin: ctx.stdin,
164
+ stdout: ctx.stdout,
165
+ history,
166
+ completer: psqlCompleter({
167
+ settings: ctx.settings,
168
+ getQueryBuf: opts.getQueryBuf,
169
+ }),
170
+ mode: initialMode,
171
+ });
172
+ // Hook: validate the value, reject unrecognised input with psql's
173
+ // `\set: VI_MODE: invalid value "X"; valid values: on, off` diagnostic,
174
+ // and on success forward to `editor.setMode` (which defers the switch to
175
+ // the next readLine boundary). Replay on registration is fine — the hook
176
+ // is idempotent for a no-op `null`/unchanged value.
177
+ ctx.settings.vars.addHook('VI_MODE', (newValue) => {
178
+ if (newValue === null) {
179
+ editor.setMode('emacs');
180
+ return true;
181
+ }
182
+ const parsed = parseBoolVar(newValue);
183
+ if (parsed === null) {
184
+ ctx.stderr.write(`\\set: VI_MODE: invalid value "${newValue}"; valid values: on, off\n`);
185
+ return false;
186
+ }
187
+ editor.setMode(parsed ? 'vi' : 'emacs');
188
+ return true;
189
+ });
190
+ return {
191
+ readLine: async (prompt) => {
192
+ const r = await editor.readLine(prompt);
193
+ if (r === editor.EOF)
194
+ return null;
195
+ return r;
196
+ },
197
+ pushHistory: (line) => {
198
+ const trimmed = line.replace(/\n+$/, '');
199
+ if (trimmed.length === 0)
200
+ return;
201
+ editor.pushHistory(trimmed);
202
+ // Best-effort persist. We don't block the REPL on disk I/O.
203
+ void appendHistory(histPath, trimmed, histControl).catch(() => undefined);
204
+ },
205
+ interject: (text) => {
206
+ editor.interject(text);
207
+ },
208
+ close: async () => {
209
+ try {
210
+ editor.close();
211
+ }
212
+ catch {
213
+ // ignore
214
+ }
215
+ // Truncate to HISTSIZE on exit (libreadline behaviour).
216
+ try {
217
+ await truncateHistory(histPath, histSize);
218
+ }
219
+ catch {
220
+ // ignore
221
+ }
222
+ },
223
+ };
224
+ };
225
+ /**
226
+ * Recognize upstream psql's `exit` / `quit` shortcut: when typed at the start
227
+ * of a fresh statement (queryBuf empty), they exit the REPL. Accepts a
228
+ * trailing `;` and/or whitespace.
229
+ */
230
+ const isQuitKeyword = (line) => {
231
+ const trimmed = line.trim();
232
+ if (trimmed.length === 0)
233
+ return false;
234
+ const stripped = trimmed.replace(/;+\s*$/u, '').trimEnd();
235
+ return stripped === 'exit' || stripped === 'quit';
236
+ };
237
+ /**
238
+ * Recognize the bare `help` keyword the same way upstream does: at the start
239
+ * of a fresh statement, it prints a one-screen reminder of the most useful
240
+ * meta-commands and continues the REPL.
241
+ */
242
+ const isHelpKeyword = (line) => {
243
+ const trimmed = line.trim();
244
+ if (trimmed.length === 0)
245
+ return false;
246
+ const stripped = trimmed.replace(/;+\s*$/u, '').trimEnd();
247
+ return stripped === 'help';
248
+ };
249
+ const HELP_TEXT = 'You are using psql-ts, the embedded TypeScript psql in neonctl.\n' +
250
+ 'Type: \\copyright for distribution terms\n' +
251
+ ' \\h for help with SQL commands\n' +
252
+ ' \\? for help with psql commands\n' +
253
+ ' \\g or terminate with semicolon to execute query\n' +
254
+ ' \\q to quit\n';
255
+ const makeLineReader = async (ctx, opts = {}) => {
256
+ const debug = process.env.NEONCTL_PSQL_DEBUG === '1';
257
+ if (ctx.settings.notty) {
258
+ if (debug) {
259
+ ctx.stderr.write('[psql-debug] notty=true; using stream reader (no line editor / no Tab completion)\n');
260
+ }
261
+ return makeStreamLineReader(ctx.stdin, ctx.stdout);
262
+ }
263
+ try {
264
+ const r = await makeEditorLineReader(ctx, opts);
265
+ if (debug) {
266
+ ctx.stderr.write('[psql-debug] LineEditor engaged (raw mode, Tab completion active)\n');
267
+ }
268
+ return r;
269
+ }
270
+ catch (err) {
271
+ if (debug) {
272
+ ctx.stderr.write(`[psql-debug] LineEditor setup failed, falling back to stream reader: ${err.message}\n`);
273
+ }
274
+ return makeStreamLineReader(ctx.stdin, ctx.stdout);
275
+ }
276
+ };
277
+ const transactionState = (ctx) => {
278
+ const db = ctx.settings.db;
279
+ if (!db)
280
+ return 'idle';
281
+ const status = db.txStatus;
282
+ switch (status) {
283
+ case 'I':
284
+ case 'idle':
285
+ return 'idle';
286
+ case 'T':
287
+ case 'in-block':
288
+ return 'in-block';
289
+ case 'E':
290
+ case 'failed':
291
+ return 'failed';
292
+ case 'unknown':
293
+ return 'unknown';
294
+ default:
295
+ return 'idle';
296
+ }
297
+ };
298
+ // ---------------------------------------------------------------------------
299
+ // BackslashContext factory — built per-invocation so the dispatched command
300
+ // sees an isolated arg-cursor.
301
+ // ---------------------------------------------------------------------------
302
+ const makeBackslashContext = (ctx, cmdName, rawArgs, queryBuf) => {
303
+ // Pre-parse the args once at construction. `nextArg` then pops from this
304
+ // queue. We over-parse a bit (every arg gets normalised through the slash
305
+ // scanner in 'normal' mode), then re-split for non-normal modes lazily on
306
+ // demand. For WP-12 the only consumers are the cond commands, which always
307
+ // request 'normal'; future WPs may need richer routing.
308
+ const varLookup = (name) => ctx.settings.vars.get(name);
309
+ const buffered = new Map();
310
+ const argsFor = (mode) => {
311
+ const cached = buffered.get(mode);
312
+ if (cached)
313
+ return cached;
314
+ const parsed = scanSlashArgs(rawArgs, mode, varLookup);
315
+ buffered.set(mode, parsed);
316
+ return parsed;
317
+ };
318
+ const cursors = new Map();
319
+ const bctx = {
320
+ settings: ctx.settings,
321
+ cmdName,
322
+ queryBuf,
323
+ rawArgs,
324
+ nextArg(mode = 'normal') {
325
+ const args = argsFor(mode);
326
+ const idx = cursors.get(mode) ?? 0;
327
+ if (idx >= args.length)
328
+ return null;
329
+ cursors.set(mode, idx + 1);
330
+ return args[idx];
331
+ },
332
+ restOfLine() {
333
+ // Whatever the user typed after the command name, verbatim.
334
+ return rawArgs;
335
+ },
336
+ };
337
+ return bctx;
338
+ };
339
+ // ---------------------------------------------------------------------------
340
+ // Error printing. Keeps the format close to libpq's `psql: ERROR: msg`.
341
+ // ---------------------------------------------------------------------------
342
+ const writeError = (ctx, message) => {
343
+ ctx.stderr.write(`psql: ERROR: ${message}\n`);
344
+ };
345
+ // ---------------------------------------------------------------------------
346
+ // Prompt context builder.
347
+ // ---------------------------------------------------------------------------
348
+ const buildPromptContext = (ctx, promptStatus, lineNumber) => ({
349
+ settings: ctx.settings,
350
+ cond: ctx.cond,
351
+ promptStatus,
352
+ lineNumber,
353
+ inTransaction: transactionState(ctx),
354
+ pipelineState: 'off',
355
+ });
356
+ // ---------------------------------------------------------------------------
357
+ // Conditional-command dispatch. Returns true if the command was a cond
358
+ // command (handled here), false otherwise. cond commands run regardless of
359
+ // whether the surrounding branch is active.
360
+ // ---------------------------------------------------------------------------
361
+ const dispatchCondCommand = async (ctx, cmdName, rawArgs, queryBuf) => {
362
+ const spec = COND_COMMANDS.get(cmdName);
363
+ if (!spec)
364
+ return { handled: false };
365
+ const bctx = makeBackslashContext(ctx, cmdName, rawArgs, queryBuf);
366
+ attachCondStack(bctx, ctx.cond);
367
+ const result = await spec.run(bctx);
368
+ // Only emit a fallback `psql: ERROR: <msg>` line for commands that did
369
+ // NOT write their own diagnostic. The `errorWritten` flag distinguishes
370
+ // these: commands using cmd_io's `errResult` (and inline writers) set it
371
+ // to `true`; cond commands (which only stash `lastErrorResult.message`)
372
+ // leave it unset so the mainloop surfaces the message.
373
+ if (result.status === 'error' &&
374
+ !result.errorWritten &&
375
+ ctx.settings.lastErrorResult?.message) {
376
+ writeError(ctx, ctx.settings.lastErrorResult.message);
377
+ }
378
+ return { handled: true, result };
379
+ };
380
+ // ---------------------------------------------------------------------------
381
+ // Backslash dispatch for non-cond commands. Only runs when cond is active.
382
+ // Returns the BackslashResult, or null if no command was found.
383
+ // ---------------------------------------------------------------------------
384
+ const dispatchRegisteredCommand = async (ctx, cmdName, rawArgs, queryBuf) => {
385
+ const spec = ctx.registry.lookup(cmdName);
386
+ if (!spec) {
387
+ writeError(ctx, `invalid command \\${cmdName}`);
388
+ // Treat the "invalid command" message as already-written so the next
389
+ // layer doesn't add a second one. (Other dispatch paths set
390
+ // `lastErrorResult.message`; this one does not, so the duplicate
391
+ // guard below would skip anyway — flag it explicitly for symmetry.)
392
+ return { status: 'error', errorWritten: true };
393
+ }
394
+ const bctx = makeBackslashContext(ctx, cmdName, rawArgs, queryBuf);
395
+ attachCondStack(bctx, ctx.cond);
396
+ const result = await spec.run(bctx);
397
+ // Same contract as `dispatchCondCommand`: only fall back to the bare
398
+ // `psql: ERROR: <msg>` shape when the command didn't already surface
399
+ // its own diagnostic. Without this guard, `\gdesc` Parse failures
400
+ // would emit a stray `psql: ERROR: <msg>` line between the LINE/`^`
401
+ // block and the `\errverbose` re-render, breaking the strict ordering
402
+ // check in the conformance regex.
403
+ if (result.status === 'error' &&
404
+ !result.errorWritten &&
405
+ ctx.settings.lastErrorResult?.message) {
406
+ writeError(ctx, ctx.settings.lastErrorResult.message);
407
+ }
408
+ return result;
409
+ };
410
+ // ---------------------------------------------------------------------------
411
+ // SendQuery — delegate to the unified pipeline in `common.ts`. Returns the
412
+ // success flag so the read loop can short-circuit under ON_ERROR_STOP.
413
+ //
414
+ // If `\bind` (WP-21) has stashed parameters on the settings, route through
415
+ // the extended-query path on the Connection. Otherwise use the simple-query
416
+ // pipeline.
417
+ // ---------------------------------------------------------------------------
418
+ /**
419
+ * Refresh psql vars that mirror connection-driven server state. Today this
420
+ * is just `ENCODING` (tracks `client_encoding` ParameterStatus). Upstream
421
+ * does the same check at the tail of `SendQuery` in common.c so a
422
+ * `SET client_encoding = ...` lands on the psql var before the next
423
+ * statement looks it up. Safe to call when no connection is bound.
424
+ */
425
+ const refreshConnectionVars = (ctx) => {
426
+ const db = ctx.settings.db;
427
+ if (!db)
428
+ return;
429
+ const enc = db.parameterStatus('client_encoding');
430
+ if (enc !== undefined && ctx.settings.vars.get('ENCODING') !== enc) {
431
+ ctx.settings.vars.set('ENCODING', enc);
432
+ }
433
+ };
434
+ const dispatchSendQuery = async (ctx, sql) => {
435
+ // Always consume the bind stash up-front so it's cleared regardless of which
436
+ // branch runs (and regardless of success / failure on the bind path).
437
+ const bind = consumeBindState(ctx.settings);
438
+ // Pipeline-active routing: when `\startpipeline` is in effect, a
439
+ // semicolon-terminated SQL must be appended to the pipeline as
440
+ // Parse/Bind/Describe/Execute (no Sync). Sending it as a simple Query
441
+ // would corrupt the pipeline — the in-flight extended-protocol replies
442
+ // would land in `handleQueryMessage` and the pipeline's ResultSet
443
+ // promises would never settle, leaving `\endpipeline` hung.
444
+ //
445
+ // Mirrors upstream psql: `SendQuery` checks `PQpipelineStatus` and routes
446
+ // through `PQsendQueryParams`/`PQsendQuery` accordingly. We use the
447
+ // session helper so the wire enqueueing matches `\sendpipeline`.
448
+ const ps = getPipelineState(ctx.settings);
449
+ if (ps && ctx.settings.db) {
450
+ // Upstream `libpq` refuses `COPY ... FROM STDIN` / `COPY ... TO
451
+ // STDOUT` inside a pipeline with the fatal diagnostic
452
+ // "COPY in a pipeline is not supported, aborting connection".
453
+ // Detect, emit the same wording client-side, and tear down the
454
+ // connection so the mainloop's `checkConnectionLost` halt path
455
+ // fires for any subsequent statement (matching the upstream
456
+ // "aborting connection" semantics).
457
+ const trimmed = sql.trimStart();
458
+ if (/^COPY\b/i.test(trimmed) &&
459
+ /\b(FROM\s+STDIN|TO\s+STDOUT)\b/i.test(trimmed)) {
460
+ ctx.stderr.write('psql: error: COPY in a pipeline is not supported, aborting connection\n');
461
+ // Hard-abort the underlying socket so isClosed() flips true and the
462
+ // mainloop's post-dispatch `checkConnectionLost` ends the loop.
463
+ try {
464
+ const db = ctx.settings.db;
465
+ if (typeof db.abortForCopyInPipeline === 'function') {
466
+ db.abortForCopyInPipeline();
467
+ }
468
+ else if (typeof db.close === 'function') {
469
+ await db.close();
470
+ }
471
+ }
472
+ catch {
473
+ // ignore — the diagnostic has already been emitted.
474
+ }
475
+ return false;
476
+ }
477
+ try {
478
+ // Pipeline-mode `;`-queries: empty parameter list, anonymous prepared
479
+ // statement, anonymous portal. The result will surface later through
480
+ // `\endpipeline` / `\getresults`.
481
+ await ps.session.parse('', sql, []);
482
+ await ps.session.bind('', bind?.values ?? []);
483
+ const exec = (async () => {
484
+ await ps.session.execute('', 0);
485
+ return undefined;
486
+ })();
487
+ ps.pending.push(exec);
488
+ // The enqueue succeeded; the actual result will flush at
489
+ // `\endpipeline` time. Mark the diagnostic vars as success-now so
490
+ // intervening `\echo :ERROR` sees "false" between pipeline appends.
491
+ refreshErrorVars(ctx.settings, { kind: 'success', rowCount: null });
492
+ return true;
493
+ }
494
+ catch (err) {
495
+ const message = captureLastError(ctx.settings, err, sql);
496
+ writeQueryError(ctx, message);
497
+ refreshErrorVars(ctx.settings, { kind: 'error' });
498
+ return false;
499
+ }
500
+ }
501
+ if (bind && ctx.settings.db) {
502
+ const started = ctx.settings.timing ? Date.now() : 0;
503
+ let lastRowCount = null;
504
+ let hadError = false;
505
+ try {
506
+ const rs = await ctx.settings.db.query(sql, bind.values);
507
+ // Route the single ResultSet through the unified printer pipeline so
508
+ // `\bind` output looks identical to a simple-query result (and honours
509
+ // `\o FILE`, format selection, expanded mode, etc.).
510
+ const r = await renderResultSet(ctx, rs, pickOut(ctx));
511
+ lastRowCount = r.lastRowCount;
512
+ return true;
513
+ }
514
+ catch (err) {
515
+ // Capture the full ErrorResponse payload (severity / code / position /
516
+ // detail / hint / location) so the layered renderer can honour
517
+ // VERBOSITY and SHOW_CONTEXT exactly like the simple-query path.
518
+ const message = captureLastError(ctx.settings, err, sql);
519
+ writeQueryError(ctx, message);
520
+ hadError = true;
521
+ return false;
522
+ }
523
+ finally {
524
+ refreshConnectionVars(ctx);
525
+ refreshErrorVars(ctx.settings, hadError
526
+ ? { kind: 'error' }
527
+ : { kind: 'success', rowCount: lastRowCount });
528
+ if (ctx.settings.timing) {
529
+ ctx.stdout.write('\n' + formatDurationMs(Date.now() - started) + '\n');
530
+ }
531
+ }
532
+ }
533
+ const stats = await sendQuery(ctx, sql);
534
+ refreshConnectionVars(ctx);
535
+ return !stats.hadError;
536
+ };
537
+ /**
538
+ * Format an async NotificationResponse (LISTEN/NOTIFY payload) the way
539
+ * upstream's `PrintNotifications` in common.c does. Empty payloads omit the
540
+ * payload clause for backward-compat with pre-9.0 servers.
541
+ */
542
+ const formatNotification = (channel, payload, pid) => {
543
+ if (payload.length > 0) {
544
+ return (`Asynchronous notification "${channel}" with payload "${payload}" ` +
545
+ `received from server process with PID ${String(pid)}.\n`);
546
+ }
547
+ return (`Asynchronous notification "${channel}" ` +
548
+ `received from server process with PID ${String(pid)}.\n`);
549
+ };
550
+ /**
551
+ * Subscribe to NotificationResponse on the active connection, rendering each
552
+ * to the REPL output (mirrors upstream `PrintNotifications` writing to
553
+ * `pset.queryFout`). Returns the disposer the connection handed us, or
554
+ * `null` when no connection is bound.
555
+ */
556
+ const installNotificationHandler = (ctx, reader) => {
557
+ const db = ctx.settings.db;
558
+ if (!db)
559
+ return null;
560
+ return db.onNotification((channel, payload, pid) => {
561
+ // Route through the reader so the LineEditor (when raw-mode active)
562
+ // can clear / re-render its prompt block around the injected line.
563
+ // The stream-reader path treats interject as a plain stdout write.
564
+ reader.interject(formatNotification(channel, payload, pid));
565
+ });
566
+ };
567
+ /**
568
+ * Render a NoticeResponse field the same way libpq's `pqBuildErrorMessage3`
569
+ * does for the default `psql_notice_processor` (which is a thin
570
+ * `fputs(msg, stderr)`). Mirrors VERBOSITY / SHOW_CONTEXT semantics:
571
+ *
572
+ * - `terse` / `sqlstate`: just `<severity>: <message>` (`sqlstate` also
573
+ * prepends the SQLSTATE on the severity line).
574
+ * - `default`: severity line + LINE/^ pointer + DETAIL/HINT, and CONTEXT
575
+ * when SHOW_CONTEXT is `always` (NOTICE is not an error, so the default
576
+ * `errors` setting suppresses its CONTEXT — upstream's libpq path
577
+ * gates context on `severity_nonlocalized == "ERROR"|"FATAL"|"PANIC"`).
578
+ * - `verbose`: full SQLSTATE / DETAIL / HINT / CONTEXT / LOCATION layers.
579
+ *
580
+ * The trailing newline mirrors libpq, so callers can `stderr.write()` the
581
+ * returned string directly.
582
+ */
583
+ const formatNotice = (notice, verbosity, showContext) => {
584
+ const severity = notice.severity || 'NOTICE';
585
+ const message = notice.message || '';
586
+ const lines = [];
587
+ if (verbosity === 'verbose' || verbosity === 'sqlstate') {
588
+ const sqlstate = notice.code ?? 'XX000';
589
+ lines.push(`${severity}: ${sqlstate}: ${message}`);
590
+ }
591
+ else {
592
+ lines.push(`${severity}: ${message}`);
593
+ }
594
+ if (verbosity === 'terse' || verbosity === 'sqlstate') {
595
+ return lines.join('\n') + '\n';
596
+ }
597
+ if (notice.detail)
598
+ lines.push(`DETAIL: ${notice.detail}`);
599
+ if (notice.hint)
600
+ lines.push(`HINT: ${notice.hint}`);
601
+ // CONTEXT gating mirrors libpq's `pqBuildErrorMessage3`:
602
+ // - `verbose` always includes CONTEXT
603
+ // - `default` shows CONTEXT only when SHOW_CONTEXT is `always` for
604
+ // non-error severities (NOTICE / WARNING / INFO / LOG / DEBUG), or
605
+ // when SHOW_CONTEXT is `errors`/`always` for ERROR-level entries.
606
+ const isError = severity === 'ERROR' || severity === 'FATAL' || severity === 'PANIC';
607
+ const includeContext = verbosity === 'verbose' ||
608
+ showContext === 'always' ||
609
+ (showContext === 'errors' && isError);
610
+ if (includeContext && notice.where) {
611
+ lines.push(`CONTEXT: ${notice.where}`);
612
+ }
613
+ if (verbosity === 'verbose' && (notice.routine || notice.file)) {
614
+ const location = (notice.routine ?? '') +
615
+ (notice.file ? `, ${notice.file}:${notice.line ?? ''}` : '');
616
+ lines.push(`LOCATION: ${location}`);
617
+ }
618
+ return lines.join('\n') + '\n';
619
+ };
620
+ /**
621
+ * Subscribe to NoticeResponse on the active connection, rendering each to
622
+ * stderr the way libpq's default `psql_notice_processor` does. Returns the
623
+ * disposer the connection handed us, or `null` when no connection is bound.
624
+ *
625
+ * NOTICEs fire synchronously as the wire layer receives them, so an
626
+ * inline `RAISE NOTICE` inside a `\;`-chained batch lands BEFORE the
627
+ * tuples-producing portion of the batch is rendered — matching upstream
628
+ * psql output.
629
+ */
630
+ const installNoticeHandler = (ctx, reader) => {
631
+ const db = ctx.settings.db;
632
+ if (!db)
633
+ return null;
634
+ return db.onNotice((notice) => {
635
+ // Skip in pipeline mode: cmd_pipeline.ts's `\endpipeline` / `\getresults`
636
+ // re-renders each result's `notices[]` array via the per-result drain so
637
+ // the NOTICE lands AT the result boundary, not before. Emitting here too
638
+ // would duplicate every notice — once when the wire layer parses it,
639
+ // once when the drain walks `rs.notices`.
640
+ if (ctx.settings.sendMode === 'extended-pipeline')
641
+ return;
642
+ const text = formatNotice(notice, ctx.settings.verbosity, ctx.settings.showContext);
643
+ // Notices go to stderr (libpq default). The LineEditor's prompt-redraw
644
+ // logic uses `interjectErr` to flush the message without disturbing the
645
+ // active prompt block — fall back to a raw stderr write when the reader
646
+ // doesn't expose that hook (stream / notty path).
647
+ const interjectErr = reader.interjectErr;
648
+ if (interjectErr) {
649
+ interjectErr.call(reader, text);
650
+ }
651
+ else {
652
+ ctx.stderr.write(text);
653
+ }
654
+ });
655
+ };
656
+ const installSigint = (ctx, state) => {
657
+ const handler = () => {
658
+ if (state.inQuery && ctx.settings.db) {
659
+ // Best-effort cancel; ignore errors.
660
+ void ctx.settings.db.cancel().catch(() => undefined);
661
+ return;
662
+ }
663
+ state.resetBuf();
664
+ };
665
+ process.on('SIGINT', handler);
666
+ return () => process.off('SIGINT', handler);
667
+ };
668
+ // ---------------------------------------------------------------------------
669
+ // The main entry point.
670
+ // ---------------------------------------------------------------------------
671
+ export const runMainLoop = async (ctx) => {
672
+ // queryBuf is declared up front so the line reader's tab completer can
673
+ // close over it via `getQueryBuf` and see the in-flight multi-line buffer
674
+ // on every Tab. The mainloop reassigns this variable across statements
675
+ // (resetBuf, after dispatch); the closure stays valid because it captures
676
+ // the binding, not a snapshot.
677
+ let queryBuf = '';
678
+ const reader = await makeLineReader(ctx, { getQueryBuf: () => queryBuf });
679
+ let scanState = initialScanState();
680
+ let stmtLineNumber = 1;
681
+ let successResult = EXIT_SUCCESS;
682
+ let exitRequested = false;
683
+ // Parallel stack of saved scanner states keyed to cond.depth(). Upstream
684
+ // `save_query_text_state` captures both `query_buf->len` AND the scanner
685
+ // state at the `\if` (and at each branch transition); `discard_query_text`
686
+ // restores both. The cond stack frame already carries `savedQueryBufLen`;
687
+ // we keep the matching scanState here so the cond commands stay decoupled
688
+ // from the scanner type. The two arrays are pushed / popped / re-anchored
689
+ // in lock-step with cond.push / cond.pop / cond.setSavedQueryBufLen.
690
+ const condScanStateStack = [];
691
+ const resetBuf = () => {
692
+ queryBuf = '';
693
+ scanState = initialScanState();
694
+ stmtLineNumber = 1;
695
+ };
696
+ // Detect mid-script connection loss and, on first detection, emit the
697
+ // upstream "connection to server was lost" diagnostic + flag EXIT_BADCONN.
698
+ // Subsequent statements would all rethrow against the closed connection;
699
+ // we halt the loop instead so we don't spam ERROR lines for every one.
700
+ const checkConnectionLost = () => {
701
+ if (ctx.settings.db?.isClosed()) {
702
+ ctx.stderr.write('psql: error: connection to server was lost\n');
703
+ successResult = EXIT_BADCONN;
704
+ exitRequested = true;
705
+ return true;
706
+ }
707
+ return false;
708
+ };
709
+ const sigintState = { inQuery: false, resetBuf };
710
+ const removeSigint = installSigint(ctx, sigintState);
711
+ // Seed the ENCODING psql var from the server's client_encoding the first
712
+ // time we enter the REPL — subsequent `SET client_encoding = ...` lands
713
+ // back through `refreshConnectionVars` after each query.
714
+ refreshConnectionVars(ctx);
715
+ // Subscribe to async NotificationResponse (LISTEN/NOTIFY) so a `NOTIFY foo`
716
+ // surfaces upstream's `Asynchronous notification "foo" ...` line. The
717
+ // disposer is run in the finally block at exit so we don't leak listeners.
718
+ const removeNotificationHandler = installNotificationHandler(ctx, reader);
719
+ // Subscribe to NoticeResponse so `RAISE NOTICE` / NOTICE DETAIL / `drop
720
+ // cascades` style server notices surface on stderr — matching libpq's
721
+ // default `psql_notice_processor`. Notices arrive synchronously during
722
+ // query execution, so inline `\;`-chain notices land at the right spot.
723
+ const removeNoticeHandler = installNoticeHandler(ctx, reader);
724
+ // Compute the prompt string for the current state. For notty input we emit
725
+ // the empty string so the stream reader doesn't see prompt bytes interleaved
726
+ // with stdout. For TTY input the LineEditor renders the prompt itself.
727
+ const computePrompt = (status) => {
728
+ if (ctx.settings.notty)
729
+ return '';
730
+ const name = queryBuf.trim().length === 0 || status === 'ready'
731
+ ? 'PROMPT1'
732
+ : status === 'copy'
733
+ ? 'PROMPT3'
734
+ : 'PROMPT2';
735
+ const promptCtx = buildPromptContext(ctx, status, stmtLineNumber);
736
+ return renderPromptByName(name, promptCtx);
737
+ };
738
+ // Resolves a psql variable for `:NAME` substitution in SQL bodies.
739
+ // Backslash command bodies do their own expansion via `scanSlashArgs` in
740
+ // `makeBackslashContext`, so this lookup only fires inside scanSql.
741
+ const sqlVarLookup = (name) => ctx.settings.vars.get(name);
742
+ // Resolves a backslash-command's argument-mode hint so scanSql can
743
+ // consume the rest of the line correctly for whole-line / filepipe
744
+ // commands. Upstream's psqlscanslash.l flips between `<xslasharg>` and
745
+ // `<xslashwholeline>` based on the same hint — without it, the SQL
746
+ // scanner would terminate a `\!` or `\sf` arg at the next `\` (e.g.
747
+ // `\! whole_line \endif` would split into `\!` + `\endif`).
748
+ const slashCmdMode = (cmdName) => {
749
+ const spec = ctx.registry.lookup(cmdName);
750
+ if (!spec)
751
+ return undefined;
752
+ if (spec.argMode === 'whole-line')
753
+ return 'whole-line';
754
+ // Backslash registry currently only distinguishes whole-line vs the
755
+ // default `lex` mode. Filepipe is signalled per-call via
756
+ // `nextArg('filepipe')` inside cmd implementations rather than the
757
+ // spec, so we infer it from a small allow-list of commands that
758
+ // upstream declares as `OT_FILEPIPE` (`\w` and `\o`). Without this,
759
+ // `\w |/no/such/file \else` would split off `\else` as a separate
760
+ // command instead of capturing it as the file's whole-line arg.
761
+ if (cmdName === 'w' || cmdName === 'o')
762
+ return 'filepipe';
763
+ return undefined;
764
+ };
765
+ /**
766
+ * Strip block / line comments cheaply before scanning so a COPY-shaped
767
+ * comment doesn't trigger pre-buffering or sink wiring.
768
+ */
769
+ const stripSqlComments = (sql) => sql.replace(/\/\*[\s\S]*?\*\//g, '').replace(/--[^\n]*/g, '');
770
+ /**
771
+ * Count the number of `COPY ... FROM STDIN` segments in `sql`. Upstream
772
+ * `handleCopyIn` in copy.c is invoked for each one that hits the wire as
773
+ * a `\;`-chained simple-query batch; the mainloop must pre-buffer the
774
+ * `\.`-terminated data block per occurrence and hand them to the wire
775
+ * layer before dispatch so CopyInResponse is satisfied without a
776
+ * blocking callback into the REPL.
777
+ *
778
+ * The regex tolerates the optional column list (`COPY t (a, b)`) and the
779
+ * format clause (`COPY t FROM STDIN WITH (...)` / `... CSV ...`). False
780
+ * positives inside string literals / comments are possible but extremely
781
+ * rare in scripted workloads — and a false positive only over-consumes
782
+ * lines from the input, which is recoverable. The conservative regex
783
+ * here matches upstream `psql`'s scanner heuristics closely enough for
784
+ * the conformance suite (`psql.sql` lines around 1467-1476).
785
+ */
786
+ const countCopyFromStdin = (sql) => {
787
+ const stripped = stripSqlComments(sql);
788
+ const re = /\bCOPY\b[\s\S]*?\bFROM\s+STDIN\b/giu;
789
+ let n = 0;
790
+ while (re.exec(stripped) !== null)
791
+ n += 1;
792
+ return n;
793
+ };
794
+ /**
795
+ * `true` when `sql` contains at least one `COPY ... TO STDOUT` segment.
796
+ * The wire layer routes mid-batch CopyData into our `copyOutMidBatchSink`
797
+ * when it's set; we install one for the duration of a batch that mentions
798
+ * `TO STDOUT` so the bytes land on the active output stream.
799
+ */
800
+ const hasCopyToStdout = (sql) => /\bCOPY\b[\s\S]*?\bTO\s+STDOUT\b/iu.test(stripSqlComments(sql));
801
+ /**
802
+ * Read one COPY-FROM-STDIN data block: consume lines from the reader
803
+ * until a bare `\.` arrives (or EOF / null). Returns the concatenated
804
+ * payload as a Buffer with trailing newlines preserved (the wire side
805
+ * sends the bytes verbatim; the server treats them as the COPY input).
806
+ * The `\.` terminator itself is NOT included.
807
+ */
808
+ const readCopyDataBlock = async () => {
809
+ const lines = [];
810
+ for (;;) {
811
+ const line = await reader.readLine('');
812
+ if (line === null)
813
+ break;
814
+ if (line.replace(/\s+$/, '') === '\\.')
815
+ break;
816
+ lines.push(line);
817
+ // Upstream `handleCopyIn` in copy.c reads COPY data lines straight
818
+ // off `copystream` with `fgets` and ships them to the server via
819
+ // `PQputCopyData` — there is no `--echo-all` branch on this path.
820
+ // Suppressing the echo here keeps the COPY-FROM-STDIN data out of
821
+ // the echo stream, matching vanilla: only the surrounding SQL
822
+ // statement (`COPY ... FROM STDIN`) lands in stdout, not its
823
+ // payload.
824
+ }
825
+ // Each line plus a trailing newline — matches the byte stream COPY
826
+ // expects on its input side.
827
+ const text = lines.length === 0 ? '' : lines.join('\n') + '\n';
828
+ return Buffer.from(text, 'utf8');
829
+ };
830
+ /**
831
+ * Process the assembled queryBuf+line through scanSql, dispatching the
832
+ * boundaries it finds. Returns when we hit `incomplete`/`eof` and need
833
+ * the next input line.
834
+ */
835
+ const processChunk = async (chunk) => {
836
+ let working = chunk;
837
+ while (working.length > 0) {
838
+ // SINGLELINE (-S / `\set SINGLELINE on`): the scanner treats a top-level
839
+ // newline as an implicit `;`, so each input line dispatches on its own.
840
+ // Read the flag fresh each pass — `\set SINGLELINE` can flip it mid-REPL.
841
+ const result = scanSql(working, scanState, sqlVarLookup, slashCmdMode, {
842
+ singleline: ctx.settings.singleline,
843
+ });
844
+ scanState = result.nextState;
845
+ if (result.kind === 'semicolon') {
846
+ // Use the substituted `result.sql` so `:NAME` references already
847
+ // resolved at scan time make it into the executed SQL.
848
+ const sqlText = queryBuf + result.sql;
849
+ queryBuf = '';
850
+ working = working.slice(result.consumed);
851
+ scanState = initialScanState();
852
+ stmtLineNumber = 1;
853
+ if (!ctx.cond.isActive()) {
854
+ // Suppressed: discard, no execution, no error.
855
+ continue;
856
+ }
857
+ // COPY ... FROM STDIN appearing as a segment of a `\;`-chained
858
+ // batch needs its CopyInResponse satisfied with the COPY data
859
+ // block(s) that follow on stdin. We pre-buffer one block per
860
+ // detected `FROM STDIN` occurrence and hand the bytes to the wire
861
+ // layer before dispatch. Mirrors upstream `handleCopyIn` in
862
+ // copy.c — except we pump the bytes up-front instead of via a
863
+ // callback into the REPL when CopyInResponse arrives.
864
+ const copyCount = ctx.settings.db ? countCopyFromStdin(sqlText) : 0;
865
+ const wantsCopyOut = ctx.settings.db !== undefined && hasCopyToStdout(sqlText);
866
+ if (copyCount > 0 && ctx.settings.db) {
867
+ // The Connection type doesn't expose `queueCopyInData` (kept
868
+ // off the frozen interface), but the concrete PgConnection
869
+ // does. We duck-type the method to avoid coupling here.
870
+ const conn = ctx.settings.db;
871
+ if (typeof conn.queueCopyInData === 'function') {
872
+ // Drop any leftover buffers from a previous (failed) batch so
873
+ // we don't accidentally re-use stale data.
874
+ conn.clearCopyInDataQueue?.();
875
+ for (let i = 0; i < copyCount; i += 1) {
876
+ const block = await readCopyDataBlock();
877
+ conn.queueCopyInData(block);
878
+ }
879
+ }
880
+ }
881
+ if (wantsCopyOut && ctx.settings.db) {
882
+ // Wire a sink so the wire layer can forward mid-batch CopyData
883
+ // bytes verbatim (matching `handleCopyOut`). Routes to the
884
+ // active query output (`\o FILE` stashed stream when set,
885
+ // otherwise `ctx.stdout`) — upstream `handleCopyOut` sinks the
886
+ // bytes into `pset.queryFout`, which is whatever `\o` last
887
+ // pointed at. Bytes already include trailing newlines on each
888
+ // row, so we pass them through unchanged.
889
+ const conn = ctx.settings.db;
890
+ const out = pickOut(ctx);
891
+ conn.copyOutMidBatchSink = (chunk) => {
892
+ out.write(chunk);
893
+ };
894
+ }
895
+ sigintState.inQuery = true;
896
+ const ok = await dispatchSendQuery(ctx, sqlText);
897
+ sigintState.inQuery = false;
898
+ // Always clear any leftover queued blocks once the batch settles
899
+ // (success or failure) so the next dispatch starts fresh.
900
+ if (copyCount > 0 && ctx.settings.db) {
901
+ const conn = ctx.settings.db;
902
+ conn.clearCopyInDataQueue?.();
903
+ }
904
+ if (wantsCopyOut && ctx.settings.db) {
905
+ const conn = ctx.settings.db;
906
+ conn.copyOutMidBatchSink = null;
907
+ }
908
+ // After any SQL statement, the server may have closed the connection
909
+ // (e.g. pg_terminate_backend on our own pid). Surface that once and
910
+ // halt — psql cannot recover from a lost connection mid-script.
911
+ if (checkConnectionLost())
912
+ return;
913
+ if (!ok && ctx.settings.onErrorStop) {
914
+ successResult = EXIT_USER;
915
+ exitRequested = true;
916
+ return;
917
+ }
918
+ continue;
919
+ }
920
+ if (result.kind === 'backslash') {
921
+ // Fold buffered SQL accumulated before the backslash into queryBuf.
922
+ // `result.sql` carries the (possibly empty) text that preceded the
923
+ // backslash in this scan pass — empty when the backslash was at the
924
+ // top of the buffer, non-empty for shapes like
925
+ // `SELECT 1 \watch c=3` or `SELECT error\gdesc`. Buffer-consuming
926
+ // commands (\g, \gx, \gset, \gexec, \gdesc, \crosstabview, \watch,
927
+ // \bind) will read this through `BackslashContext.queryBuf` and
928
+ // return `reset-buf` to clear it; commands that don't care leave it
929
+ // intact for the next dispatch.
930
+ //
931
+ // Track whether this scan started cleanly: empty queryBuf and the
932
+ // backslash was at the head of `working`. In that case the slash
933
+ // command is the ENTIRE source line — the trailing `\n` left in
934
+ // `working` after the slice is just the line terminator, not an
935
+ // inter-line continuation separator. We need to drop it after
936
+ // dispatch so the next chunk's scanSql doesn't return an `eof`
937
+ // with `sql: '\n'` and accumulate a stray leading newline into
938
+ // the NEXT statement's queryBuf. Mirrors upstream `MainLoop()`'s
939
+ // `query_buf->len == added_nl_pos` strip (mainloop.c lines
940
+ // 480-484): when a line contains only a backslash command and
941
+ // the scanner added nothing to the buffer, the appended `\n` is
942
+ // taken back off so the buffer's `LINE N:` counting matches the
943
+ // user's mental model.
944
+ const slashOnlyLine = result.sql.length === 0 && queryBuf.length === 0;
945
+ queryBuf += result.sql;
946
+ working = working.slice(result.consumed);
947
+ const cmdName = result.cmd;
948
+ // Cond commands run unconditionally; everything else respects
949
+ // cond.isActive().
950
+ if (COND_COMMAND_NAMES.has(cmdName)) {
951
+ // Snapshot scanState BEFORE the cond command runs — `\if` will
952
+ // want the snapshot taken at its dispatch point (matches upstream
953
+ // `save_query_text_state` capturing the scanner's input lexer
954
+ // state). Cheap shallow copy: ScanState fields are primitives or
955
+ // small immutable objects.
956
+ const scanStateBefore = { ...scanState };
957
+ const r = await dispatchCondCommand(ctx, cmdName, result.rest, queryBuf);
958
+ if (r.handled && r.result?.status === 'exit') {
959
+ exitRequested = true;
960
+ return;
961
+ }
962
+ // Cond commands implement upstream's `discard_query_text` via
963
+ // the `truncateBufTo` field: when `\elif`/`\else`/`\endif`
964
+ // leaves an INACTIVE branch, the SQL text the skipped branch
965
+ // accumulated is rolled back to the snapshot captured at the
966
+ // matching `\if`/`\elif`/`\else`. We also restore the scanner
967
+ // state captured at that checkpoint — without this a `(` inside
968
+ // the skipped branch would leave `parenDepth > 0` and the next
969
+ // `;` wouldn't trigger a dispatch boundary. Stmt line number
970
+ // stays as-is: the surrounding statement is still in flight.
971
+ if (r.handled && r.result?.truncateBufTo !== undefined) {
972
+ const target = r.result.truncateBufTo;
973
+ if (target < queryBuf.length) {
974
+ queryBuf = queryBuf.slice(0, target);
975
+ }
976
+ const savedScan = condScanStateStack[condScanStateStack.length - 1];
977
+ if (savedScan !== undefined) {
978
+ scanState = { ...savedScan };
979
+ }
980
+ }
981
+ // Sync the parallel scan-state stack with whatever cond.push /
982
+ // cond.pop / cond.setSavedQueryBufLen the command performed.
983
+ // `\if` → push the scan state captured at dispatch.
984
+ // `\elif`/`\else` → replace the top entry with the scan state
985
+ // that prevails AFTER any truncate-on-leaving-
986
+ // inactive restoration (so the new branch
987
+ // starts from a clean checkpoint).
988
+ // `\endif` → pop the top entry.
989
+ if (cmdName === 'if') {
990
+ condScanStateStack.push(scanStateBefore);
991
+ }
992
+ else if (cmdName === 'elif' || cmdName === 'else') {
993
+ // Errors leave the top untouched (cond.setState not called on
994
+ // the no-matching/double-else paths). Only re-anchor when the
995
+ // command succeeded — `status: 'ok'` covers both the active
996
+ // and truncated paths.
997
+ if (condScanStateStack.length > 0 && r.result?.status === 'ok') {
998
+ condScanStateStack[condScanStateStack.length - 1] = {
999
+ ...scanState,
1000
+ };
1001
+ }
1002
+ }
1003
+ else if (cmdName === 'endif') {
1004
+ // Pop only on success — `\endif` with no matching `\if`
1005
+ // returns an error and doesn't actually pop the cond frame.
1006
+ if (r.result?.status === 'ok') {
1007
+ condScanStateStack.pop();
1008
+ }
1009
+ }
1010
+ // Note: we intentionally do NOT update `lastWasError` for cond
1011
+ // errors. Upstream psql exits 0 from a script whose only failure
1012
+ // was `\endif: no matching \if` (or any other cond diagnostic) —
1013
+ // these are printed and the loop continues, but they don't taint
1014
+ // the terminal `lastWasError → EXIT_USER` escalation. Only
1015
+ // ON_ERROR_STOP can escalate cond failures.
1016
+ if (r.handled &&
1017
+ r.result?.status === 'error' &&
1018
+ ctx.settings.onErrorStop) {
1019
+ successResult = EXIT_USER;
1020
+ exitRequested = true;
1021
+ return;
1022
+ }
1023
+ continue;
1024
+ }
1025
+ // Upstream `HandleSlashCmds` looks up the command name BEFORE
1026
+ // consulting the conditional stack: an unknown name emits
1027
+ // `invalid command \X` to stderr regardless of branch state.
1028
+ // Without this, e.g. `\if false \n \lo \n \endif` silently passes
1029
+ // `\lo` through, but vanilla surfaces the diagnostic. Looking the
1030
+ // command up here also lets us short-circuit the inactive branch
1031
+ // without losing the unknown-command error.
1032
+ if (!ctx.cond.isActive()) {
1033
+ if (ctx.registry.lookup(cmdName) === undefined) {
1034
+ // Bare wording (no `psql: ERROR:` prefix) — vanilla's
1035
+ // `psql_log_pre_callback` short-circuits when input has no
1036
+ // line-number context (stdin pipe path), so the diagnostic
1037
+ // is emitted via `pg_log_error_internal` as a raw string.
1038
+ // Matches the expected output's `invalid command \lo` shape
1039
+ // at psql.out:4698. Other writeError() call sites keep the
1040
+ // prefix because they're errors WE emit from a known
1041
+ // dispatch path, not from the unknown-command lookup miss.
1042
+ ctx.stderr.write(`invalid command \\${cmdName}\n`);
1043
+ // Errors emitted in an inactive branch must NOT taint
1044
+ // `lastWasError` (vanilla exits 0 from `\if false; \lo; \endif`)
1045
+ // and must NOT trigger ON_ERROR_STOP. The diagnostic stands
1046
+ // alone; the loop continues to the next chunk.
1047
+ }
1048
+ // Skip non-cond commands inside an inactive branch (run or not,
1049
+ // registered or not).
1050
+ continue;
1051
+ }
1052
+ const bres = await dispatchRegisteredCommand(ctx, cmdName, result.rest, queryBuf);
1053
+ if (bres?.status === 'exit') {
1054
+ exitRequested = true;
1055
+ return;
1056
+ }
1057
+ if (bres?.status === 'reset-buf') {
1058
+ queryBuf = bres.newBuf ?? '';
1059
+ scanState = initialScanState();
1060
+ stmtLineNumber = 1;
1061
+ // The SQL scanner intentionally stops the backslash boundary on
1062
+ // (not past) the trailing line terminator so that an inter-line
1063
+ // `\n` separating a slash command from continuing SQL on the
1064
+ // next line survives in `working`. That's the right call when
1065
+ // the slash command leaves `queryBuf` intact — the `\n` keeps
1066
+ // line breaks in the assembled multi-line query.
1067
+ //
1068
+ // For `reset-buf`, however, the buffer is being intentionally
1069
+ // dropped: the slash command (`\g`, `\gset`, `\gdesc`, `\gexec`,
1070
+ // `\crosstabview`, `\watch`, `\bind`, `\parse`, …) has just
1071
+ // consumed and dispatched whatever was buffered. A residual
1072
+ // `\n` at the head of `working` is then leftover line-terminator
1073
+ // bytes from the slash-command line itself — NOT a continuation
1074
+ // separator. If we let it survive, the next scanSql pass returns
1075
+ // an `eof` with `sql: '\n'`, the loop's
1076
+ // `queryBuf += result.sql` line folds it into the NEXT
1077
+ // statement's buffer, and commands that store the buffer
1078
+ // verbatim (notably `\parse`, which uses the buffer text as the
1079
+ // prepared-statement source) emit a stray leading 0x0a byte.
1080
+ //
1081
+ // Strip the line terminator here so the next pass starts cleanly.
1082
+ // This matches upstream `psql_scan_slash_command_end()`'s eat-
1083
+ // through-newline behaviour for the buffer-reset case — without
1084
+ // changing the scanner's semantics for the inline-slash + multi-
1085
+ // line shape that depends on the `\n` surviving.
1086
+ if (working.startsWith('\r\n')) {
1087
+ working = working.slice(2);
1088
+ }
1089
+ else if (working.startsWith('\n') || working.startsWith('\r')) {
1090
+ working = working.slice(1);
1091
+ }
1092
+ }
1093
+ // For status='ok' (the buffer was NOT consumed by the slash command),
1094
+ // also drop the `\n` left in `working` when the slash command was
1095
+ // the sole content of this source line. Upstream's
1096
+ // `query_buf->len == added_nl_pos` strip (mainloop.c lines 480-484)
1097
+ // covers the same shape: a line whose only token is a slash command
1098
+ // doesn't contribute a `\n` to `query_buf`. Without this, e.g.
1099
+ // \set ECHO errors
1100
+ // SELECT * FROM bad;
1101
+ // would assemble the SELECT's queryBuf as `\n` + `SELECT...` —
1102
+ // shifting the server's `LINE N` count by one and contaminating
1103
+ // the `STATEMENT: ...` echo emitted on error.
1104
+ if (bres?.status !== 'reset-buf' && slashOnlyLine) {
1105
+ if (working.startsWith('\r\n')) {
1106
+ working = working.slice(2);
1107
+ }
1108
+ else if (working.startsWith('\n') || working.startsWith('\r')) {
1109
+ working = working.slice(1);
1110
+ }
1111
+ }
1112
+ // Upstream `mainloop.c`: on PSQL_CMD_ERROR, the query buffer is
1113
+ // reset and the scanner state is dropped. Mirrors `resetPQExpBuffer`
1114
+ // + `psql_scan_reset`. Without this, a buffer-consuming command
1115
+ // that fails (e.g. `SELECT 1 \watch 1 1` rejecting duplicate
1116
+ // positional intervals) would leave `SELECT 1 ` in the buffer for
1117
+ // the next prompt — and in notty mode the tail dispatch would
1118
+ // execute it, masking the failure exit code.
1119
+ //
1120
+ // Upstream `HandleSlashCmds` additionally silently discards the
1121
+ // remainder of the current line via `psql_scan_slash_option(scan_state,
1122
+ // OT_WHOLE_LINE, …)` when a backslash command returns PSQL_CMD_ERROR.
1123
+ // Mirror that here by dropping `working` up to and including the next
1124
+ // newline. Without this, `\bind_named NAME 1 2 \gset pref02_ \echo X`
1125
+ // would still execute `\echo X` after the pipeline-mode `\gset`
1126
+ // rejection — vanilla suppresses it.
1127
+ if (bres?.status === 'error') {
1128
+ queryBuf = '';
1129
+ scanState = initialScanState();
1130
+ stmtLineNumber = 1;
1131
+ // Discard any trailing content on the SAME physical line — but NOT
1132
+ // the rest of the script. The scanner consumes a slash command's
1133
+ // args but typically leaves the line terminator (`\n`) at the head
1134
+ // of `working`. If `working` starts with `\n` / `\r\n`, the failed
1135
+ // command was already at end-of-line — just drop the terminator
1136
+ // and let the next line dispatch normally. If `working` has
1137
+ // non-newline chars before the next `\n`, drop up to and including
1138
+ // that `\n` (mirrors upstream `HandleSlashCmds`' `OT_WHOLE_LINE`
1139
+ // discard). Without this branch a stack of `\gdesc\n\gdesc\n…`
1140
+ // lines collapses to a single dispatched `\gdesc` because the
1141
+ // first discard ate the second line.
1142
+ if (working.startsWith('\r\n')) {
1143
+ working = working.slice(2);
1144
+ }
1145
+ else if (working.startsWith('\n') || working.startsWith('\r')) {
1146
+ working = working.slice(1);
1147
+ }
1148
+ else {
1149
+ const nlIdx = working.indexOf('\n');
1150
+ working = nlIdx === -1 ? '' : working.slice(nlIdx + 1);
1151
+ }
1152
+ }
1153
+ // Backslash commands like \connect can also tear down the connection.
1154
+ if (checkConnectionLost())
1155
+ return;
1156
+ if (bres?.status === 'error' && ctx.settings.onErrorStop) {
1157
+ successResult = EXIT_USER;
1158
+ exitRequested = true;
1159
+ return;
1160
+ }
1161
+ continue;
1162
+ }
1163
+ // incomplete or eof — keep accumulating. Use the substituted
1164
+ // `result.sql` so `:NAME` tokens that were fully consumed in this
1165
+ // chunk land in the buffer in expanded form. (A `:NAME` that
1166
+ // straddles two chunks falls back to the literal — a corner case
1167
+ // upstream also handles only when the variable name fits inside the
1168
+ // current buffer; the line-reader feeds whole lines so this is
1169
+ // effectively unreachable in interactive use.)
1170
+ queryBuf += result.sql;
1171
+ working = '';
1172
+ return;
1173
+ }
1174
+ };
1175
+ // -----------------------------------------------------------------------
1176
+ // Read loop. Each iteration:
1177
+ // 1. Drain any pending input enqueued by `\i FILE` (WP-15) — those lines
1178
+ // take precedence over fresh stdin so the include behaves as a
1179
+ // prepend on the input source.
1180
+ // 2. Otherwise, ask the reader for the next line. For notty input this
1181
+ // is a `readline` stream; for TTY input it's the LineEditor with
1182
+ // emacs keybindings + persistent history (WP-24 + WP-25).
1183
+ // 3. Each submitted line is recorded in history.
1184
+ // -----------------------------------------------------------------------
1185
+ try {
1186
+ while (!exitRequested) {
1187
+ // Prompt status drives `%R`. When the query buffer holds an incomplete
1188
+ // statement but the scanner isn't inside any special context (paren,
1189
+ // comment, quoted-string), it still reports `'ready'`; map that to
1190
+ // `'continue'` so PROMPT2 renders `-` instead of `=`. A whitespace-only
1191
+ // residue (e.g. a trailing `\n` left over after a `;` boundary) counts
1192
+ // as empty so the next prompt is PROMPT1 not PROMPT2.
1193
+ const status = queryBuf.trim().length === 0
1194
+ ? 'ready'
1195
+ : scanState.promptStatus === 'ready'
1196
+ ? 'continue'
1197
+ : scanState.promptStatus;
1198
+ const prompt = computePrompt(status);
1199
+ // 1. Pending input from \i: process as a single chunk and loop again.
1200
+ const queued = consumeQueuedInput();
1201
+ if (queued !== null) {
1202
+ await processChunk(queued.endsWith('\n') ? queued : queued + '\n');
1203
+ continue;
1204
+ }
1205
+ // 2. Read the next line from stdin / line editor.
1206
+ let line;
1207
+ try {
1208
+ line = await reader.readLine(prompt);
1209
+ }
1210
+ catch (err) {
1211
+ // SignalError (Ctrl-C on an interactive line) — drop the partial
1212
+ // buffer and re-prompt, matching upstream psql.
1213
+ if (err.name === 'SignalError') {
1214
+ resetBuf();
1215
+ continue;
1216
+ }
1217
+ throw err;
1218
+ }
1219
+ if (line === null)
1220
+ break; // EOF
1221
+ // Upstream `mainloop.c` MainLoop():
1222
+ //
1223
+ // if (line[0] == '\0' && !psql_scan_in_quote(scan_state))
1224
+ // {
1225
+ // free(line);
1226
+ // continue;
1227
+ // }
1228
+ //
1229
+ // I.e., bare-empty lines are skipped entirely (no echo, no scanner
1230
+ // pass) UNLESS the scanner is mid-quote (single-, double-, dollar-,
1231
+ // or block-comment continuation). Inside a quote we keep the empty
1232
+ // line so it lands in the assembled query buffer (e.g. a quoted
1233
+ // identifier `"ab\n\nc"` spans multiple input lines including blanks),
1234
+ // and `--echo-all` surfaces it so the echo stream tracks the source
1235
+ // verbatim.
1236
+ //
1237
+ // `psql_scan_in_quote` returns true for all start_states except
1238
+ // INITIAL and xqs — we approximate with the scanner-state fields
1239
+ // that track each quoted construct. `parenDepth` is intentionally
1240
+ // omitted (upstream doesn't count it as in-quote).
1241
+ const scanInQuote = scanState.inBlockComment > 0 ||
1242
+ scanState.inSingleQuote ||
1243
+ scanState.inDoubleQuote ||
1244
+ scanState.dollarTag !== null;
1245
+ if (line.length === 0 && !scanInQuote) {
1246
+ continue;
1247
+ }
1248
+ // 2'. ECHO=all — upstream `--echo-all` / `\set ECHO all` echoes every
1249
+ // input line to stdout *before* it's processed. Blank lines outside a
1250
+ // quote already short-circuited above, so a blank reaching here means
1251
+ // the scanner is mid-quote and the line is part of the assembled
1252
+ // statement. ECHO=queries echoes only completed queries — handled
1253
+ // separately by the exec path.
1254
+ if (ctx.settings.echo === 'all') {
1255
+ ctx.stdout.write(line + '\n');
1256
+ }
1257
+ // 2a. `exit`/`quit` keyword handling.
1258
+ //
1259
+ // - Empty buffer → exit the REPL.
1260
+ // - Non-empty buf → print "Use \\q to quit." hint and continue
1261
+ // (buffer is preserved so the user can resume editing).
1262
+ //
1263
+ // The buffer may carry whitespace from a prior line's tail, so we
1264
+ // trim before checking.
1265
+ // Bare `quit`/`exit` (and `help` below) are an INTERACTIVE-only
1266
+ // convenience — upstream gates them on `cur_cmd_interactive`. In a
1267
+ // non-interactive script (`printf 'quit;\n' | psql`) they must fall
1268
+ // through and be sent to the server as SQL (syntax error → exit 3),
1269
+ // not silently exit 0.
1270
+ if (!ctx.settings.notty && isQuitKeyword(line)) {
1271
+ if (queryBuf.trim().length === 0) {
1272
+ reader.pushHistory(line);
1273
+ exitRequested = true;
1274
+ break;
1275
+ }
1276
+ ctx.stdout.write('Use \\q to quit.\n');
1277
+ continue;
1278
+ }
1279
+ // 2b. `help` keyword handling, same shape.
1280
+ //
1281
+ // - Empty buffer → print the help text, continue.
1282
+ // - Non-empty buf → print "Use \\? for help." hint, continue.
1283
+ if (!ctx.settings.notty && isHelpKeyword(line)) {
1284
+ if (queryBuf.trim().length === 0) {
1285
+ reader.pushHistory(line);
1286
+ ctx.stdout.write(HELP_TEXT);
1287
+ }
1288
+ else {
1289
+ ctx.stdout.write('Use \\? for help.\n');
1290
+ }
1291
+ continue;
1292
+ }
1293
+ // 3. Push to history once we have a complete submitted line (only
1294
+ // when there's something non-blank to record).
1295
+ reader.pushHistory(line);
1296
+ await processChunk(line + '\n');
1297
+ }
1298
+ // EOF: if there's a residual non-empty buffer in non-interactive mode,
1299
+ // dispatch it (mirroring upstream's tail-of-MainLoop block). For
1300
+ // interactive mode upstream skips this; we match the behaviour. We also
1301
+ // require the buffer to contain non-whitespace SQL — trailing blanks
1302
+ // between statement boundaries and EOF should not produce an empty
1303
+ // execSimple call.
1304
+ if (!exitRequested &&
1305
+ queryBuf.trim().length > 0 &&
1306
+ ctx.settings.notty &&
1307
+ successResult === EXIT_SUCCESS) {
1308
+ if (ctx.cond.isActive()) {
1309
+ sigintState.inQuery = true;
1310
+ const ok = await dispatchSendQuery(ctx, queryBuf);
1311
+ sigintState.inQuery = false;
1312
+ if (ctx.settings.db?.isClosed()) {
1313
+ ctx.stderr.write('psql: error: connection to server was lost\n');
1314
+ successResult = EXIT_BADCONN;
1315
+ }
1316
+ else if (!ok && ctx.settings.onErrorStop) {
1317
+ successResult = EXIT_USER;
1318
+ }
1319
+ }
1320
+ queryBuf = '';
1321
+ }
1322
+ // Warn about unbalanced \if blocks (psql's tail-of-MainLoop check).
1323
+ if (!exitRequested && ctx.cond.depth() > 0) {
1324
+ writeError(ctx, 'reached EOF without finding closing \\endif(s)');
1325
+ if (ctx.settings.onErrorStop && ctx.settings.notty) {
1326
+ successResult = EXIT_USER;
1327
+ }
1328
+ }
1329
+ // NOTE: we deliberately do NOT escalate to EXIT_USER just because the last
1330
+ // statement errored. Real psql exits 0 from piped stdin / `-f` even when a
1331
+ // statement failed, UNLESS ON_ERROR_STOP is set — and that case is already
1332
+ // handled by the per-statement `successResult = EXIT_USER` paths above
1333
+ // (verified on psql 18.4: `printf 'SELECT 1;\nSELECT 1/0;\n' | psql` → 0).
1334
+ }
1335
+ finally {
1336
+ await reader.close();
1337
+ removeSigint();
1338
+ if (removeNotificationHandler)
1339
+ removeNotificationHandler();
1340
+ if (removeNoticeHandler)
1341
+ removeNoticeHandler();
1342
+ }
1343
+ return successResult;
1344
+ };
1345
+ /**
1346
+ * Test-only surface. Exposes the small VI_MODE helpers so the matching unit
1347
+ * tests can exercise the parse / translate logic without engaging the
1348
+ * raw-mode LineEditor. Treated as private — callers should not rely on it.
1349
+ */
1350
+ export const __testing = {
1351
+ parseBoolVar,
1352
+ viModeOption,
1353
+ };