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
package/psql/index.js ADDED
@@ -0,0 +1,2005 @@
1
+ import { PgConnection } from './wire/connection.js';
2
+ import { applyEnvOverrides, defaultSettings } from './core/settings.js';
3
+ import { createVarStore } from './core/variables.js';
4
+ import { syncConnectionVars } from './core/syncVars.js';
5
+ import { defaultRegistry } from './command/dispatch.js';
6
+ import { createCondStack } from './command/cmd_cond.js';
7
+ import { runMainLoop, EXIT_BADCONN, EXIT_FAILURE, EXIT_SUCCESS, EXIT_USER, } from './core/mainloop.js';
8
+ import { applyStartupArgs, parseStartupArgs } from './core/startup.js';
9
+ import { executeInputString, loadPsqlrc } from './io/psqlrc.js';
10
+ import { loadPgPass } from './io/pgpass.js';
11
+ import { loadPgServices } from './io/pgservice.js';
12
+ import { promises as fs, existsSync } from 'node:fs';
13
+ import * as os from 'node:os';
14
+ import * as path from 'node:path';
15
+ /**
16
+ * Embedded TypeScript psql entrypoint.
17
+ *
18
+ * Argv shape mirrors the legacy native-psql call site:
19
+ * argv[0] = connection URI (postgresql://user:pw@host:port/db?sslmode=...)
20
+ * OR an empty string `''` when the caller provides the
21
+ * connection target via libpq flags (-h/-p/-U/-d) and/or PG*
22
+ * env. The downstream layered resolver picks those up just like
23
+ * upstream psql does.
24
+ * argv[1..] = forwarded psql args (parsed by `parseStartupArgs`).
25
+ */
26
+ export const runPsql = async (argv, stdio = {}) => {
27
+ const stdin = stdio.stdin ?? process.stdin;
28
+ const stdout = stdio.stdout ?? process.stdout;
29
+ const stderr = stdio.stderr ?? process.stderr;
30
+ const connectionUri = argv[0] ?? '';
31
+ // Parse argv[0] in one of three shapes:
32
+ // - URI scheme (`postgres://…` / `postgresql://…`): the URI-partial
33
+ // parser handles authority, query, and `?service=…`.
34
+ // - libpq conninfo string (`key=value …`, no scheme): `parseConninfo`
35
+ // extracts each known key (including `service`).
36
+ // - Bare database name (e.g. `mydb`): no parsing; the rest of the
37
+ // resolver picks up host/port/user/etc. from env/pgpass/service/
38
+ // defaults.
39
+ //
40
+ // `looksLikeConnectionString` (libpq parity: `recognized_connection_
41
+ // string()`) decides between the first two and the third.
42
+ //
43
+ // When `connectionUri` is empty (the standalone-psql shim case), we
44
+ // skip parsing entirely and rely on libpq flags + env to populate the
45
+ // ConnectOptions layers.
46
+ let uriPartial = {};
47
+ let uriService;
48
+ if (connectionUri !== '' && looksLikeConnectionString(connectionUri)) {
49
+ try {
50
+ if (connectionUri.startsWith('postgres://') ||
51
+ connectionUri.startsWith('postgresql://')) {
52
+ uriPartial = parseConnectionUriPartial(connectionUri);
53
+ uriService = parseConnectionUriService(connectionUri);
54
+ }
55
+ else {
56
+ // Bare `key=value …` conninfo string. `parseConninfo` parks the
57
+ // service name on a private `_service` staging slot (it's not
58
+ // part of ConnectOptions); pull it out so the layered resolver
59
+ // can look it up.
60
+ const parsed = parseConninfo(connectionUri);
61
+ if (typeof parsed._service === 'string' && parsed._service.length > 0) {
62
+ uriService = parsed._service;
63
+ }
64
+ delete parsed._service;
65
+ uriPartial = parsed;
66
+ }
67
+ }
68
+ catch (err) {
69
+ stderr.write(`psql: error: ${err.message}\n`);
70
+ return EXIT_BADCONN;
71
+ }
72
+ }
73
+ // Parse psql args (argv[1..]). argv[0] is the connection URI consumed above.
74
+ const parsed = parseStartupArgs(argv.slice(1));
75
+ if ('kind' in parsed) {
76
+ if (parsed.kind === 'help' || parsed.kind === 'version') {
77
+ stdout.write(parsed.message);
78
+ if (!parsed.message.endsWith('\n'))
79
+ stdout.write('\n');
80
+ return EXIT_SUCCESS;
81
+ }
82
+ stderr.write(`psql: error: ${parsed.message}\n`);
83
+ return EXIT_FAILURE;
84
+ }
85
+ const vars = createVarStore();
86
+ const settings = defaultSettings(vars);
87
+ applyEnvOverrides(settings, process.env);
88
+ // Track interactive-ness from the actual stdin we'll read.
89
+ settings.notty = !stdin.isTTY;
90
+ // Resolve external configuration sources (pgpass, pg_service.conf) before
91
+ // running the layered merge. `loadPgPass` always degrades silently to
92
+ // an empty result. `loadPgServices` only errors when the user named a
93
+ // missing file via `$PGSERVICEFILE` (libpq parity for `006_service.pl`);
94
+ // bubble that out as a connection error.
95
+ const pgpassEntries = await loadPgPass(undefined, {
96
+ env: process.env,
97
+ stderr,
98
+ });
99
+ let services;
100
+ try {
101
+ services = await loadPgServices();
102
+ }
103
+ catch (err) {
104
+ stderr.write(`psql: error: ${err.message}\n`);
105
+ return EXIT_BADCONN;
106
+ }
107
+ let resolved;
108
+ try {
109
+ resolved = applyStartupArgs(parsed, settings, undefined, {
110
+ env: process.env,
111
+ uriPartial,
112
+ serviceName: uriService,
113
+ pgpassEntries,
114
+ services,
115
+ });
116
+ }
117
+ catch (err) {
118
+ // `resolveLayeredConnect` throws on unknown service name (libpq
119
+ // parity). Surface as a connection-setup error and bail.
120
+ stderr.write(`psql: error: ${err.message}\n`);
121
+ return EXIT_BADCONN;
122
+ }
123
+ const { connect: connectOpts, preActions } = resolved;
124
+ let connection;
125
+ try {
126
+ connection = await PgConnection.connect(connectOpts);
127
+ }
128
+ catch (err) {
129
+ const e = err;
130
+ stderr.write(`psql: error: connection to server failed: ${e.message ?? String(err)}\n`);
131
+ return EXIT_BADCONN;
132
+ }
133
+ settings.db = connection;
134
+ // Mirror upstream psql's SyncVariables(): populate the connection-driven
135
+ // psql vars (DBNAME/USER/HOST/PORT/ENCODING/SERVER_VERSION_*) so scripts
136
+ // can interpolate `:DBNAME`, `:USER`, etc. from the first prompt onward.
137
+ syncConnectionVars(settings.vars, connection);
138
+ const registry = defaultRegistry();
139
+ const cond = createCondStack();
140
+ const ctx = {
141
+ settings,
142
+ registry,
143
+ cond,
144
+ stdin,
145
+ stdout,
146
+ stderr,
147
+ };
148
+ try {
149
+ // Startup banner — mirrors upstream psql's
150
+ // psql (<client>, server <server>)
151
+ // SSL connection (protocol: …, cipher: …)
152
+ // Type "help" for help.
153
+ // Suppressed in quiet mode and when stdin isn't a TTY (scripted use).
154
+ if (!settings.quiet && !settings.notty && preActions.length === 0) {
155
+ writeStartupBanner(connection, stdout);
156
+ }
157
+ // Run .psqlrc unless -X was specified.
158
+ await loadPsqlrc(ctx, { skip: parsed.noPsqlrc, env: process.env });
159
+ // If the user supplied -c / -f actions, execute them sequentially and
160
+ // exit (mirrors upstream psql behaviour). Otherwise, fall through to
161
+ // the REPL.
162
+ if (preActions.length > 0) {
163
+ return await runPreActions(ctx, preActions, parsed.singleTransaction);
164
+ }
165
+ return await runMainLoop(ctx);
166
+ }
167
+ finally {
168
+ try {
169
+ await connection.close();
170
+ }
171
+ catch {
172
+ // ignore close errors
173
+ }
174
+ }
175
+ };
176
+ /**
177
+ * Execute the ordered list of `-c` / `-f` actions and return the upstream
178
+ * psql exit code.
179
+ *
180
+ * Upstream `process_psqlrc_and_targets()` (in `startup.c`) plus the
181
+ * dispatcher in `MainLoop()` cooperate to give each switch one of three
182
+ * outcomes:
183
+ *
184
+ * - `-c "SQL or \backslash"`:
185
+ * * a client-side failure (e.g. bad `\copy`) marks the SWITCH itself
186
+ * as failed → the overall exit code is non-zero, even when
187
+ * ON_ERROR_STOP is off and the transaction commits.
188
+ * * a server-side failure follows the same rule.
189
+ *
190
+ * - `-f file`:
191
+ * * `process_file()` returns a success status by default, so a
192
+ * failing statement inside the file does NOT bubble up to the
193
+ * outer exit code (without ON_ERROR_STOP). Only an I/O failure
194
+ * opening the file flips the switch status.
195
+ *
196
+ * - `--single-transaction`:
197
+ * * before the FIRST action, issue `BEGIN`. After the LAST action,
198
+ * issue `COMMIT` (success) or `ROLLBACK` (when ON_ERROR_STOP fired
199
+ * and we stopped early).
200
+ * * Without ON_ERROR_STOP, the transaction commits even when some
201
+ * individual statements failed — the failing statements only
202
+ * influence the exit code (see the `-c` / `-f` distinction above).
203
+ */
204
+ const runPreActions = async (ctx, preActions, singleTransaction) => {
205
+ const { settings, stderr } = ctx;
206
+ let status = EXIT_SUCCESS;
207
+ let beganTransaction = false;
208
+ let earlyStopOnError = false;
209
+ let connectionLost = false;
210
+ // --single-transaction: wrap the entire batch in BEGIN ... COMMIT/ROLLBACK.
211
+ // We do this with `db.execSimple` directly so the wrapper does not itself
212
+ // count as a "failed switch" for exit-code purposes.
213
+ if (singleTransaction && settings.db) {
214
+ try {
215
+ await settings.db.execSimple('BEGIN');
216
+ beganTransaction = true;
217
+ }
218
+ catch (err) {
219
+ const msg = err instanceof Error ? err.message : String(err);
220
+ stderr.write(`psql: error: could not begin transaction: ${msg}\n`);
221
+ return EXIT_FAILURE;
222
+ }
223
+ }
224
+ for (const action of preActions) {
225
+ if (connectionLost)
226
+ break;
227
+ if (action.kind === 'command') {
228
+ let outcome;
229
+ try {
230
+ outcome = await executeInputString(action.sql, ctx, { print: true });
231
+ }
232
+ catch (err) {
233
+ // Defensive: executeInputString shouldn't throw, but if a downstream
234
+ // command bubbles an exception we still want to surface it as a
235
+ // failed switch rather than crashing.
236
+ const msg = err instanceof Error ? err.message : String(err);
237
+ stderr.write(`psql: ERROR: ${msg}\n`);
238
+ status = EXIT_FAILURE;
239
+ if (settings.onErrorStop) {
240
+ earlyStopOnError = true;
241
+ break;
242
+ }
243
+ continue;
244
+ }
245
+ if (outcome.connectionLost) {
246
+ connectionLost = true;
247
+ status = EXIT_BADCONN;
248
+ break;
249
+ }
250
+ // For `-c`: any failure flips the exit code to EXIT_FAILURE (1) — real
251
+ // psql exits 1 for a `-c` error regardless of ON_ERROR_STOP, NOT the
252
+ // EXIT_USER (3) used for ON_ERROR_STOP aborts in script/stdin mode
253
+ // (verified on psql 18.4). The `\copy` errors are on stderr.
254
+ if (outcome.hadError || outcome.stoppedOnError) {
255
+ status = EXIT_FAILURE;
256
+ }
257
+ if (outcome.stoppedOnError) {
258
+ earlyStopOnError = true;
259
+ break;
260
+ }
261
+ }
262
+ else {
263
+ // -f file: I/O failure (missing file, permission denied) is a hard
264
+ // EXIT_FAILURE on the switch. Per-statement failures inside the file
265
+ // are SWALLOWED for exit-code purposes (mirrors `process_file()` in
266
+ // upstream, which only escalates to a stop when ON_ERROR_STOP fires).
267
+ let contents;
268
+ try {
269
+ contents = await fs.readFile(action.path, 'utf8');
270
+ }
271
+ catch (err) {
272
+ const msg = err instanceof Error ? err.message : String(err);
273
+ stderr.write(`psql: error: ${msg}\n`);
274
+ status = EXIT_FAILURE;
275
+ if (settings.onErrorStop) {
276
+ earlyStopOnError = true;
277
+ break;
278
+ }
279
+ continue;
280
+ }
281
+ let outcome;
282
+ try {
283
+ outcome = await executeInputString(contents, ctx, { print: true });
284
+ }
285
+ catch (err) {
286
+ const msg = err instanceof Error ? err.message : String(err);
287
+ stderr.write(`psql: ERROR: ${msg}\n`);
288
+ status = EXIT_USER;
289
+ if (settings.onErrorStop) {
290
+ earlyStopOnError = true;
291
+ break;
292
+ }
293
+ continue;
294
+ }
295
+ if (outcome.connectionLost) {
296
+ connectionLost = true;
297
+ status = EXIT_BADCONN;
298
+ break;
299
+ }
300
+ if (outcome.stoppedOnError) {
301
+ status = EXIT_USER;
302
+ earlyStopOnError = true;
303
+ break;
304
+ }
305
+ // Per-statement errors inside the file do NOT propagate to the outer
306
+ // exit code (matches upstream `process_file` returning success).
307
+ }
308
+ }
309
+ // Wrap up the single-transaction envelope. If we stopped early on error
310
+ // and ON_ERROR_STOP fired, roll back. Otherwise commit — upstream commits
311
+ // even when individual statements failed without ON_ERROR_STOP.
312
+ if (beganTransaction && settings.db && !connectionLost) {
313
+ const closing = earlyStopOnError ? 'ROLLBACK' : 'COMMIT';
314
+ try {
315
+ await settings.db.execSimple(closing);
316
+ }
317
+ catch (err) {
318
+ // If COMMIT fails the data is gone; surface it but don't override an
319
+ // existing error status. If we tried to COMMIT cleanly and that
320
+ // failed, escalate to EXIT_FAILURE so the caller knows the batch
321
+ // didn't go through.
322
+ const msg = err instanceof Error ? err.message : String(err);
323
+ stderr.write(`psql: error: ${closing} failed: ${msg}\n`);
324
+ if (status === EXIT_SUCCESS)
325
+ status = EXIT_FAILURE;
326
+ }
327
+ }
328
+ return status;
329
+ };
330
+ const writeStartupBanner = (connection, out) => {
331
+ const serverVersion = connection.parameterStatus('server_version') ?? 'unknown';
332
+ // Client identifier. Matches upstream's `psql (18.4, server X.Y)` shape
333
+ // but signals that this is the embedded TS implementation so users can tell
334
+ // when they're on the fallback path.
335
+ out.write(`psql-ts (neonctl, server ${serverVersion})\n`);
336
+ const tls = connection.getTlsInfo();
337
+ if (tls) {
338
+ const parts = [
339
+ `protocol: ${tls.protocol}`,
340
+ `cipher: ${tls.cipher}`,
341
+ `compression: ${tls.compression}`,
342
+ ];
343
+ if (tls.alpn)
344
+ parts.push(`ALPN: ${tls.alpn}`);
345
+ out.write(`SSL connection (${parts.join(', ')})\n`);
346
+ }
347
+ out.write('Type "help" for help.\n\n');
348
+ };
349
+ // Recognized libpq connection parameter keywords (subset matching
350
+ // https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS).
351
+ // We don't necessarily honor every value (e.g. hostaddr, krbsrvname), but we
352
+ // recognize them as valid keys so callers don't get a spurious "unknown key"
353
+ // rejection for a libpq-spec key they expect to work.
354
+ const KNOWN_QUERY_KEYS = new Set([
355
+ 'host',
356
+ 'hostaddr',
357
+ 'port',
358
+ 'dbname',
359
+ 'user',
360
+ 'password',
361
+ 'passfile',
362
+ 'channel_binding',
363
+ 'require_auth',
364
+ 'connect_timeout',
365
+ 'client_encoding',
366
+ 'options',
367
+ 'application_name',
368
+ 'fallback_application_name',
369
+ 'keepalives',
370
+ 'keepalives_idle',
371
+ 'keepalives_interval',
372
+ 'keepalives_count',
373
+ 'sslmode',
374
+ 'sslnegotiation',
375
+ 'sslcompression',
376
+ 'sslcert',
377
+ 'sslkey',
378
+ 'sslcertmode',
379
+ 'sslrootcert',
380
+ 'sslcrl',
381
+ 'sslcrldir',
382
+ 'sslkeylogfile',
383
+ 'sslsni',
384
+ 'requirepeer',
385
+ 'ssl_min_protocol_version',
386
+ 'ssl_max_protocol_version',
387
+ 'krbsrvname',
388
+ 'gsslib',
389
+ 'gssencmode',
390
+ 'service',
391
+ 'target_session_attrs',
392
+ 'load_balance_hosts',
393
+ 'replication',
394
+ ]);
395
+ /**
396
+ * Tokenize a postgres connection URI into raw components.
397
+ *
398
+ * Hand-rolled rather than using `new URL()` because libpq accepts shapes
399
+ * the WHATWG URL parser rejects, e.g. `postgresql://user@` (userinfo with
400
+ * no host), `postgres://:12345/` (port-only), `postgres://uri-user@/db`
401
+ * (userinfo with empty host). The upstream conformance suite in
402
+ * `src/interfaces/libpq/t/001_uri.pl` exercises these forms.
403
+ */
404
+ const tokenizeConnectionUri = (uri) => {
405
+ // Strip scheme. Only postgres:// and postgresql:// are accepted; libpq
406
+ // rejects everything else with a "missing schema" error.
407
+ let rest;
408
+ if (uri.startsWith('postgresql://')) {
409
+ rest = uri.slice('postgresql://'.length);
410
+ }
411
+ else if (uri.startsWith('postgres://')) {
412
+ rest = uri.slice('postgres://'.length);
413
+ }
414
+ else {
415
+ throw new Error(`unsupported scheme in URI: ${uri}`);
416
+ }
417
+ // Split off query string.
418
+ let query = '';
419
+ const qIdx = rest.indexOf('?');
420
+ if (qIdx >= 0) {
421
+ query = rest.slice(qIdx + 1);
422
+ rest = rest.slice(0, qIdx);
423
+ }
424
+ // Split off path (database).
425
+ let database;
426
+ const pIdx = rest.indexOf('/');
427
+ if (pIdx >= 0) {
428
+ const pathRaw = rest.slice(pIdx + 1);
429
+ database = pathRaw === '' ? undefined : decodePercent(pathRaw);
430
+ rest = rest.slice(0, pIdx);
431
+ }
432
+ // What's left is the authority: [userinfo@][host[:port]]
433
+ let userinfo;
434
+ const atIdx = rest.lastIndexOf('@');
435
+ if (atIdx >= 0) {
436
+ userinfo = rest.slice(0, atIdx);
437
+ rest = rest.slice(atIdx + 1);
438
+ }
439
+ let user;
440
+ let password;
441
+ if (userinfo !== undefined) {
442
+ const colon = userinfo.indexOf(':');
443
+ if (colon >= 0) {
444
+ user = decodePercent(userinfo.slice(0, colon));
445
+ password = decodePercent(userinfo.slice(colon + 1));
446
+ }
447
+ else {
448
+ user = decodePercent(userinfo);
449
+ }
450
+ }
451
+ // hostport: either [ipv6]:port, [ipv6], host:port, or host. With multi-host
452
+ // (libpq 10+), the authority may be a comma-separated list:
453
+ // `h1:5432,h2,[::1]:5433`
454
+ // We split on commas at the top level (i.e. not inside `[...]` IPv6
455
+ // brackets) and parse each segment using the single-host grammar.
456
+ const tuples = splitAuthorityTuples(rest, uri);
457
+ let host;
458
+ let port;
459
+ const hosts = [];
460
+ for (const tuple of tuples) {
461
+ const parsed = parseAuthorityTuple(tuple, uri);
462
+ if (parsed.host !== undefined) {
463
+ hosts.push({ host: parsed.host, port: parsed.port });
464
+ }
465
+ }
466
+ if (hosts.length > 0) {
467
+ host = hosts[0].host;
468
+ port = hosts[0].port;
469
+ }
470
+ const queryMap = parseQuery(query);
471
+ return {
472
+ user,
473
+ password,
474
+ host,
475
+ port,
476
+ ...(hosts.length > 1 ? { hosts } : {}),
477
+ database,
478
+ query: queryMap,
479
+ };
480
+ };
481
+ /**
482
+ * Split a multi-host authority string into one tuple per top-level comma.
483
+ * IPv6 bracket regions are atomic — commas inside `[…]` don't split.
484
+ *
485
+ * Examples:
486
+ * `h1:5432,h2,h3:5434` -> ['h1:5432','h2','h3:5434']
487
+ * `[::1]:5432,[2001:db8::1]` -> ['[::1]:5432','[2001:db8::1]']
488
+ * `h1` -> ['h1']
489
+ * `` -> [''] (single empty tuple — caller may
490
+ * treat as no-host)
491
+ */
492
+ const splitAuthorityTuples = (rest, uri) => {
493
+ if (rest === '')
494
+ return [''];
495
+ const tuples = [];
496
+ let start = 0;
497
+ let i = 0;
498
+ while (i < rest.length) {
499
+ const ch = rest[i];
500
+ if (ch === '[') {
501
+ const closeIdx = rest.indexOf(']', i);
502
+ if (closeIdx < 0) {
503
+ throw new Error(`missing matching "]" in IPv6 host address: ${uri}`);
504
+ }
505
+ i = closeIdx + 1;
506
+ continue;
507
+ }
508
+ if (ch === ',') {
509
+ tuples.push(rest.slice(start, i));
510
+ i += 1;
511
+ start = i;
512
+ continue;
513
+ }
514
+ i += 1;
515
+ }
516
+ tuples.push(rest.slice(start));
517
+ return tuples;
518
+ };
519
+ const parseAuthorityTuple = (tuple, uri) => {
520
+ if (tuple === '')
521
+ return {};
522
+ if (tuple.startsWith('[')) {
523
+ const closeIdx = tuple.indexOf(']');
524
+ if (closeIdx < 0) {
525
+ throw new Error(`missing matching "]" in IPv6 host address: ${uri}`);
526
+ }
527
+ const host = tuple.slice(1, closeIdx);
528
+ if (host === '') {
529
+ throw new Error(`IPv6 host address may not be empty: ${uri}`);
530
+ }
531
+ const after = tuple.slice(closeIdx + 1);
532
+ if (after === '')
533
+ return { host };
534
+ if (after.startsWith(':')) {
535
+ return { host, port: after.slice(1) };
536
+ }
537
+ throw new Error(`unexpected characters after IPv6 host address in URI: ${uri}`);
538
+ }
539
+ const colon = tuple.indexOf(':');
540
+ if (colon >= 0) {
541
+ return {
542
+ host: decodePercent(tuple.slice(0, colon)),
543
+ port: tuple.slice(colon + 1),
544
+ };
545
+ }
546
+ return { host: decodePercent(tuple) };
547
+ };
548
+ const parseQuery = (raw) => {
549
+ const out = new Map();
550
+ if (raw === '')
551
+ return out;
552
+ for (const segment of raw.split('&')) {
553
+ if (segment === '')
554
+ continue;
555
+ const eq = segment.indexOf('=');
556
+ if (eq < 0) {
557
+ // libpq: every query parameter must be `key=value`. Bare keys (no `=`)
558
+ // are rejected. Matches the upstream 001_uri.pl `?zzz` and
559
+ // `?value1&value2` cases.
560
+ throw new Error(`missing "=" after "${segment.trim()}" in connection info string`);
561
+ }
562
+ const keyRaw = segment.slice(0, eq);
563
+ const valueRaw = segment.slice(eq + 1);
564
+ // libpq rejects an extra `=` in either key or value; matches the
565
+ // `?key=key=value` upstream case.
566
+ if (valueRaw.includes('=')) {
567
+ throw new Error(`extra "=" in query parameter "${decodePercent(keyRaw).trim()}"`);
568
+ }
569
+ const key = decodePercent(keyRaw).trim();
570
+ const value = decodePercent(valueRaw).trim();
571
+ if (key === '')
572
+ continue;
573
+ if (!KNOWN_QUERY_KEYS.has(key)) {
574
+ throw new Error(`invalid URI query parameter: "${key}"`);
575
+ }
576
+ out.set(key, value);
577
+ }
578
+ return out;
579
+ };
580
+ /**
581
+ * Percent-decode a URI component. libpq strictly validates percent-encoding:
582
+ * - `%XX` must be two hex digits
583
+ * - bare `%` or `%X` is invalid
584
+ * - `%00` is forbidden (NUL bytes can't appear in connection params)
585
+ *
586
+ * `decodeURIComponent` throws URIError on malformed escapes — we surface that
587
+ * as a clear Error. It accepts `%00` (returns `\0`); we explicitly reject.
588
+ */
589
+ const decodePercent = (s) => {
590
+ let decoded;
591
+ try {
592
+ decoded = decodeURIComponent(s);
593
+ }
594
+ catch {
595
+ throw new Error(`invalid percent-encoded token in URI: ${s}`);
596
+ }
597
+ if (decoded.includes('\x00')) {
598
+ throw new Error(`forbidden NUL byte (%00) in URI: ${s}`);
599
+ }
600
+ return decoded;
601
+ };
602
+ export const parseConnectionUri = (uri) => {
603
+ const raw = tokenizeConnectionUri(uri);
604
+ // libpq-style: query string can override authority components.
605
+ const queryUser = raw.query.get('user');
606
+ const queryPassword = raw.query.get('password');
607
+ const queryPort = raw.query.get('port');
608
+ const queryDbname = raw.query.get('dbname');
609
+ const queryHost = raw.query.get('host');
610
+ // Multi-host: either from the authority (`h1,h2,h3:5434`) or from
611
+ // `?host=h1,h2,h3&port=5432,5433,5434`. Query-string overrides authority
612
+ // (matching libpq: query params take precedence over URI structural
613
+ // components). Both `host=` and `port=` lists must be the same length OR
614
+ // a single value (broadcast).
615
+ const hostsTuples = computeHostsTuples({
616
+ rawHost: raw.host,
617
+ rawPort: raw.port,
618
+ rawAuthorityHosts: raw.hosts,
619
+ queryHost,
620
+ queryPort,
621
+ });
622
+ // Single-host fallbacks (preserve current behaviour for the `host` / `port`
623
+ // surface — the wire layer prefers `hosts` when set).
624
+ const host = hostsTuples.length > 0 && hostsTuples[0].host !== ''
625
+ ? hostsTuples[0].host
626
+ : 'localhost';
627
+ const port = hostsTuples.length > 0 ? hostsTuples[0].port : 5432;
628
+ const user = queryUser !== undefined && queryUser !== ''
629
+ ? queryUser
630
+ : raw.user !== undefined && raw.user !== ''
631
+ ? raw.user
632
+ : (process.env.USER ?? '');
633
+ const password = queryPassword ?? raw.password;
634
+ const database = queryDbname ?? raw.database ?? user;
635
+ let ssl = normalizeSslMode(raw.query.get('sslmode') ?? null);
636
+ const channelBinding = normalizeChannelBinding(raw.query.get('channel_binding') ?? null);
637
+ // GSSAPI is unsupported (no native Kerberos dep); validate+reject require.
638
+ validateGssEncMode(raw.query.get('gssencmode') ?? null);
639
+ const options = raw.query.get('options');
640
+ // Match upstream psql: default `application_name` to `'psql'` so users see
641
+ // the expected value in `pg_stat_activity`. The neonctl-specific identifier
642
+ // is still discoverable via the User-Agent the protocol layer sends.
643
+ const applicationName = raw.query.get('application_name') ?? 'psql';
644
+ const replication = normalizeReplication(raw.query.get('replication') ?? null);
645
+ const targetSessionAttrs = normalizeTargetSessionAttrs(raw.query.get('target_session_attrs') ?? null);
646
+ const loadBalanceHosts = normalizeLoadBalanceHosts(raw.query.get('load_balance_hosts') ?? null);
647
+ // libpq PEM file paths. Empty string is treated as "not set" so a URI
648
+ // like `?sslcert=` doesn't surface as an attempt to load `""` from disk.
649
+ const sslcert = nonEmpty(raw.query.get('sslcert'));
650
+ const sslkey = nonEmpty(raw.query.get('sslkey'));
651
+ const sslcertmode = normalizeSslCertMode(raw.query.get('sslcertmode') ?? null);
652
+ const sslnegotiation = normalizeSslNegotiation(raw.query.get('sslnegotiation') ?? null);
653
+ const sslrootcert = nonEmpty(raw.query.get('sslrootcert'));
654
+ const sslcrl = nonEmpty(raw.query.get('sslcrl'));
655
+ const sslcrldir = nonEmpty(raw.query.get('sslcrldir'));
656
+ const sslkeylogfile = nonEmpty(raw.query.get('sslkeylogfile'));
657
+ // libpq sslsni / keepalives toggles (0/1) + keepalives_idle (seconds) +
658
+ // requirepeer (OS user, validated but not enforceable in Node).
659
+ const sslsni = parseLibpqBool(nonEmpty(raw.query.get('sslsni')));
660
+ const keepalives = parseLibpqBool(nonEmpty(raw.query.get('keepalives')));
661
+ const keepalivesIdle = parseKeepalivesIdle(nonEmpty(raw.query.get('keepalives_idle')));
662
+ const requirepeer = nonEmpty(raw.query.get('requirepeer'));
663
+ // libpq: `sslrootcert=system` raises the effective sslmode to verify-full.
664
+ // verify-full is the strongest mode, so this only ever raises it.
665
+ if (sslrootcert === 'system' && ssl !== 'verify-full') {
666
+ ssl = 'verify-full';
667
+ }
668
+ // libpq `hostaddr`: a fixed IP that bypasses DNS while `host` still drives
669
+ // TLS SNI / cert verification. Empty string is "not set".
670
+ const hostaddr = nonEmpty(raw.query.get('hostaddr'));
671
+ const sslMinProtocolVersion = normalizeTlsProtocolVersion(nonEmpty(raw.query.get('ssl_min_protocol_version')), 'ssl_min_protocol_version');
672
+ const sslMaxProtocolVersion = normalizeTlsProtocolVersion(nonEmpty(raw.query.get('ssl_max_protocol_version')), 'ssl_max_protocol_version');
673
+ assertTlsProtocolRange(sslMinProtocolVersion, sslMaxProtocolVersion);
674
+ assertTlsMaxProtocolSupported(sslMaxProtocolVersion);
675
+ // libpq rejects `sslnegotiation=direct` paired with a weak sslmode. The URI
676
+ // surface always resolves a concrete `ssl` (defaulting to 'prefer'), so the
677
+ // check is authoritative here.
678
+ assertSslNegotiationModeCompatible(ssl, sslnegotiation);
679
+ return {
680
+ host,
681
+ port,
682
+ user,
683
+ password,
684
+ database,
685
+ ssl,
686
+ channelBinding,
687
+ applicationName,
688
+ options,
689
+ ...(sslnegotiation !== undefined ? { sslnegotiation } : {}),
690
+ ...(hostaddr !== undefined ? { hostaddr } : {}),
691
+ ...(replication !== undefined ? { replication } : {}),
692
+ ...(hostsTuples.length > 1
693
+ ? { hosts: hostsTuples.map((t) => ({ host: t.host, port: t.port })) }
694
+ : {}),
695
+ ...(targetSessionAttrs !== undefined ? { targetSessionAttrs } : {}),
696
+ ...(loadBalanceHosts !== undefined ? { loadBalanceHosts } : {}),
697
+ ...(sslcert !== undefined ? { sslcert } : {}),
698
+ ...(sslkey !== undefined ? { sslkey } : {}),
699
+ ...(sslcertmode !== undefined ? { sslcertmode } : {}),
700
+ ...(sslrootcert !== undefined ? { sslrootcert } : {}),
701
+ ...(sslcrl !== undefined ? { sslcrl } : {}),
702
+ ...(sslcrldir !== undefined ? { sslcrldir } : {}),
703
+ ...(sslkeylogfile !== undefined ? { sslkeylogfile } : {}),
704
+ ...(sslsni !== undefined ? { sslsni } : {}),
705
+ ...(keepalives !== undefined ? { keepalives } : {}),
706
+ ...(keepalivesIdle !== undefined ? { keepalivesIdle } : {}),
707
+ ...(requirepeer !== undefined ? { requirepeer } : {}),
708
+ ...(sslMinProtocolVersion !== undefined ? { sslMinProtocolVersion } : {}),
709
+ ...(sslMaxProtocolVersion !== undefined ? { sslMaxProtocolVersion } : {}),
710
+ };
711
+ };
712
+ /**
713
+ * Parse a libpq 0/1 boolean connection parameter (`sslsni`, `keepalives`).
714
+ * libpq's `parse_bool_with_len` accepts `1`/`0`, `true`/`false`, `yes`/`no`,
715
+ * `on`/`off` (case-insensitive). Returns `undefined` for unset / empty /
716
+ * unrecognised so the caller falls back to libpq's default (enabled).
717
+ */
718
+ const parseLibpqBool = (raw) => {
719
+ if (raw === undefined || raw === '')
720
+ return undefined;
721
+ switch (raw.toLowerCase()) {
722
+ case '1':
723
+ case 'true':
724
+ case 'yes':
725
+ case 'on':
726
+ return true;
727
+ case '0':
728
+ case 'false':
729
+ case 'no':
730
+ case 'off':
731
+ return false;
732
+ default:
733
+ return undefined;
734
+ }
735
+ };
736
+ /**
737
+ * Parse `keepalives_idle` (seconds, non-negative integer). Returns the value
738
+ * in seconds, or `undefined` if unset / malformed (the wire layer converts to
739
+ * milliseconds for `socket.setKeepAlive`'s `initialDelay`).
740
+ */
741
+ const parseKeepalivesIdle = (raw) => {
742
+ if (raw === undefined || raw === '')
743
+ return undefined;
744
+ const n = Number.parseInt(raw, 10);
745
+ return Number.isFinite(n) && n >= 0 ? n : undefined;
746
+ };
747
+ const nonEmpty = (v) => v === undefined || v === '' ? undefined : v;
748
+ /**
749
+ * Resolve the final {host, port}[] list for a URI:
750
+ *
751
+ * 1. Start from the authority. `?host=`/`?port=` query overrides take
752
+ * precedence (libpq semantics).
753
+ * 2. If `host=h1,h2,…` is supplied, parse the comma-list. Ports come from
754
+ * `port=p1,p2,…`; must match the host count or be a single value
755
+ * (broadcast to all hosts).
756
+ * 3. If only the authority had multi-host tuples (`postgresql://h1,h2/db`),
757
+ * use those.
758
+ * 4. Otherwise fall back to single-host.
759
+ *
760
+ * Validates every port is in 1..65535 and surfaces a clear error otherwise.
761
+ */
762
+ const computeHostsTuples = (input) => {
763
+ const { rawHost, rawPort, rawAuthorityHosts, queryHost, queryPort } = input;
764
+ // Case A: ?host=… overrides the authority host(s). Port resolution still
765
+ // prefers `?port=` (if supplied), but falls back to the authority port so
766
+ // e.g. `postgres://:12345?host=/path/to/socket` keeps `port=12345`.
767
+ if (queryHost !== undefined && queryHost !== '') {
768
+ const hosts = queryHost.split(',').map((h) => h.trim());
769
+ const portList = queryPort !== undefined && queryPort !== ''
770
+ ? queryPort.split(',').map((p) => p.trim())
771
+ : null;
772
+ if (portList !== null &&
773
+ portList.length !== 1 &&
774
+ portList.length !== hosts.length) {
775
+ throw new Error(`could not match ${String(portList.length)} port numbers to ${String(hosts.length)} hosts`);
776
+ }
777
+ return hosts.map((h, idx) => {
778
+ let portStr;
779
+ if (portList !== null) {
780
+ portStr = portList.length === 1 ? portList[0] : portList[idx];
781
+ }
782
+ else {
783
+ // Fall back to the authority port. Multi-host without an explicit
784
+ // ?port= list shares the authority port across all hosts.
785
+ portStr = rawPort;
786
+ }
787
+ return { host: h, port: parsePort(portStr) };
788
+ });
789
+ }
790
+ // Case B: authority carried a comma-list (`postgresql://h1,h2:5433/db`).
791
+ // Query-string `?port=` can still broadcast or pair with this list.
792
+ if (rawAuthorityHosts !== undefined && rawAuthorityHosts.length > 0) {
793
+ const portList = queryPort !== undefined && queryPort !== ''
794
+ ? queryPort.split(',').map((p) => p.trim())
795
+ : null;
796
+ if (portList !== null &&
797
+ portList.length !== 1 &&
798
+ portList.length !== rawAuthorityHosts.length) {
799
+ throw new Error(`could not match ${String(portList.length)} port numbers to ${String(rawAuthorityHosts.length)} hosts`);
800
+ }
801
+ return rawAuthorityHosts.map((t, idx) => ({
802
+ host: t.host,
803
+ port: parsePort(portList !== null
804
+ ? portList.length === 1
805
+ ? portList[0]
806
+ : portList[idx]
807
+ : t.port),
808
+ }));
809
+ }
810
+ // Case C: single-host. Honour `?port=` (single value) if provided.
811
+ const portStr = queryPort ?? rawPort;
812
+ const host = rawHost !== undefined && rawHost !== '' ? rawHost : '';
813
+ return [{ host, port: parsePort(portStr) }];
814
+ };
815
+ const parsePort = (raw) => {
816
+ if (raw === undefined || raw === '')
817
+ return 5432;
818
+ // `Number.parseInt` silently tolerates trailing junk (`parseInt("12345 12")`
819
+ // === 12345), which would let an internal-whitespace value like the upstream
820
+ // `port = 12345 12` URI case sneak through as port 12345. libpq rejects that
821
+ // shape with `invalid integer value "<v>" for connection option "port"`,
822
+ // pointing at the whole bogus value (the whitespace included) rather than a
823
+ // generic out-of-range message. Detect any digits-then-garbage value here so
824
+ // that exact wording fires; genuinely non-numeric (`abc`) and out-of-range
825
+ // (`99999`) values keep the shorter `invalid port:` diagnostic.
826
+ if (/^\d/.test(raw) && !/^\d+$/.test(raw)) {
827
+ throw new Error(`invalid integer value "${raw}" for connection option "port"`);
828
+ }
829
+ const p = Number.parseInt(raw, 10);
830
+ if (!Number.isFinite(p) || p <= 0 || p > 65535) {
831
+ throw new Error(`invalid port: ${raw}`);
832
+ }
833
+ return p;
834
+ };
835
+ /**
836
+ * Parse a libpq-style conninfo string into a `Partial<ConnectOptions>`.
837
+ *
838
+ * The grammar is roughly `key = value` pairs separated by whitespace, where
839
+ * values may be quoted with single quotes (libpq's `\'` is honored as an
840
+ * embedded single-quote, but we don't model the full backslash-escape
841
+ * universe — there's no test corpus that exercises it). Unknown keys are
842
+ * rejected so a typo like `replicate=database` produces a clear error
843
+ * rather than silently dropping the parameter.
844
+ *
845
+ * Recognised keys mirror the URI-side `KNOWN_QUERY_KEYS` allowlist plus
846
+ * the authority-style keys (`host`, `port`, `user`, `dbname`, `password`).
847
+ * `replication` is normalised through `normalizeReplication` for libpq
848
+ * value-set compatibility.
849
+ *
850
+ * Out of scope: percent-decoding (conninfo strings are NOT percent-encoded),
851
+ * `service` resolution from `pg_service.conf`, `passfile` resolution.
852
+ */
853
+ export const parseConninfo = (input) => {
854
+ const out = {};
855
+ let i = 0;
856
+ const n = input.length;
857
+ while (i < n) {
858
+ // Skip whitespace between pairs.
859
+ while (i < n && /\s/.test(input[i]))
860
+ i += 1;
861
+ if (i >= n)
862
+ break;
863
+ // Parse key: chars up to `=` or whitespace.
864
+ const keyStart = i;
865
+ while (i < n && input[i] !== '=' && !/\s/.test(input[i]))
866
+ i += 1;
867
+ const key = input.slice(keyStart, i).toLowerCase();
868
+ if (key === '')
869
+ break;
870
+ // Skip whitespace before `=`.
871
+ while (i < n && /\s/.test(input[i]))
872
+ i += 1;
873
+ if (i >= n || input[i] !== '=') {
874
+ throw new Error(`missing "=" after "${key}" in conninfo string`);
875
+ }
876
+ i += 1; // consume `=`
877
+ // Skip whitespace after `=`.
878
+ while (i < n && /\s/.test(input[i]))
879
+ i += 1;
880
+ // Parse value: either single-quoted (with `\'` and `\\` escapes) or
881
+ // bare up to next whitespace.
882
+ let value;
883
+ if (i < n && input[i] === "'") {
884
+ i += 1; // consume opening quote
885
+ const parts = [];
886
+ while (i < n && input[i] !== "'") {
887
+ if (input[i] === '\\' && i + 1 < n) {
888
+ parts.push(input[i + 1]);
889
+ i += 2;
890
+ }
891
+ else {
892
+ parts.push(input[i]);
893
+ i += 1;
894
+ }
895
+ }
896
+ if (i >= n) {
897
+ throw new Error(`unterminated single quote in conninfo string for key "${key}"`);
898
+ }
899
+ i += 1; // consume closing quote
900
+ value = parts.join('');
901
+ }
902
+ else {
903
+ const valStart = i;
904
+ while (i < n && !/\s/.test(input[i]))
905
+ i += 1;
906
+ value = input.slice(valStart, i);
907
+ }
908
+ applyConninfoPair(out, key, value);
909
+ }
910
+ // Materialise multi-host list. The scalar `host`/`port` already hold the
911
+ // first entry (so single-host callers see no surface change); we only
912
+ // surface `hosts` when the comma-list had ≥2 entries.
913
+ const hostList = out._hostList;
914
+ const portList = out._portList;
915
+ if (hostList !== undefined && hostList.length > 0) {
916
+ if (portList !== undefined &&
917
+ portList.length !== 1 &&
918
+ portList.length !== hostList.length) {
919
+ throw new Error(`could not match ${String(portList.length)} port numbers to ${String(hostList.length)} hosts`);
920
+ }
921
+ if (hostList.length > 1) {
922
+ out.hosts = hostList.map((h, idx) => ({
923
+ host: h,
924
+ port: portList === undefined
925
+ ? (out.port ?? 5432)
926
+ : portList.length === 1
927
+ ? parsePort(portList[0])
928
+ : parsePort(portList[idx]),
929
+ }));
930
+ }
931
+ }
932
+ // Drop the private staging fields before returning to the caller.
933
+ // `_service` is left in place — the layered connect resolver in
934
+ // `core/startup.ts` doesn't see this struct; only `runPsql` extracts
935
+ // and forwards the service name. The caller deletes the slot after
936
+ // reading it.
937
+ delete out._hostList;
938
+ delete out._portList;
939
+ assertTlsProtocolRange(out.sslMinProtocolVersion, out.sslMaxProtocolVersion);
940
+ assertTlsMaxProtocolSupported(out.sslMaxProtocolVersion);
941
+ return out;
942
+ };
943
+ const applyConninfoPair = (out, key, value) => {
944
+ switch (key) {
945
+ case 'host': {
946
+ // Multi-host: `host=h1,h2,h3`. Store the list aside; the post-pass
947
+ // (finalizeConninfo) materialises it into `hosts` + matches up against
948
+ // any `port=p1,p2,p3` list.
949
+ if (value.includes(',')) {
950
+ out._hostList = value.split(',').map((h) => h.trim());
951
+ out.host = out._hostList[0];
952
+ }
953
+ else {
954
+ out.host = value;
955
+ out._hostList = undefined;
956
+ }
957
+ return;
958
+ }
959
+ case 'port': {
960
+ if (value.includes(',')) {
961
+ out._portList = value.split(',').map((p) => p.trim());
962
+ // First port still goes into the scalar slot for back-compat.
963
+ out.port = parsePort(out._portList[0]);
964
+ }
965
+ else {
966
+ out.port = parsePort(value);
967
+ out._portList = undefined;
968
+ }
969
+ return;
970
+ }
971
+ case 'user':
972
+ out.user = value;
973
+ return;
974
+ case 'password':
975
+ out.password = value;
976
+ return;
977
+ case 'dbname':
978
+ out.database = value;
979
+ return;
980
+ case 'application_name':
981
+ out.applicationName = value;
982
+ return;
983
+ case 'sslmode':
984
+ out.ssl = normalizeSslMode(value);
985
+ return;
986
+ case 'channel_binding': {
987
+ const cb = normalizeChannelBinding(value);
988
+ if (cb !== undefined)
989
+ out.channelBinding = cb;
990
+ return;
991
+ }
992
+ case 'require_auth': {
993
+ const ra = normalizeRequireAuth(value);
994
+ if (ra !== undefined)
995
+ out.requireAuth = ra;
996
+ return;
997
+ }
998
+ case 'connect_timeout': {
999
+ const t = Number.parseInt(value, 10);
1000
+ if (Number.isFinite(t) && t >= 0) {
1001
+ out.connectTimeoutMs = t * 1000;
1002
+ }
1003
+ return;
1004
+ }
1005
+ case 'client_encoding':
1006
+ out.clientEncoding = value;
1007
+ return;
1008
+ case 'options':
1009
+ out.options = value;
1010
+ return;
1011
+ case 'replication': {
1012
+ const rep = normalizeReplication(value);
1013
+ if (rep !== undefined)
1014
+ out.replication = rep;
1015
+ return;
1016
+ }
1017
+ case 'target_session_attrs': {
1018
+ const tsa = normalizeTargetSessionAttrs(value);
1019
+ if (tsa !== undefined)
1020
+ out.targetSessionAttrs = tsa;
1021
+ return;
1022
+ }
1023
+ case 'load_balance_hosts': {
1024
+ const lbh = normalizeLoadBalanceHosts(value);
1025
+ if (lbh !== undefined)
1026
+ out.loadBalanceHosts = lbh;
1027
+ return;
1028
+ }
1029
+ case 'gssencmode':
1030
+ // Unsupported (no GSSAPI); accept disable/prefer, reject require.
1031
+ validateGssEncMode(value);
1032
+ return;
1033
+ case 'sslcert':
1034
+ if (value !== '')
1035
+ out.sslcert = value;
1036
+ return;
1037
+ case 'sslkey':
1038
+ if (value !== '')
1039
+ out.sslkey = value;
1040
+ return;
1041
+ case 'sslcertmode': {
1042
+ const cm = normalizeSslCertMode(value);
1043
+ if (cm !== undefined)
1044
+ out.sslcertmode = cm;
1045
+ return;
1046
+ }
1047
+ case 'sslnegotiation': {
1048
+ const sn = normalizeSslNegotiation(value);
1049
+ if (sn !== undefined)
1050
+ out.sslnegotiation = sn;
1051
+ return;
1052
+ }
1053
+ case 'sslrootcert':
1054
+ if (value !== '')
1055
+ out.sslrootcert = value;
1056
+ return;
1057
+ case 'sslcrl':
1058
+ if (value !== '')
1059
+ out.sslcrl = value;
1060
+ return;
1061
+ case 'sslcrldir':
1062
+ if (value !== '')
1063
+ out.sslcrldir = value;
1064
+ return;
1065
+ case 'sslkeylogfile':
1066
+ if (value !== '')
1067
+ out.sslkeylogfile = value;
1068
+ return;
1069
+ case 'hostaddr':
1070
+ if (value !== '')
1071
+ out.hostaddr = value;
1072
+ return;
1073
+ case 'ssl_min_protocol_version': {
1074
+ const v = normalizeTlsProtocolVersion(value === '' ? undefined : value, 'ssl_min_protocol_version');
1075
+ if (v !== undefined)
1076
+ out.sslMinProtocolVersion = v;
1077
+ return;
1078
+ }
1079
+ case 'ssl_max_protocol_version': {
1080
+ const v = normalizeTlsProtocolVersion(value === '' ? undefined : value, 'ssl_max_protocol_version');
1081
+ if (v !== undefined)
1082
+ out.sslMaxProtocolVersion = v;
1083
+ return;
1084
+ }
1085
+ case 'sslsni': {
1086
+ const b = parseLibpqBool(value);
1087
+ if (b !== undefined)
1088
+ out.sslsni = b;
1089
+ return;
1090
+ }
1091
+ case 'keepalives': {
1092
+ const b = parseLibpqBool(value);
1093
+ if (b !== undefined)
1094
+ out.keepalives = b;
1095
+ return;
1096
+ }
1097
+ case 'keepalives_idle': {
1098
+ const n = parseKeepalivesIdle(value);
1099
+ if (n !== undefined)
1100
+ out.keepalivesIdle = n;
1101
+ return;
1102
+ }
1103
+ case 'requirepeer':
1104
+ if (value !== '')
1105
+ out.requirepeer = value;
1106
+ return;
1107
+ // Recognised libpq keys that we don't model — accept silently so we
1108
+ // don't reject legitimate connection strings. keepalives_interval /
1109
+ // keepalives_count have no Node net API equivalent (setKeepAlive only
1110
+ // exposes enable + initial delay) — recognised but cannot be applied.
1111
+ case 'passfile':
1112
+ case 'sslcompression':
1113
+ case 'krbsrvname':
1114
+ case 'gsslib':
1115
+ case 'fallback_application_name':
1116
+ case 'keepalives_interval':
1117
+ case 'keepalives_count':
1118
+ return;
1119
+ case 'service': {
1120
+ // Service name is NOT a ConnectOptions field — it's resolved by
1121
+ // the layered connect resolver in `core/startup.ts`. Stash it on
1122
+ // a private staging slot so the caller (`runPsql`) can extract it
1123
+ // alongside the URI-side `?service=…` parser.
1124
+ out._service = value;
1125
+ return;
1126
+ }
1127
+ default:
1128
+ throw new Error(`invalid conninfo key: "${key}"`);
1129
+ }
1130
+ };
1131
+ /**
1132
+ * Heuristic: does the `-d` value look like a connection URI or a conninfo
1133
+ * string (vs. a bare database name)? Mirrors libpq's
1134
+ * `recognized_connection_string()` test.
1135
+ */
1136
+ export const looksLikeConnectionString = (s) => {
1137
+ if (s.startsWith('postgresql://') || s.startsWith('postgres://'))
1138
+ return true;
1139
+ // A bare key=value pair (or several) — conninfo. We require the `=` to
1140
+ // appear before any whitespace so values like "weird name" (a bareword
1141
+ // database name with a space) don't get misclassified.
1142
+ const eq = s.indexOf('=');
1143
+ if (eq < 0)
1144
+ return false;
1145
+ const head = s.slice(0, eq);
1146
+ return !/\s/.test(head);
1147
+ };
1148
+ const normalizeSslMode = (raw) => {
1149
+ // Only null/unset defaults to 'prefer'. An explicit but unrecognized value
1150
+ // (e.g. a `verify-ful` typo) MUST be rejected — silently falling back to
1151
+ // 'prefer' would proceed with no cert-chain / hostname verification, a TLS
1152
+ // downgrade. Mirrors libpq's `invalid sslmode value: "..."` and every
1153
+ // sibling validator (normalizeChannelBinding, normalizeSslCertMode, …).
1154
+ if (raw === null)
1155
+ return 'prefer';
1156
+ const value = raw.toLowerCase();
1157
+ switch (value) {
1158
+ case 'disable':
1159
+ case 'allow':
1160
+ case 'prefer':
1161
+ case 'require':
1162
+ case 'verify-ca':
1163
+ case 'verify-full':
1164
+ return value;
1165
+ default:
1166
+ throw new Error(`invalid sslmode value: "${raw}"`);
1167
+ }
1168
+ };
1169
+ /**
1170
+ * libpq's accepted TLS protocol-version names, in ascending order. The
1171
+ * index doubles as the comparison key for the `min > max` check. Matching
1172
+ * is case-insensitive on input (libpq lowercases before comparing) but we
1173
+ * keep the canonical mixed-case spelling Node's `tls` module expects for
1174
+ * `minVersion` / `maxVersion`.
1175
+ */
1176
+ const TLS_PROTOCOL_VERSIONS = ['TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3'];
1177
+ /**
1178
+ * Validate and canonicalise a `ssl_{min,max}_protocol_version` value. Returns
1179
+ * the canonical spelling (`TLSv1.2` etc.) or `undefined` for empty / unset.
1180
+ * Throws libpq's `invalid <key> value: "<raw>"` wording on a malformed value.
1181
+ */
1182
+ const normalizeTlsProtocolVersion = (raw, key) => {
1183
+ if (raw === undefined || raw === '')
1184
+ return undefined;
1185
+ const match = TLS_PROTOCOL_VERSIONS.find((v) => v.toLowerCase() === raw.toLowerCase());
1186
+ if (match === undefined) {
1187
+ throw new Error(`invalid ${key} value: "${raw}"`);
1188
+ }
1189
+ return match;
1190
+ };
1191
+ /**
1192
+ * Reject a `ssl_max_protocol_version` ceiling below TLSv1.2. TLS 1.0/1.1 are
1193
+ * disabled in Node's bundled OpenSSL, so capping the ceiling there leaves no
1194
+ * negotiable protocol and the handshake otherwise fails with an opaque
1195
+ * `ERR_SSL_NO_PROTOCOLS_AVAILABLE` — surface an actionable message at parse
1196
+ * time instead. Only the MAX is gated: a low *min*
1197
+ * (`ssl_min_protocol_version=TLSv1.1`) is harmless because Node still
1198
+ * negotiates the highest mutually-supported version (1.2/1.3), exactly as
1199
+ * libpq does on a modern OpenSSL. Called AFTER {@link assertTlsProtocolRange}
1200
+ * so an inverted range (min > max) reports the range error first, matching
1201
+ * libpq's ordering.
1202
+ */
1203
+ const assertTlsMaxProtocolSupported = (max) => {
1204
+ if (max === 'TLSv1' || max === 'TLSv1.1') {
1205
+ throw new Error(`ssl_max_protocol_version "${max}" is not supported by this ` +
1206
+ `runtime's TLS library — TLS 1.0/1.1 are disabled in Node's OpenSSL; ` +
1207
+ `the minimum negotiable version is TLSv1.2`);
1208
+ }
1209
+ };
1210
+ /**
1211
+ * Reject a `ssl_min_protocol_version` that is higher than
1212
+ * `ssl_max_protocol_version`, matching libpq's
1213
+ * `ssl_min_protocol_version must be <= ssl_max_protocol_version` diagnostic.
1214
+ * Both arguments must already be canonicalised by
1215
+ * {@link normalizeTlsProtocolVersion}.
1216
+ */
1217
+ const assertTlsProtocolRange = (min, max) => {
1218
+ if (min === undefined || max === undefined)
1219
+ return;
1220
+ if (TLS_PROTOCOL_VERSIONS.indexOf(min) > TLS_PROTOCOL_VERSIONS.indexOf(max)) {
1221
+ throw new Error(`ssl_min_protocol_version must be <= ssl_max_protocol_version`);
1222
+ }
1223
+ };
1224
+ const normalizeChannelBinding = (raw) => {
1225
+ if (raw === null || raw === '')
1226
+ return undefined;
1227
+ const value = raw.toLowerCase();
1228
+ switch (value) {
1229
+ case 'disable':
1230
+ case 'prefer':
1231
+ case 'require':
1232
+ return value;
1233
+ default:
1234
+ // Mirror libpq's `invalid channel_binding value: "<raw>"`
1235
+ // diagnostic (upstream test `002_scram.pl`). Empty / unset
1236
+ // returns `undefined` above so the wire-layer default applies.
1237
+ throw new Error(`invalid channel_binding value: "${raw}"`);
1238
+ }
1239
+ };
1240
+ /**
1241
+ * Validate libpq's `gssencmode` (GSSAPI transport encryption).
1242
+ *
1243
+ * This client has NO GSSAPI support: GSS-API `gss_wrap`/`gss_unwrap` would
1244
+ * require a native Kerberos addon (e.g. the `kerberos` npm), which the
1245
+ * embedded psql deliberately avoids (pure-TS, zero native bindings — the
1246
+ * same reason the line editor is hand-rolled). `node-postgres` doesn't
1247
+ * support it either. So:
1248
+ * - `disable` / `prefer` — accepted and ignored: neither needs GSS
1249
+ * (`prefer` means "try GSS, else fall back", and falling back to the
1250
+ * non-GSS path is exactly what we always do).
1251
+ * - `require` — rejected with a clear diagnostic; we cannot satisfy it.
1252
+ * - anything else — `invalid gssencmode value`.
1253
+ * We recognise the parameter (rather than rejecting it as an unknown key)
1254
+ * so the many tools that always append `gssencmode=...` to a URI keep
1255
+ * working against Neon.
1256
+ */
1257
+ const validateGssEncMode = (raw) => {
1258
+ if (raw === null || raw === '')
1259
+ return;
1260
+ const value = raw.toLowerCase();
1261
+ if (value === 'disable' || value === 'prefer')
1262
+ return;
1263
+ if (value === 'require') {
1264
+ throw new Error('gssencmode=require is not supported: this client has no GSSAPI support');
1265
+ }
1266
+ throw new Error(`invalid gssencmode value: "${raw}"`);
1267
+ };
1268
+ /**
1269
+ * Parse libpq's `sslcertmode` value (`disable` / `allow` / `require`).
1270
+ * Empty / unset returns `undefined` so the wire-layer default (`allow`)
1271
+ * applies. A malformed value throws libpq's
1272
+ * `invalid sslcertmode value: "<raw>"` diagnostic.
1273
+ */
1274
+ const normalizeSslCertMode = (raw) => {
1275
+ if (raw === null || raw === '')
1276
+ return undefined;
1277
+ const value = raw.toLowerCase();
1278
+ switch (value) {
1279
+ case 'disable':
1280
+ case 'allow':
1281
+ case 'require':
1282
+ return value;
1283
+ default:
1284
+ throw new Error(`invalid sslcertmode value: "${raw}"`);
1285
+ }
1286
+ };
1287
+ /**
1288
+ * Parse libpq's `sslnegotiation` value (`postgres` / `direct`). Empty / unset
1289
+ * returns `undefined` so the wire-layer default (`postgres`, the classic
1290
+ * SSLRequest flow) applies. A malformed value throws libpq's
1291
+ * `invalid sslnegotiation value: "<raw>"` diagnostic.
1292
+ */
1293
+ const normalizeSslNegotiation = (raw) => {
1294
+ if (raw === null || raw === '')
1295
+ return undefined;
1296
+ const value = raw.toLowerCase();
1297
+ switch (value) {
1298
+ case 'postgres':
1299
+ case 'direct':
1300
+ return value;
1301
+ default:
1302
+ throw new Error(`invalid sslnegotiation value: "${raw}"`);
1303
+ }
1304
+ };
1305
+ /**
1306
+ * libpq constraint: `sslnegotiation=direct` may only be used with an encrypted
1307
+ * sslmode (`require` / `verify-ca` / `verify-full`). Direct SSL starts the TLS
1308
+ * handshake immediately with no plaintext fallback, so a "weak" mode that could
1309
+ * end up unencrypted (`disable` / `allow` / `prefer`) is rejected with libpq's
1310
+ * exact `pqConnectOptions2` wording. No-op unless `sslnegotiation` is `direct`.
1311
+ */
1312
+ const assertSslNegotiationModeCompatible = (ssl, sslnegotiation) => {
1313
+ if (sslnegotiation !== 'direct')
1314
+ return;
1315
+ if (ssl === 'require' || ssl === 'verify-ca' || ssl === 'verify-full') {
1316
+ return;
1317
+ }
1318
+ throw new Error(`weak sslmode "${ssl}" may not be used with sslnegotiation=direct`);
1319
+ };
1320
+ const VALID_REQUIRE_AUTH_METHODS = new Set([
1321
+ 'password',
1322
+ 'md5',
1323
+ 'gss',
1324
+ 'sspi',
1325
+ 'scram-sha-256',
1326
+ 'creds',
1327
+ 'none',
1328
+ ]);
1329
+ /**
1330
+ * Parse libpq's `require_auth` value: a comma-separated list of method
1331
+ * names where each entry may be prefixed with `!` to negate. Mixing
1332
+ * positive and negative entries is forbidden (libpq matches this).
1333
+ *
1334
+ * Returns `undefined` for empty input so the wire-layer default applies.
1335
+ * Throws on invalid syntax with libpq-parity wording, surfaced via the
1336
+ * outer `psql: error: ...` channel.
1337
+ */
1338
+ const normalizeRequireAuth = (raw) => {
1339
+ if (raw === null || raw === '')
1340
+ return undefined;
1341
+ const tokens = raw
1342
+ .split(',')
1343
+ .map((s) => s.trim())
1344
+ .filter((s) => s.length > 0);
1345
+ if (tokens.length === 0)
1346
+ return undefined;
1347
+ const methods = new Set();
1348
+ let polarity = null;
1349
+ for (const token of tokens) {
1350
+ const isNeg = token.startsWith('!');
1351
+ const name = (isNeg ? token.slice(1) : token).toLowerCase();
1352
+ if (!VALID_REQUIRE_AUTH_METHODS.has(name)) {
1353
+ throw new Error(`invalid require_auth method: "${token}"`);
1354
+ }
1355
+ if (polarity === null) {
1356
+ polarity = isNeg;
1357
+ }
1358
+ else if (polarity !== isNeg) {
1359
+ // libpq wording: "negative require_auth method ... cannot be mixed
1360
+ // with non-negative methods". We use a slightly shorter form here.
1361
+ throw new Error('require_auth methods cannot mix positive and negative entries');
1362
+ }
1363
+ methods.add(name);
1364
+ }
1365
+ return { methods, negated: polarity ?? false };
1366
+ };
1367
+ /**
1368
+ * Accept the libpq-spec set for `target_session_attrs`. Aliases `read-write`/
1369
+ * `primary` and `read-only`/`standby` are kept distinct because the wire
1370
+ * layer treats the canonical four values identically — we only normalise
1371
+ * unknown / empty inputs to `undefined` so the wire-layer default ('any')
1372
+ * applies. Throws on unrecognised values, matching libpq behaviour.
1373
+ */
1374
+ const normalizeTargetSessionAttrs = (raw) => {
1375
+ if (raw === null || raw === '')
1376
+ return undefined;
1377
+ const value = raw.toLowerCase();
1378
+ switch (value) {
1379
+ case 'any':
1380
+ case 'read-write':
1381
+ case 'read-only':
1382
+ case 'primary':
1383
+ case 'standby':
1384
+ case 'prefer-standby':
1385
+ return value;
1386
+ default:
1387
+ throw new Error(`invalid value for "target_session_attrs": "${raw}"`);
1388
+ }
1389
+ };
1390
+ /**
1391
+ * Accept the libpq-spec set for `load_balance_hosts`:
1392
+ * - `disable` (default) — preserve list order
1393
+ * - `random` — shuffle before iteration
1394
+ *
1395
+ * Unknown values throw; empty / unset returns `undefined` so the wire layer
1396
+ * default ('disable') applies.
1397
+ */
1398
+ const normalizeLoadBalanceHosts = (raw) => {
1399
+ if (raw === null || raw === '')
1400
+ return undefined;
1401
+ const value = raw.toLowerCase();
1402
+ if (value === 'disable' || value === 'random')
1403
+ return value;
1404
+ throw new Error(`invalid value for "load_balance_hosts": "${raw}"`);
1405
+ };
1406
+ /**
1407
+ * libpq accepts a wide set of "truthy" values for `replication`:
1408
+ * - `true` / `on` / `yes` / `1` → physical replication (mapped to `true`)
1409
+ * - `false` / `off` / `no` / `0` → not a walsender (no replication mode)
1410
+ * - `database` → logical replication on that DB
1411
+ *
1412
+ * Returns `undefined` when no value was supplied or when it explicitly
1413
+ * disables replication; throws for unrecognised input (matching libpq's
1414
+ * `"invalid <…> value"` semantics so users see a clear error rather than
1415
+ * silently sending an unexpected startup-message parameter).
1416
+ */
1417
+ const normalizeReplication = (raw) => {
1418
+ if (raw === null || raw === '')
1419
+ return undefined;
1420
+ const value = raw.toLowerCase();
1421
+ if (value === 'database')
1422
+ return 'database';
1423
+ if (value === 'true' || value === 'on' || value === 'yes' || value === '1') {
1424
+ return 'true';
1425
+ }
1426
+ if (value === 'false' || value === 'off' || value === 'no' || value === '0') {
1427
+ return undefined;
1428
+ }
1429
+ throw new Error(`invalid value for "replication": "${raw}"`);
1430
+ };
1431
+ // ---------------------------------------------------------------------------
1432
+ // Layered connection-parameter resolution.
1433
+ //
1434
+ // Vanilla psql consults several sources in priority order when filling in
1435
+ // connection parameters. Order (highest → lowest):
1436
+ //
1437
+ // 1. Explicit URI / conninfo (what the user passed on the command line)
1438
+ // 2. Argv flags (`-h`, `-p`, etc.)
1439
+ // 3. PG* env vars
1440
+ // 4. ~/.pgpass (password only; matched against host/port/db/user)
1441
+ // 5. pg_service.conf (when PGSERVICE / ?service= is set)
1442
+ // 6. libpq compiled-in defaults (localhost / 5432 / USER / database=user)
1443
+ //
1444
+ // The historical `parseConnectionUri` bakes (1) and (6) into a single
1445
+ // `ConnectOptions`, which makes layering impossible. `parseConnectionUriPartial`
1446
+ // gives the same parser surface but returns ONLY the fields the URI
1447
+ // explicitly set — leaving env / pgpass / service / defaults to fill the
1448
+ // gaps via `mergeConnectOptions`.
1449
+ // ---------------------------------------------------------------------------
1450
+ /**
1451
+ * Service name extracted from a connection URI's `?service=` query
1452
+ * parameter, if any. Surfaced alongside `parseConnectionUriPartial` so the
1453
+ * caller can route the lookup into `applyStartupArgs` without re-parsing
1454
+ * the URI.
1455
+ */
1456
+ export const parseConnectionUriService = (uri) => {
1457
+ const raw = tokenizeConnectionUri(uri);
1458
+ const value = raw.query.get('service');
1459
+ return value === undefined || value === '' ? undefined : value;
1460
+ };
1461
+ /**
1462
+ * Parse a URI into a `Partial<ConnectOptions>` containing only the fields
1463
+ * the URI explicitly supplied. Returned shape:
1464
+ *
1465
+ * - missing fields are absent (no `undefined` placeholders)
1466
+ * - `host`/`port` are populated only when the URI authority or `?host=`
1467
+ * specified them
1468
+ * - `user`/`password`/`database`/`ssl`/`channelBinding`/... follow the
1469
+ * same rule — present iff explicitly set
1470
+ *
1471
+ * This is the building block for the layered merge in `applyStartupArgs`.
1472
+ * The full-defaults variant `parseConnectionUri` remains the right choice
1473
+ * for callers that want a complete `ConnectOptions` (e.g. the existing
1474
+ * `-d URI` path); it's kept untouched for back-compat.
1475
+ */
1476
+ export const parseConnectionUriPartial = (uri) => {
1477
+ const raw = tokenizeConnectionUri(uri);
1478
+ const queryUser = raw.query.get('user');
1479
+ const queryPassword = raw.query.get('password');
1480
+ const queryPort = raw.query.get('port');
1481
+ const queryDbname = raw.query.get('dbname');
1482
+ const queryHost = raw.query.get('host');
1483
+ // Multi-host: same resolution as the full parser, but we treat its absence
1484
+ // as "URI didn't say anything about hosts" rather than synthesising a
1485
+ // localhost default.
1486
+ const hostsTuples = computeHostsTuples({
1487
+ rawHost: raw.host,
1488
+ rawPort: raw.port,
1489
+ rawAuthorityHosts: raw.hosts,
1490
+ queryHost,
1491
+ queryPort,
1492
+ });
1493
+ // Did the URI actually mention a port anywhere? `parsePort()` defaults
1494
+ // empty input to 5432, so `hostsTuples[i].port` is ALWAYS a number even
1495
+ // for a URI like `postgres:///?service=foo` (no port specified). We
1496
+ // need to distinguish "URI explicitly said 5432" from "URI said nothing
1497
+ // about a port" so the service-file's port wins when we layer this
1498
+ // partial above the service layer. Mirrors libpq's behaviour for
1499
+ // `006_service.pl`'s `postgres:///?service=…` cases.
1500
+ const portInUri = (raw.port !== undefined && raw.port !== '') ||
1501
+ (queryPort !== undefined && queryPort !== '') ||
1502
+ (raw.hosts?.some((t) => t.port !== undefined && t.port !== '') ?? false);
1503
+ const out = {};
1504
+ if (hostsTuples.length > 0) {
1505
+ // First tuple drives the single-host surface; the full multi-host list
1506
+ // is included only when the URI specified more than one. An empty-host
1507
+ // tuple (e.g. `postgres://:12345/`) means "explicit port, no host" —
1508
+ // we record the port but leave host to the next layer.
1509
+ if (hostsTuples[0].host !== '')
1510
+ out.host = hostsTuples[0].host;
1511
+ if (portInUri && hostsTuples[0].port !== 0)
1512
+ out.port = hostsTuples[0].port;
1513
+ if (hostsTuples.length > 1) {
1514
+ out.hosts = hostsTuples.map((t) => ({ host: t.host, port: t.port }));
1515
+ }
1516
+ }
1517
+ const userExplicit = queryUser !== undefined && queryUser !== ''
1518
+ ? queryUser
1519
+ : raw.user !== undefined && raw.user !== ''
1520
+ ? raw.user
1521
+ : undefined;
1522
+ if (userExplicit !== undefined)
1523
+ out.user = userExplicit;
1524
+ const password = queryPassword ?? raw.password;
1525
+ if (password !== undefined)
1526
+ out.password = password;
1527
+ const database = queryDbname ?? raw.database;
1528
+ if (database !== undefined)
1529
+ out.database = database;
1530
+ const sslRaw = raw.query.get('sslmode');
1531
+ if (sslRaw !== undefined && sslRaw !== '') {
1532
+ out.ssl = normalizeSslMode(sslRaw);
1533
+ }
1534
+ const cb = normalizeChannelBinding(raw.query.get('channel_binding') ?? null);
1535
+ if (cb !== undefined)
1536
+ out.channelBinding = cb;
1537
+ const ra = normalizeRequireAuth(raw.query.get('require_auth') ?? null);
1538
+ if (ra !== undefined)
1539
+ out.requireAuth = ra;
1540
+ const options = raw.query.get('options');
1541
+ if (options !== undefined && options !== '')
1542
+ out.options = options;
1543
+ const appName = raw.query.get('application_name');
1544
+ if (appName !== undefined && appName !== '')
1545
+ out.applicationName = appName;
1546
+ const replication = normalizeReplication(raw.query.get('replication') ?? null);
1547
+ if (replication !== undefined)
1548
+ out.replication = replication;
1549
+ const targetSessionAttrs = normalizeTargetSessionAttrs(raw.query.get('target_session_attrs') ?? null);
1550
+ if (targetSessionAttrs !== undefined) {
1551
+ out.targetSessionAttrs = targetSessionAttrs;
1552
+ }
1553
+ const loadBalanceHosts = normalizeLoadBalanceHosts(raw.query.get('load_balance_hosts') ?? null);
1554
+ if (loadBalanceHosts !== undefined)
1555
+ out.loadBalanceHosts = loadBalanceHosts;
1556
+ const sslcert = nonEmpty(raw.query.get('sslcert'));
1557
+ if (sslcert !== undefined)
1558
+ out.sslcert = sslcert;
1559
+ const sslkey = nonEmpty(raw.query.get('sslkey'));
1560
+ if (sslkey !== undefined)
1561
+ out.sslkey = sslkey;
1562
+ const sslcertmode = normalizeSslCertMode(raw.query.get('sslcertmode') ?? null);
1563
+ if (sslcertmode !== undefined)
1564
+ out.sslcertmode = sslcertmode;
1565
+ const sslnegotiation = normalizeSslNegotiation(raw.query.get('sslnegotiation') ?? null);
1566
+ if (sslnegotiation !== undefined)
1567
+ out.sslnegotiation = sslnegotiation;
1568
+ const sslrootcert = nonEmpty(raw.query.get('sslrootcert'));
1569
+ if (sslrootcert !== undefined)
1570
+ out.sslrootcert = sslrootcert;
1571
+ const sslcrl = nonEmpty(raw.query.get('sslcrl'));
1572
+ if (sslcrl !== undefined)
1573
+ out.sslcrl = sslcrl;
1574
+ const sslcrldir = nonEmpty(raw.query.get('sslcrldir'));
1575
+ if (sslcrldir !== undefined)
1576
+ out.sslcrldir = sslcrldir;
1577
+ const sslkeylogfile = nonEmpty(raw.query.get('sslkeylogfile'));
1578
+ if (sslkeylogfile !== undefined)
1579
+ out.sslkeylogfile = sslkeylogfile;
1580
+ const sslsni = parseLibpqBool(nonEmpty(raw.query.get('sslsni')));
1581
+ if (sslsni !== undefined)
1582
+ out.sslsni = sslsni;
1583
+ const keepalives = parseLibpqBool(nonEmpty(raw.query.get('keepalives')));
1584
+ if (keepalives !== undefined)
1585
+ out.keepalives = keepalives;
1586
+ const keepalivesIdle = parseKeepalivesIdle(nonEmpty(raw.query.get('keepalives_idle')));
1587
+ if (keepalivesIdle !== undefined)
1588
+ out.keepalivesIdle = keepalivesIdle;
1589
+ const requirepeer = nonEmpty(raw.query.get('requirepeer'));
1590
+ if (requirepeer !== undefined)
1591
+ out.requirepeer = requirepeer;
1592
+ const hostaddr = nonEmpty(raw.query.get('hostaddr'));
1593
+ if (hostaddr !== undefined)
1594
+ out.hostaddr = hostaddr;
1595
+ const sslMin = normalizeTlsProtocolVersion(nonEmpty(raw.query.get('ssl_min_protocol_version')), 'ssl_min_protocol_version');
1596
+ if (sslMin !== undefined)
1597
+ out.sslMinProtocolVersion = sslMin;
1598
+ const sslMax = normalizeTlsProtocolVersion(nonEmpty(raw.query.get('ssl_max_protocol_version')), 'ssl_max_protocol_version');
1599
+ if (sslMax !== undefined)
1600
+ out.sslMaxProtocolVersion = sslMax;
1601
+ assertTlsProtocolRange(out.sslMinProtocolVersion, out.sslMaxProtocolVersion);
1602
+ assertTlsMaxProtocolSupported(out.sslMaxProtocolVersion);
1603
+ const connectTimeoutSec = raw.query.get('connect_timeout');
1604
+ if (connectTimeoutSec !== undefined && connectTimeoutSec !== '') {
1605
+ const t = Number.parseInt(connectTimeoutSec, 10);
1606
+ if (Number.isFinite(t) && t >= 0)
1607
+ out.connectTimeoutMs = t * 1000;
1608
+ }
1609
+ const clientEncoding = raw.query.get('client_encoding');
1610
+ if (clientEncoding !== undefined && clientEncoding !== '') {
1611
+ out.clientEncoding = clientEncoding;
1612
+ }
1613
+ return out;
1614
+ };
1615
+ // Field map for PG* env vars. Order in the table is documentation; resolution
1616
+ // only depends on whether the var is set.
1617
+ const PG_ENV_FIELD_MAP = {
1618
+ PGHOST: 'host',
1619
+ PGHOSTADDR: 'hostaddr',
1620
+ PGPORT: 'port',
1621
+ PGUSER: 'user',
1622
+ PGDATABASE: 'database',
1623
+ PGPASSWORD: 'password',
1624
+ PGAPPNAME: 'applicationName',
1625
+ PGOPTIONS: 'options',
1626
+ PGCLIENTENCODING: 'clientEncoding',
1627
+ PGSSLMODE: 'ssl',
1628
+ PGSSLROOTCERT: 'sslrootcert',
1629
+ PGSSLCERT: 'sslcert',
1630
+ PGSSLKEY: 'sslkey',
1631
+ PGSSLCERTMODE: 'sslcertmode',
1632
+ PGSSLNEGOTIATION: 'sslnegotiation',
1633
+ PGSSLCRL: 'sslcrl',
1634
+ PGSSLCRLDIR: 'sslcrldir',
1635
+ PGSSLKEYLOGFILE: 'sslkeylogfile',
1636
+ PGCHANNELBINDING: 'channelBinding',
1637
+ };
1638
+ /**
1639
+ * Resolve the PG* env vars into a `Partial<ConnectOptions>`. Only set keys
1640
+ * end up in the result; unset / empty env vars are skipped so the caller
1641
+ * can layer this between URI overrides and pgpass / service / libpq
1642
+ * defaults without clobbering anything.
1643
+ *
1644
+ * Validation: any malformed value (e.g. `PGPORT=abc`) is silently dropped.
1645
+ * libpq behaves the same — the connection then fails later with a clearer
1646
+ * "could not parse" message, but the env-var lookup itself does not throw.
1647
+ *
1648
+ * Notes:
1649
+ * - `PGHOSTADDR` maps to {@link ConnectOptions.hostaddr}: the wire layer
1650
+ * dials this fixed IP while `PGHOST` still drives TLS SNI / cert
1651
+ * verification.
1652
+ * - `PGCONNECT_TIMEOUT` is in seconds; we convert to milliseconds.
1653
+ * - `PGCHANNELBINDING` accepts disable/prefer/require.
1654
+ * - `PGSERVICE` is consumed by the caller (it drives the
1655
+ * pg_service.conf lookup) and is NOT a direct ConnectOptions field.
1656
+ * - `PGSERVICEFILE`, `PGSYSCONFDIR`, `PGPASSFILE` are likewise consumed
1657
+ * by the loaders, not surfaced here.
1658
+ */
1659
+ export const envConnectionDefaults = (env) => {
1660
+ const out = {};
1661
+ const get = (k) => {
1662
+ const v = env[k];
1663
+ return v !== undefined && v !== '' ? v : undefined;
1664
+ };
1665
+ for (const [envName, field] of Object.entries(PG_ENV_FIELD_MAP)) {
1666
+ const value = get(envName);
1667
+ if (value === undefined)
1668
+ continue;
1669
+ applyEnvValue(out, field, value);
1670
+ }
1671
+ const timeoutRaw = get('PGCONNECT_TIMEOUT');
1672
+ if (timeoutRaw !== undefined) {
1673
+ const t = Number.parseInt(timeoutRaw, 10);
1674
+ if (Number.isFinite(t) && t >= 0)
1675
+ out.connectTimeoutMs = t * 1000;
1676
+ }
1677
+ // GSSAPI is unsupported; PGGSSENCMODE=require is rejected, disable/prefer
1678
+ // accepted-and-ignored. Same contract as the URI/conninfo `gssencmode`.
1679
+ validateGssEncMode(get('PGGSSENCMODE') ?? null);
1680
+ return out;
1681
+ };
1682
+ const applyEnvValue = (out, field, value) => {
1683
+ switch (field) {
1684
+ case 'host':
1685
+ out.host = value;
1686
+ return;
1687
+ case 'port': {
1688
+ const p = Number.parseInt(value, 10);
1689
+ if (Number.isFinite(p) && p > 0 && p <= 65535)
1690
+ out.port = p;
1691
+ return;
1692
+ }
1693
+ case 'user':
1694
+ out.user = value;
1695
+ return;
1696
+ case 'database':
1697
+ out.database = value;
1698
+ return;
1699
+ case 'password':
1700
+ out.password = value;
1701
+ return;
1702
+ case 'applicationName':
1703
+ out.applicationName = value;
1704
+ return;
1705
+ case 'options':
1706
+ out.options = value;
1707
+ return;
1708
+ case 'clientEncoding':
1709
+ out.clientEncoding = value;
1710
+ return;
1711
+ case 'ssl':
1712
+ out.ssl = normalizeSslMode(value);
1713
+ return;
1714
+ case 'sslrootcert':
1715
+ out.sslrootcert = value;
1716
+ return;
1717
+ case 'sslcert':
1718
+ out.sslcert = value;
1719
+ return;
1720
+ case 'sslkey':
1721
+ out.sslkey = value;
1722
+ return;
1723
+ case 'sslcertmode': {
1724
+ const cm = normalizeSslCertMode(value);
1725
+ if (cm !== undefined)
1726
+ out.sslcertmode = cm;
1727
+ return;
1728
+ }
1729
+ case 'sslnegotiation': {
1730
+ const sn = normalizeSslNegotiation(value);
1731
+ if (sn !== undefined)
1732
+ out.sslnegotiation = sn;
1733
+ return;
1734
+ }
1735
+ case 'sslcrl':
1736
+ out.sslcrl = value;
1737
+ return;
1738
+ case 'sslcrldir':
1739
+ out.sslcrldir = value;
1740
+ return;
1741
+ case 'sslkeylogfile':
1742
+ out.sslkeylogfile = value;
1743
+ return;
1744
+ case 'hostaddr':
1745
+ out.hostaddr = value;
1746
+ return;
1747
+ case 'channelBinding': {
1748
+ const cb = normalizeChannelBinding(value);
1749
+ if (cb !== undefined)
1750
+ out.channelBinding = cb;
1751
+ return;
1752
+ }
1753
+ default:
1754
+ // Unhandled field — silently drop. Tightening this would require
1755
+ // narrowing the field-map type; not worth the complexity.
1756
+ return;
1757
+ }
1758
+ };
1759
+ /**
1760
+ * libpq compiled-in defaults. The lowest-priority layer in the merge chain.
1761
+ *
1762
+ * - host: 'localhost'
1763
+ * - port: 5432
1764
+ * - user: $USER ?? '' (the wire layer surfaces a clear error if the user
1765
+ * is still empty at connect time)
1766
+ * - database: deferred — libpq defaults dbname to the user; we wire that
1767
+ * in `mergeConnectOptions` after layering so a `PGUSER` env can flow
1768
+ * into `database` when the user didn't specify one.
1769
+ * - ssl: 'prefer'
1770
+ * - applicationName: 'psql' — matches upstream so `pg_stat_activity` shows
1771
+ * the value users expect.
1772
+ * - sslcert / sslkey: libpq auto-loads the default client cert/key at
1773
+ * `~/.postgresql/postgresql.crt` / `.key` when neither is configured AND
1774
+ * the file exists. We seed these as the lowest-priority defaults via
1775
+ * {@link defaultClientCertDefaults}; any explicit URI / env / conninfo
1776
+ * value overrides them. A non-existent default file is simply not set
1777
+ * (no error), matching libpq — only an explicit path that's missing
1778
+ * surfaces an error (at TLS-load time, in the wire layer).
1779
+ */
1780
+ export const libpqConnectionDefaults = (env) => ({
1781
+ host: 'localhost',
1782
+ port: 5432,
1783
+ user: env.USER ?? '',
1784
+ database: '',
1785
+ ssl: 'prefer',
1786
+ applicationName: 'psql',
1787
+ ...defaultClientCertDefaults(env),
1788
+ });
1789
+ /**
1790
+ * libpq default client-certificate discovery. When the user has NOT set
1791
+ * `sslcert` / `sslkey` (explicit paths and `PGSSLCERT` / `PGSSLKEY` are
1792
+ * higher-priority layers), libpq falls back to `~/.postgresql/postgresql.crt`
1793
+ * and `~/.postgresql/postgresql.key` — but only if those files actually
1794
+ * exist. We mirror that here so a present default cert satisfies e.g.
1795
+ * `sslcertmode=require`.
1796
+ *
1797
+ * The home directory is taken from `env.HOME` (falling back to
1798
+ * `os.homedir()`), the same convention as the pgpass / pgservice loaders;
1799
+ * passing a synthetic `HOME` keeps this hermetic in tests.
1800
+ *
1801
+ * Exported for unit testing.
1802
+ */
1803
+ export const defaultClientCertDefaults = (env) => {
1804
+ const home = env.HOME ?? os.homedir();
1805
+ if (home === undefined || home === '')
1806
+ return {};
1807
+ const out = {};
1808
+ const certPath = path.join(home, '.postgresql', 'postgresql.crt');
1809
+ if (existsSync(certPath))
1810
+ out.sslcert = certPath;
1811
+ const keyPath = path.join(home, '.postgresql', 'postgresql.key');
1812
+ if (existsSync(keyPath))
1813
+ out.sslkey = keyPath;
1814
+ return out;
1815
+ };
1816
+ /**
1817
+ * Translate a `pg_service.conf` entry into a `Partial<ConnectOptions>`.
1818
+ * Unknown keys are silently dropped — the service file format admits
1819
+ * arbitrary keys but only the libpq-spec subset maps to ConnectOptions.
1820
+ *
1821
+ * Numeric / enum validation mirrors `parseConninfo` so an out-of-range
1822
+ * port or bogus sslmode in the service file fails the same way (silently
1823
+ * dropped here, since libpq itself only warns on invalid service values).
1824
+ */
1825
+ export const serviceEntryToConnectOptions = (entry) => {
1826
+ const out = {};
1827
+ for (const [k, v] of Object.entries(entry)) {
1828
+ const key = k.toLowerCase();
1829
+ switch (key) {
1830
+ case 'host':
1831
+ if (v !== '')
1832
+ out.host = v;
1833
+ break;
1834
+ case 'port': {
1835
+ const p = Number.parseInt(v, 10);
1836
+ if (Number.isFinite(p) && p > 0 && p <= 65535)
1837
+ out.port = p;
1838
+ break;
1839
+ }
1840
+ case 'user':
1841
+ if (v !== '')
1842
+ out.user = v;
1843
+ break;
1844
+ case 'dbname':
1845
+ if (v !== '')
1846
+ out.database = v;
1847
+ break;
1848
+ case 'password':
1849
+ out.password = v;
1850
+ break;
1851
+ case 'application_name':
1852
+ if (v !== '')
1853
+ out.applicationName = v;
1854
+ break;
1855
+ case 'sslmode':
1856
+ if (v !== '')
1857
+ out.ssl = normalizeSslMode(v);
1858
+ break;
1859
+ case 'channel_binding': {
1860
+ const cb = normalizeChannelBinding(v);
1861
+ if (cb !== undefined)
1862
+ out.channelBinding = cb;
1863
+ break;
1864
+ }
1865
+ case 'require_auth': {
1866
+ const ra = normalizeRequireAuth(v);
1867
+ if (ra !== undefined)
1868
+ out.requireAuth = ra;
1869
+ break;
1870
+ }
1871
+ case 'options':
1872
+ if (v !== '')
1873
+ out.options = v;
1874
+ break;
1875
+ case 'client_encoding':
1876
+ if (v !== '')
1877
+ out.clientEncoding = v;
1878
+ break;
1879
+ case 'sslcert':
1880
+ if (v !== '')
1881
+ out.sslcert = v;
1882
+ break;
1883
+ case 'sslkey':
1884
+ if (v !== '')
1885
+ out.sslkey = v;
1886
+ break;
1887
+ case 'sslcertmode': {
1888
+ const cm = normalizeSslCertMode(v);
1889
+ if (cm !== undefined)
1890
+ out.sslcertmode = cm;
1891
+ break;
1892
+ }
1893
+ case 'sslnegotiation': {
1894
+ const sn = normalizeSslNegotiation(v);
1895
+ if (sn !== undefined)
1896
+ out.sslnegotiation = sn;
1897
+ break;
1898
+ }
1899
+ case 'sslrootcert':
1900
+ if (v !== '')
1901
+ out.sslrootcert = v;
1902
+ break;
1903
+ case 'sslcrl':
1904
+ if (v !== '')
1905
+ out.sslcrl = v;
1906
+ break;
1907
+ case 'sslcrldir':
1908
+ if (v !== '')
1909
+ out.sslcrldir = v;
1910
+ break;
1911
+ case 'sslkeylogfile':
1912
+ if (v !== '')
1913
+ out.sslkeylogfile = v;
1914
+ break;
1915
+ case 'sslsni': {
1916
+ const b = parseLibpqBool(v);
1917
+ if (b !== undefined)
1918
+ out.sslsni = b;
1919
+ break;
1920
+ }
1921
+ case 'keepalives': {
1922
+ const b = parseLibpqBool(v);
1923
+ if (b !== undefined)
1924
+ out.keepalives = b;
1925
+ break;
1926
+ }
1927
+ case 'keepalives_idle': {
1928
+ const n = parseKeepalivesIdle(v);
1929
+ if (n !== undefined)
1930
+ out.keepalivesIdle = n;
1931
+ break;
1932
+ }
1933
+ case 'requirepeer':
1934
+ if (v !== '')
1935
+ out.requirepeer = v;
1936
+ break;
1937
+ case 'hostaddr':
1938
+ if (v !== '')
1939
+ out.hostaddr = v;
1940
+ break;
1941
+ case 'ssl_min_protocol_version': {
1942
+ const pv = normalizeTlsProtocolVersion(v === '' ? undefined : v, 'ssl_min_protocol_version');
1943
+ if (pv !== undefined)
1944
+ out.sslMinProtocolVersion = pv;
1945
+ break;
1946
+ }
1947
+ case 'ssl_max_protocol_version': {
1948
+ const pv = normalizeTlsProtocolVersion(v === '' ? undefined : v, 'ssl_max_protocol_version');
1949
+ if (pv !== undefined)
1950
+ out.sslMaxProtocolVersion = pv;
1951
+ break;
1952
+ }
1953
+ case 'connect_timeout': {
1954
+ const t = Number.parseInt(v, 10);
1955
+ if (Number.isFinite(t) && t >= 0)
1956
+ out.connectTimeoutMs = t * 1000;
1957
+ break;
1958
+ }
1959
+ // Recognised but not mapped — service files may contain `passfile`,
1960
+ // `krbsrvname`, etc. We drop silently rather than complain.
1961
+ default:
1962
+ break;
1963
+ }
1964
+ }
1965
+ assertTlsProtocolRange(out.sslMinProtocolVersion, out.sslMaxProtocolVersion);
1966
+ assertTlsMaxProtocolSupported(out.sslMaxProtocolVersion);
1967
+ return out;
1968
+ };
1969
+ /**
1970
+ * Merge layered partial ConnectOptions into a complete ConnectOptions.
1971
+ *
1972
+ * Layers are listed in PRIORITY order (highest first). For each output
1973
+ * field, the first layer that supplies a value wins. The implementation
1974
+ * walks the layers in reverse so the spread-into semantics match.
1975
+ *
1976
+ * `database` has a libpq-specific fallback: if every layer omits it, the
1977
+ * default is the resolved `user` (so `psql -U alice` connects to a
1978
+ * database named `alice`). We apply this AFTER all layers have run.
1979
+ */
1980
+ export const mergeConnectOptions = (layers, defaults) => {
1981
+ let out = { ...defaults };
1982
+ // Apply layers from LOWEST → HIGHEST so higher-priority layers overwrite.
1983
+ for (let i = layers.length - 1; i >= 0; i--) {
1984
+ const layer = layers[i];
1985
+ out = { ...out, ...layer };
1986
+ }
1987
+ // libpq: database defaults to the resolved user when no layer set it.
1988
+ // The default `database: ''` from `libpqConnectionDefaults` is the
1989
+ // sentinel for "nothing supplied".
1990
+ if (out.database === '') {
1991
+ out.database = out.user;
1992
+ }
1993
+ // libpq: `sslrootcert=system` raises the effective sslmode to verify-full
1994
+ // (it makes no sense to trust the public CA store without verifying the
1995
+ // chain AND the hostname). verify-full is the strongest mode, so this can
1996
+ // only ever raise — never downgrade — an explicitly requested mode.
1997
+ if (out.sslrootcert === 'system' && out.ssl !== 'verify-full') {
1998
+ out.ssl = 'verify-full';
1999
+ }
2000
+ // libpq validates `sslnegotiation=direct` against the FINAL sslmode (after
2001
+ // any `sslrootcert=system` raise and cross-layer merge), rejecting a weak
2002
+ // mode that could end up plaintext. Authoritative check across all layers.
2003
+ assertSslNegotiationModeCompatible(out.ssl, out.sslnegotiation);
2004
+ return out;
2005
+ };