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,2187 @@
1
+ /**
2
+ * psql I/O & control backslash commands.
3
+ *
4
+ * TypeScript port of the following `exec_command_*` functions in upstream
5
+ * PostgreSQL's `src/bin/psql/command.c`:
6
+ *
7
+ * - `\i`, `\include` → exec_command_include (normal)
8
+ * - `\ir`, `\include_relative` → exec_command_include (relative=true)
9
+ * - `\o`, `\out` → exec_command_out
10
+ * - `\w`, `\write` → exec_command_write
11
+ * - `\g` → exec_command_g
12
+ * - `\gx` → exec_command_g (force_expanded=true)
13
+ * - `\gset` → exec_command_gset
14
+ * - `\gdesc` → exec_command_gdesc
15
+ * - `\gexec` → exec_command_gexec
16
+ * - `\watch` → exec_command_watch
17
+ *
18
+ * Each is exported as a `BackslashCmdSpec` and registered via
19
+ * {@link registerIoCommands}. The single line that wires us into the
20
+ * default dispatcher lives in `dispatch.ts::defaultRegistry()`.
21
+ *
22
+ * # Integration touch-points and known limitations
23
+ *
24
+ * Several of these commands really want to participate in the mainloop's
25
+ * scanner/printer pipeline. This WP keeps `src/psql/core/mainloop.ts`
26
+ * untouched, so we provide the data structures and let a follow-up WP wire
27
+ * the consumption sites. Limitations documented per-command:
28
+ *
29
+ * - `\i FILE` enqueues the file's contents on a small input queue
30
+ * (`./inputQueue.ts`) AND, as a stop-gap, executes the file's SQL
31
+ * directly via `Connection.execSimple`. Backslash commands embedded in
32
+ * the file are NOT processed by the scanner; the include is a "best
33
+ * effort: run as one big SQL blob". Once mainloop adopts the queue API
34
+ * this becomes a true include.
35
+ *
36
+ * - `\o FILE` opens a writable stream and stashes it under a symbol on
37
+ * `settings`. We expose a getter (`getQueryFout`) for the mainloop to
38
+ * consult; until that wiring happens, query output continues to flow
39
+ * to the mainloop's `ctx.stdout`. The stash + close-on-rebind logic is
40
+ * in place and fully tested.
41
+ *
42
+ * - `\g` (no arg) executes the current queryBuf directly through
43
+ * `Connection.execSimple` and renders via the aligned printer. This
44
+ * duplicates a tiny slice of mainloop's send/print pipeline, which is
45
+ * fine for the bytewise-simple cases this WP needs to support. For
46
+ * `\g FILE` / `\g |cmd` the output goes through the temporary writer.
47
+ *
48
+ * - `\gx` toggles `topt.expanded` for the single execution and restores
49
+ * the prior value in a `try { ... } finally { ... }`.
50
+ *
51
+ * - `\gset [PREFIX]` executes via `execSimple`, requires the last result
52
+ * to have exactly one row, and stores `${prefix}${colname}` → value
53
+ * for each column on `settings.vars`.
54
+ *
55
+ * - `\gdesc` parses the buffered query with the extended protocol
56
+ * (Parse + Describe by statement, no Execute), then assembles a
57
+ * synthetic `Column / Type` ResultSet and renders it through the
58
+ * active printer (`alignedPrinter` by default; the format picker
59
+ * honours `\pset format`). Tuples-only mode (`\t on`) and `\o FILE`
60
+ * redirects ride along automatically because the same ResultSet
61
+ * goes through the same printer the REPL would use for a query.
62
+ *
63
+ * - `\gexec` iterates the cells of the last result row-major and feeds
64
+ * each non-null cell back as SQL through `execSimple`. Each statement's
65
+ * output is rendered to stdout (or to the active queryFout stash).
66
+ *
67
+ * - `\watch [INTERVAL]` re-executes the queryBuf every `INTERVAL` seconds
68
+ * (default 2) until SIGINT or until the iteration count limit is hit.
69
+ * We hook SIGINT via a transient listener that's removed on completion.
70
+ * Tests bypass the listener by using an AbortController exposed via
71
+ * `WATCH_TEST_CONTROLLER`.
72
+ *
73
+ * # Error format
74
+ *
75
+ * Upstream prints `<cmd>: <msg>` to stderr and returns failure. We mirror
76
+ * that and also stash the message on `settings.lastErrorResult` so the
77
+ * mainloop's `writeError()` wrapper can pick it up.
78
+ */
79
+ import { spawn } from 'node:child_process';
80
+ import { promises as fsPromises, closeSync, createWriteStream, fsyncSync, openSync, } from 'node:fs';
81
+ import * as path from 'node:path';
82
+ import { platform } from 'node:os';
83
+ import { alignedPrinter } from '../print/aligned.js';
84
+ import { asciidocPrinter } from '../print/asciidoc.js';
85
+ import { csvPrinter } from '../print/csv.js';
86
+ import { htmlPrinter } from '../print/html.js';
87
+ import { jsonPrinter } from '../print/json.js';
88
+ import { latexLongtablePrinter, latexPrinter } from '../print/latex.js';
89
+ import { troffMsPrinter } from '../print/troff.js';
90
+ import { unalignedPrinter } from '../print/unaligned.js';
91
+ import { writeErr, writeOut } from './shared.js';
92
+ import { formatErrorReport, psqlErrorPrefix } from './cmd_meta.js';
93
+ import { applyPset } from './cmd_format.js';
94
+ import { consumeBindState, lookupPrepared, stagedNamedBindPresent, } from './cmd_pipeline.js';
95
+ import { captureLastError, refreshErrorVars, stripLeadingCommentsAndWS, } from '../core/common.js';
96
+ // ---------------------------------------------------------------------------
97
+ // Query-output (queryFout) stash.
98
+ //
99
+ // psql tracks a "query output" file pointer separately from stdout (see
100
+ // pset.queryFout in upstream settings.h). Our PsqlSettings type is frozen
101
+ // at WP-00, so we stash the stream on the settings object via a well-known
102
+ // symbol — the same approach used for the CondStack in cmd_cond.ts.
103
+ // ---------------------------------------------------------------------------
104
+ const QUERY_FOUT_KEY = Symbol.for('neonctl.psql.queryFout');
105
+ /**
106
+ * Return the currently active queryFout stream (or `null` if none).
107
+ * The mainloop is encouraged to call this in lieu of writing directly to
108
+ * `ctx.stdout` for query results.
109
+ */
110
+ export const getQueryFout = (settings) => {
111
+ const stash = settings;
112
+ return stash[QUERY_FOUT_KEY]?.stream ?? null;
113
+ };
114
+ const setQueryFout = (settings, entry) => {
115
+ const stash = settings;
116
+ if (entry === null) {
117
+ stash[QUERY_FOUT_KEY] = undefined;
118
+ }
119
+ else {
120
+ stash[QUERY_FOUT_KEY] = entry;
121
+ }
122
+ };
123
+ const closeQueryFout = async (settings) => {
124
+ const stash = settings;
125
+ const prev = stash[QUERY_FOUT_KEY];
126
+ if (prev) {
127
+ stash[QUERY_FOUT_KEY] = undefined;
128
+ await prev.close();
129
+ }
130
+ };
131
+ // ---------------------------------------------------------------------------
132
+ // Watch SIGINT escape hatch (tests).
133
+ //
134
+ // `\watch` installs a SIGINT handler so Ctrl-C breaks the polling loop in
135
+ // real psql sessions. Tests need to break the loop deterministically; we
136
+ // expose an AbortController hook that, if set, takes precedence.
137
+ // ---------------------------------------------------------------------------
138
+ export const WATCH_TEST_CONTROLLER = {
139
+ ref: null,
140
+ };
141
+ // ---------------------------------------------------------------------------
142
+ // Small helpers.
143
+ // ---------------------------------------------------------------------------
144
+ const errResult = (ctx, message) => {
145
+ ctx.settings.lastErrorResult = { message };
146
+ // Upstream psql prefixes every diagnostic with the `psql:[<file>:<n>]:`
147
+ // tag that `pg_log_pre_callback` adds. Mirror that here so backslash
148
+ // command errors look like upstream when surfaced via `psql_fails_like`.
149
+ const prefix = psqlErrorPrefix(ctx.settings);
150
+ writeErr(`${prefix}\\${ctx.cmdName}: ${message}\n`);
151
+ // Tell the mainloop the error has already been surfaced — without this
152
+ // it would also write a `psql: ERROR: <msg>` fallback, producing a stray
153
+ // duplicate that breaks the `\errverbose` ordering check on tests like
154
+ // `SELECT error\gdesc\n\errverbose`.
155
+ return { status: 'error', errorWritten: true };
156
+ };
157
+ /**
158
+ * Reject buffer-consuming commands when an extended pipeline is open. Upstream
159
+ * `exec_command_g` / `gx` / `gset` / `gexec` / `watch` all guard with
160
+ * `PQpipelineStatus(pset.db) != PQ_PIPELINE_OFF` and emit
161
+ * `pg_log_error("\\%s not allowed in pipeline mode", cmd)` (note: no `:`
162
+ * after the command name — different shape from `errResult`).
163
+ *
164
+ * `\gdesc` is the odd one out: upstream uses
165
+ * `pg_log_error("synchronous command execution functions are not allowed in
166
+ * pipeline mode")` because the underlying `PQdescribePrepared`/`PQfn`-style
167
+ * helpers all share that text — the regress baseline asserts this exact
168
+ * wording at three call sites in `psql_pipeline.out`.
169
+ *
170
+ * If the command proceeded it would inject a synchronous Query/Sync into the
171
+ * queue, corrupt the pipeline state, and leave `\endpipeline` waiting forever.
172
+ *
173
+ * Returns `null` when not in pipeline mode (caller proceeds); otherwise
174
+ * returns a populated error result the caller should bubble up.
175
+ *
176
+ * Upstream psql 18.4 leaks gate diagnostics through `pg_log_error_internal`
177
+ * which appends to the libpq result error log on the underlying PGresult;
178
+ * each subsequent gate hit RE-EMITS the full accumulated log plus its own
179
+ * line ("Error messages accumulate and are repeated" — the regress comment
180
+ * is the spec). Two `\gdesc` calls back-to-back therefore emit 3 lines
181
+ * total: 1 for the first call, 2 for the second (one accumulated + one
182
+ * own). Mirror that with a settings-stashed accumulator keyed off the
183
+ * current pipeline session; reset when the pipeline ends.
184
+ */
185
+ const PIPELINE_GATE_ERRORS_KEY = Symbol.for('neonctl.psql.pipelineGateErrors');
186
+ const getGateErrors = (settings) => {
187
+ const s = settings;
188
+ let cur = s[PIPELINE_GATE_ERRORS_KEY];
189
+ if (!cur) {
190
+ cur = [];
191
+ s[PIPELINE_GATE_ERRORS_KEY] = cur;
192
+ }
193
+ return cur;
194
+ };
195
+ /**
196
+ * Drop the accumulated pipeline-gate errors. Called from
197
+ * `\endpipeline` so the next pipeline session starts fresh — without
198
+ * this, gate errors from a closed pipeline would leak into the next
199
+ * one.
200
+ */
201
+ export const clearPipelineGateErrors = (settings) => {
202
+ const s = settings;
203
+ s[PIPELINE_GATE_ERRORS_KEY] = undefined;
204
+ };
205
+ const pipelineGate = (ctx) => {
206
+ if (ctx.settings.sendMode !== 'extended-pipeline')
207
+ return null;
208
+ const message = ctx.cmdName === 'gdesc'
209
+ ? 'synchronous command execution functions are not allowed in pipeline mode'
210
+ : `\\${ctx.cmdName} not allowed in pipeline mode`;
211
+ ctx.settings.lastErrorResult = { message };
212
+ const prefix = psqlErrorPrefix(ctx.settings);
213
+ // Only `\gdesc` accumulates: each call appends its own line to the
214
+ // log AND re-emits the full log to stderr ("Error messages
215
+ // accumulate and are repeated" — regress spec at expected line 648:
216
+ // two consecutive `\gdesc` emit 1+2 = 3 lines total). Upstream's
217
+ // underlying `PQdescribePrepared` path is the one that leaks into
218
+ // the session-scoped error log; other gated commands (`\g`, `\gx`,
219
+ // `\gset`, `\gexec`, `\watch`) emit a single line per invocation
220
+ // and do NOT participate in the accumulator.
221
+ if (ctx.cmdName === 'gdesc') {
222
+ const log = getGateErrors(ctx.settings);
223
+ log.push(message);
224
+ for (const m of log) {
225
+ writeErr(`${prefix}${m}\n`);
226
+ }
227
+ }
228
+ else {
229
+ writeErr(`${prefix}${message}\n`);
230
+ }
231
+ return { status: 'error', errorWritten: true };
232
+ };
233
+ /**
234
+ * Set of psql variables upstream marks as "specially treated" — i.e. names
235
+ * that have a substitute / assign hook installed in `startup.c`'s
236
+ * `EstablishVariableSpace`. Used by `\gset` to reject assignments into
237
+ * those names (matching upstream `StoreQueryTuple`'s `VariableHasHook`
238
+ * check). We mirror the upstream list directly so a `\gset IGNORE` into
239
+ * `IGNOREEOF` produces the conformance-expected warning even though our
240
+ * settings.ts hasn't installed the IGNOREEOF / HISTFILE hooks yet — that
241
+ * gap is tracked separately and harmless because the values are read-only
242
+ * for us.
243
+ */
244
+ const UPSTREAM_SPECIAL_VAR_NAMES = new Set([
245
+ 'AUTOCOMMIT',
246
+ 'COMP_KEYWORD_CASE',
247
+ 'ECHO',
248
+ 'ECHO_HIDDEN',
249
+ 'FETCH_COUNT',
250
+ 'HIDE_TABLEAM',
251
+ 'HIDE_TOAST_COMPRESSION',
252
+ 'HISTCONTROL',
253
+ 'HISTFILE',
254
+ 'HISTSIZE',
255
+ 'IGNOREEOF',
256
+ 'ON_ERROR_ROLLBACK',
257
+ 'ON_ERROR_STOP',
258
+ 'PROMPT1',
259
+ 'PROMPT2',
260
+ 'PROMPT3',
261
+ 'QUIET',
262
+ 'SHOW_ALL_RESULTS',
263
+ 'SHOW_CONTEXT',
264
+ 'SINGLELINE',
265
+ 'SINGLESTEP',
266
+ 'VERBOSITY',
267
+ ]);
268
+ /**
269
+ * True when `name` is a psql variable that `\gset` must skip with an
270
+ * "attempt to \gset into specially treated variable" message. Combines the
271
+ * registered-hook check (so future hook installations are automatically
272
+ * covered) with the upstream-canonical list above (so cases like
273
+ * IGNOREEOF that aren't hooked in our settings.ts still match upstream's
274
+ * `\gset` behaviour exactly).
275
+ */
276
+ const isSpeciallyTreatedVar = (settings, name) => settings.vars.hasSubstituteHook(name) || UPSTREAM_SPECIAL_VAR_NAMES.has(name);
277
+ // `stripLeadingCommentsAndWS` lives in core/common.ts so the wire path
278
+ // (sendQuery / executeAndPrint) and the slash-command paths share one
279
+ // implementation. Re-imported from there at the top of the file.
280
+ /**
281
+ * Strip line and block comments from `sql` so a COPY-shaped token inside a
282
+ * comment (e.g. dash-dash `COPY t TO STDOUT`) doesn't trigger the
283
+ * `\g FILE` mid-batch sink. Mirrors the cheap normaliser the mainloop uses
284
+ * before wiring `copyOutMidBatchSink`. Embedded literals are NOT stripped —
285
+ * `'COPY x TO STDOUT'` would still match `hasCopyToStdout`, but that's the
286
+ * same false-positive shape upstream tolerates (the regex sweep is
287
+ * intentionally conservative, and the worst-case outcome is "route bytes
288
+ * that never arrive to the file" — harmless).
289
+ */
290
+ const stripSqlCommentsForCopyScan = (sql) => sql.replace(/\/\*[\s\S]*?\*\//gu, '').replace(/--[^\n]*/gu, '');
291
+ /**
292
+ * True when `sql` contains at least one `COPY ... TO STDOUT` segment.
293
+ * Used by `runGCore` to install a CopyData sink while `\g` / `\gx` /
294
+ * `\g FILE` / `\g |cmd` dispatches a `\;`-chained batch that mixes
295
+ * COPY-OUT with regular SELECT statements. Without the sink the wire
296
+ * layer drops the CopyData bytes on the floor, and the file/pipe ends
297
+ * up with the surrounding tuple results only — see regress/psql lines
298
+ * 5760-5787 (`COPY (SELECT 'foo') TO STDOUT \; COPY (SELECT 'bar') TO
299
+ * STDOUT \g :g_out_file`).
300
+ */
301
+ const hasCopyToStdout = (sql) => /\bCOPY\b[\s\S]*?\bTO\s+STDOUT\b/iu.test(stripSqlCommentsForCopyScan(sql));
302
+ /**
303
+ * Render a server-side error in upstream psql's 3-line shape (severity +
304
+ * message, then `LINE N:` / `^` re-print) and refresh the `:LAST_ERROR_*`
305
+ * diagnostic variables so a subsequent `\errverbose` sees the rich payload.
306
+ *
307
+ * Mirrors the path that `core/common.ts::writeQueryError` takes for top-level
308
+ * statement errors: capture full ErrorResponse fields onto
309
+ * `settings.lastErrorResult`, render via `formatErrorReport` (honouring
310
+ * VERBOSITY + SHOW_CONTEXT), prefix only the leading severity line with
311
+ * `psql:[<file>:<n>]:`, and update the per-statement diagnostic vars via
312
+ * `refreshErrorVars`.
313
+ *
314
+ * Used by `\g`, `\gx`, `\gset`, `\gdesc`, and `\gexec` so a server-rejected
315
+ * statement dispatched through them renders the same shape vanilla psql
316
+ * produces, instead of the legacy `\<cmd>: <message>` one-liner.
317
+ */
318
+ const formatServerError = (ctx, err, sql) => {
319
+ // Stash full ErrorResponse payload so `\errverbose` can re-render later.
320
+ const msg = captureLastError(ctx.settings, err, sql);
321
+ const e = ctx.settings.lastErrorResult;
322
+ if (e) {
323
+ const lines = formatErrorReport(e, ctx.settings.verbosity, ctx.settings.showContext);
324
+ const prefix = psqlErrorPrefix(ctx.settings);
325
+ const prefixed = [prefix + lines[0], ...lines.slice(1)];
326
+ writeErr(prefixed.join('\n') + '\n');
327
+ }
328
+ else {
329
+ // Defensive fallback — captureLastError always sets lastErrorResult,
330
+ // but if a future caller bypasses it, surface at least the message.
331
+ const prefix = psqlErrorPrefix(ctx.settings);
332
+ writeErr(`${prefix}ERROR: ${msg}\n`);
333
+ }
334
+ // Refresh `:SQLSTATE`, `:ERROR`, `:LAST_ERROR_*`, `:ROW_COUNT` so the
335
+ // following `\echo :LAST_ERROR_MESSAGE` and `\errverbose` see the new
336
+ // outcome. Matches upstream's `SetErrorVariables` call after every
337
+ // failed dispatch.
338
+ refreshErrorVars(ctx.settings, { kind: 'error' });
339
+ return { status: 'error', errorWritten: true };
340
+ };
341
+ /**
342
+ * Open a writable destination for `\o` / `\w` / `\g FILE` / `\g |cmd`.
343
+ *
344
+ * `target` of the form `|cmd` spawns `sh -c cmd` and pipes to its stdin.
345
+ * The returned closer waits for the child to exit and resolves to its
346
+ * status + terminating signal (if any) so callers can render
347
+ * `wait_result_to_str`-style errors. Any other string is treated as a
348
+ * file path; the file is truncated.
349
+ */
350
+ const openWriter = (target) => {
351
+ if (target.startsWith('|')) {
352
+ const cmd = target.slice(1);
353
+ const child = spawn('sh', ['-c', cmd], {
354
+ stdio: ['pipe', 'inherit', 'inherit'],
355
+ });
356
+ // Swallow EPIPE on the stdin pipe — the child may exit before we
357
+ // finish writing, and Node would otherwise raise an unhandled error.
358
+ child.stdin.on('error', (err) => {
359
+ if (err.code !== 'EPIPE') {
360
+ // Re-raise non-EPIPE errors as a crash so they show up; tests
361
+ // run with the default unhandledRejection handler and will see
362
+ // these via the failing assertion.
363
+ throw err;
364
+ }
365
+ });
366
+ return {
367
+ stream: child.stdin,
368
+ isPipe: true,
369
+ close: () => new Promise((resolve) => {
370
+ let settled = false;
371
+ const finish = (code, signal) => {
372
+ if (settled)
373
+ return;
374
+ settled = true;
375
+ resolve({ exitCode: code, signal });
376
+ };
377
+ child.once('close', (code, signal) => {
378
+ finish(code, signal);
379
+ });
380
+ child.once('error', () => {
381
+ // spawn failure or stdio glitch — treat as a non-zero exit so
382
+ // \w sees a failure. \g intentionally ignores this, mirroring
383
+ // upstream `CloseGOutput` which only sets SHELL_ERROR /
384
+ // SHELL_EXIT_CODE.
385
+ finish(127, null);
386
+ });
387
+ // Half-close stdin so the child sees EOF and exits.
388
+ if (!child.stdin.destroyed) {
389
+ child.stdin.end();
390
+ }
391
+ }),
392
+ };
393
+ }
394
+ // Open the file synchronously up-front so a bad path (ENOENT,
395
+ // EACCES, EISDIR, …) throws here — before any write — instead of
396
+ // emitting an asynchronous `'error'` event on the lazily-opened
397
+ // WriteStream that Node would then re-raise as an unhandled
398
+ // exception and kill the process. Upstream psql calls `fopen()`
399
+ // synchronously and reports the failure via `pg_log_error` while
400
+ // continuing to read the next command, which is the behaviour we
401
+ // need to mirror for `\g FILE`, `\o FILE`, `\w FILE` and friends.
402
+ //
403
+ // Wrapping the resulting fd in `createWriteStream({ fd })` retains
404
+ // the streaming write interface the rest of the code expects. Disable
405
+ // `autoClose` so we control the close order — we fsync before close so
406
+ // a follow-on server-side `COPY FROM` (Docker bind-mount on macOS) sees
407
+ // the fully flushed file even when the next command immediately follows
408
+ // the `\g`.
409
+ const fd = openSync(target, 'w');
410
+ const stream = createWriteStream(target, {
411
+ encoding: 'utf8',
412
+ fd,
413
+ autoClose: false,
414
+ });
415
+ // openSync catches OPEN failures synchronously, but a WRITE-time failure
416
+ // (ENOSPC / EDQUOT after a clean open, e.g. a multi-MB result to a
417
+ // quota-limited fs) emits an asynchronous 'error'. Without a listener Node
418
+ // re-raises it as an uncaught exception and kills the whole neonctl process.
419
+ // Capture it; close() surfaces it to the caller.
420
+ let writeError = null;
421
+ stream.on('error', (err) => {
422
+ writeError = writeError ?? err;
423
+ });
424
+ return {
425
+ stream,
426
+ isPipe: false,
427
+ close: () => new Promise((resolve, reject) => {
428
+ if (writeError !== null) {
429
+ try {
430
+ closeSync(fd);
431
+ }
432
+ catch {
433
+ // swallow — the write error takes precedence
434
+ }
435
+ reject(writeError);
436
+ return;
437
+ }
438
+ // `stream.end(cb)` fires after the internal buffer drains to the
439
+ // underlying fd. Once that returns, the fd still holds dirty data
440
+ // in the kernel buffer cache; on macOS + Docker bind mounts the
441
+ // server inside the container can read the file before the cache
442
+ // flushes through to the bind mount, returning a partial view.
443
+ // Force an fsync against the open fd before closing so the
444
+ // bytes are guaranteed visible to subsequent reads — including
445
+ // server-side `COPY FROM` reading via the mount.
446
+ stream.end((err) => {
447
+ if (err) {
448
+ try {
449
+ closeSync(fd);
450
+ }
451
+ catch {
452
+ // swallow — the original error takes precedence
453
+ }
454
+ reject(err);
455
+ return;
456
+ }
457
+ try {
458
+ fsyncSync(fd);
459
+ }
460
+ catch {
461
+ // ignore — fsync best-effort; the close below still cleans up.
462
+ }
463
+ try {
464
+ closeSync(fd);
465
+ }
466
+ catch (closeErr) {
467
+ reject(closeErr);
468
+ return;
469
+ }
470
+ // Docker Desktop on macOS uses virtiofs/gRPC-FUSE for bind
471
+ // mounts; cache propagation from host writes to the container's
472
+ // view is eventual, not synchronous. A subsequent server-side
473
+ // `COPY FROM '/bind/mount/file'` can read a partial view even
474
+ // though the file is fully synced on the host. Linux + Windows
475
+ // bind mounts are coherent, so this branch is macOS-only.
476
+ if (platform() === 'darwin') {
477
+ setTimeout(() => {
478
+ resolve({});
479
+ }, 25);
480
+ return;
481
+ }
482
+ resolve({});
483
+ });
484
+ }),
485
+ };
486
+ };
487
+ /**
488
+ * Map a Node.js errno (`err.code`) to the libc `strerror()` string
489
+ * upstream psql renders in its `pg_log_error("%s: %m", fname)` path.
490
+ *
491
+ * Falls back to `err.message` (with the verbose `ENOENT: ...` prefix
492
+ * stripped if present) so unmapped errno values still surface
493
+ * meaningful text instead of a cryptic Node-internal phrasing.
494
+ */
495
+ const errnoToStrerror = (err) => {
496
+ switch (err.code) {
497
+ case 'ENOENT':
498
+ return 'No such file or directory';
499
+ case 'EACCES':
500
+ return 'Permission denied';
501
+ case 'EISDIR':
502
+ return 'Is a directory';
503
+ case 'ENOTDIR':
504
+ return 'Not a directory';
505
+ case 'EEXIST':
506
+ return 'File exists';
507
+ case 'EROFS':
508
+ return 'Read-only file system';
509
+ case 'ELOOP':
510
+ return 'Too many levels of symbolic links';
511
+ case 'ENAMETOOLONG':
512
+ return 'File name too long';
513
+ case 'ENOSPC':
514
+ return 'No space left on device';
515
+ case 'EMFILE':
516
+ return 'Too many open files';
517
+ case 'ENFILE':
518
+ return 'Too many open files in system';
519
+ case 'EIO':
520
+ return 'Input/output error';
521
+ case 'EFBIG':
522
+ return 'File too large';
523
+ case 'EDQUOT':
524
+ return 'Disk quota exceeded';
525
+ case 'EPERM':
526
+ return 'Operation not permitted';
527
+ case 'EINVAL':
528
+ return 'Invalid argument';
529
+ default: {
530
+ // Strip Node's `ENOENT: no such file or directory, open '/x'`
531
+ // prefix when present so the fallback at least looks like the
532
+ // libc form. The leading `/, ` slice keeps the human-readable
533
+ // phrase ("no such file or directory") if Node's message
534
+ // mirrors the `strerror` text but lowercases it.
535
+ const m = /^[A-Z]+: ([^,]+)/.exec(err.message);
536
+ return m ? m[1] : err.message;
537
+ }
538
+ }
539
+ };
540
+ /**
541
+ * Emit a file-open failure for `\g FILE`, `\o FILE`, `\w FILE` in the
542
+ * exact shape vanilla psql produces: a bare `<path>: <strerror>` line
543
+ * on stderr, no `\<cmd>:` prefix (matches `pg_log_error` under terse
544
+ * mode, which is what `psql -X` uses).
545
+ *
546
+ * The leading `psql:[<file>:<n>]:` tag is still applied when we're
547
+ * reading SQL from a `\i FILE` include — `psqlErrorPrefix` returns ''
548
+ * for stdin so the line stays bare for the interactive / harness case.
549
+ *
550
+ * Returns an `error` envelope with `errorWritten: true` so the mainloop
551
+ * doesn't write a duplicate `psql: ERROR:` fallback.
552
+ */
553
+ const reportFileOpenFailure = (ctx, target, err) => {
554
+ const errno = err;
555
+ const phrase = errnoToStrerror(errno);
556
+ const line = `${target}: ${phrase}`;
557
+ ctx.settings.lastErrorResult = { message: line };
558
+ const prefix = psqlErrorPrefix(ctx.settings);
559
+ writeErr(`${prefix}${line}\n`);
560
+ return { status: 'error', errorWritten: true };
561
+ };
562
+ /**
563
+ * True when `err` was thrown by our synchronous `openSync` in
564
+ * {@link openWriter} (i.e. has an errno `code`) and the caller should
565
+ * render it via {@link reportFileOpenFailure} rather than the generic
566
+ * `\<cmd>: <msg>` path.
567
+ */
568
+ const isFileOpenFailure = (err) => {
569
+ if (!err || typeof err !== 'object')
570
+ return false;
571
+ const e = err;
572
+ return typeof e.code === 'string' && e.code.startsWith('E');
573
+ };
574
+ /**
575
+ * Format a child process exit code + signal into upstream psql's
576
+ * `wait_result_to_str` style. Mirrors the C helper in
577
+ * `src/common/wait_error.c`:
578
+ *
579
+ * - exit code 127 → `command not found`
580
+ * - exit code 126 → `command was not executable`
581
+ * - any other code → `child process exited with exit code N`
582
+ * - terminated by signal S → `child process was terminated by
583
+ * signal N: <SIG>`
584
+ *
585
+ * Returns null when the child exited cleanly (code 0, no signal).
586
+ */
587
+ const formatChildWaitResult = (exitCode, signal) => {
588
+ if (signal) {
589
+ // Node doesn't expose the numeric signal number; surface the name as
590
+ // upstream's `pg_strsignal` would, with a stable prefix.
591
+ return `child process was terminated by signal: ${signal}`;
592
+ }
593
+ if (exitCode === null || exitCode === undefined)
594
+ return null;
595
+ if (exitCode === 0)
596
+ return null;
597
+ if (exitCode === 127)
598
+ return 'command not found';
599
+ if (exitCode === 126)
600
+ return 'command was not executable';
601
+ return `child process exited with exit code ${String(exitCode)}`;
602
+ };
603
+ /**
604
+ * Compose the `CommandComplete`-tag line upstream prints for non-tuples-
605
+ * producing results (DDL, DML without RETURNING, COPY). Mirrors
606
+ * `formatCommandTag` in `core/common.ts` — duplicated to avoid the
607
+ * cmd_io → common import cycle. Returns an empty string when no tag is
608
+ * available (e.g. `EmptyQueryResponse` carries `command = ''`).
609
+ */
610
+ const formatCommandTagText = (rs) => {
611
+ const command = (rs.command || '').trim();
612
+ if (command.length === 0)
613
+ return '';
614
+ if (command === 'INSERT') {
615
+ // INSERT is the only tag with the legacy oid in front of rowCount.
616
+ return `INSERT ${String(rs.oid ?? 0)} ${String(rs.rowCount ?? 0)}`;
617
+ }
618
+ if (rs.rowCount !== null && rs.rowCount !== undefined) {
619
+ return `${command} ${String(rs.rowCount)}`;
620
+ }
621
+ return command;
622
+ };
623
+ /**
624
+ * Render a `ResultSet` to the supplied writable stream using the printer
625
+ * picked from the active `\pset format`. Upstream's `do_watch`, `do_gset`,
626
+ * `do_gexec`, and `\i` all funnel results through the standard query
627
+ * output pipeline (`ExecQueryAndProcessResults` → `printQuery`), which
628
+ * honours `\pset format`. Hard-coding the aligned printer here breaks the
629
+ * conformance harness's `psql -A` runs (which expect unaligned tuples-only
630
+ * output for things like `\watch` polled rows).
631
+ *
632
+ * Non-tuples-producing results (CommandComplete with `fields.length === 0`,
633
+ * which covers DDL, DML without RETURNING, and the post-CopyDone tag for
634
+ * `COPY ... TO STDOUT`) are rendered as a single status line instead of
635
+ * the printer's `(0 rows)` empty-table block — matching `renderResultSets`
636
+ * in `core/common.ts`. Tuples-only (`\t`) and quiet (`--quiet`) both
637
+ * suppress the tag entirely.
638
+ */
639
+ const renderResult = async (settings, rs, out) => {
640
+ // `COPY ... TO STDOUT` segment — emit the accumulated CopyData payloads
641
+ // in arrival order at this result's position in the `\;`-chain.
642
+ if (rs.copyOutBytes && rs.copyOutBytes.length > 0) {
643
+ for (const chunk of rs.copyOutBytes) {
644
+ out.write(chunk);
645
+ }
646
+ }
647
+ if (rs.fields.length === 0) {
648
+ // Mirrors `renderResultSets`'s zero-fields branch: emit the tag (e.g.
649
+ // `COPY 1`) unless quiet / tuples-only suppresses it. Without this
650
+ // branch the aligned printer renders an empty header + `(0 rows)`
651
+ // footer for the COPY-TO-STDOUT command complete, which doesn't
652
+ // match upstream (where the data already streamed via the COPY-OUT
653
+ // sink and the tag goes to the user's status stream, not the
654
+ // queryFout). The regress fixture sets QUIET=true before the
655
+ // COPY-OUT `\g` shape so the tag stays out of the file under test.
656
+ // For COPY-out results, the tag is suppressed regardless — the bytes
657
+ // already flowed; upstream's `handleCopyOut` doesn't emit `COPY N`
658
+ // on the queryFout.
659
+ if (!settings.popt.topt.tuplesOnly && !settings.quiet && !rs.copyOutBytes) {
660
+ const tag = formatCommandTagText(rs);
661
+ if (tag.length > 0)
662
+ out.write(`${tag}\n`);
663
+ }
664
+ return;
665
+ }
666
+ await pickActivePrinter(settings).printQuery(rs, settings.popt, out);
667
+ };
668
+ /**
669
+ * Pick the printer for the active output format. Mirrors `pickPrinter`
670
+ * in `core/common.ts` — duplicated here to avoid the cmd_io → common
671
+ * import cycle (common.ts depends on this file for `getQueryFout`).
672
+ *
673
+ * `wrapped` falls back to the aligned printer (which renders `wrapped`
674
+ * mode itself via `topt.format`).
675
+ */
676
+ const pickActivePrinter = (settings) => {
677
+ switch (settings.popt.topt.format) {
678
+ case 'aligned':
679
+ case 'wrapped':
680
+ return alignedPrinter;
681
+ case 'unaligned':
682
+ return unalignedPrinter;
683
+ case 'csv':
684
+ return csvPrinter;
685
+ case 'json':
686
+ return jsonPrinter;
687
+ case 'html':
688
+ return htmlPrinter;
689
+ case 'asciidoc':
690
+ return asciidocPrinter;
691
+ case 'latex':
692
+ return latexPrinter;
693
+ case 'latex-longtable':
694
+ return latexLongtablePrinter;
695
+ case 'troff-ms':
696
+ return troffMsPrinter;
697
+ default:
698
+ return alignedPrinter;
699
+ }
700
+ };
701
+ /**
702
+ * Pick the output target for a query result.
703
+ *
704
+ * Precedence: explicit `oneShot` (e.g. `\g FILE`) > the settings stash
705
+ * (`\o FILE`) > `process.stdout`.
706
+ */
707
+ const pickOut = (settings, oneShot) => {
708
+ if (oneShot)
709
+ return oneShot;
710
+ return getQueryFout(settings) ?? process.stdout;
711
+ };
712
+ // ---------------------------------------------------------------------------
713
+ // \i FILE / \include FILE
714
+ // ---------------------------------------------------------------------------
715
+ const runInclude = async (ctx, relative) => {
716
+ const arg = ctx.nextArg('normal');
717
+ if (arg === null || arg.length === 0) {
718
+ return errResult(ctx, 'missing required argument');
719
+ }
720
+ // Resolve path: \ir resolves relative to the current input file's
721
+ // directory (if any); \i resolves relative to cwd unless absolute.
722
+ let resolved;
723
+ if (path.isAbsolute(arg)) {
724
+ resolved = arg;
725
+ }
726
+ else if (relative && ctx.settings.inputfile) {
727
+ resolved = path.resolve(path.dirname(ctx.settings.inputfile), arg);
728
+ }
729
+ else {
730
+ resolved = path.resolve(process.cwd(), arg);
731
+ }
732
+ let contents;
733
+ try {
734
+ contents = await fsPromises.readFile(resolved, 'utf8');
735
+ }
736
+ catch (err) {
737
+ const msg = err instanceof Error ? err.message : String(err);
738
+ return errResult(ctx, msg);
739
+ }
740
+ // Execute the included file's SQL directly here. This is the single
741
+ // execution path for BOTH the interactive REPL and the non-interactive
742
+ // -c/-f/stdin path: the latter (`executeInputString`) does not drain the
743
+ // `\i` input queue, so an `enqueueInput()` here would (a) never run under
744
+ // -f/-c and (b) double-run interactively (the mainloop drains the queue
745
+ // AND we run execSimple). See.
746
+ if (!ctx.settings.db) {
747
+ return errResult(ctx, 'no connection to the server');
748
+ }
749
+ const trimmed = contents.trim();
750
+ if (trimmed.length === 0) {
751
+ return { status: 'ok' };
752
+ }
753
+ // Track the prior inputfile so `\ir` chains relative to the included
754
+ // file's directory.
755
+ const priorInputFile = ctx.settings.inputfile;
756
+ ctx.settings.inputfile = resolved;
757
+ try {
758
+ const results = await ctx.settings.db.execSimple(trimmed);
759
+ const out = pickOut(ctx.settings, null);
760
+ for (const rs of results) {
761
+ await renderResult(ctx.settings, rs, out);
762
+ }
763
+ return { status: 'ok' };
764
+ }
765
+ catch (err) {
766
+ const msg = err instanceof Error ? err.message : String(err);
767
+ return errResult(ctx, msg);
768
+ }
769
+ finally {
770
+ ctx.settings.inputfile = priorInputFile;
771
+ }
772
+ };
773
+ export const cmdInclude = {
774
+ name: 'i',
775
+ aliases: ['include'],
776
+ helpKey: 'i',
777
+ run: (ctx) => runInclude(ctx, false),
778
+ };
779
+ export const cmdIncludeRel = {
780
+ name: 'ir',
781
+ aliases: ['include_relative'],
782
+ helpKey: 'ir',
783
+ run: (ctx) => runInclude(ctx, true),
784
+ };
785
+ // ---------------------------------------------------------------------------
786
+ // \o [FILE|cmd] / \out
787
+ // ---------------------------------------------------------------------------
788
+ export const cmdOut = {
789
+ name: 'o',
790
+ aliases: ['out'],
791
+ helpKey: 'o',
792
+ async run(ctx) {
793
+ const arg = ctx.nextArg('filepipe');
794
+ // Drain any previous target first so writes flush before we rebind.
795
+ await closeQueryFout(ctx.settings);
796
+ if (arg === null || arg.length === 0) {
797
+ // Restore default (stdout).
798
+ return { status: 'ok' };
799
+ }
800
+ try {
801
+ const entry = openWriter(arg);
802
+ setQueryFout(ctx.settings, entry);
803
+ return { status: 'ok' };
804
+ }
805
+ catch (err) {
806
+ // File targets fail synchronously in `openWriter` via `openSync`;
807
+ // surface them in upstream's `<path>: <strerror>` shape (bare,
808
+ // no `\o:` prefix) and continue with the loop so a follow-up
809
+ // `SELECT` still executes. Pipe spawn failures (which lack an
810
+ // errno code) fall through to the generic `\o: <msg>` path.
811
+ if (!arg.startsWith('|') && isFileOpenFailure(err)) {
812
+ return reportFileOpenFailure(ctx, arg, err);
813
+ }
814
+ const msg = err instanceof Error ? err.message : String(err);
815
+ return errResult(ctx, msg);
816
+ }
817
+ },
818
+ };
819
+ // ---------------------------------------------------------------------------
820
+ // \w FILE / \write FILE
821
+ // ---------------------------------------------------------------------------
822
+ export const cmdWrite = {
823
+ name: 'w',
824
+ aliases: ['write'],
825
+ helpKey: 'w',
826
+ async run(ctx) {
827
+ const arg = ctx.nextArg('filepipe');
828
+ if (arg === null || arg.length === 0) {
829
+ return errResult(ctx, 'missing required argument');
830
+ }
831
+ let entry;
832
+ try {
833
+ entry = openWriter(arg);
834
+ }
835
+ catch (err) {
836
+ // Same upstream-shape pivot as `\o`: a missing / unwritable file
837
+ // path errors out as a bare `<path>: <strerror>` line and the
838
+ // shim keeps reading commands. Pipe spawn failures still use
839
+ // the generic `\w: <msg>` envelope.
840
+ if (!arg.startsWith('|') && isFileOpenFailure(err)) {
841
+ return reportFileOpenFailure(ctx, arg, err);
842
+ }
843
+ const msg = err instanceof Error ? err.message : String(err);
844
+ return errResult(ctx, msg);
845
+ }
846
+ try {
847
+ await new Promise((resolve, reject) => {
848
+ entry.stream.write(ctx.queryBuf, (err) => {
849
+ if (err)
850
+ reject(err);
851
+ else
852
+ resolve();
853
+ });
854
+ });
855
+ }
856
+ catch (err) {
857
+ // On pipe targets a fast-exiting child (e.g. `| false` or a
858
+ // command-not-found shell exit) closes its stdin before we finish
859
+ // writing, surfacing as EPIPE. Linux fires this reliably; macOS
860
+ // sometimes races it past us. In either case the child's exit
861
+ // status is what we want to report, NOT the write error — so we
862
+ // swallow EPIPE on pipes and fall through to entry.close() which
863
+ // awaits the child and emits the upstream-shape wait_result_to_str.
864
+ const isEpipe = err instanceof Error && err.code === 'EPIPE';
865
+ if (!entry.isPipe || !isEpipe) {
866
+ try {
867
+ await entry.close();
868
+ }
869
+ catch {
870
+ // ignore
871
+ }
872
+ const msg = err instanceof Error ? err.message : String(err);
873
+ return errResult(ctx, msg);
874
+ }
875
+ }
876
+ // Wait for the target to drain. For pipe targets a non-zero exit /
877
+ // killing signal is surfaced as `<fname>: <wait_result_to_str>`,
878
+ // mirroring upstream `exec_command_write`:
879
+ //
880
+ // pg_log_error("%s: %s", fname, wait_result_to_str(result));
881
+ //
882
+ // Note that upstream's `fname` retains the leading `|`, and the
883
+ // message does NOT carry the `\w:` cmd-prefix that the other
884
+ // backslash-command errors use — `pg_log_error` writes the bare
885
+ // formatted message (under terse mode, which is the conformance
886
+ // harness setup). We bypass `errResult` to match that shape exactly.
887
+ try {
888
+ const result = await entry.close();
889
+ if (entry.isPipe) {
890
+ const msg = formatChildWaitResult(result.exitCode, result.signal);
891
+ if (msg !== null) {
892
+ // `arg` still has the leading `|`; emit it verbatim so the
893
+ // text reads `| program: child process exited with exit code 1`.
894
+ const line = `${arg}: ${msg}`;
895
+ ctx.settings.lastErrorResult = { message: line };
896
+ const prefix = psqlErrorPrefix(ctx.settings);
897
+ writeErr(`${prefix}${line}\n`);
898
+ return { status: 'error', errorWritten: true };
899
+ }
900
+ }
901
+ return { status: 'ok' };
902
+ }
903
+ catch (err) {
904
+ const msg = err instanceof Error ? err.message : String(err);
905
+ return errResult(ctx, msg);
906
+ }
907
+ },
908
+ };
909
+ // ---------------------------------------------------------------------------
910
+ // \g, \gx — execute the query buffer with optional one-shot redirect.
911
+ // ---------------------------------------------------------------------------
912
+ /**
913
+ * Parse the body of a `\g (option=value option2=value2 ...)` clause —
914
+ * the text between the outer parentheses, already stripped. Options
915
+ * are separated by whitespace; values may be single-quoted to embed
916
+ * spaces. Unquoted values run to the next whitespace.
917
+ *
918
+ * Mirrors upstream's `parse_slash_pgopts_list`. We deliberately stay
919
+ * narrow — the conformance corpus exercises `format=`, `csv_fieldsep=`,
920
+ * and `title=` only.
921
+ */
922
+ const parseGPsetOptions = (body) => {
923
+ const out = [];
924
+ let i = 0;
925
+ while (i < body.length) {
926
+ // Skip whitespace between pairs.
927
+ while (i < body.length && /\s/.test(body[i]))
928
+ i++;
929
+ if (i >= body.length)
930
+ break;
931
+ // Read option name up to `=`.
932
+ const optStart = i;
933
+ while (i < body.length && body[i] !== '=' && !/\s/.test(body[i]))
934
+ i++;
935
+ const option = body.slice(optStart, i);
936
+ if (option.length === 0)
937
+ break;
938
+ let value = '';
939
+ if (body[i] === '=') {
940
+ i++; // skip '='
941
+ // Value: single-quoted or unquoted.
942
+ if (body[i] === "'") {
943
+ i++;
944
+ while (i < body.length && body[i] !== "'") {
945
+ // Single-quoted strings support `''` doubling and a few
946
+ // C-style escapes (\n, \t, \\, \'). Mirror enough of the
947
+ // upstream `xslashquote` handling to round-trip the regress
948
+ // corpus.
949
+ if (body[i] === '\\' && i + 1 < body.length) {
950
+ const next = body[i + 1];
951
+ if (next === 'n')
952
+ value += '\n';
953
+ else if (next === 't')
954
+ value += '\t';
955
+ else if (next === 'r')
956
+ value += '\r';
957
+ else if (next === '\\')
958
+ value += '\\';
959
+ else if (next === "'")
960
+ value += "'";
961
+ else
962
+ value += next;
963
+ i += 2;
964
+ continue;
965
+ }
966
+ value += body[i++];
967
+ }
968
+ if (body[i] === "'")
969
+ i++;
970
+ }
971
+ else {
972
+ const vStart = i;
973
+ while (i < body.length && !/\s/.test(body[i]))
974
+ i++;
975
+ value = body.slice(vStart, i);
976
+ }
977
+ }
978
+ out.push({ option, value });
979
+ }
980
+ return out;
981
+ };
982
+ const runGCore = async (ctx, forceExpanded) => {
983
+ const gated = pipelineGate(ctx);
984
+ if (gated !== null)
985
+ return gated;
986
+ // Strip leading whitespace + `--`/`/* */` comments so the SQL we hand to
987
+ // the wire (and use for `LINE N:` re-print on error) matches what vanilla
988
+ // psql sends through `PQexec`. Without the strip, queryBuf accumulated
989
+ // across `\bind` re-entries carries blank+comment lines from the gap
990
+ // between the previous `\g` and this one, and the server-relative
991
+ // position lands on `LINE 3` instead of `LINE 1`.
992
+ const trimmedBuf = stripLeadingCommentsAndWS(ctx.queryBuf);
993
+ const bufSql = trimmedBuf.trim();
994
+ let target;
995
+ let psetOverrides = null;
996
+ // `\g (option=value ...)` — temporary pset overrides for this query
997
+ // only. Upstream `exec_command_g` recognises a leading `(` and slurps
998
+ // the rest of the args until matching `)`. We can't call nextArg in
999
+ // two different modes against the BackslashContext (each mode has its
1000
+ // own cursor), so when the leading char is `(`, parse the entire raw
1001
+ // arg block ourselves; otherwise fall back to normal filepipe arg
1002
+ // extraction.
1003
+ const rawTrimmed = ctx.rawArgs.trimStart();
1004
+ if (rawTrimmed.startsWith('(')) {
1005
+ const close = rawTrimmed.indexOf(')');
1006
+ if (close === -1) {
1007
+ return errResult(ctx, 'missing right parenthesis in \\g options');
1008
+ }
1009
+ // Strip parens; parse `key=value` pairs (values may be single-
1010
+ // quoted). The conformance corpus exercises `format=`,
1011
+ // `csv_fieldsep=`, and `title=` only.
1012
+ psetOverrides = parseGPsetOptions(rawTrimmed.slice(1, close).trim());
1013
+ // Anything after the matching `)` is the output target — `\g (format=csv)
1014
+ // out.txt` writes to out.txt. Previously this was dropped, so the file/pipe
1015
+ // redirect was silently ignored whenever options were present.
1016
+ const afterParen = rawTrimmed.slice(close + 1).trim();
1017
+ target = afterParen.length > 0 ? afterParen : null;
1018
+ }
1019
+ else {
1020
+ target = ctx.nextArg('filepipe');
1021
+ }
1022
+ // `\g` / `\gx` with an empty buffer re-runs the most recently submitted
1023
+ // query — upstream tracks this in `pset.last_query` and `PSQLexec` reads
1024
+ // it when the active buffer is empty. We mirror via `settings.lastQuery`,
1025
+ // populated in `sendQuery` before dispatch. Preserve trailing whitespace
1026
+ // on the re-run so the server's `position` (and the `LINE N:` echo we
1027
+ // render on failure) match upstream byte-for-byte — vanilla passes the
1028
+ // un-trimmed `pset.last_query` straight to `PQexec`.
1029
+ const sql = bufSql.length > 0 ? bufSql : ctx.settings.lastQuery;
1030
+ // If a `\bind_named NAME` has staged a server-side prepared statement
1031
+ // lookup, we don't need any SQL text — the prepared statement carries
1032
+ // it server-side. Skip the empty-sql guard so the bind branch below
1033
+ // can do its thing.
1034
+ const hasPendingNamedBind = stagedNamedBindPresent(ctx.settings);
1035
+ if (sql.length === 0 && !hasPendingNamedBind) {
1036
+ // No buffered SQL, no prior query, no staged bind — silent no-op
1037
+ // like upstream.
1038
+ return { status: 'reset-buf', newBuf: '' };
1039
+ }
1040
+ if (!ctx.settings.db) {
1041
+ return errResult(ctx, 'no connection to the server');
1042
+ }
1043
+ // Open the one-shot writer if a target was supplied; close it on the way
1044
+ // out so the file/pipe is flushed before we return.
1045
+ let oneShot = null;
1046
+ if (target !== null && target.length > 0) {
1047
+ try {
1048
+ oneShot = openWriter(target);
1049
+ }
1050
+ catch (err) {
1051
+ // A `\g FILE` whose path is unopenable (ENOENT, EACCES, EISDIR,
1052
+ // …) — typically because an unresolved `:VAR` substitution left
1053
+ // a literal `:VAR` in the path — must NOT crash the process the
1054
+ // way Node's lazy WriteStream `'error'` event would. Render in
1055
+ // upstream's bare `<path>: <strerror>` shape and continue so the
1056
+ // next command in the script still executes. Pipe spawn
1057
+ // failures retain the generic `\g: <msg>` envelope.
1058
+ if (!target.startsWith('|') && isFileOpenFailure(err)) {
1059
+ return reportFileOpenFailure(ctx, target, err);
1060
+ }
1061
+ const msg = err instanceof Error ? err.message : String(err);
1062
+ return errResult(ctx, msg);
1063
+ }
1064
+ }
1065
+ const topt = ctx.settings.popt.topt;
1066
+ // Snapshot topt BEFORE any per-query mutation so the restore in
1067
+ // `finally` covers both `\gx`'s `expanded = 'on'` and any `\g (...)`
1068
+ // pset overrides in one shot. Snapshotting AFTER the `forceExpanded`
1069
+ // mutation would persist `expanded = 'on'` across queries.
1070
+ const toptSnapshot = { ...topt };
1071
+ if (forceExpanded)
1072
+ topt.expanded = 'on';
1073
+ // Apply per-query pset overrides silently. Upstream applies the
1074
+ // temporary options without emitting the status lines that
1075
+ // interactive `\pset` would.
1076
+ if (psetOverrides) {
1077
+ for (const { option, value } of psetOverrides) {
1078
+ applyPset(topt, option, value, ctx.cmdName, true);
1079
+ }
1080
+ }
1081
+ // Track for `\g` / `\gx` re-run with empty buffer. Upstream sets
1082
+ // `pset.last_query` in `PSQLexec` before dispatch.
1083
+ ctx.settings.lastQuery = sql;
1084
+ // Consume any pending `\bind` / `\bind_named` state. Upstream's
1085
+ // `\g` routes through the extended-query protocol when bind params
1086
+ // are set: anonymous `\bind` re-prepares from the buffer; named
1087
+ // `\bind_named NAME` looks up the server-side prepared statement
1088
+ // by NAME (set earlier via `\parse NAME`) and just runs Bind +
1089
+ // Execute against it.
1090
+ const bindState = consumeBindState(ctx.settings);
1091
+ let execError = null;
1092
+ // Track whether we wired the mid-batch COPY-OUT sink so the `finally`
1093
+ // can clear it deterministically — even if `execSimple` threw.
1094
+ let copyOutSinkConn = null;
1095
+ try {
1096
+ const out = pickOut(ctx.settings, oneShot?.stream ?? null);
1097
+ if (bindState?.byName) {
1098
+ // \bind_named NAME — execute the previously-prepared statement
1099
+ // identified by NAME. The cache was populated by `\parse NAME`.
1100
+ // The empty-string NAME is the upstream "unnamed" prepared
1101
+ // statement slot.
1102
+ const ps = lookupPrepared(ctx.settings, bindState.name);
1103
+ if (!ps) {
1104
+ // Synthesise a thrown-Error-like object so formatServerError can
1105
+ // render the same `ERROR: <msg>` shape vanilla emits for the
1106
+ // server's `prepared statement "X" does not exist` error.
1107
+ execError = Object.assign(new Error(`prepared statement "${bindState.name}" does not exist`), { severity: 'ERROR', code: '26000' });
1108
+ }
1109
+ else {
1110
+ // Bind + Execute MUST go in one extended-protocol batch: the
1111
+ // anonymous portal is implicitly closed at the next Sync, so a
1112
+ // separate ps.bind() then ps.execute() would lose the portal in
1113
+ // between. `bindAndExecute` issues both messages before the
1114
+ // Sync.
1115
+ const rs = await ps.bindAndExecute(bindState.values);
1116
+ await renderResult(ctx.settings, rs, out);
1117
+ }
1118
+ }
1119
+ else if (bindState) {
1120
+ // Anonymous \bind — re-prepare from the current buffer (or
1121
+ // lastQuery fallback) and execute with the supplied params.
1122
+ const rs = await ctx.settings.db.query(sql, bindState.values);
1123
+ await renderResult(ctx.settings, rs, out);
1124
+ }
1125
+ else {
1126
+ // Plain `\g` / `\gx`: simple-query dispatch.
1127
+ //
1128
+ // When the batch contains `COPY ... TO STDOUT`, the wire layer
1129
+ // forwards CopyData bytes via `copyOutMidBatchSink`. Mainloop wires
1130
+ // that sink to `ctx.stdout` for top-level dispatches; here in `\g`
1131
+ // we redirect it to the current output target (`\g FILE`,
1132
+ // `\g |cmd`, or `\o`-stashed stream when neither is set). Without
1133
+ // this, `COPY (SELECT 'foo') TO STDOUT \g :file` silently drops
1134
+ // `foo` on the floor and the file ends up with only the empty
1135
+ // `(0 rows)` shape printed by `renderResult` for the wire's empty
1136
+ // ResultSet. Matches upstream's `do_copy` / `handleCopyOut` path:
1137
+ // the COPY OUT bytes go wherever the active queryFout points.
1138
+ if (hasCopyToStdout(sql)) {
1139
+ copyOutSinkConn = ctx.settings.db;
1140
+ copyOutSinkConn.copyOutMidBatchSink = (chunk) => {
1141
+ out.write(chunk);
1142
+ };
1143
+ }
1144
+ const results = await ctx.settings.db.execSimple(sql);
1145
+ for (const rs of results) {
1146
+ await renderResult(ctx.settings, rs, out);
1147
+ }
1148
+ }
1149
+ }
1150
+ catch (err) {
1151
+ execError = err;
1152
+ }
1153
+ finally {
1154
+ // Restore the pre-query topt verbatim — covers both the `\gx`
1155
+ // `expanded = 'on'` swap and any `\g (...)` pset overrides, so a
1156
+ // subsequent plain `\g` runs in the user's persistent print mode.
1157
+ Object.assign(topt, toptSnapshot);
1158
+ // Tear down the COPY-OUT sink so subsequent top-level batches reach
1159
+ // mainloop's installer with a clean slate. (Mainloop reinstalls per
1160
+ // batch; leaving ours pointed at a now-closed file would cause a
1161
+ // write-after-close on the next CopyData burst.)
1162
+ if (copyOutSinkConn)
1163
+ copyOutSinkConn.copyOutMidBatchSink = null;
1164
+ }
1165
+ // Close the one-shot writer regardless of execution success so any
1166
+ // partial output is flushed.
1167
+ //
1168
+ // Note: a non-zero exit from `\g | program` is intentionally NOT
1169
+ // surfaced as an error. Upstream `CloseGOutput` (src/bin/psql/common.c)
1170
+ // only feeds the wait status to `SetShellResultVariables`, which sets
1171
+ // `SHELL_ERROR` / `SHELL_EXIT_CODE` for user inspection — no
1172
+ // `pg_log_error` call. This matches `\g | false` in vanilla psql:
1173
+ // silent, exit code 0, the next command (`\echo after`) prints
1174
+ // normally. Bookkeeping for the SHELL_* vars is a follow-up; what
1175
+ // matters here is that we don't emit a stray "program exited" line
1176
+ // that the conformance harness would diff against an empty upstream
1177
+ // stderr.
1178
+ //
1179
+ // The only failure we still surface from a pipe target is a synchronous
1180
+ // `close()` rejection (e.g. EPIPE escaping the swallow above), which
1181
+ // would indicate a genuine bug in our wiring rather than the child
1182
+ // program's exit code.
1183
+ let pipeError = null;
1184
+ if (oneShot) {
1185
+ try {
1186
+ await oneShot.close();
1187
+ }
1188
+ catch (err) {
1189
+ pipeError = err instanceof Error ? err.message : String(err);
1190
+ }
1191
+ }
1192
+ if (execError !== null) {
1193
+ // Render in upstream's `ERROR: <msg>\nLINE N: ...\n ^` shape
1194
+ // by funnelling through `formatServerError` — same path top-level
1195
+ // statement errors take in `core/common.ts::writeQueryError`. The
1196
+ // `\<cmd>:` prefix is reserved for client-side I/O / parse errors
1197
+ // (e.g. `\g: no connection`), not server-side ErrorResponse-shaped
1198
+ // failures. Pass the COMMENT-STRIPPED buffer (`trimmedBuf`) so the
1199
+ // `LINE N:` count starts at the first content line — vanilla strips
1200
+ // leading comments + blank lines from queryBuf before `PQexec`, and
1201
+ // the server's reported `position` is a 1-based offset into THAT
1202
+ // trimmed buffer. We preserve trailing whitespace so a `\g` after
1203
+ // `SELECT $1, $2 ` still renders `LINE 1: SELECT $1, $2 ` verbatim.
1204
+ // When buffer was empty (lastQuery fallback or named-bind path), the
1205
+ // dispatched SQL is `sql` — pass that instead so the `LINE N:` echo
1206
+ // still reflects the executed statement (e.g. `\bind_named NAME \g`
1207
+ // after a `\parse NAME` of `SELECT $1, $2`).
1208
+ return formatServerError(ctx, execError, bufSql.length > 0 ? trimmedBuf : sql);
1209
+ }
1210
+ if (pipeError !== null) {
1211
+ return errResult(ctx, pipeError);
1212
+ }
1213
+ return { status: 'reset-buf', newBuf: '' };
1214
+ };
1215
+ export const cmdG = {
1216
+ name: 'g',
1217
+ helpKey: 'g',
1218
+ run: (ctx) => runGCore(ctx, false),
1219
+ };
1220
+ export const cmdGx = {
1221
+ name: 'gx',
1222
+ helpKey: 'gx',
1223
+ run: (ctx) => runGCore(ctx, true),
1224
+ };
1225
+ // ---------------------------------------------------------------------------
1226
+ // \p / \print — print the current or previous query buffer.
1227
+ // ---------------------------------------------------------------------------
1228
+ /**
1229
+ * `\p` / `\print` — print the query buffer the next `\g` would execute.
1230
+ *
1231
+ * Mirrors upstream `exec_command_print` in `src/bin/psql/command.c`:
1232
+ *
1233
+ * if (query_buf && query_buf->len > 0)
1234
+ * puts(query_buf->data);
1235
+ * else if (previous_buf && previous_buf->len > 0)
1236
+ * puts(previous_buf->data);
1237
+ * else if (!pset.quiet)
1238
+ * puts(_("Query buffer is empty."));
1239
+ *
1240
+ * Buffer-vs-previous-buffer precedence matters for the regress sequence:
1241
+ *
1242
+ * SELECT 1; -- executes, previous_buf := "SELECT 1;"
1243
+ * \p -- queryBuf empty → prints previous_buf
1244
+ * SELECT 2 \r -- queryBuf="SELECT 2 ", \r resets to "" without
1245
+ * -- touching previous_buf
1246
+ * \p -- queryBuf still empty → prints previous_buf
1247
+ * SELECT 3 \p -- queryBuf="SELECT 3 ", non-empty → prints queryBuf
1248
+ *
1249
+ * Implementation notes:
1250
+ *
1251
+ * - We use `settings.lastQuery` as the previous-buffer source. Upstream
1252
+ * tracks `previous_buf` independently of `pset.last_query`, but our
1253
+ * `lastQuery` is set at the exact same point upstream sets
1254
+ * `previous_buf` (the dispatch site in `SendQuery`-equivalent code paths
1255
+ * in `core/common.ts` and `cmd_io.ts`'s `\g` implementation), so the
1256
+ * semantics match for every shape exercised by the conformance corpus.
1257
+ * - We must NOT clear queryBuf — return `status: 'ok'` so the mainloop
1258
+ * leaves the buffer untouched. The user is inspecting, not executing.
1259
+ * - `puts()` appends a trailing newline. We use `writeOut` and append `\n`
1260
+ * explicitly to match.
1261
+ */
1262
+ export const cmdPrint = {
1263
+ name: 'p',
1264
+ aliases: ['print'],
1265
+ helpKey: 'p',
1266
+ run: (ctx) => {
1267
+ // `queryBuf.trim()` for the emptiness check — not the printed text.
1268
+ // Upstream's `query_buf->len > 0` is a byte-length check that, in
1269
+ // upstream, is reliably zero after a `;`-dispatch (because PQexec is
1270
+ // followed by `resetPQExpBuffer(query_buf)`). Our mainloop leaves a
1271
+ // residual `\n` in queryBuf after a top-level dispatch when the next
1272
+ // source line starts with a slash command — so a raw `length > 0`
1273
+ // check here would route to the "print the buffer" arm and emit
1274
+ // `\n\n` instead of falling through to `lastQuery`. The trim-only
1275
+ // emptiness check is purely an empty-vs-content discriminator; the
1276
+ // actual writeOut still uses the un-trimmed buffer text so an inline
1277
+ // `SELECT 3 \p` correctly emits the trailing space upstream prints.
1278
+ if (ctx.queryBuf.trim().length > 0) {
1279
+ writeOut(`${ctx.queryBuf}\n`);
1280
+ }
1281
+ else if (ctx.settings.lastQuery.length > 0) {
1282
+ writeOut(`${ctx.settings.lastQuery}\n`);
1283
+ }
1284
+ else if (!ctx.settings.quiet) {
1285
+ writeOut('Query buffer is empty.\n');
1286
+ }
1287
+ return Promise.resolve({ status: 'ok' });
1288
+ },
1289
+ };
1290
+ // ---------------------------------------------------------------------------
1291
+ // \gset [PREFIX]
1292
+ // ---------------------------------------------------------------------------
1293
+ const formatCell = (value) => {
1294
+ if (value === null || value === undefined)
1295
+ return '';
1296
+ if (typeof value === 'string')
1297
+ return value;
1298
+ if (Buffer.isBuffer(value))
1299
+ return value.toString('utf8');
1300
+ if (typeof value === 'number' ||
1301
+ typeof value === 'boolean' ||
1302
+ typeof value === 'bigint') {
1303
+ return String(value);
1304
+ }
1305
+ // Plain objects / arrays from JSON columns: JSON-stringify so the test
1306
+ // surface is deterministic and avoids "[object Object]".
1307
+ try {
1308
+ return JSON.stringify(value);
1309
+ }
1310
+ catch {
1311
+ return '';
1312
+ }
1313
+ };
1314
+ export const cmdGset = {
1315
+ name: 'gset',
1316
+ helpKey: 'gset',
1317
+ async run(ctx) {
1318
+ const gated = pipelineGate(ctx);
1319
+ if (gated !== null)
1320
+ return gated;
1321
+ // Strip leading whitespace + comments — see runGCore for the rationale.
1322
+ const trimmedBuf = stripLeadingCommentsAndWS(ctx.queryBuf);
1323
+ const bufSql = trimmedBuf.trim();
1324
+ const prefix = ctx.nextArg('normal') ?? '';
1325
+ // Empty buffer behaviour mirrors upstream `exec_command_gset`'s
1326
+ // `PSQL_CMD_SEND` return: the dispatch loop sends the active
1327
+ // `pset.last_query` (or nothing). Upstream does NOT emit an error
1328
+ // — it's a silent no-op when there's no buffer AND no prior query.
1329
+ // We mirror via `settings.lastQuery`, populated in `sendQuery` before
1330
+ // dispatch.
1331
+ const sql = bufSql.length > 0 ? bufSql : ctx.settings.lastQuery.trim();
1332
+ if (sql.length === 0) {
1333
+ return { status: 'reset-buf', newBuf: '' };
1334
+ }
1335
+ if (!ctx.settings.db) {
1336
+ return errResult(ctx, 'no connection to the server');
1337
+ }
1338
+ // Track for a subsequent `\g` re-run with empty buffer. Upstream
1339
+ // `exec_command_gset` updates `pset.last_query` to the dispatched SQL
1340
+ // before sending, so a follow-on `\g` (with the buffer reset by the
1341
+ // implicit `\\` separator in `... \gset pref01_ \\ \g`) re-executes
1342
+ // this same statement and prints the result table.
1343
+ ctx.settings.lastQuery = sql;
1344
+ let results;
1345
+ try {
1346
+ results = await ctx.settings.db.execSimple(sql);
1347
+ }
1348
+ catch (err) {
1349
+ // Server-side ErrorResponse — render in upstream's 3-line shape
1350
+ // (severity + message + LINE N / caret) instead of `\gset: <msg>`.
1351
+ // Pass the comment-stripped buffer so the `LINE N:` count matches
1352
+ // vanilla. When the buffer was empty, fall back to the re-run SQL
1353
+ // — the user wants to see WHICH statement failed.
1354
+ return formatServerError(ctx, err, bufSql.length > 0 ? trimmedBuf : sql);
1355
+ }
1356
+ // `\;`-chained batches: render every result EXCEPT the last to the
1357
+ // active output before `\gset` captures the last. Upstream's
1358
+ // `ExecQueryAndProcessResults` walks the libpq result list and runs
1359
+ // `PrintQueryResults` on each one in order; the trailing `\gset`
1360
+ // applies to the FINAL result (`StoreQueryTuple` in common.c) and
1361
+ // suppresses its print. Without this loop, a script like
1362
+ // `SELECT 3 AS three \; SELECT warn('3.5') \; SELECT 4 AS four \gset`
1363
+ // would silently drop the `three` table + the warn NOTICE's
1364
+ // surrounding tuples row.
1365
+ if (results.length > 1) {
1366
+ const out = pickOut(ctx.settings, null);
1367
+ for (let i = 0; i < results.length - 1; i++) {
1368
+ await renderResult(ctx.settings, results[i], out);
1369
+ }
1370
+ }
1371
+ // Use the last result that returned rows. Upstream uses the most-recent
1372
+ // tuples-producing statement; results without a row descriptor (e.g.
1373
+ // pure DDL) are skipped.
1374
+ // Upstream `StoreQueryTuple` only runs against the LAST PGresult and
1375
+ // only when that result is `PGRES_TUPLES_OK` (a tuples-producing
1376
+ // statement). Non-tuples results (DDL, INSERT/UPDATE without RETURNING)
1377
+ // fall through to a plain status print — `\gset` is a no-op there, NOT
1378
+ // an error. Mirror that here: pick the last result; if it isn't
1379
+ // tuples-producing, skip the variable-assignment step entirely.
1380
+ const lastRs = results[results.length - 1];
1381
+ if (!lastRs || lastRs.fields.length === 0) {
1382
+ return { status: 'reset-buf', newBuf: '' };
1383
+ }
1384
+ const rs = lastRs;
1385
+ if (rs.rows.length === 0) {
1386
+ // Bare `no rows returned for \gset` (no `\gset:` prefix) — matches
1387
+ // upstream psql's `pg_log_error("no rows returned for \\gset")`.
1388
+ ctx.settings.lastErrorResult = {
1389
+ message: 'no rows returned for \\gset',
1390
+ };
1391
+ const errPrefix = psqlErrorPrefix(ctx.settings);
1392
+ writeErr(`${errPrefix}no rows returned for \\gset\n`);
1393
+ return { status: 'error', errorWritten: true };
1394
+ }
1395
+ if (rs.rows.length > 1) {
1396
+ // Match upstream psql's exact wording from `exec_command_gset` —
1397
+ // bare `more than one row returned for \gset` (no `\gset:` prefix).
1398
+ // Verified against vanilla psql; vendored psql.out emits it bare.
1399
+ ctx.settings.lastErrorResult = {
1400
+ message: 'more than one row returned for \\gset',
1401
+ };
1402
+ const errPrefix = psqlErrorPrefix(ctx.settings);
1403
+ writeErr(`${errPrefix}more than one row returned for \\gset\n`);
1404
+ return { status: 'error', errorWritten: true };
1405
+ }
1406
+ const row = rs.rows[0];
1407
+ for (let i = 0; i < rs.fields.length; i++) {
1408
+ const fieldName = rs.fields[i].name;
1409
+ const name = `${prefix}${fieldName}`;
1410
+ const cell = row[i];
1411
+ const isNull = cell === null || cell === undefined;
1412
+ // Upstream skips assignments where the target maps to a "specially
1413
+ // treated" variable (one with a substitute / assign hook installed)
1414
+ // whose value would be rejected by the hook. The non-special columns
1415
+ // continue to be assigned: only the offending one is skipped, with
1416
+ // an informational stderr line. See psql.out line ~240:
1417
+ // attempt to \gset into specially treated variable "IGNOREEOF" ignored
1418
+ if (isSpeciallyTreatedVar(ctx.settings, name)) {
1419
+ // The target maps to a "specially treated" variable (one with a
1420
+ // substitute / assign hook installed). Upstream skips just this
1421
+ // assignment with an informational stderr line; other columns
1422
+ // are still processed. We don't actually call the hook — even a
1423
+ // value that the hook would accept must be rejected per upstream:
1424
+ // see `exec_command_gset` and `VariableHasHook`.
1425
+ const errPrefix = psqlErrorPrefix(ctx.settings);
1426
+ writeErr(`${errPrefix}attempt to \\gset into specially treated variable ` +
1427
+ `"${name}" ignored\n`);
1428
+ continue;
1429
+ }
1430
+ // Upstream `StoreQueryTuple` in src/bin/psql/common.c:
1431
+ //
1432
+ // if (PQgetisnull(result, 0, i))
1433
+ // UnsetVariable(pset.vars, varname);
1434
+ // else if (!SetVariable(pset.vars, varname, PQgetvalue(...))) { ... }
1435
+ //
1436
+ // i.e. a NULL cell unsets the target variable (so a subsequent
1437
+ // `:var` interpolates to the literal `:var` via the scanner's
1438
+ // unset-var passthrough) rather than setting it to the empty
1439
+ // string. Mirror that semantics here.
1440
+ if (isNull) {
1441
+ ctx.settings.vars.unset(name);
1442
+ continue;
1443
+ }
1444
+ const value = formatCell(cell);
1445
+ if (!ctx.settings.vars.set(name, value)) {
1446
+ // Bare `invalid variable name: "<name>"` (no `\gset:` prefix) —
1447
+ // matches upstream psql.out wording for `\gset` exactly.
1448
+ ctx.settings.lastErrorResult = {
1449
+ message: `invalid variable name: "${fieldName}"`,
1450
+ };
1451
+ const errPrefix = psqlErrorPrefix(ctx.settings);
1452
+ writeErr(`${errPrefix}invalid variable name: "${fieldName}"\n`);
1453
+ return { status: 'error', errorWritten: true };
1454
+ }
1455
+ }
1456
+ return { status: 'reset-buf', newBuf: '' };
1457
+ },
1458
+ };
1459
+ // ---------------------------------------------------------------------------
1460
+ // \gdesc — describe the current query without executing it.
1461
+ //
1462
+ // Mirrors upstream `exec_command_gdesc` in `src/bin/psql/command.c`: parse
1463
+ // the buffered query through the extended protocol (Parse + Describe by
1464
+ // statement, no Execute), then build a synthetic two-column ResultSet of
1465
+ // `Column` and `Type` rows and route it through the printer the user's
1466
+ // `\pset format` selected. Tuples-only mode (`\t on`) suppresses the
1467
+ // header / `(N columns)` footer the same way it would for a real query
1468
+ // result, because we hand the synthetic ResultSet to the same printer.
1469
+ //
1470
+ // Type names come from a follow-up `SELECT ... format_type(tp, tpm)`
1471
+ // over a VALUES literal — exactly the round-trip upstream uses so
1472
+ // non-builtin types and typmod modifiers (`numeric(10,2)`, `varchar(64)`)
1473
+ // render with their canonical form.
1474
+ // ---------------------------------------------------------------------------
1475
+ /**
1476
+ * Build the SQL that resolves each describe-result column's `Type` via
1477
+ * `pg_catalog.format_type(typoid, typmod)`. We feed the names + OIDs
1478
+ * + typmods through a `VALUES` literal so the server does the formatting
1479
+ * for us — the same query upstream issues from `describeFieldsByType`.
1480
+ *
1481
+ * Returns null when there are zero fields (caller emits `(0 rows)` form
1482
+ * by hand because PostgreSQL rejects an empty VALUES list).
1483
+ */
1484
+ const buildGdescFormatQuery = (fields) => {
1485
+ if (fields.length === 0)
1486
+ return null;
1487
+ // Each row literal escapes the column name with the standard E'' string
1488
+ // form so embedded quotes survive the round trip. The pg_type catalogue
1489
+ // expects oid + int4 typmod, so we cast accordingly. `_idx` keeps the
1490
+ // VALUES list in insertion order; `format_type` handles -1 typmod
1491
+ // (== "no modifier") natively.
1492
+ const rows = fields
1493
+ .map((f, i) => {
1494
+ const safeName = f.name.replace(/'/gu, "''");
1495
+ const oid = String(f.dataTypeID >>> 0);
1496
+ const typmod = String(f.dataTypeModifier | 0);
1497
+ return `(${String(i)}, '${safeName}', ${oid}::oid, ${typmod}::int4)`;
1498
+ })
1499
+ .join(', ');
1500
+ // ORDER BY _idx preserves the describe order regardless of how the server
1501
+ // happens to evaluate the VALUES list. Aliases match upstream column
1502
+ // titles exactly so the printer header is identical.
1503
+ return ('SELECT name AS "Column", pg_catalog.format_type(tp, tpm) AS "Type"' +
1504
+ ` FROM (VALUES ${rows}) AS x(_idx, name, tp, tpm) ORDER BY _idx`);
1505
+ };
1506
+ /**
1507
+ * Field descriptors for the synthetic `Column / Type` ResultSet that
1508
+ * `\gdesc` emits when format_type resolution fails or yields nothing.
1509
+ *
1510
+ * We fall back to the field's raw OID so the user still sees a value.
1511
+ */
1512
+ const GDESC_SYNTHETIC_FIELDS = [
1513
+ {
1514
+ name: 'Column',
1515
+ tableID: 0,
1516
+ columnID: 0,
1517
+ dataTypeID: 25,
1518
+ dataTypeSize: -1,
1519
+ dataTypeModifier: -1,
1520
+ format: 0,
1521
+ },
1522
+ {
1523
+ name: 'Type',
1524
+ tableID: 0,
1525
+ columnID: 0,
1526
+ dataTypeID: 25,
1527
+ dataTypeSize: -1,
1528
+ dataTypeModifier: -1,
1529
+ format: 0,
1530
+ },
1531
+ ];
1532
+ const buildSyntheticGdescResultSet = (rows) => ({
1533
+ command: 'SELECT',
1534
+ rowCount: rows.length,
1535
+ oid: null,
1536
+ fields: GDESC_SYNTHETIC_FIELDS,
1537
+ rows,
1538
+ notices: [],
1539
+ });
1540
+ export const cmdGdesc = {
1541
+ name: 'gdesc',
1542
+ helpKey: 'gdesc',
1543
+ async run(ctx) {
1544
+ const gated = pipelineGate(ctx);
1545
+ if (gated !== null)
1546
+ return gated;
1547
+ // Strip leading whitespace + comments — see runGCore for the rationale.
1548
+ const trimmedBuf = stripLeadingCommentsAndWS(ctx.queryBuf);
1549
+ const sql = trimmedBuf.trim();
1550
+ if (sql.length === 0) {
1551
+ // Upstream `\gdesc` with no buffer falls through `PSQL_CMD_SEND` to
1552
+ // the printer which renders the synthetic 0-column result via
1553
+ // `PrintQueryStatus`'s "The command has no result, or the result
1554
+ // has no columns." line. Stdout, exit 0 — not an error. Verified
1555
+ // against vanilla psql 18.
1556
+ process.stdout.write('The command has no result, or the result has no columns.\n');
1557
+ // Match upstream's post-PSQL_CMD_SEND state vars: success, 0 rows.
1558
+ refreshErrorVars(ctx.settings, { kind: 'success', rowCount: 0 });
1559
+ return { status: 'reset-buf', newBuf: '' };
1560
+ }
1561
+ if (!ctx.settings.db) {
1562
+ return errResult(ctx, 'no connection to the server');
1563
+ }
1564
+ // Track for a subsequent `\g` re-run with empty buffer. Upstream
1565
+ // `exec_command_gdesc` updates `pset.last_query` to the dispatched SQL
1566
+ // before sending, so a follow-on `\g` (with the buffer reset because
1567
+ // `\gdesc` dispatches via PSQL_CMD_SEND) re-executes this same statement
1568
+ // and prints the result table. Without this, the regress sequence
1569
+ // SELECT 1 AS x, ... \gdesc
1570
+ // \g
1571
+ // would silently drop the `\g` (empty buffer + stale lastQuery), and
1572
+ // any later `TABLE bububu;` failure would taint `\g`'s re-run output.
1573
+ ctx.settings.lastQuery = sql;
1574
+ let fields;
1575
+ try {
1576
+ const stmt = await ctx.settings.db.prepare('', sql);
1577
+ fields = await stmt.describe();
1578
+ // Close the unnamed prepared statement so we don't leak it. Failure
1579
+ // to close (e.g. server already in error state) is non-fatal.
1580
+ try {
1581
+ await stmt.close();
1582
+ }
1583
+ catch {
1584
+ // ignore
1585
+ }
1586
+ }
1587
+ catch (err) {
1588
+ // Capture + render the full ErrorResponse-shaped payload in upstream's
1589
+ // 3-line shape (severity + message + LINE N / caret), refresh the
1590
+ // diagnostic vars, and signal `errorWritten` to the mainloop so the
1591
+ // `\errverbose` re-render after `\gdesc` sees the rich layers. Pass
1592
+ // the comment-stripped buffer so the `LINE N:` count starts at the
1593
+ // first content line (matches vanilla — see runGCore).
1594
+ return formatServerError(ctx, err, trimmedBuf);
1595
+ }
1596
+ // When the prepared statement describes back zero columns (DDL, empty
1597
+ // SELECT list, etc.), upstream `exec_command_gdesc` prints the
1598
+ // pg_log_info "The command has no result, or the result has no
1599
+ // columns." line to stdout and skips the synthetic-table render.
1600
+ // Verified against vanilla psql 18: `SELECT \gdesc` and
1601
+ // `CREATE TABLE bububu(a int) \gdesc` both produce that text.
1602
+ if (fields.length === 0) {
1603
+ process.stdout.write('The command has no result, or the result has no columns.\n');
1604
+ // Match upstream's post-PSQL_CMD_SEND state vars: success, 0 rows.
1605
+ refreshErrorVars(ctx.settings, { kind: 'success', rowCount: 0 });
1606
+ return { status: 'reset-buf', newBuf: '' };
1607
+ }
1608
+ // Resolve canonical type names via a follow-up round trip when we have
1609
+ // at least one field. On failure (or when the server returns nothing —
1610
+ // a mock or an unusual connection state) fall back to the raw OID so
1611
+ // the user still sees a row per described column.
1612
+ let rows;
1613
+ const formatQuery = buildGdescFormatQuery(fields);
1614
+ if (formatQuery === null) {
1615
+ rows = [];
1616
+ }
1617
+ else {
1618
+ const fallbackRows = () => fields.map((f) => [f.name, String(f.dataTypeID)]);
1619
+ try {
1620
+ const sets = await ctx.settings.db.execSimple(formatQuery);
1621
+ const last = sets[sets.length - 1];
1622
+ rows = last && last.rows.length > 0 ? last.rows : fallbackRows();
1623
+ }
1624
+ catch {
1625
+ rows = fallbackRows();
1626
+ }
1627
+ }
1628
+ const rs = buildSyntheticGdescResultSet(rows);
1629
+ const printer = pickActivePrinter(ctx.settings);
1630
+ const out = pickOut(ctx.settings, null);
1631
+ try {
1632
+ await printer.printQuery(rs, ctx.settings.popt, out);
1633
+ }
1634
+ catch (err) {
1635
+ const msg = err instanceof Error ? err.message : String(err);
1636
+ return errResult(ctx, msg);
1637
+ }
1638
+ // Refresh state vars to mark the describe success: `:ERROR=false`,
1639
+ // `:SQLSTATE=00000`, `:ROW_COUNT=<#-described-columns>`. Upstream
1640
+ // routes `\gdesc` through `PSQL_CMD_SEND` so its post-dispatch
1641
+ // `SetResultVariables` sees the synthetic 2-column tuple result and
1642
+ // assigns ROW_COUNT to the field count we just rendered.
1643
+ refreshErrorVars(ctx.settings, {
1644
+ kind: 'success',
1645
+ rowCount: rs.rowCount,
1646
+ });
1647
+ return { status: 'reset-buf', newBuf: '' };
1648
+ },
1649
+ };
1650
+ // ---------------------------------------------------------------------------
1651
+ // \gexec — treat each cell of the result as SQL to execute.
1652
+ // ---------------------------------------------------------------------------
1653
+ export const cmdGexec = {
1654
+ name: 'gexec',
1655
+ helpKey: 'gexec',
1656
+ async run(ctx) {
1657
+ const gated = pipelineGate(ctx);
1658
+ if (gated !== null)
1659
+ return gated;
1660
+ // Strip leading whitespace + comments — see runGCore for the rationale.
1661
+ const trimmedBuf = stripLeadingCommentsAndWS(ctx.queryBuf);
1662
+ const bufSql = trimmedBuf.trim();
1663
+ // Upstream `\gexec` with no buffer falls through `PSQL_CMD_SEND` and
1664
+ // re-runs `pset.last_query` (or nothing). Silent on empty + no prior
1665
+ // query — exit 0, no message. Verified against vanilla psql 18.
1666
+ const sql = bufSql.length > 0 ? bufSql : ctx.settings.lastQuery.trim();
1667
+ if (sql.length === 0) {
1668
+ return { status: 'reset-buf', newBuf: '' };
1669
+ }
1670
+ if (!ctx.settings.db) {
1671
+ return errResult(ctx, 'no connection to the server');
1672
+ }
1673
+ // Track the outer (meta) query for a subsequent `\g` re-run with an empty
1674
+ // buffer. Upstream `exec_command_gexec` runs through PSQL_CMD_SEND, which
1675
+ // bumps `pset.last_query` before dispatch.
1676
+ ctx.settings.lastQuery = sql;
1677
+ let firstPass;
1678
+ try {
1679
+ firstPass = await ctx.settings.db.execSimple(sql);
1680
+ }
1681
+ catch (err) {
1682
+ // Render the first-pass server error in upstream's 3-line shape.
1683
+ // Pass the comment-stripped buffer so the `LINE N:` count matches
1684
+ // vanilla — see runGCore for the rationale.
1685
+ return formatServerError(ctx, err, trimmedBuf);
1686
+ }
1687
+ const tupled = firstPass.filter((r) => r.fields.length > 0);
1688
+ if (tupled.length === 0) {
1689
+ return { status: 'reset-buf', newBuf: '' };
1690
+ }
1691
+ const out = pickOut(ctx.settings, null);
1692
+ // Echo each generated SQL when ECHO is `all` or `queries`. Vanilla
1693
+ // `exec_command_gexec` calls `SendQuery` for each row's text, and
1694
+ // SendQuery itself prints the statement via the standard query-echo
1695
+ // path: stdout, no `\gexec:` / `psql:` prefix, trailing LF. The echo
1696
+ // appears BEFORE the result body so the conformance harness sees
1697
+ // the same interleaving vanilla produces.
1698
+ const echo = ctx.settings.echo;
1699
+ const shouldEcho = echo === 'all' || echo === 'queries';
1700
+ // Per-row errors are tolerated: upstream `\gexec` calls
1701
+ // `SendQuery` in a loop and ignores its return value (the only
1702
+ // escape is the global ON_ERROR_STOP variable, which the
1703
+ // conformance harness sets to 0). Without this, the regress
1704
+ // expects `drop table gexec_test\nERROR: ...\nselect ...` and we'd
1705
+ // truncate at the ERROR.
1706
+ let sawError = false;
1707
+ for (const rs of tupled) {
1708
+ for (const row of rs.rows) {
1709
+ for (const cell of row) {
1710
+ if (cell === null || cell === undefined)
1711
+ continue;
1712
+ const statement = formatCell(cell).trim();
1713
+ if (statement.length === 0)
1714
+ continue;
1715
+ if (shouldEcho) {
1716
+ out.write(statement + '\n');
1717
+ }
1718
+ try {
1719
+ const nested = await ctx.settings.db.execSimple(statement);
1720
+ for (const sub of nested) {
1721
+ if (sub.fields.length > 0) {
1722
+ await renderResult(ctx.settings, sub, out);
1723
+ }
1724
+ }
1725
+ }
1726
+ catch (err) {
1727
+ // Each iteration is its own statement; render the per-row
1728
+ // server error in upstream's 3-line shape (LINE / caret are
1729
+ // positioned against `statement`, the offending row text)
1730
+ // but DO NOT return — vanilla continues to the next row.
1731
+ formatServerError(ctx, err, statement);
1732
+ sawError = true;
1733
+ // Honour ON_ERROR_STOP: when set, halt the loop after the
1734
+ // first failing row. Upstream's `do_gexec` consults the
1735
+ // global `pset.on_error_stop` flag via `SendQuery`'s
1736
+ // return; we mirror by checking the setting directly.
1737
+ if (ctx.settings.onErrorStop) {
1738
+ return { status: 'error', errorWritten: true };
1739
+ }
1740
+ }
1741
+ }
1742
+ }
1743
+ }
1744
+ // Even with errors, return `reset-buf` so the mainloop clears the
1745
+ // outer `\gexec` buffer. Per-row error rendering already happened;
1746
+ // returning `error` here would re-trigger the writeError path.
1747
+ void sawError;
1748
+ return { status: 'reset-buf', newBuf: '' };
1749
+ },
1750
+ };
1751
+ // ---------------------------------------------------------------------------
1752
+ // \watch [args...]
1753
+ //
1754
+ // Upstream `\watch` accepts:
1755
+ //
1756
+ // \watch [SEC] — legacy positional interval (seconds)
1757
+ // \watch i=SEC — interval as named flag
1758
+ // \watch c=N — iteration count limit
1759
+ // \watch m=N — minimum row count: keep polling until the
1760
+ // result has >= N rows; uses `interval` as the
1761
+ // sleep between polls
1762
+ // \watch min_rows=N — long-form alias of `m=`
1763
+ //
1764
+ // Flags may be combined in any order. Duplicates (including the positional
1765
+ // interval colliding with `i=`) are rejected upstream with the message
1766
+ // "<thing> is specified more than once".
1767
+ //
1768
+ // The `WATCH_INTERVAL` psql variable supplies the default `interval` value
1769
+ // when `i=` is not given (and when there is no positional). The variable is
1770
+ // validated at `\set` time via a hook installed by `defaultSettings`.
1771
+ // ---------------------------------------------------------------------------
1772
+ const sleepCancellable = (ms, signal) => new Promise((resolve) => {
1773
+ const timer = setTimeout(() => {
1774
+ signal.removeEventListener('abort', onAbort);
1775
+ resolve();
1776
+ }, ms);
1777
+ const onAbort = () => {
1778
+ clearTimeout(timer);
1779
+ signal.removeEventListener('abort', onAbort);
1780
+ resolve();
1781
+ };
1782
+ if (signal.aborted) {
1783
+ clearTimeout(timer);
1784
+ resolve();
1785
+ return;
1786
+ }
1787
+ signal.addEventListener('abort', onAbort);
1788
+ });
1789
+ /**
1790
+ * Strictly parse a non-negative finite float.
1791
+ *
1792
+ * Returns the parsed number, or `null` for any of:
1793
+ * - empty string
1794
+ * - non-numeric trailing characters (e.g. `10ab`)
1795
+ * - negative values (e.g. `-10`)
1796
+ * - out-of-range / non-finite results (e.g. `10e400` → Infinity)
1797
+ *
1798
+ * Used to validate `\watch` intervals and the `WATCH_INTERVAL` variable.
1799
+ */
1800
+ const parseStrictNonNegativeFloat = (raw) => {
1801
+ if (raw.length === 0)
1802
+ return null;
1803
+ // Reject anything that doesn't look like a plain float literal. We
1804
+ // accept optional sign + digits + optional fractional + optional
1805
+ // exponent. Trailing garbage (`10ab`), negative values, and exponents
1806
+ // that overflow to Infinity all funnel into the null result.
1807
+ const re = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/;
1808
+ if (!re.test(raw))
1809
+ return null;
1810
+ const value = parseFloat(raw);
1811
+ if (!Number.isFinite(value))
1812
+ return null;
1813
+ if (value < 0)
1814
+ return null;
1815
+ return value;
1816
+ };
1817
+ /**
1818
+ * Parse a strict non-negative integer (no exponent, no fractional).
1819
+ * Used for `c=` and `m=` / `min_rows=` argument values.
1820
+ */
1821
+ const parseStrictNonNegativeInt = (raw) => {
1822
+ if (raw.length === 0)
1823
+ return null;
1824
+ if (!/^\d+$/.test(raw))
1825
+ return null;
1826
+ const value = parseInt(raw, 10);
1827
+ if (!Number.isFinite(value))
1828
+ return null;
1829
+ return value;
1830
+ };
1831
+ /**
1832
+ * Default `\watch` interval (seconds). Mirrors upstream
1833
+ * `DEFAULT_WATCH_INTERVAL`. Exported so `defaultSettings` can substitute
1834
+ * it when the user unsets the `WATCH_INTERVAL` variable — upstream's
1835
+ * `watch_interval_substitute_hook` reseeds the value to `2` on null.
1836
+ */
1837
+ export const DEFAULT_WATCH_INTERVAL = '2';
1838
+ /**
1839
+ * Render `\watch`'s per-iteration timestamp in upstream psql's
1840
+ * `ctime`-style layout: `Day Mon DD HH:MM:SS YYYY` (e.g. `Mon May 25
1841
+ * 19:41:55 2026`). Upstream calls `strftime("%c", &tm)` with the C locale;
1842
+ * we reproduce the field order in vanilla English so the output matches
1843
+ * regardless of the host locale.
1844
+ *
1845
+ * Exported only for unit-testing the format ladder.
1846
+ */
1847
+ const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
1848
+ const MONTHS = [
1849
+ 'Jan',
1850
+ 'Feb',
1851
+ 'Mar',
1852
+ 'Apr',
1853
+ 'May',
1854
+ 'Jun',
1855
+ 'Jul',
1856
+ 'Aug',
1857
+ 'Sep',
1858
+ 'Oct',
1859
+ 'Nov',
1860
+ 'Dec',
1861
+ ];
1862
+ const pad2 = (n) => (n < 10 ? `0${String(n)}` : String(n));
1863
+ export const formatWatchTimestamp = (now) => {
1864
+ const weekday = WEEKDAYS[now.getDay()];
1865
+ const month = MONTHS[now.getMonth()];
1866
+ const day = pad2(now.getDate());
1867
+ const hh = pad2(now.getHours());
1868
+ const mm = pad2(now.getMinutes());
1869
+ const ss = pad2(now.getSeconds());
1870
+ const year = String(now.getFullYear());
1871
+ return `${weekday} ${month} ${day} ${hh}:${mm}:${ss} ${year}`;
1872
+ };
1873
+ /**
1874
+ * Upper bound on the `WATCH_INTERVAL` variable and the positional interval
1875
+ * — matches upstream which rejects "out of range" values. Upstream uses
1876
+ * `strtod` and rejects ±Infinity; we tighten further so a single watch loop
1877
+ * cannot sleep for longer than ~100 hours, which catches obvious typos
1878
+ * without breaking legitimate slow polls.
1879
+ */
1880
+ const WATCH_INTERVAL_MAX_SECONDS = 100 * 3600;
1881
+ /**
1882
+ * Resolve the effective default `\watch` interval from the `WATCH_INTERVAL`
1883
+ * psql variable. Returns the parsed value, the documented default
1884
+ * (`DEFAULT_WATCH_INTERVAL`), or an `error` envelope if the variable is set
1885
+ * but parses out of range.
1886
+ */
1887
+ const resolveWatchIntervalDefault = (settings) => {
1888
+ // The variable is seeded to `DEFAULT_WATCH_INTERVAL` by `defaultSettings`
1889
+ // (and re-seeded on `\unset`), so it's typically a string at use time.
1890
+ // If a future code path leaves it undefined we fall back to the same
1891
+ // documented default — upstream's `ParseVariableDouble` substitutes
1892
+ // `DEFAULT_WATCH_INTERVAL` when the var slot is empty.
1893
+ const raw = settings.vars.get('WATCH_INTERVAL') ?? DEFAULT_WATCH_INTERVAL;
1894
+ const parsed = parseStrictNonNegativeFloat(raw);
1895
+ if (parsed === null || parsed > WATCH_INTERVAL_MAX_SECONDS) {
1896
+ return {
1897
+ error: `WATCH_INTERVAL "${raw}" is out of range`,
1898
+ };
1899
+ }
1900
+ return { value: parsed };
1901
+ };
1902
+ /**
1903
+ * Spawn the `\watch` pager for the full duration of the polling loop.
1904
+ *
1905
+ * Upstream `do_watch` wraps the loop in a single `popen` of
1906
+ * `PSQL_WATCH_PAGER`. It deliberately ignores `PSQL_PAGER` and `$PAGER`:
1907
+ *
1908
+ * > we ignore the regular PSQL_PAGER or PAGER environment variables,
1909
+ * > because traditional pagers probably won't be very useful for
1910
+ * > showing a stream of results.
1911
+ *
1912
+ * Mirror that here. Reading from `$PAGER` would silently hijack
1913
+ * `\watch` output for any user whose shell sets `PAGER=less` (the
1914
+ * common default), which makes the loop's output disappear into a
1915
+ * subprocess that diff harnesses can't capture.
1916
+ *
1917
+ * We use a single `sh -c <pager>` spawn so the user can set the
1918
+ * variable to a full command string (`less -R`, `tee /tmp/log`, …)
1919
+ * without the caller having to tokenise it. EPIPE on the stdin pipe is
1920
+ * swallowed for the same reason as in `openWriter`: the user may quit
1921
+ * `less` while we still have writes pending in the next iteration.
1922
+ *
1923
+ * Returns `null` when `PSQL_WATCH_PAGER` is unset or whitespace-only
1924
+ * (upstream's "no pager" rule), so the caller falls back to the normal
1925
+ * output target.
1926
+ */
1927
+ const openWatchPager = () => {
1928
+ const cmd = process.env.PSQL_WATCH_PAGER ?? '';
1929
+ if (cmd.trim().length === 0)
1930
+ return null;
1931
+ const child = spawn('sh', ['-c', cmd], {
1932
+ stdio: ['pipe', 'inherit', 'inherit'],
1933
+ });
1934
+ child.stdin.on('error', (err) => {
1935
+ if (err.code !== 'EPIPE') {
1936
+ throw err;
1937
+ }
1938
+ });
1939
+ return {
1940
+ stream: child.stdin,
1941
+ close: () => new Promise((resolve) => {
1942
+ // If the pager already exited (e.g. PSQL_WATCH_PAGER=false, or the
1943
+ // user quit it) the 'close'/'error' events have ALREADY fired, so a
1944
+ // freshly-registered `once()` listener would never run and close()
1945
+ // would hang forever — taking `\watch` with it.
1946
+ if (child.exitCode !== null || child.signalCode !== null) {
1947
+ resolve();
1948
+ return;
1949
+ }
1950
+ let settled = false;
1951
+ const finish = () => {
1952
+ if (settled)
1953
+ return;
1954
+ settled = true;
1955
+ resolve();
1956
+ };
1957
+ child.once('close', finish);
1958
+ child.once('error', finish);
1959
+ if (!child.stdin.destroyed) {
1960
+ try {
1961
+ child.stdin.end();
1962
+ }
1963
+ catch (err) {
1964
+ const e = err;
1965
+ if (e.code !== 'EPIPE')
1966
+ finish();
1967
+ }
1968
+ }
1969
+ }),
1970
+ };
1971
+ };
1972
+ export const cmdWatch = {
1973
+ name: 'watch',
1974
+ helpKey: 'watch',
1975
+ async run(ctx) {
1976
+ const gated = pipelineGate(ctx);
1977
+ if (gated !== null)
1978
+ return gated;
1979
+ // Strip leading whitespace + comments — see runGCore for the rationale.
1980
+ const trimmedBuf = stripLeadingCommentsAndWS(ctx.queryBuf);
1981
+ const sql = trimmedBuf.trim();
1982
+ if (sql.length === 0) {
1983
+ return errResult(ctx, 'no query buffer');
1984
+ }
1985
+ if (!ctx.settings.db) {
1986
+ return errResult(ctx, 'no connection to the server');
1987
+ }
1988
+ // Track which options have been seen so we can reject duplicates with
1989
+ // the upstream-formatted "<thing> is specified more than once" message.
1990
+ let intervalSet = false;
1991
+ let interval = null;
1992
+ let iterSet = false;
1993
+ let iterMax = 0; // 0 = unlimited (matches upstream's "no -c").
1994
+ let minRowsSet = false;
1995
+ let minRows = 0;
1996
+ let positionalSeen = false;
1997
+ // Drain all args. Each is either a `key=value` token or a bare
1998
+ // positional (only allowed as the very first arg, and only once).
1999
+ while (true) {
2000
+ const arg = ctx.nextArg('normal');
2001
+ if (arg === null)
2002
+ break;
2003
+ if (arg.length === 0)
2004
+ continue;
2005
+ // Identify named flags by looking for `=`. Upstream tolerates an
2006
+ // empty value (treats it as the option not being provided), but we
2007
+ // mirror its stricter behaviour for the values we care about.
2008
+ const eqIdx = arg.indexOf('=');
2009
+ if (eqIdx > 0) {
2010
+ const key = arg.slice(0, eqIdx);
2011
+ const value = arg.slice(eqIdx + 1);
2012
+ if (key === 'i') {
2013
+ if (intervalSet) {
2014
+ return errResult(ctx, 'interval value is specified more than once');
2015
+ }
2016
+ const parsed = parseStrictNonNegativeFloat(value);
2017
+ if (parsed === null || parsed > WATCH_INTERVAL_MAX_SECONDS) {
2018
+ return errResult(ctx, `incorrect interval value "${value}"`);
2019
+ }
2020
+ interval = parsed;
2021
+ intervalSet = true;
2022
+ continue;
2023
+ }
2024
+ if (key === 'c') {
2025
+ if (iterSet) {
2026
+ return errResult(ctx, 'iteration count is specified more than once');
2027
+ }
2028
+ const parsed = parseStrictNonNegativeInt(value);
2029
+ // Upstream parses the count with `option_parse_int(..., 1, INT_MAX)`
2030
+ // so the iteration count must be >= 1; `c=0` is rejected as out of
2031
+ // range. We reserve the internal `iterMax = 0` sentinel purely for
2032
+ // "no `c=` given" (unlimited continuous mode), so accepting `c=0`
2033
+ // here would silently cap the loop at a single iteration instead.
2034
+ if (parsed === null || parsed === 0) {
2035
+ return errResult(ctx, `incorrect iteration count "${value}"`);
2036
+ }
2037
+ iterMax = parsed;
2038
+ iterSet = true;
2039
+ continue;
2040
+ }
2041
+ if (key === 'm' || key === 'min_rows') {
2042
+ if (minRowsSet) {
2043
+ return errResult(ctx, 'minimum row count specified more than once');
2044
+ }
2045
+ const parsed = parseStrictNonNegativeInt(value);
2046
+ if (parsed === null) {
2047
+ return errResult(ctx, `incorrect minimum row count "${value}"`);
2048
+ }
2049
+ minRows = parsed;
2050
+ minRowsSet = true;
2051
+ continue;
2052
+ }
2053
+ // Unknown key=value: surface a generic error mirroring upstream
2054
+ // ("unrecognized value …").
2055
+ return errResult(ctx, `unrecognized option "${key}"`);
2056
+ }
2057
+ // Positional argument — legacy interval. Allowed only once, and
2058
+ // only collides with `i=` under the same upstream "specified more
2059
+ // than once" rubric.
2060
+ if (positionalSeen || intervalSet) {
2061
+ return errResult(ctx, 'interval value is specified more than once');
2062
+ }
2063
+ const parsed = parseStrictNonNegativeFloat(arg);
2064
+ if (parsed === null || parsed > WATCH_INTERVAL_MAX_SECONDS) {
2065
+ return errResult(ctx, `incorrect interval value "${arg}"`);
2066
+ }
2067
+ interval = parsed;
2068
+ intervalSet = true;
2069
+ positionalSeen = true;
2070
+ }
2071
+ // If no explicit interval was supplied, fall back to WATCH_INTERVAL.
2072
+ if (interval === null) {
2073
+ const resolved = resolveWatchIntervalDefault(ctx.settings);
2074
+ if ('error' in resolved) {
2075
+ return errResult(ctx, resolved.error);
2076
+ }
2077
+ interval = resolved.value;
2078
+ }
2079
+ const intervalMs = Math.round(interval * 1000);
2080
+ // Prefer a test-supplied controller; otherwise install a transient
2081
+ // SIGINT listener that aborts the loop.
2082
+ const controller = WATCH_TEST_CONTROLLER.ref ?? new AbortController();
2083
+ const sigintHandler = () => {
2084
+ controller.abort();
2085
+ };
2086
+ const installedSigint = WATCH_TEST_CONTROLLER.ref === null;
2087
+ if (installedSigint) {
2088
+ process.once('SIGINT', sigintHandler);
2089
+ }
2090
+ // Open the pager once for the whole loop (upstream `do_watch` wraps the
2091
+ // entire session, not each iteration, so the user can scroll the
2092
+ // accumulated output in one go). When PSQL_WATCH_PAGER / PAGER aren't
2093
+ // set we fall through to the normal `pickOut` target.
2094
+ const pager = openWatchPager();
2095
+ const out = pager?.stream ?? pickOut(ctx.settings, null);
2096
+ try {
2097
+ // CONTINUOUS mode: when `c=` is absent, `iterSet` stays false and the
2098
+ // iteration-cap break below never fires, so the loop re-runs the query
2099
+ // on the interval forever — exactly upstream `do_watch`'s `for (i = 0;
2100
+ // !iter || i < iter; i++)` when `iter == 0`. The only exits are a
2101
+ // SIGINT (or the test controller) aborting `controller.signal`, a
2102
+ // server error, or the `min_rows` CONTINUE predicate failing.
2103
+ let iter = 0;
2104
+ while (!controller.signal.aborted) {
2105
+ iter++;
2106
+ const stamp = formatWatchTimestamp(new Date());
2107
+ out.write(`${stamp} (every ${String(interval)}s)\n\n`);
2108
+ let lastRowCount = 0;
2109
+ try {
2110
+ const results = await ctx.settings.db.execSimple(sql);
2111
+ for (const rs of results) {
2112
+ if (rs.fields.length > 0) {
2113
+ await renderResult(ctx.settings, rs, out);
2114
+ lastRowCount = rs.rows.length;
2115
+ }
2116
+ }
2117
+ }
2118
+ catch (err) {
2119
+ // Surface in upstream's 3-line ErrorResponse shape (severity +
2120
+ // message + LINE / caret) — same path top-level statement errors
2121
+ // take. The `\watch:` prefix is reserved for client-side
2122
+ // argument-parsing errors (e.g. `incorrect interval value "-10"`).
2123
+ // Pass the comment-stripped buffer so the `LINE N:` count starts
2124
+ // at the first content line — see runGCore for the rationale.
2125
+ return formatServerError(ctx, err, trimmedBuf);
2126
+ }
2127
+ // Stop if `c=` reached the configured iteration cap, OR if `m=`
2128
+ // was set and the previous result returned FEWER than `min_rows`
2129
+ // tuples. Upstream's `ExecQueryAndProcessResults` sets `return_early`
2130
+ // exactly when `min_rows > 0 && PQntuples(result) < min_rows`, and
2131
+ // `do_watch` breaks out of the loop on that signal — see PG source
2132
+ // `src/bin/psql/common.c::ExecQueryAndProcessResults`. In other
2133
+ // words `min_rows` is a CONTINUE predicate: keep polling while
2134
+ // the result has at least `min_rows` rows; stop the moment it
2135
+ // doesn't.
2136
+ if (iterSet && iter >= iterMax)
2137
+ break;
2138
+ if (minRowsSet && lastRowCount < minRows)
2139
+ break;
2140
+ if (controller.signal.aborted)
2141
+ break;
2142
+ // sleep_ms == 0 is upstream's "tight loop, no wait needed" — skip
2143
+ // the timer round-trip entirely so we don't queue a setTimeout(0)
2144
+ // for every iteration. Matches `do_watch`'s `if (sleep_ms == 0)
2145
+ // continue;` branch.
2146
+ if (intervalMs > 0) {
2147
+ await sleepCancellable(intervalMs, controller.signal);
2148
+ }
2149
+ }
2150
+ // Upstream `do_watch` injects a trailing newline AFTER the loop
2151
+ // ends when no pager is attached, to clear the cursor after a
2152
+ // possible `^C` echo. Mirror that here so the conformance output
2153
+ // shape (`...\n(N rows)\n\n\n`) matches vanilla psql.
2154
+ if (!pager) {
2155
+ out.write('\n');
2156
+ }
2157
+ return { status: 'reset-buf', newBuf: '' };
2158
+ }
2159
+ finally {
2160
+ if (installedSigint) {
2161
+ process.removeListener('SIGINT', sigintHandler);
2162
+ }
2163
+ // Drain the pager so its child has a chance to exit before \watch
2164
+ // returns. Failures are swallowed: a broken pager shouldn't mask the
2165
+ // (already-flushed) query results.
2166
+ if (pager) {
2167
+ await pager.close();
2168
+ }
2169
+ }
2170
+ },
2171
+ };
2172
+ // ---------------------------------------------------------------------------
2173
+ // Registration entry point.
2174
+ // ---------------------------------------------------------------------------
2175
+ export const registerIoCommands = (registry) => {
2176
+ registry.register(cmdInclude);
2177
+ registry.register(cmdIncludeRel);
2178
+ registry.register(cmdOut);
2179
+ registry.register(cmdWrite);
2180
+ registry.register(cmdG);
2181
+ registry.register(cmdGx);
2182
+ registry.register(cmdPrint);
2183
+ registry.register(cmdGset);
2184
+ registry.register(cmdGdesc);
2185
+ registry.register(cmdGexec);
2186
+ registry.register(cmdWatch);
2187
+ };