neonctl 2.22.2 → 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 (113) hide show
  1. package/README.md +84 -0
  2. package/analytics.js +5 -2
  3. package/commands/branches.js +9 -1
  4. package/commands/connection_string.js +9 -1
  5. package/commands/functions.js +277 -0
  6. package/commands/index.js +4 -0
  7. package/commands/neon_auth.js +1013 -0
  8. package/commands/projects.js +9 -1
  9. package/commands/psql.js +6 -1
  10. package/functions_api.js +44 -0
  11. package/package.json +15 -5
  12. package/psql/cli.js +51 -0
  13. package/psql/command/cmd_cond.js +437 -0
  14. package/psql/command/cmd_connect.js +815 -0
  15. package/psql/command/cmd_copy.js +1025 -0
  16. package/psql/command/cmd_describe.js +1810 -0
  17. package/psql/command/cmd_format.js +909 -0
  18. package/psql/command/cmd_io.js +2187 -0
  19. package/psql/command/cmd_lo.js +385 -0
  20. package/psql/command/cmd_meta.js +970 -0
  21. package/psql/command/cmd_misc.js +187 -0
  22. package/psql/command/cmd_pipeline.js +1141 -0
  23. package/psql/command/cmd_restrict.js +171 -0
  24. package/psql/command/cmd_show.js +751 -0
  25. package/psql/command/dispatch.js +343 -0
  26. package/psql/command/inputQueue.js +42 -0
  27. package/psql/command/shared.js +71 -0
  28. package/psql/complete/filenames.js +139 -0
  29. package/psql/complete/index.js +104 -0
  30. package/psql/complete/matcher.js +314 -0
  31. package/psql/complete/psqlVars.js +247 -0
  32. package/psql/complete/queries.js +491 -0
  33. package/psql/complete/rules.js +2387 -0
  34. package/psql/core/common.js +1250 -0
  35. package/psql/core/help.js +576 -0
  36. package/psql/core/mainloop.js +1353 -0
  37. package/psql/core/prompt.js +437 -0
  38. package/psql/core/settings.js +684 -0
  39. package/psql/core/sqlHelp.js +1066 -0
  40. package/psql/core/startup.js +840 -0
  41. package/psql/core/syncVars.js +116 -0
  42. package/psql/core/variables.js +287 -0
  43. package/psql/describe/formatters.js +1277 -0
  44. package/psql/describe/processNamePattern.js +270 -0
  45. package/psql/describe/queries.js +2373 -0
  46. package/psql/describe/versionGate.js +43 -0
  47. package/psql/index.js +2005 -0
  48. package/psql/io/history.js +299 -0
  49. package/psql/io/input.js +120 -0
  50. package/psql/io/lineEditor/buffer.js +323 -0
  51. package/psql/io/lineEditor/complete.js +227 -0
  52. package/psql/io/lineEditor/filename.js +159 -0
  53. package/psql/io/lineEditor/index.js +891 -0
  54. package/psql/io/lineEditor/keymap.js +738 -0
  55. package/psql/io/lineEditor/vt100.js +363 -0
  56. package/psql/io/pgpass.js +202 -0
  57. package/psql/io/pgservice.js +194 -0
  58. package/psql/io/psqlrc.js +422 -0
  59. package/psql/print/aligned.js +1756 -0
  60. package/psql/print/asciidoc.js +248 -0
  61. package/psql/print/crosstab.js +460 -0
  62. package/psql/print/csv.js +92 -0
  63. package/psql/print/html.js +258 -0
  64. package/psql/print/json.js +96 -0
  65. package/psql/print/latex.js +396 -0
  66. package/psql/print/pager.js +265 -0
  67. package/psql/print/troff.js +258 -0
  68. package/psql/print/unaligned.js +118 -0
  69. package/psql/print/units.js +135 -0
  70. package/psql/scanner/slash.js +513 -0
  71. package/psql/scanner/sql.js +910 -0
  72. package/psql/scanner/stringutils.js +390 -0
  73. package/psql/types/backslash.js +1 -0
  74. package/psql/types/connection.js +1 -0
  75. package/psql/types/index.js +7 -0
  76. package/psql/types/printer.js +1 -0
  77. package/psql/types/repl.js +1 -0
  78. package/psql/types/scanner.js +24 -0
  79. package/psql/types/settings.js +1 -0
  80. package/psql/types/variables.js +1 -0
  81. package/psql/wire/connection.js +2844 -0
  82. package/psql/wire/copy.js +108 -0
  83. package/psql/wire/notify.js +59 -0
  84. package/psql/wire/pipeline.js +519 -0
  85. package/psql/wire/protocol.js +466 -0
  86. package/psql/wire/sasl.js +296 -0
  87. package/psql/wire/tls.js +596 -0
  88. package/test_utils/fixtures.js +1 -0
  89. package/utils/esbuild.js +147 -0
  90. package/utils/psql.js +107 -11
  91. package/utils/zip.js +4 -0
  92. package/writer.js +1 -1
  93. package/commands/auth.test.js +0 -211
  94. package/commands/branches.test.js +0 -460
  95. package/commands/checkout.test.js +0 -170
  96. package/commands/connection_string.test.js +0 -196
  97. package/commands/data_api.test.js +0 -169
  98. package/commands/databases.test.js +0 -39
  99. package/commands/help.test.js +0 -9
  100. package/commands/init.test.js +0 -56
  101. package/commands/ip_allow.test.js +0 -59
  102. package/commands/link.test.js +0 -381
  103. package/commands/operations.test.js +0 -7
  104. package/commands/orgs.test.js +0 -7
  105. package/commands/projects.test.js +0 -144
  106. package/commands/psql.test.js +0 -49
  107. package/commands/roles.test.js +0 -37
  108. package/commands/set_context.test.js +0 -159
  109. package/commands/vpc_endpoints.test.js +0 -69
  110. package/context.test.js +0 -119
  111. package/env.test.js +0 -55
  112. package/utils/formats.test.js +0 -32
  113. package/writer.test.js +0 -104
@@ -0,0 +1,1141 @@
1
+ /**
2
+ * psql pipeline / extended-query backslash commands (WP-21).
3
+ *
4
+ * Implements the subset of upstream psql's "pipeline mode" backslash commands
5
+ * that drive the extended protocol directly. These are typically used together
6
+ * with the existing `\g` flow: a `\bind` (or `\parse`) command stashes
7
+ * parameter / statement state on the {@link BackslashContext}'s settings via
8
+ * a Symbol-keyed slot; when the mainloop then sees a `;` (or a `\g`), it can
9
+ * notice the stashed state and route the query through
10
+ * `Connection.query(sql, params)` instead of `execSimple`.
11
+ *
12
+ * Because the mainloop integration is owned by other WPs (and was deliberately
13
+ * left untouched here), this module exposes both the command specs AND a
14
+ * small helper, {@link getPipelineState}, that the mainloop will use to
15
+ * consult the stashed state. The commands operate on the buffered query the
16
+ * same way `\g` does — they execute or queue the buffer and reset it.
17
+ *
18
+ * Commands shipped:
19
+ *
20
+ * \bind [VALUE ...] stash params for next ; / \g
21
+ * \bind_named NAME [V ...] stash params + named prepared statement
22
+ * \parse NAME prepare current query buffer as NAME
23
+ * \close_prepared NAME Close('S', NAME)
24
+ * \startpipeline begin a pipeline session (settings.sendMode)
25
+ * \endpipeline end the pipeline session, drain results
26
+ * \syncpipeline send Sync mid-pipeline
27
+ * \sendpipeline submit the current buffered query w/o waiting
28
+ * \flushrequest send Flush
29
+ * \flush alias for \flushrequest
30
+ * \getresults [N] drain pending pipeline results
31
+ * \gdesc describe-without-execute the buffered query
32
+ *
33
+ * The set is registered in bulk via {@link registerPipelineCommands}.
34
+ */
35
+ import { writeErr } from './shared.js';
36
+ import { alignedPrinter } from '../print/aligned.js';
37
+ import { clearPipelineGateErrors } from './cmd_io.js';
38
+ // ---------------------------------------------------------------------------
39
+ // Settings stash. We can't add new fields to PsqlSettings (frozen WP-00) so
40
+ // we attach Symbol-keyed state.
41
+ // ---------------------------------------------------------------------------
42
+ const BIND_STATE_KEY = Symbol.for('neonctl.psql.bindState');
43
+ const PIPELINE_KEY = Symbol.for('neonctl.psql.pipeline');
44
+ const PREPARED_BY_NAME_KEY = Symbol.for('neonctl.psql.preparedByName');
45
+ const stashOf = (settings) => settings;
46
+ /** Read (and clear) the pending bind params, if any. */
47
+ export const consumeBindState = (settings) => {
48
+ const s = stashOf(settings);
49
+ const cur = s[BIND_STATE_KEY] ?? null;
50
+ s[BIND_STATE_KEY] = undefined;
51
+ return cur;
52
+ };
53
+ /**
54
+ * Peek at the bind stash without consuming. Used by `\g` to decide
55
+ * whether to skip the "empty buffer, no prior query" no-op guard:
56
+ * when a `\bind_named NAME` is pending, the prepared statement carries
57
+ * the SQL server-side so no buffer text is needed.
58
+ */
59
+ export const stagedNamedBindPresent = (settings) => {
60
+ const cur = stashOf(settings)[BIND_STATE_KEY];
61
+ return !!cur && cur.byName;
62
+ };
63
+ /** Stash a PreparedStatement for later `\bind_named NAME \g` lookup. */
64
+ export const stashPrepared = (settings, name, ps) => {
65
+ const s = stashOf(settings);
66
+ let map = s[PREPARED_BY_NAME_KEY];
67
+ if (!map) {
68
+ map = new Map();
69
+ s[PREPARED_BY_NAME_KEY] = map;
70
+ }
71
+ map.set(name, ps);
72
+ };
73
+ /** Look up a previously-stashed PreparedStatement by name. */
74
+ export const lookupPrepared = (settings, name) => {
75
+ const map = stashOf(settings)[PREPARED_BY_NAME_KEY];
76
+ return map?.get(name) ?? null;
77
+ };
78
+ /** Drop a stashed PreparedStatement (after `\close_prepared`). */
79
+ export const dropPrepared = (settings, name) => {
80
+ const map = stashOf(settings)[PREPARED_BY_NAME_KEY];
81
+ if (map)
82
+ map.delete(name);
83
+ };
84
+ /** Peek at the current pipeline session (or null). */
85
+ export const getPipelineState = (settings) => stashOf(settings)[PIPELINE_KEY] ?? null;
86
+ // ---------------------------------------------------------------------------
87
+ // PIPELINE_* counter book-keeping.
88
+ //
89
+ // Upstream psql 18 exposes three pipeline counters as `:VAR`-interpolatable
90
+ // session variables (initialized to "0" in `settings.ts` at startup):
91
+ //
92
+ // PIPELINE_COMMAND_COUNT number of P/B/E batches queued since last Sync
93
+ // PIPELINE_SYNC_COUNT number of Syncs issued in the current pipeline
94
+ // PIPELINE_RESULT_COUNT results queued server-side but not yet fetched
95
+ //
96
+ // The counter rules (verified empirically against vanilla psql 18.4):
97
+ //
98
+ // \startpipeline — all three reset to "0".
99
+ // \parse — COMMAND_COUNT++.
100
+ // \sendpipeline — COMMAND_COUNT++.
101
+ // ;-query in pipeline mode — COMMAND_COUNT++ (each implicit-`;` send).
102
+ // \syncpipeline — SYNC_COUNT++, RESULT_COUNT += COMMAND_COUNT,
103
+ // COMMAND_COUNT = 0.
104
+ // \flushrequest /
105
+ // \flush — RESULT_COUNT += COMMAND_COUNT, COMMAND_COUNT = 0.
106
+ // \getresults [N] — RESULT_COUNT -= actually-drained; if RESULT_COUNT
107
+ // hits 0, SYNC_COUNT also resets to 0 (full-drain
108
+ // returns the pipeline to a clean slate).
109
+ // \endpipeline — all three reset to "0".
110
+ //
111
+ // For `;`-queries the increment fires from a wrapper installed around the
112
+ // stashed `Pipeline.execute` in `cmdStartPipeline.run` — mainloop's
113
+ // `dispatchSendQuery` calls `ps.session.parse/bind/execute` directly, and
114
+ // the wrapper picks that up without mainloop having to know about the var
115
+ // store.
116
+ // ---------------------------------------------------------------------------
117
+ const readCounter = (settings, name) => {
118
+ const raw = settings.vars.get(name);
119
+ if (raw === undefined)
120
+ return 0;
121
+ const n = parseInt(raw, 10);
122
+ return Number.isFinite(n) && n >= 0 ? n : 0;
123
+ };
124
+ const setCounter = (settings, name, value) => {
125
+ settings.vars.set(name, String(Math.max(0, value)));
126
+ };
127
+ const bumpCounter = (settings, name, delta) => {
128
+ setCounter(settings, name, readCounter(settings, name) + delta);
129
+ };
130
+ const resetPipelineCounters = (settings) => {
131
+ setCounter(settings, 'PIPELINE_COMMAND_COUNT', 0);
132
+ setCounter(settings, 'PIPELINE_SYNC_COUNT', 0);
133
+ setCounter(settings, 'PIPELINE_RESULT_COUNT', 0);
134
+ };
135
+ /**
136
+ * Wrap `Pipeline.execute` so each enqueued Execute message bumps
137
+ * `PIPELINE_COMMAND_COUNT`. The wrapping covers both call sites:
138
+ *
139
+ * - `cmdSendPipeline` (this file) — `\sendpipeline`.
140
+ * - `dispatchSendQuery` (mainloop) — implicit `;`-queries while pipeline
141
+ * is active. Mainloop calls `ps.session.execute('', 0)` so the wrapper
142
+ * fires automatically without mainloop knowing about the var store.
143
+ *
144
+ * The wrapper preserves the original function's `this` binding via `apply`.
145
+ */
146
+ const wrapSessionForCounters = (session, settings) => {
147
+ const origExecute = session.execute.bind(session);
148
+ session.execute = (name, maxRows) => {
149
+ bumpCounter(settings, 'PIPELINE_COMMAND_COUNT', 1);
150
+ return origExecute(name, maxRows);
151
+ };
152
+ return session;
153
+ };
154
+ // ---------------------------------------------------------------------------
155
+ // Helpers
156
+ // ---------------------------------------------------------------------------
157
+ const errResult = (ctx, message) => {
158
+ ctx.settings.lastErrorResult = { message };
159
+ writeErr(`\\${ctx.cmdName}: ${message}\n`);
160
+ // Tell mainloop the diagnostic is already on stderr so it doesn't add
161
+ // a `psql: ERROR: <msg>` fallback line.
162
+ return { status: 'error', errorWritten: true };
163
+ };
164
+ /**
165
+ * Coerce any thrown / rejected value into a printable string. The
166
+ * extended-protocol driver in `PgConnection` rejects with raw
167
+ * ConnectError records (`{severity, code, message, detail, ...}`) —
168
+ * not `Error` instances — so `String(err)` would produce
169
+ * `[object Object]` in the conformance output. We probe for a
170
+ * `.message` property (covering both `Error` and the ConnectError
171
+ * shape) and fall back to JSON.stringify only when there's no
172
+ * message field at all.
173
+ */
174
+ const errorToMessage = (err) => {
175
+ if (err instanceof Error)
176
+ return err.message;
177
+ if (err !== null &&
178
+ typeof err === 'object' &&
179
+ 'message' in err &&
180
+ typeof err.message === 'string') {
181
+ return err.message;
182
+ }
183
+ try {
184
+ return JSON.stringify(err);
185
+ }
186
+ catch {
187
+ return String(err);
188
+ }
189
+ };
190
+ /**
191
+ * Render a pipeline-collected ConnectError to stderr in the upstream
192
+ * shape: a `SEVERITY: message` line, followed by optional `DETAIL:` /
193
+ * `HINT:` / `CONTEXT:` lines (each on its own line). Matches the
194
+ * subset of `formatErrorReport`-style output the conformance corpus
195
+ * expects from `\endpipeline` for non-FATAL pipeline errors. We
196
+ * inline a minimal renderer here rather than importing the full
197
+ * `formatErrorReport` from `cmd_meta` because we always want the
198
+ * "default verbosity, no LINE/caret context" form — pipeline errors
199
+ * arrive after the buffered query has been reset and we don't carry
200
+ * the originating SQL position through `session.lastError`.
201
+ */
202
+ const renderPipelineError = (err) => {
203
+ if (err === null || typeof err !== 'object') {
204
+ writeErr(`ERROR: ${errorToMessage(err)}\n`);
205
+ return;
206
+ }
207
+ const e = err;
208
+ // libpq's `PGRES_PIPELINE_ABORTED` marker — the message is the bare
209
+ // "Pipeline aborted, command did not run" text with no `ERROR:` /
210
+ // `SEVERITY:` prefix and no DETAIL/HINT/CONTEXT layers. Mirrors the
211
+ // wording the regress baseline asserts for cascaded skips after a
212
+ // preceding ErrorResponse.
213
+ if (e.pipelineAborted) {
214
+ writeErr(`${e.message ?? 'Pipeline aborted, command did not run'}\n`);
215
+ return;
216
+ }
217
+ const severity = e.severity ?? 'ERROR';
218
+ const message = e.message ?? '';
219
+ writeErr(`${severity}: ${message}\n`);
220
+ if (e.detail)
221
+ writeErr(`DETAIL: ${e.detail}\n`);
222
+ if (e.hint)
223
+ writeErr(`HINT: ${e.hint}\n`);
224
+ if (e.where)
225
+ writeErr(`CONTEXT: ${e.where}\n`);
226
+ };
227
+ const readAllArgs = (ctx) => {
228
+ const out = [];
229
+ for (;;) {
230
+ const arg = ctx.nextArg('normal');
231
+ if (arg === null)
232
+ break;
233
+ out.push(arg);
234
+ }
235
+ return out;
236
+ };
237
+ // ---------------------------------------------------------------------------
238
+ // \bind [VALUE ...]
239
+ // ---------------------------------------------------------------------------
240
+ export const cmdBind = {
241
+ name: 'bind',
242
+ helpKey: 'bind',
243
+ run(ctx) {
244
+ const values = readAllArgs(ctx);
245
+ stashOf(ctx.settings)[BIND_STATE_KEY] = {
246
+ name: '',
247
+ values,
248
+ byName: false,
249
+ };
250
+ return Promise.resolve({ status: 'ok' });
251
+ },
252
+ };
253
+ // ---------------------------------------------------------------------------
254
+ // \bind_named NAME [VALUE ...]
255
+ // ---------------------------------------------------------------------------
256
+ export const cmdBindNamed = {
257
+ name: 'bind_named',
258
+ helpKey: 'bind_named',
259
+ run(ctx) {
260
+ const name = ctx.nextArg('normal');
261
+ // Upstream `exec_command_bind_named` rejects only the missing-arg
262
+ // case. `''` IS valid — it addresses the unnamed prepared statement
263
+ // slot (set via `\parse ''`).
264
+ if (name === null) {
265
+ // Upstream wipes any prior `\bind_named` state on this error so a
266
+ // follow-on `\g` falls back to `pset.last_query` (the previous
267
+ // successful query) instead of executing against a stale handle.
268
+ stashOf(ctx.settings)[BIND_STATE_KEY] = undefined;
269
+ return Promise.resolve(errResult(ctx, 'missing required argument'));
270
+ }
271
+ const values = readAllArgs(ctx);
272
+ stashOf(ctx.settings)[BIND_STATE_KEY] = { name, values, byName: true };
273
+ return Promise.resolve({ status: 'ok' });
274
+ },
275
+ };
276
+ // ---------------------------------------------------------------------------
277
+ // \parse NAME — prepare current queryBuf as NAME.
278
+ // ---------------------------------------------------------------------------
279
+ export const cmdParse = {
280
+ name: 'parse',
281
+ helpKey: 'parse',
282
+ async run(ctx) {
283
+ const name = ctx.nextArg('normal');
284
+ // Upstream `exec_command_parse` rejects only the missing-arg case
285
+ // with `missing required argument`. An explicit empty string `''`
286
+ // IS valid — it's the "unnamed" prepared statement slot, addressable
287
+ // later via `\bind_named ''`.
288
+ if (name === null) {
289
+ return errResult(ctx, 'missing required argument');
290
+ }
291
+ // Upstream `exec_command_parse` passes the query buffer verbatim to
292
+ // `PQsendPrepare`, with no trim — the server then stores the bytes
293
+ // exactly in `pg_prepared_statements.statement`. Trimming here would
294
+ // strip trailing whitespace that the conformance corpus (and any
295
+ // `LINE 1:` ErrorResponse echo) expects to round-trip byte-for-byte.
296
+ // The empty-buffer guard still uses a trimmed view so a whitespace-
297
+ // only buffer reports `no query buffer` like upstream does.
298
+ const sql = ctx.queryBuf;
299
+ if (sql.trim().length === 0) {
300
+ return errResult(ctx, 'no query buffer');
301
+ }
302
+ if (!ctx.settings.db) {
303
+ return errResult(ctx, 'no connection to the server');
304
+ }
305
+ // In pipeline mode, route the Parse through the active session so
306
+ // it gets queued behind the in-flight P/B/E ops (and the server
307
+ // defers any ParseError until the next Sync). Doing a `db.prepare`
308
+ // here would issue its own Sync mid-pipeline, corrupting the
309
+ // pipeline's reply ordering — the conformance corpus exercises a
310
+ // `\parse '' \parse '' \parse pipeline_1` triple-Parse and
311
+ // expects the third Parse's `could not determine data type` error
312
+ // to surface AT `\endpipeline` time, not synchronously here.
313
+ const pipelineActive = getPipelineState(ctx.settings);
314
+ if (pipelineActive !== null) {
315
+ try {
316
+ // `\parse NAME` is a USER-level command (one entry on libpq's
317
+ // result queue), so route through `parseSlot` (real
318
+ // PipelineSession) which both enqueues the Parse wire op AND
319
+ // registers a `cmdSlots` entry. `\getresults` walks `cmdSlots`,
320
+ // so without the slot the cmd would be invisible to drain
321
+ // accounting. Test mocks that don't implement `parseSlot` fall
322
+ // through to the plain `parse()` method.
323
+ const session = pipelineActive.session;
324
+ if (typeof session.parseSlot === 'function') {
325
+ await session.parseSlot(name, sql, []);
326
+ }
327
+ else {
328
+ await session.parse(name, sql, []);
329
+ }
330
+ ctx.settings.lastQuery = sql;
331
+ // Upstream `exec_command_parse` bumps `pset.piped_commands` after
332
+ // PQsendPrepare succeeds — the Parse is one queued command.
333
+ bumpCounter(ctx.settings, 'PIPELINE_COMMAND_COUNT', 1);
334
+ return { status: 'reset-buf', newBuf: '' };
335
+ }
336
+ catch (err) {
337
+ return errResult(ctx, errorToMessage(err));
338
+ }
339
+ }
340
+ try {
341
+ const ps = await ctx.settings.db.prepare(name, sql);
342
+ // Cache for `\bind_named NAME \g` lookup later. Upstream tracks
343
+ // server-side prepared statements by name in `pset.psqlScanState`-
344
+ // adjacent state; we keep a local map so a follow-on `\g` can
345
+ // bind + execute without re-parsing.
346
+ stashPrepared(ctx.settings, name, ps);
347
+ // Upstream `exec_command_parse` also updates `pset.last_query` to
348
+ // the prepared SQL so a subsequent `\g` (e.g. after a failed
349
+ // `\bind_named NAME` that wipes bind state) re-runs the parsed
350
+ // text via the simple-query path. Without this, our `\g` would
351
+ // either no-op or fall back to a stale prior query, missing the
352
+ // "ERROR: there is no parameter $1" the conformance corpus
353
+ // expects from `SELECT $1, $2` executed without bind params.
354
+ ctx.settings.lastQuery = sql;
355
+ return { status: 'reset-buf', newBuf: '' };
356
+ }
357
+ catch (err) {
358
+ return errResult(ctx, errorToMessage(err));
359
+ }
360
+ },
361
+ };
362
+ // ---------------------------------------------------------------------------
363
+ // \close_prepared NAME
364
+ // ---------------------------------------------------------------------------
365
+ export const cmdClosePrepared = {
366
+ name: 'close_prepared',
367
+ helpKey: 'close_prepared',
368
+ async run(ctx) {
369
+ const name = ctx.nextArg('normal');
370
+ // Empty string `''` is the valid unnamed prepared statement.
371
+ if (name === null) {
372
+ return errResult(ctx, 'missing required argument');
373
+ }
374
+ const db = ctx.settings.db;
375
+ if (!db) {
376
+ return errResult(ctx, 'no connection to the server');
377
+ }
378
+ // Inside an open pipeline, upstream routes through
379
+ // `PQsendClosePrepared` (queues a `Close('S', name)` behind the
380
+ // already-pending P/B/E ops, no Sync). Issuing a Sync here — as the
381
+ // out-of-pipeline `db.closePreparedStatement` path does — would split
382
+ // the in-flight batch and surface the pipeline's sticky error on the
383
+ // wire, leading to `\close_prepared: bind message supplies …`
384
+ // diagnostics that vanilla never emits.
385
+ const ps = getPipelineState(ctx.settings);
386
+ if (ps !== null) {
387
+ try {
388
+ // `\close_prepared NAME` is a USER-level command — route through
389
+ // `closeSlot` so it registers on `cmdSlots` like Parse / Execute
390
+ // (see the parseSlot comment in `\parse` above). Test mocks
391
+ // that don't implement `closeSlot` fall through to plain
392
+ // `close()`.
393
+ const session = ps.session;
394
+ if (typeof session.closeSlot === 'function') {
395
+ await session.closeSlot(name);
396
+ }
397
+ else {
398
+ await session.close(name);
399
+ }
400
+ // Upstream `exec_command_close_prepared` bumps `piped_commands`
401
+ // (PIPELINE_COMMAND_COUNT) after a successful PQsendClosePrepared.
402
+ bumpCounter(ctx.settings, 'PIPELINE_COMMAND_COUNT', 1);
403
+ // Drop any cached binding so a later `\bind_named NAME \g`
404
+ // errors cleanly instead of using a stale handle.
405
+ dropPrepared(ctx.settings, name);
406
+ return { status: 'ok' };
407
+ }
408
+ catch (err) {
409
+ return errResult(ctx, errorToMessage(err));
410
+ }
411
+ }
412
+ try {
413
+ // Out-of-pipeline path: upstream issues `Close('S', name) + Sync`
414
+ // directly; the server treats Close on a missing name as a no-op
415
+ // (CloseComplete without diagnostics), so we don't need to know
416
+ // whether the statement exists. A previous implementation faked a
417
+ // `prepare(name, 'SELECT 1')` to reach the same Close, which broke
418
+ // when the name was already prepared on the server (Parse fails
419
+ // with `prepared statement "NAME" already exists`).
420
+ if (db.closePreparedStatement) {
421
+ await db.closePreparedStatement(name);
422
+ }
423
+ else {
424
+ // Backwards-compat path for Connection mocks that don't
425
+ // implement the dedicated entry point. The real PgConnection
426
+ // always provides closePreparedStatement; this branch only
427
+ // fires under unit tests with bespoke Connection mocks.
428
+ const stmt = await db.prepare(name, 'SELECT 1');
429
+ await stmt.close();
430
+ }
431
+ // Drop any cached binding so a later `\bind_named NAME \g` errors
432
+ // cleanly instead of using a stale handle.
433
+ dropPrepared(ctx.settings, name);
434
+ return { status: 'ok' };
435
+ }
436
+ catch (err) {
437
+ return errResult(ctx, errorToMessage(err));
438
+ }
439
+ },
440
+ };
441
+ // ---------------------------------------------------------------------------
442
+ // \startpipeline / \endpipeline
443
+ // ---------------------------------------------------------------------------
444
+ export const cmdStartPipeline = {
445
+ name: 'startpipeline',
446
+ helpKey: 'startpipeline',
447
+ run(ctx) {
448
+ if (!ctx.settings.db) {
449
+ return Promise.resolve(errResult(ctx, 'no connection to the server'));
450
+ }
451
+ // Vanilla psql 18.4 treats a duplicate `\startpipeline` as a silent
452
+ // no-op (no warning on stdout OR stderr) — verified empirically:
453
+ // `psql --no-psqlrc --echo-all --quiet -X -c '\startpipeline
454
+ // \startpipeline \endpipeline' 2>&1` prints only the three echoed
455
+ // lines. Our prior `errResult('pipeline already active')` was a
456
+ // divergence; match upstream's quiet path.
457
+ if (getPipelineState(ctx.settings) !== null) {
458
+ return Promise.resolve({ status: 'ok' });
459
+ }
460
+ try {
461
+ const session = wrapSessionForCounters(ctx.settings.db.pipeline(), ctx.settings);
462
+ stashOf(ctx.settings)[PIPELINE_KEY] = {
463
+ session,
464
+ pending: [],
465
+ drainedCount: 0,
466
+ };
467
+ ctx.settings.sendMode = 'extended-pipeline';
468
+ // Upstream `exec_command_startpipeline` calls SetVariable for the three
469
+ // counter vars (zeroing whatever the prior pipeline left behind).
470
+ resetPipelineCounters(ctx.settings);
471
+ return Promise.resolve({ status: 'ok' });
472
+ }
473
+ catch (err) {
474
+ return Promise.resolve(errResult(ctx, errorToMessage(err)));
475
+ }
476
+ },
477
+ };
478
+ export const cmdEndPipeline = {
479
+ name: 'endpipeline',
480
+ helpKey: 'endpipeline',
481
+ async run(ctx) {
482
+ const ps = getPipelineState(ctx.settings);
483
+ if (!ps) {
484
+ // Upstream `exec_command_endpipeline` writes the diagnostic via
485
+ // `pg_log_error`; in psql 18.4 the line lands on stderr with NO
486
+ // `psql:` / `\endpipeline:` prefix (verified empirically with
487
+ // `psql --no-psqlrc --echo-all --quiet -X -c '\endpipeline'
488
+ // 2>&1` — the only stderr line is the raw message). The
489
+ // conformance corpus mirrors that bare form, so we bypass
490
+ // `errResult` (which would inject `\endpipeline: `) and write
491
+ // the line directly.
492
+ ctx.settings.lastErrorResult = {
493
+ message: 'cannot send pipeline when not in pipeline mode',
494
+ };
495
+ writeErr('cannot send pipeline when not in pipeline mode\n');
496
+ return { status: 'error', errorWritten: true };
497
+ }
498
+ try {
499
+ // Snapshot how many slots were already surfaced by prior
500
+ // `\getresults` calls — only the residual that the final Sync
501
+ // inside `session.end()` flushes should be printed. Upstream
502
+ // psql achieves this implicitly via `PQgetResult()` consuming
503
+ // results from libpq's queue inside `\getresults`; we mirror
504
+ // the semantics with an explicit cursor (`drainedCount`).
505
+ const alreadyDrained = ps.drainedCount;
506
+ // Hold a reference to the session BEFORE end() clears the stash —
507
+ // we still need `lastError` after the pipeline has been torn down.
508
+ // The session is `Pipeline` (interface) but the concrete impl
509
+ // (`PipelineSession`) exposes per-USER-command slot tracking; cast
510
+ // and probe for the field so test mocks (which don't carry the
511
+ // extra fields) still work — they get an empty snapshot and the
512
+ // path degenerates to the previous `sets`-only rendering.
513
+ const session = ps.session;
514
+ // Capture per-USER-command slots as a snapshot — `end()` settles
515
+ // them but doesn't expose the per-op rejection records, which
516
+ // we need to interleave errors at the correct ordinal position.
517
+ const cmdSlotsSnapshot = Array.isArray(session.cmdSlots)
518
+ ? session.cmdSlots.slice()
519
+ : [];
520
+ // FETCH_COUNT-in-pipeline detection — when the user set FETCH_COUNT
521
+ // and the pipeline is being aborted, upstream emits the
522
+ // "fetching results in chunked mode failed" wording in lieu of
523
+ // (or in addition to) the regular Pipeline aborted line. Read
524
+ // BEFORE end() resets the counters.
525
+ const fetchCountActive = (ctx.settings.vars.get('FETCH_COUNT') ?? '0') !== '0';
526
+ const sets = await ps.session.end();
527
+ stashOf(ctx.settings)[PIPELINE_KEY] = undefined;
528
+ ctx.settings.sendMode = 'extended-query';
529
+ // Upstream `exec_command_endpipeline` zeroes the counters once the
530
+ // pipeline has drained — mirrors the empirical behaviour of vanilla
531
+ // psql 18.4 where `\echo :PIPELINE_*` reads "0" after `\endpipeline`.
532
+ resetPipelineCounters(ctx.settings);
533
+ // Drop any accumulated pipeline-gate diagnostics (`\gdesc not
534
+ // allowed in pipeline mode` etc.) so a future pipeline starts
535
+ // with a clean error log. Upstream resets the equivalent
536
+ // libpq-side error stack at the same boundary.
537
+ clearPipelineGateErrors(ctx.settings);
538
+ // Walk the per-USER-command slots in issue order, interleaving
539
+ // ErrorResponse renderings with successful ResultSets. Upstream
540
+ // psql 18.4 emits errors EXACTLY where the failed op sat in the
541
+ // wire stream (see expected/psql_pipeline.out line 433: the
542
+ // `bind message supplies 0 parameters` ERROR prints BEFORE the
543
+ // second `\sendpipeline`'s `?column?` table because the failed
544
+ // bind was the first Execute and the successful query was the
545
+ // second). Plain "print all sets then error" would invert the
546
+ // order.
547
+ const settled = await Promise.allSettled(cmdSlotsSnapshot);
548
+ let errorRendered = false;
549
+ // When the snapshot is empty (test mocks that don't track
550
+ // cmdSlots), fall back to printing the `sets` returned by
551
+ // `end()` directly. This preserves the historical behaviour for
552
+ // mocks while keeping the in-order interleaving for the real
553
+ // PipelineSession.
554
+ const entries = settled.length > 0
555
+ ? settled
556
+ : sets.map((rs) => ({
557
+ status: 'fulfilled',
558
+ value: rs,
559
+ }));
560
+ // Pre-scan THIS slice (entries from `alreadyDrained` onward) for
561
+ // the first non-aborted rejection. The wire layer cascade-rejects
562
+ // every queued non-sync op on ErrorResponse: the first failing op
563
+ // gets the real `ConnectError`, follow-on ops are rejected with
564
+ // the synthetic `pipelineAborted` marker. When the failing op
565
+ // lives in `pending` (Parse / Bind / Close — none of which are
566
+ // tracked on `cmdSlots` as a separate slot), the slot inherits
567
+ // the cascaded marker — in that case fall through to
568
+ // `session.lastError` which captures the original ERROR from the
569
+ // wire-layer `sync()` / `end()` path.
570
+ const sliceForError = entries.slice(alreadyDrained);
571
+ const realFromSlice = (() => {
572
+ for (const r of sliceForError) {
573
+ if (r.status !== 'rejected')
574
+ continue;
575
+ const reason = r.reason;
576
+ const isAborted = typeof reason === 'object' &&
577
+ reason !== null &&
578
+ reason.pipelineAborted === true;
579
+ if (!isAborted)
580
+ return reason;
581
+ }
582
+ return null;
583
+ })();
584
+ const realLastError = realFromSlice ??
585
+ (() => {
586
+ const le = session.lastError;
587
+ if (le === null || le === undefined)
588
+ return null;
589
+ if (typeof le === 'object' &&
590
+ le.pipelineAborted) {
591
+ return null;
592
+ }
593
+ return le;
594
+ })();
595
+ for (let i = alreadyDrained; i < entries.length; i++) {
596
+ const r = entries[i];
597
+ if (r.status === 'fulfilled') {
598
+ const rs = r.value;
599
+ // Emit any NoticeResponse messages attached to this result
600
+ // to stderr in the upstream libpq shape
601
+ // (`${severity}: ${message}\n`). In pipeline mode the
602
+ // server emits notices interleaved with Bind / Execute
603
+ // replies, so they're attached to the corresponding
604
+ // ResultSet's `notices` array; vanilla psql 18.4 prints
605
+ // them BEFORE the result body at `\endpipeline` time
606
+ // (e.g. `regress/psql_pipeline.out` line 671: the
607
+ // `WARNING: SET LOCAL can only be used in transaction
608
+ // blocks` lands right before the first `statement_timeout`
609
+ // table).
610
+ for (const n of rs.notices) {
611
+ let out = `${n.severity}: ${n.message}\n`;
612
+ if (n.detail !== undefined)
613
+ out += `DETAIL: ${n.detail}\n`;
614
+ if (n.hint !== undefined)
615
+ out += `HINT: ${n.hint}\n`;
616
+ writeErr(out);
617
+ }
618
+ // Print real tuples-producing results — including the 0-column
619
+ // 1-row shape from `SELECT \bind \sendpipeline` which upstream
620
+ // psql renders as `--\n(1 row)\n` (the table glyphs are just
621
+ // the trailing separator row plus the default row-count footer).
622
+ // Skip our internal Sync marker (empty `command`, see
623
+ // wire/pipeline.ts) and DDL-style CommandComplete-only sets
624
+ // (non-empty `command` but no fields and no rows).
625
+ const isSyncOrPlaceholder = rs.fields.length === 0 && rs.command === '' && rs.rows.length === 0;
626
+ const isCommandOnly = rs.fields.length === 0 && rs.rows.length === 0 && rs.command !== '';
627
+ if (!isSyncOrPlaceholder && !isCommandOnly) {
628
+ if (rs.fields.length === 0 && rs.rows.length > 0) {
629
+ // 0-column tuples result: the aligned printer's
630
+ // header/rule machinery degenerates to whitespace because
631
+ // there are no column widths to drive the dividers. Emit
632
+ // the upstream-shaped placeholder (`--` separator + row
633
+ // count) inline so we match `psql_pipeline.out`'s
634
+ // `\watch`-rejected SELECT output byte-for-byte.
635
+ const tuplesOnly = ctx.settings.popt.topt.tuplesOnly;
636
+ if (!tuplesOnly) {
637
+ process.stdout.write('--\n');
638
+ process.stdout.write(`(${rs.rows.length} ${rs.rows.length === 1 ? 'row' : 'rows'})\n\n`);
639
+ }
640
+ }
641
+ else {
642
+ await alignedPrinter.printQuery(rs, ctx.settings.popt, process.stdout);
643
+ }
644
+ }
645
+ }
646
+ else if (!errorRendered) {
647
+ // Render only the FIRST rejection inline — subsequent ops
648
+ // in an aborted pipeline reject with the synthetic
649
+ // `pipelineAborted` marker which we coalesce to one line.
650
+ // When the wire layer cascade-rejected from a Parse / Bind /
651
+ // Close that lives in `pending`, the only entry visible here
652
+ // is one rejected with `pipelineAborted` — fall back to
653
+ // `session.lastError` / `peekRealError` for the original
654
+ // ERROR.
655
+ const reason = r.reason;
656
+ const isAborted = typeof reason === 'object' &&
657
+ reason !== null &&
658
+ reason.pipelineAborted === true;
659
+ // FETCH_COUNT-in-pipeline: upstream emits the chunked-mode
660
+ // diagnostic in addition to the per-op line. Both go to
661
+ // stderr; the chunked-mode line comes FIRST and addresses the
662
+ // SQL-shaped failure (libpq's PQsetSingleRowMode rejection
663
+ // inside a pipeline). Mirror that for any rejection that
664
+ // surfaces at `\endpipeline` time while FETCH_COUNT was set.
665
+ // The chunked-mode prefix is gated on FETCH_COUNT, but the REAL
666
+ // ERROR must still be surfaced — the old code printed the prefix +
667
+ // "Pipeline aborted" for ANY rejection under FETCH_COUNT, swallowing
668
+ // the actual ERROR: text (review: minor divergences). "Pipeline
669
+ // aborted, command did not run" is shown ONLY for the synthetic
670
+ // queue-skip marker (no real error behind it).
671
+ if (fetchCountActive) {
672
+ writeErr('fetching results in chunked mode failed\n');
673
+ }
674
+ if (isAborted && realLastError !== null) {
675
+ renderPipelineError(realLastError);
676
+ }
677
+ else if (isAborted) {
678
+ writeErr('Pipeline aborted, command did not run\n');
679
+ }
680
+ else {
681
+ renderPipelineError(reason);
682
+ }
683
+ errorRendered = true;
684
+ }
685
+ }
686
+ // Fallback: if `session.lastError` was set but no per-op
687
+ // rejection was observed (can happen when the error came from
688
+ // the trailing Sync rather than a specific Execute), render it
689
+ // here. Otherwise the diagnostic would be lost.
690
+ if (!errorRendered) {
691
+ const lastErr = session.lastError;
692
+ if (lastErr !== null && lastErr !== undefined) {
693
+ // A real trailing-Sync error: name the chunked-mode failure when
694
+ // FETCH_COUNT was active, but still render the actual ERROR rather
695
+ // than the synthetic "Pipeline aborted" line (review: minor).
696
+ if (fetchCountActive) {
697
+ writeErr('fetching results in chunked mode failed\n');
698
+ }
699
+ renderPipelineError(lastErr);
700
+ }
701
+ }
702
+ return { status: 'ok' };
703
+ }
704
+ catch (err) {
705
+ stashOf(ctx.settings)[PIPELINE_KEY] = undefined;
706
+ ctx.settings.sendMode = 'extended-query';
707
+ // Even on a failed `\endpipeline` (e.g. server hung up mid-drain),
708
+ // mirror upstream and clear the counters — the pipeline state is
709
+ // gone, so any non-zero value would be misleading. Same for the
710
+ // pipeline-gate error log.
711
+ resetPipelineCounters(ctx.settings);
712
+ clearPipelineGateErrors(ctx.settings);
713
+ return errResult(ctx, errorToMessage(err));
714
+ }
715
+ },
716
+ };
717
+ // ---------------------------------------------------------------------------
718
+ // \syncpipeline / \flushrequest / \flush
719
+ // ---------------------------------------------------------------------------
720
+ export const cmdSyncPipeline = {
721
+ name: 'syncpipeline',
722
+ helpKey: 'syncpipeline',
723
+ async run(ctx) {
724
+ const ps = getPipelineState(ctx.settings);
725
+ if (!ps)
726
+ return errResult(ctx, 'no pipeline active');
727
+ try {
728
+ await ps.session.sync();
729
+ // Upstream `exec_command_syncpipeline`:
730
+ // piped_results += piped_commands;
731
+ // piped_commands = 0;
732
+ // piped_syncs++;
733
+ // The pending commands have transitioned to "queued results" on the
734
+ // server, and Sync is itself counted as a piped command boundary.
735
+ const queued = readCounter(ctx.settings, 'PIPELINE_COMMAND_COUNT');
736
+ setCounter(ctx.settings, 'PIPELINE_COMMAND_COUNT', 0);
737
+ bumpCounter(ctx.settings, 'PIPELINE_RESULT_COUNT', queued);
738
+ bumpCounter(ctx.settings, 'PIPELINE_SYNC_COUNT', 1);
739
+ return { status: 'ok' };
740
+ }
741
+ catch (err) {
742
+ return errResult(ctx, errorToMessage(err));
743
+ }
744
+ },
745
+ };
746
+ export const cmdFlushRequest = {
747
+ name: 'flushrequest',
748
+ helpKey: 'flushrequest',
749
+ async run(ctx) {
750
+ const ps = getPipelineState(ctx.settings);
751
+ if (!ps)
752
+ return errResult(ctx, 'no pipeline active');
753
+ try {
754
+ await ps.session.flush();
755
+ // Upstream `exec_command_flushrequest`:
756
+ // piped_results += piped_commands;
757
+ // piped_commands = 0;
758
+ // The pending commands move to "queued results" but SYNC isn't issued.
759
+ const queued = readCounter(ctx.settings, 'PIPELINE_COMMAND_COUNT');
760
+ setCounter(ctx.settings, 'PIPELINE_COMMAND_COUNT', 0);
761
+ bumpCounter(ctx.settings, 'PIPELINE_RESULT_COUNT', queued);
762
+ return { status: 'ok' };
763
+ }
764
+ catch (err) {
765
+ return errResult(ctx, errorToMessage(err));
766
+ }
767
+ },
768
+ };
769
+ export const cmdFlush = {
770
+ name: 'flush',
771
+ helpKey: 'flush',
772
+ run(ctx) {
773
+ return cmdFlushRequest.run(ctx);
774
+ },
775
+ };
776
+ // ---------------------------------------------------------------------------
777
+ // \sendpipeline — submit current buffer with stashed bind params.
778
+ // ---------------------------------------------------------------------------
779
+ export const cmdSendPipeline = {
780
+ name: 'sendpipeline',
781
+ helpKey: 'sendpipeline',
782
+ async run(ctx) {
783
+ const ps = getPipelineState(ctx.settings);
784
+ if (!ps) {
785
+ // Upstream wording (psql 18.4): "\\sendpipeline not allowed
786
+ // outside of pipeline mode". Verified empirically; no
787
+ // `\sendpipeline: ` prefix on stderr.
788
+ //
789
+ // Upstream `exec_command_sendpipeline` also calls
790
+ // `clean_extended_state()` here, which clears any pending
791
+ // `\bind` / `\bind_named` parameters. Mirror that so a later
792
+ // `\startpipeline` followed by a bare `\sendpipeline` reports
793
+ // the missing-bind diagnostic instead of replaying the stale
794
+ // parameters from before the failed-outside-pipeline send.
795
+ consumeBindState(ctx.settings);
796
+ ctx.settings.lastErrorResult = {
797
+ message: '\\sendpipeline not allowed outside of pipeline mode',
798
+ };
799
+ writeErr('\\sendpipeline not allowed outside of pipeline mode\n');
800
+ return { status: 'error', errorWritten: true };
801
+ }
802
+ const bind = consumeBindState(ctx.settings);
803
+ // Upstream `exec_command_sendpipeline` (in pipeline mode) requires
804
+ // a preceding `\bind` or `\bind_named`. Without it, the error is
805
+ // "\sendpipeline must be used after \bind or \bind_named", emitted
806
+ // BEFORE the empty-buffer check. The conformance test exercises
807
+ // both `\sendpipeline` (no buffer, no bind) and `SELECT 1
808
+ // \sendpipeline` (with buffer, no bind) — both must produce the
809
+ // same diagnostic, so order matters here.
810
+ if (bind === null) {
811
+ ctx.settings.lastErrorResult = {
812
+ message: '\\sendpipeline must be used after \\bind or \\bind_named',
813
+ };
814
+ writeErr('\\sendpipeline must be used after \\bind or \\bind_named\n');
815
+ return { status: 'error', errorWritten: true };
816
+ }
817
+ const sql = ctx.queryBuf.trim();
818
+ const stmtName = bind.name;
819
+ const params = bind.values;
820
+ // `\bind_named NAME` re-uses a server-side prep stmt, so an empty
821
+ // buffer is fine — we skip the Parse and just Bind + Execute. The
822
+ // anonymous-`\bind` path still needs a buffer because we must
823
+ // Parse the SQL first.
824
+ if (!bind.byName && sql.length === 0) {
825
+ return errResult(ctx, 'no query buffer');
826
+ }
827
+ try {
828
+ // We send the full P/B/E sequence without an intervening Sync — the
829
+ // user is expected to call \syncpipeline or \endpipeline to commit.
830
+ // For `\bind_named`, skip Parse (the prep stmt already exists on
831
+ // the server, named by the user). For anonymous `\bind`, queue
832
+ // an unnamed Parse so the SQL is parsed on the server in this
833
+ // batch.
834
+ if (!bind.byName) {
835
+ await ps.session.parse('', sql, []);
836
+ }
837
+ await ps.session.bind(stmtName, params);
838
+ const exec = (async () => {
839
+ await ps.session.execute('', 0);
840
+ // PipelineSession.execute resolves with void on the public API; the
841
+ // session internally tracks the ResultSet and surfaces it in end().
842
+ return {
843
+ command: '',
844
+ rowCount: null,
845
+ oid: null,
846
+ fields: [],
847
+ rows: [],
848
+ notices: [],
849
+ };
850
+ })();
851
+ ps.pending.push(exec);
852
+ return { status: 'reset-buf', newBuf: '' };
853
+ }
854
+ catch (err) {
855
+ return errResult(ctx, errorToMessage(err));
856
+ }
857
+ },
858
+ };
859
+ // ---------------------------------------------------------------------------
860
+ // \getresults [N] — drain N pending results (or all if N omitted).
861
+ // ---------------------------------------------------------------------------
862
+ export const cmdGetResults = {
863
+ name: 'getresults',
864
+ helpKey: 'getresults',
865
+ async run(ctx) {
866
+ const ps = getPipelineState(ctx.settings);
867
+ const arg = ctx.nextArg('normal');
868
+ // Upstream `exec_command_getresults` parses the optional count BEFORE
869
+ // checking pipeline state, so an invalid count surfaces even when
870
+ // there's no pipeline active. The wording matches upstream verbatim
871
+ // ("invalid number of requested results").
872
+ let requested = null;
873
+ if (arg !== null && arg.length > 0) {
874
+ const parsed = parseInt(arg, 10);
875
+ if (!Number.isFinite(parsed) || parsed < 0) {
876
+ return errResult(ctx, 'invalid number of requested results');
877
+ }
878
+ requested = parsed;
879
+ }
880
+ // No active pipeline → upstream prints `No pending results to get`
881
+ // (the "no-op idle" message), NOT a hard "no pipeline active" error.
882
+ // This matches the conformance test that runs `\getresults` BOTH
883
+ // outside of `\startpipeline / \endpipeline` brackets and right
884
+ // after `\endpipeline`.
885
+ if (!ps) {
886
+ process.stdout.write('No pending results to get\n');
887
+ return { status: 'ok' };
888
+ }
889
+ // Available items to drain = PIPELINE_SYNC_COUNT + PIPELINE_RESULT_COUNT.
890
+ // Sync markers and data-result entries both occupy slots in libpq's
891
+ // pipeline result queue; vanilla's `\getresults N` walks the queue
892
+ // FIFO, draining either kind. A `\sendpipeline` queued on the
893
+ // client but not yet `\flushrequest`-ed / `\syncpipeline`-ed does
894
+ // NOT count — those commands have a still-pending Execute promise
895
+ // but the server hasn't been told to flush replies. Verified
896
+ // empirically with vanilla psql 18.4: SQL like
897
+ // \syncpipeline \syncpipeline SELECT $1 \bind 1 \sendpipeline
898
+ // \flushrequest \getresults 1
899
+ // prints nothing on the first \getresults 1 (SyncMarker drained,
900
+ // SYNC_COUNT: 2 → 1), the second prints nothing (SYNC_COUNT: 1 →
901
+ // 0), and the third prints the SELECT result.
902
+ const syncAvailable = readCounter(ctx.settings, 'PIPELINE_SYNC_COUNT');
903
+ const resultAvailable = readCounter(ctx.settings, 'PIPELINE_RESULT_COUNT');
904
+ const available = syncAvailable + resultAvailable;
905
+ if (available === 0) {
906
+ process.stdout.write('No pending results to get\n');
907
+ return { status: 'ok' };
908
+ }
909
+ // `\getresults 0` and bare `\getresults` mean "all pending"
910
+ // (upstream semantics).
911
+ const n = requested === null || requested === 0
912
+ ? available
913
+ : Math.min(requested, available);
914
+ // Pull the next `n` per-USER-COMMAND slots from `cmdSlots`. Each
915
+ // entry mirrors one `PQgetResult` boundary in libpq: a
916
+ // `\sendpipeline` (Parse+Bind+Execute → one slot resolving to the
917
+ // ResultSet), a `\parse NAME` (one slot resolving to a silent
918
+ // placeholder), a `\close_prepared NAME` (silent placeholder),
919
+ // or a `\syncpipeline` (silent SyncMarker). `drainedCount`
920
+ // advances by `n` so a follow-on `\endpipeline` knows to skip
921
+ // what we've already walked. Test mocks that don't populate
922
+ // `cmdSlots` fall back to the legacy counter-only path.
923
+ const session = ps.session;
924
+ const slots = Array.isArray(session.cmdSlots)
925
+ ? session.cmdSlots
926
+ : [];
927
+ const start = ps.drainedCount;
928
+ const end = start + n;
929
+ const slice = slots.slice(start, end);
930
+ ps.drainedCount = end;
931
+ // Keep `ps.pending` in sync for any legacy callers that read its
932
+ // length — splice off the consumed count. The spliced promises are
933
+ // synthetic placeholders, so we discard the return value.
934
+ void ps.pending.splice(0, n);
935
+ try {
936
+ const settled = await Promise.allSettled(slice);
937
+ // The wire layer's cascade-reject puts the real `ConnectError` on
938
+ // the FIRST failing op (Parse / Bind / Close — pushed onto
939
+ // `pending`, not `cmdSlots` as a separate slot), and stamps the
940
+ // synthetic `pipelineAborted` marker onto every op queued behind
941
+ // it. The visible cmdSlot for the Execute in the same `\sendpipeline`
942
+ // therefore inherits the cascaded marker — we need a separate
943
+ // look-up to surface the original ERROR.
944
+ //
945
+ // Strategy:
946
+ // 1. Prefer a non-aborted rejection found IN this slice (e.g.
947
+ // a Parse-only command whose Parse failed at the SLOT level).
948
+ // 2. Otherwise fall back to `peekRealError()` which scans
949
+ // pending ∪ results for the first non-aborted rejection.
950
+ // After `\syncpipeline` clears `pending`, this returns null
951
+ // for purely-cascaded batches — which is correct, the slot
952
+ // message ("Pipeline aborted, command did not run") is the
953
+ // one that should surface.
954
+ const sliceErr = (() => {
955
+ for (const r of settled) {
956
+ if (r.status !== 'rejected')
957
+ continue;
958
+ const reason = r.reason;
959
+ const isAborted = typeof reason === 'object' &&
960
+ reason !== null &&
961
+ reason.pipelineAborted === true;
962
+ if (!isAborted)
963
+ return reason;
964
+ }
965
+ return null;
966
+ })();
967
+ const sessPeek = session;
968
+ const realErr = sliceErr ??
969
+ (typeof sessPeek.peekRealError === 'function'
970
+ ? await sessPeek.peekRealError()
971
+ : null);
972
+ // Per upstream `\getresults`: emit AT MOST ONE error / aborted
973
+ // line per call, even when the slice contains multiple rejections.
974
+ // First rejection's line wins; subsequent ones are suppressed
975
+ // inline (still implicitly accounted via the counter decrement
976
+ // below). Real ERROR trumps the synthetic `Pipeline aborted, …`
977
+ // marker — see `peekRealError`'s discovery semantics.
978
+ let errorRenderedHere = false;
979
+ let walkedItems = 0;
980
+ let syncsDrained = 0;
981
+ let resultsDrained = 0;
982
+ // Cursor into `slots`: indices ≥ this represent SyncMarkers
983
+ // pushed by `session.sync()`. We can't tag the slot in flight
984
+ // without changing the public shape; instead we attribute the
985
+ // first `syncAvailable` silent placeholders to SYNC_COUNT and
986
+ // the remainder to RESULT_COUNT post-walk.
987
+ for (const r of settled) {
988
+ walkedItems++;
989
+ if (r.status !== 'fulfilled') {
990
+ // Rejected promise — Parse / Bind / Execute / Close that the
991
+ // server responded to with ErrorResponse, or a cascaded
992
+ // pipelineAborted marker. Upstream `\getresults` walks
993
+ // libpq's per-Sync result queue inline: the failed entry
994
+ // produces an `ERROR: …` (or `Pipeline aborted, …`) on
995
+ // stderr at the `\getresults` line, not deferred to
996
+ // `\endpipeline`. Match that — but only render the FIRST
997
+ // rejection in this call so we don't double-print when an
998
+ // aborted pipeline funnels multiple sticky rejections
999
+ // through the same `\getresults`.
1000
+ resultsDrained++;
1001
+ if (!errorRenderedHere) {
1002
+ const reason = r.reason;
1003
+ const isAborted = typeof reason === 'object' &&
1004
+ reason !== null &&
1005
+ reason.pipelineAborted ===
1006
+ true;
1007
+ if (isAborted && realErr !== null) {
1008
+ renderPipelineError(realErr);
1009
+ }
1010
+ else {
1011
+ renderPipelineError(reason);
1012
+ }
1013
+ errorRenderedHere = true;
1014
+ }
1015
+ continue;
1016
+ }
1017
+ const rs = r.value;
1018
+ // Emit any NoticeResponse messages attached to this result to
1019
+ // stderr (libpq shape). Notices arrive interleaved with the
1020
+ // Bind / Execute replies and stick to the relevant ResultSet;
1021
+ // upstream psql renders them inline with each result's prelude.
1022
+ for (const n of rs.notices) {
1023
+ let out = `${n.severity}: ${n.message}\n`;
1024
+ if (n.detail !== undefined)
1025
+ out += `DETAIL: ${n.detail}\n`;
1026
+ if (n.hint !== undefined)
1027
+ out += `HINT: ${n.hint}\n`;
1028
+ writeErr(out);
1029
+ }
1030
+ // Silent placeholder: empty fields, empty command, no rows.
1031
+ // Either a SyncMarker (from `session.sync()`) or a successful
1032
+ // Parse-only / Close-only slot. Both print nothing; counter
1033
+ // attribution is fixed up at the tail of this function based
1034
+ // on `syncAvailable`.
1035
+ if (rs.fields.length === 0 &&
1036
+ rs.command === '' &&
1037
+ rs.rows.length === 0) {
1038
+ syncsDrained++;
1039
+ continue;
1040
+ }
1041
+ resultsDrained++;
1042
+ if (rs.fields.length === 0 && rs.rows.length > 0) {
1043
+ // 0-column tuples result — same upstream placeholder shape as
1044
+ // `\endpipeline` (see comment there).
1045
+ const tuplesOnly = ctx.settings.popt.topt.tuplesOnly;
1046
+ if (!tuplesOnly) {
1047
+ process.stdout.write('--\n');
1048
+ process.stdout.write(`(${rs.rows.length} ${rs.rows.length === 1 ? 'row' : 'rows'})\n\n`);
1049
+ }
1050
+ }
1051
+ else if (rs.fields.length > 0) {
1052
+ await alignedPrinter.printQuery(rs, ctx.settings.popt, process.stdout);
1053
+ }
1054
+ }
1055
+ // Tell the wire layer how far the cmd layer has consumed from its
1056
+ // results queue. `PipelineSession.end()` uses this offset to skip
1057
+ // entries that `\getresults` has already inspected; otherwise the
1058
+ // rejected promise we just rendered here would get re-stashed on
1059
+ // `session.lastError` and `\endpipeline`'s fallback would
1060
+ // double-print the same `ERROR: …` line. The offset is into
1061
+ // `results` (the per-Execute promise list) not `cmdSlots`; we
1062
+ // approximate by passing `end` (close enough for the
1063
+ // `_externalDrained` check, which is only consulted by `end()`
1064
+ // to skip ALREADY-INSPECTED rejections).
1065
+ const sessMark = session;
1066
+ if (typeof sessMark.markDrained === 'function') {
1067
+ sessMark.markDrained(end);
1068
+ }
1069
+ // Once we surface a rejection inline here, also clear any sticky
1070
+ // `lastError` already stashed by a previous wire-layer scan so
1071
+ // `\endpipeline`'s fallback doesn't re-emit the same diagnostic.
1072
+ if (errorRenderedHere) {
1073
+ const sessAny = session;
1074
+ if (typeof sessAny.clearLastError === 'function') {
1075
+ sessAny.clearLastError();
1076
+ }
1077
+ }
1078
+ // Fallback when `cmdSlots` wasn't populated (test mocks):
1079
+ // decrement RESULT_COUNT first, then SYNC_COUNT. Real
1080
+ // PipelineSession populates `cmdSlots` so the walk above already
1081
+ // attributed each drain to the right counter.
1082
+ if (walkedItems === 0) {
1083
+ const ddata = Math.min(n, resultAvailable);
1084
+ resultsDrained = ddata;
1085
+ syncsDrained = n - ddata;
1086
+ }
1087
+ else {
1088
+ // The walk lumped successful silent placeholders (Parse / Close
1089
+ // OK) into `syncsDrained`. Re-attribute: SYNC_COUNT only goes
1090
+ // down by the number of SyncMarkers we actually drained (capped
1091
+ // at `syncAvailable`); the rest are result-style drains.
1092
+ const actualSyncs = Math.min(syncsDrained, syncAvailable);
1093
+ const extraResults = syncsDrained - actualSyncs;
1094
+ syncsDrained = actualSyncs;
1095
+ resultsDrained += extraResults;
1096
+ }
1097
+ // Decrement counters. Upstream `exec_command_getresults` does the
1098
+ // same accounting: PIPELINE_SYNC_COUNT and PIPELINE_RESULT_COUNT
1099
+ // are decremented by the actually-consumed items in each
1100
+ // category. SYNC_COUNT only goes down when a drain walks an
1101
+ // actual SyncMarker slot — we don't force-reset it when
1102
+ // RESULT_COUNT hits zero, because the queue may still hold the
1103
+ // pending PGRES_PIPELINE_SYNC entry. The regress test
1104
+ // `\getresults 1` x5 after 4 commands + 1 sync drains the 5th
1105
+ // call silently against the SyncMarker; a 6th call would emit
1106
+ // "No pending results to get".
1107
+ bumpCounter(ctx.settings, 'PIPELINE_SYNC_COUNT', -syncsDrained);
1108
+ bumpCounter(ctx.settings, 'PIPELINE_RESULT_COUNT', -resultsDrained);
1109
+ return { status: 'ok' };
1110
+ }
1111
+ catch (err) {
1112
+ return errResult(ctx, errorToMessage(err));
1113
+ }
1114
+ },
1115
+ };
1116
+ // ---------------------------------------------------------------------------
1117
+ // \gdesc — describe the buffered query without executing it.
1118
+ //
1119
+ // The implementation lives in `./cmd_io.ts` (it shares the printer-routing
1120
+ // machinery used by `\g` / `\gx` / `\watch`); we re-export the spec here
1121
+ // so the existing pipeline test (which imports `cmdGdesc` from this
1122
+ // module) continues to compile. Registration is left to `cmd_io.ts`'s
1123
+ // `registerIoCommands` so we don't double-register.
1124
+ // ---------------------------------------------------------------------------
1125
+ export { cmdGdesc } from './cmd_io.js';
1126
+ // ---------------------------------------------------------------------------
1127
+ // Registration entry point.
1128
+ // ---------------------------------------------------------------------------
1129
+ export const registerPipelineCommands = (registry) => {
1130
+ registry.register(cmdBind);
1131
+ registry.register(cmdBindNamed);
1132
+ registry.register(cmdParse);
1133
+ registry.register(cmdClosePrepared);
1134
+ registry.register(cmdStartPipeline);
1135
+ registry.register(cmdEndPipeline);
1136
+ registry.register(cmdSyncPipeline);
1137
+ registry.register(cmdFlushRequest);
1138
+ registry.register(cmdFlush);
1139
+ registry.register(cmdSendPipeline);
1140
+ registry.register(cmdGetResults);
1141
+ };