@effect/sql-pg 4.0.0-rc.115 → 4.0.0-rc.117

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.
@@ -17,6 +17,7 @@ import { randomBytes } from "node:crypto";
17
17
  import * as Net from "node:net";
18
18
  import * as Tls from "node:tls";
19
19
  import { internalsKey } from "./internal/connection.js";
20
+ import * as PasswordInternal from "./internal/password.js";
20
21
  import { classifySqlState, validateChannelName } from "./internal/sqlError.js";
21
22
  import * as PgAuth from "./PgAuth.js";
22
23
  import * as PgProtocol from "./PgProtocol.js";
@@ -49,19 +50,22 @@ export const PgConnection = /*#__PURE__*/Context.Service("@effect/sql-pg/PgConne
49
50
  *
50
51
  * **Details**
51
52
  *
52
- * The transport, optional `SSLRequest`, startup, and authentication steps run
53
- * under `connectTimeout` (5 seconds by default). The effect resolves once the
54
- * backend sends `ReadyForQuery`. When the scope closes, the
55
- * session sends `Terminate` and ends the socket.
53
+ * Password resolution, transport, optional `SSLRequest`, startup, and
54
+ * authentication run under `connectTimeout` (5 seconds by default). The effect
55
+ * resolves when the backend sends `ReadyForQuery`. Closing the scope sends
56
+ * `Terminate` and ends the socket.
56
57
  *
57
- * When `ssl` is enabled, a server that rejects `SSLRequest` fails the
58
- * connection. Unix sockets and custom streams should set `ssl.servername`
59
- * explicitly.
58
+ * Use `sslmode=require` or explicit `ssl: true` to require encryption.
59
+ * Unix sockets and custom streams should set `ssl.servername` explicitly.
60
60
  *
61
61
  * @category constructors
62
62
  * @since 4.0.0
63
63
  */
64
- export const make = options => Effect.flatMap(resolveConfig(options), config => Effect.acquireRelease(Effect.map(connect(config), session => new PgConnectionImpl(options, config, session, options.types)), connection => Effect.sync(() => connection.closeUnsafe()), {
64
+ export const make = options => Effect.flatMap(resolveConfig(options), config => Effect.acquireRelease(Effect.gen(function* () {
65
+ const password = yield* PasswordInternal.resolve(config.password);
66
+ const session = yield* connect(config, password);
67
+ return new PgConnectionImpl(options, config, session, options.types);
68
+ }), connection => Effect.sync(() => connection.closeUnsafe()), {
65
69
  interruptible: true
66
70
  }).pipe(Effect.timeoutOrElse({
67
71
  duration: config.connectTimeout,
@@ -74,6 +78,9 @@ export const make = options => Effect.flatMap(resolveConfig(options), config =>
74
78
  }))
75
79
  })));
76
80
  const abortDrainTimeoutMillis = 5000;
81
+ const abortDrainGraceMillis = 10;
82
+ /** PostgreSQL `query_canceled` SQLSTATE. */
83
+ const queryCanceledCode = "57014";
77
84
  const cancelRequestTimeoutMillis = 5000;
78
85
  /** How many statements a multiplexed session keeps on the wire at once. */
79
86
  const maxPipelineDepth = 128;
@@ -98,8 +105,9 @@ class PgConnectionImpl {
98
105
  consumer;
99
106
  deadWith;
100
107
  closed = false;
108
+ cancelPending = false;
101
109
  channels = /*#__PURE__*/new Map();
102
- fatalHooks = /*#__PURE__*/new Set();
110
+ retireHooks = /*#__PURE__*/new Set();
103
111
  /** Queued but not yet written; drained into `pipelineInFlight` on flush. */
104
112
  pipelinePending = [];
105
113
  /** Written and awaiting their `ReadyForQuery`, oldest first. */
@@ -122,7 +130,7 @@ class PgConnectionImpl {
122
130
  this[internalsKey] = {
123
131
  base: this,
124
132
  deadError: () => this.deadWith,
125
- fatalHooks: this.fatalHooks
133
+ retireHooks: this.retireHooks
126
134
  };
127
135
  this.pinnedView = new PinnedPgConnection(this);
128
136
  session.socket.on("data", this.onData);
@@ -144,6 +152,15 @@ class PgConnectionImpl {
144
152
  dispatch = message => {
145
153
  if (this.deadWith !== undefined) return;
146
154
  switch (message._tag) {
155
+ case "ErrorResponse":
156
+ if (message.fields.code === queryCanceledCode) this.cancelPending = false;
157
+ break;
158
+ case "ReadyForQuery":
159
+ if (this.cancelPending) {
160
+ this.cancelPending = false;
161
+ this.retire();
162
+ }
163
+ break;
147
164
  case "NotificationResponse":
148
165
  {
149
166
  const queues = this.channels.get(message.channel);
@@ -181,12 +198,15 @@ class PgConnectionImpl {
181
198
  consumer?.onFatal(error);
182
199
  const sets = Array.from(this.channels.values());
183
200
  this.channels.clear();
201
+ const cause = this.closed ? Cause.interrupt() : Cause.fail(error);
184
202
  for (const set of sets) {
185
- for (const queue of set) Queue.failCauseUnsafe(queue, Cause.interrupt());
186
- }
187
- if (!this.closed) {
188
- for (const hook of this.fatalHooks) hook();
203
+ for (const queue of set) Queue.failCauseUnsafe(queue, cause);
189
204
  }
205
+ this.retire();
206
+ }
207
+ retire() {
208
+ if (this.closed) return;
209
+ for (const hook of this.retireHooks) hook();
190
210
  }
191
211
  closeUnsafe() {
192
212
  this.closed = true;
@@ -373,7 +393,9 @@ class PgConnectionImpl {
373
393
  /** Sends a `CancelRequest` for this session on a side connection. */
374
394
  cancel = /*#__PURE__*/Effect.suspend(() => {
375
395
  if (this.deadWith !== undefined) return Effect.void;
376
- return sendCancelRequest(this.resolved, this.session.processId, this.session.secretKey);
396
+ this.cancelPending = true;
397
+ if (this.consumer === undefined) this.retire();
398
+ return sendCancelRequest(this.resolved, this.session);
377
399
  });
378
400
  pin = /*#__PURE__*/Effect.suspend(() => {
379
401
  const reserve = this[internalsKey].reserve;
@@ -457,13 +479,7 @@ const inferScalar = value => {
457
479
  // or timestamp column the way it did with the text-protocol drivers.
458
480
  return inferredParameter(0, value);
459
481
  }
460
- if (value instanceof Date) {
461
- const time = value.getTime();
462
- if (Number.isNaN(time)) throw new PgTypes.CodecError({
463
- message: "Invalid Date parameter"
464
- });
465
- return inferredParameter(PgTypes.OID.timestamptz, time);
466
- }
482
+ if (value instanceof Date) return inferredParameter(PgTypes.OID.timestamptz, value);
467
483
  if (value instanceof Uint8Array) return inferredParameter(PgTypes.OID.bytea, value);
468
484
  if (value instanceof Int8Array) {
469
485
  return inferredParameter(PgTypes.OID.bytea, new Uint8Array(value.buffer, value.byteOffset, value.byteLength));
@@ -662,6 +678,7 @@ const encodeQuery = (sql, params, registry, encodeBind, cache) => {
662
678
  class PreparedCache {
663
679
  max;
664
680
  statements = /*#__PURE__*/new Map();
681
+ namespace = /*#__PURE__*/randomBytes(8).toString("hex");
665
682
  closes;
666
683
  counter = 0;
667
684
  constructor(max) {
@@ -679,7 +696,7 @@ class PreparedCache {
679
696
  return found;
680
697
  }
681
698
  const prepared = {
682
- name: `effect${++this.counter}`,
699
+ name: `effect_${this.namespace}_${++this.counter}`,
683
700
  key,
684
701
  ready: false,
685
702
  parsing: false,
@@ -1018,6 +1035,21 @@ class QueryMachine {
1018
1035
  }
1019
1036
  const emptyRows = [];
1020
1037
  const emptyValues = [];
1038
+ /** Drains an aborted statement, sending a cancel after a short grace period and failing if it stalls. */
1039
+ const drainAborted = (conn, timeoutMessage, abort) => {
1040
+ const drained = Deferred.makeUnsafe();
1041
+ const timeout = setTimeout(() => conn.fatal(connectionQueryError(new Error(timeoutMessage), `PgConnection: ${timeoutMessage}`)), abortDrainTimeoutMillis);
1042
+ abort(() => {
1043
+ clearTimeout(timeout);
1044
+ Deferred.doneUnsafe(drained, Effect.void);
1045
+ });
1046
+ const grace = Effect.callback(resume => {
1047
+ const timer = setTimeout(() => resume(Effect.void), abortDrainGraceMillis);
1048
+ return Effect.sync(() => clearTimeout(timer));
1049
+ });
1050
+ const cancelAndDrain = Effect.andThen(conn.cancel, Deferred.await(drained));
1051
+ return Effect.andThen(Effect.raceFirst(Deferred.await(drained), grace), Effect.suspend(() => Deferred.isDoneUnsafe(drained) ? Effect.void : cancelAndDrain));
1052
+ };
1021
1053
  /** One cycle on a connection that carries a single statement at a time. */
1022
1054
  const runQuery = (conn, plan, wantRows) => Effect.callback(resume => {
1023
1055
  if (conn.deadWith !== undefined) {
@@ -1037,19 +1069,12 @@ const runQuery = (conn, plan, wantRows) => Effect.callback(resume => {
1037
1069
  } catch (cause) {
1038
1070
  conn.fatal(connectionQueryError(cause, "PgConnection: Failed to write query"));
1039
1071
  }
1040
- // On interruption: cancel the statement and drain the connection back to
1041
- // ReadyForQuery so it stays usable, destroying it when the drain stalls.
1042
1072
  return Effect.suspend(() => {
1043
1073
  if (machine.isDone()) return Effect.void;
1044
- const wait = Effect.callback(resumeWait => {
1045
- const timer = setTimeout(() => conn.fatal(connectionQueryError(new Error("Query cancellation timed out"), "PgConnection: Query cancellation timed out")), abortDrainTimeoutMillis);
1046
- machine.abort(() => {
1047
- clearTimeout(timer);
1048
- conn.consumer = undefined;
1049
- resumeWait(Effect.void);
1050
- });
1051
- });
1052
- return Effect.andThen(conn.cancel, wait);
1074
+ return drainAborted(conn, "Query cancellation timed out", onDrained => machine.abort(() => {
1075
+ conn.consumer = undefined;
1076
+ onDrained();
1077
+ }));
1053
1078
  });
1054
1079
  });
1055
1080
  const emptyFields = [];
@@ -1262,21 +1287,14 @@ const streamRows = (conn, pin, sql, params) => Stream.fromChannel(Channel.fromTr
1262
1287
  if (done || aborted) return;
1263
1288
  if (pending !== undefined) deliver();else if (buffer.length >= streamPauseThreshold) setPaused(true);
1264
1289
  };
1265
- // On early abort: cancel the statement and drain back to ReadyForQuery so
1266
- // the pinned connection stays usable, destroying it when the drain stalls.
1267
1290
  yield* Scope.addFinalizer(scope, Effect.suspend(() => {
1268
1291
  if (done) return Effect.void;
1269
1292
  aborted = true;
1270
- setPaused(false);
1271
- const wait = Effect.callback(resumeWait => {
1272
- if (done) return resumeWait(Effect.void);
1273
- const timer = setTimeout(() => conn.fatal(connectionQueryError(new Error("Stream cancellation timed out"), "PgConnection: Stream cancellation timed out")), abortDrainTimeoutMillis);
1274
- drainDone = () => {
1275
- clearTimeout(timer);
1276
- resumeWait(Effect.void);
1277
- };
1293
+ const drain = drainAborted(conn, "Stream cancellation timed out", onDrained => {
1294
+ drainDone = onDrained;
1278
1295
  });
1279
- return Effect.andThen(conn.cancel, wait);
1296
+ setPaused(false);
1297
+ return drain;
1280
1298
  }));
1281
1299
  conn.consumer = {
1282
1300
  onMessage,
@@ -1330,7 +1348,7 @@ const listenChannel = (conn, pin, channel) => Effect.uninterruptibleMask(restore
1330
1348
  return queue;
1331
1349
  })).pipe(Effect.tapCause(cause => Scope.close(scope, Exit.failCause(cause))));
1332
1350
  }));
1333
- const sendCancelRequest = (config, pid, secret) => Effect.callback(resume => {
1351
+ const sendCancelRequest = (config, session) => Effect.callback(resume => {
1334
1352
  let done = false;
1335
1353
  let socket;
1336
1354
  let timer;
@@ -1342,8 +1360,8 @@ const sendCancelRequest = (config, pid, secret) => Effect.callback(resume => {
1342
1360
  resume(Effect.void);
1343
1361
  };
1344
1362
  const frame = PgProtocol.encodeCancelRequest({
1345
- pid,
1346
- secret
1363
+ pid: session.processId,
1364
+ secret: session.secretKey
1347
1365
  });
1348
1366
  // After the frame is written the server processes the request and closes
1349
1367
  // the connection, which lands in the `close` handler.
@@ -1354,8 +1372,8 @@ const sendCancelRequest = (config, pid, secret) => Effect.callback(resume => {
1354
1372
  if (config.ssl === false) return send();
1355
1373
  socket.once("data", chunk => {
1356
1374
  if (done) return;
1357
- // Never send the cancel secret over a connection the server refused
1358
- // to upgrade.
1375
+ // A TLS session's cancel secret must stay encrypted.
1376
+ if (chunk.length === 1 && chunk[0] === 0x4e && config.sslOptional && !session.encrypted) return send();
1359
1377
  if (chunk.length !== 1 || chunk[0] !== 0x53) return finish();
1360
1378
  const raw = socket;
1361
1379
  raw.off("error", finish);
@@ -1390,9 +1408,10 @@ const sendCancelRequest = (config, pid, secret) => Effect.callback(resume => {
1390
1408
  if (config.stream !== undefined) begin();else socket.once("connect", begin);
1391
1409
  return Effect.sync(finish);
1392
1410
  });
1393
- const connect = config => Effect.callback(resume => {
1411
+ const connect = (config, resolvedPassword) => Effect.callback(resume => {
1394
1412
  let done = false;
1395
1413
  let socket;
1414
+ let encrypted = false;
1396
1415
  let parser;
1397
1416
  let sslErrorParser;
1398
1417
  let scram;
@@ -1419,11 +1438,11 @@ const connect = config => Effect.callback(resume => {
1419
1438
  const onError = cause => failConnect(cause, "PgConnection: Failed to connect");
1420
1439
  const onClose = () => failConnect(new Error("Connection closed unexpectedly"), "PgConnection: Connection closed during startup");
1421
1440
  const password = () => {
1422
- if (config.password === undefined) {
1441
+ if (resolvedPassword === undefined) {
1423
1442
  failAuth(new Error("The server requested password authentication"), "PgConnection: No password configured");
1424
1443
  return undefined;
1425
1444
  }
1426
- return config.password;
1445
+ return resolvedPassword;
1427
1446
  };
1428
1447
  const handleMessage = message => {
1429
1448
  switch (message._tag) {
@@ -1525,6 +1544,7 @@ const connect = config => Effect.callback(resume => {
1525
1544
  socket.on("error", ignoreError);
1526
1545
  resume(Effect.succeed({
1527
1546
  socket,
1547
+ encrypted,
1528
1548
  parser: parser,
1529
1549
  processId,
1530
1550
  secretKey
@@ -1551,11 +1571,7 @@ const connect = config => Effect.callback(resume => {
1551
1571
  maxMessageSize: config.maxMessageSize
1552
1572
  });
1553
1573
  socket.on("data", onData);
1554
- socket.write(PgProtocol.encodeStartupMessage({
1555
- user: config.username,
1556
- database: config.database,
1557
- application_name: config.applicationName
1558
- }));
1574
+ socket.write(PgProtocol.encodeStartupMessage(config.startupParameters));
1559
1575
  };
1560
1576
  const onSslResponse = chunk => {
1561
1577
  if (done) return;
@@ -1586,6 +1602,7 @@ const connect = config => Effect.callback(resume => {
1586
1602
  return failConnect(response.failure, "PgConnection: Invalid SSLRequest response");
1587
1603
  }
1588
1604
  if (response.success === "N") {
1605
+ if (config.sslOptional) return startup();
1589
1606
  return failConnect(new Error("The server does not support TLS"), "PgConnection: Server refused TLS");
1590
1607
  }
1591
1608
  const raw = socket;
@@ -1599,7 +1616,10 @@ const connect = config => Effect.callback(resume => {
1599
1616
  });
1600
1617
  socket.on("error", onError);
1601
1618
  socket.on("close", onClose);
1602
- socket.once("secureConnect", startup);
1619
+ socket.once("secureConnect", () => {
1620
+ encrypted = true;
1621
+ startup();
1622
+ });
1603
1623
  };
1604
1624
  const begin = () => {
1605
1625
  if (config.ssl === false) return startup();
@@ -1649,7 +1669,7 @@ const configError = (message, cause) => new SqlError({
1649
1669
  })
1650
1670
  });
1651
1671
  const resolveConfig = options => Effect.suspend(() => {
1652
- const parsed = options.url !== undefined ? parseUrl(Redacted.value(options.url), options.ssl !== undefined) : EffectResult.succeed({});
1672
+ const parsed = options.url !== undefined ? parseUrl(Redacted.value(options.url)) : EffectResult.succeed({});
1653
1673
  if (EffectResult.isFailure(parsed)) return Effect.fail(parsed.failure);
1654
1674
  const url = parsed.success;
1655
1675
  const host = options.host ?? url.host ?? "localhost";
@@ -1658,16 +1678,47 @@ const resolveConfig = options => Effect.suspend(() => {
1658
1678
  if (username === undefined) {
1659
1679
  return Effect.fail(configError("No username configured"));
1660
1680
  }
1681
+ const named = Object.create(null);
1682
+ for (const [name, value] of Object.entries(options.startupParameters ?? {})) {
1683
+ const key = name.toLowerCase();
1684
+ if (key === "user" || key === "database" || key === "replication" || key === "options") {
1685
+ return Effect.fail(configError(`Reserved startup parameter: "${name}"`));
1686
+ }
1687
+ if (key === "client_encoding") {
1688
+ const encoding = value.toUpperCase();
1689
+ if (encoding !== "UTF8" && encoding !== "UTF-8") {
1690
+ return Effect.fail(configError("Startup parameter client_encoding must be UTF8 or UTF-8"));
1691
+ }
1692
+ named[key] = "UTF8";
1693
+ } else {
1694
+ named[key] = value;
1695
+ }
1696
+ if (name === "" || name.includes("\0") || value.includes("\0")) {
1697
+ return Effect.fail(configError("Startup parameter names must be nonempty and names/values must not contain NUL"));
1698
+ }
1699
+ }
1700
+ const startupParameters = {
1701
+ ...named,
1702
+ user: username,
1703
+ database: options.database ?? url.database,
1704
+ application_name: options.applicationName ?? named.application_name ?? url.applicationName ?? "@effect/sql-pg",
1705
+ options: options.startupOptions ?? url.options
1706
+ };
1707
+ for (const [name, value] of Object.entries(startupParameters)) {
1708
+ if (value?.includes("\0")) {
1709
+ return Effect.fail(configError(`Startup parameter "${name}" must not contain NUL`));
1710
+ }
1711
+ }
1661
1712
  return Effect.succeed({
1662
1713
  host,
1663
1714
  port,
1664
1715
  path: options.path ?? (host.startsWith("/") ? `${host}/.s.PGSQL.${port}` : undefined),
1665
- ssl: options.ssl ?? url.ssl ?? false,
1666
- database: options.database ?? url.database,
1716
+ ssl: options.ssl ?? (url.ssl === "prefer" ? true : url.ssl ?? false),
1717
+ sslOptional: options.ssl === undefined && url.ssl === "prefer",
1667
1718
  username,
1668
- password: options.password !== undefined ? Redacted.value(options.password) : url.password,
1719
+ password: options.password ?? (url.password !== undefined ? Redacted.make(url.password) : undefined),
1669
1720
  connectTimeout: Duration.fromInputUnsafe(options.connectTimeout ?? url.connectTimeout ?? Duration.seconds(5)),
1670
- applicationName: options.applicationName ?? url.applicationName ?? "@effect/sql-pg",
1721
+ startupParameters,
1671
1722
  stream: options.stream,
1672
1723
  maxMessageSize: options.maxMessageSize
1673
1724
  });
@@ -1683,7 +1734,7 @@ const parsePort = (value, what) => {
1683
1734
  const port = Number(value);
1684
1735
  return !Number.isInteger(port) || port < 1 || port > 65535 ? EffectResult.fail(configError(`Invalid port in URL ${what}: "${value}"`)) : EffectResult.succeed(port);
1685
1736
  };
1686
- const parseUrl = (raw, hasExplicitSsl) => {
1737
+ const parseUrl = raw => {
1687
1738
  let url;
1688
1739
  try {
1689
1740
  url = new URL(raw);
@@ -1745,6 +1796,9 @@ const parseUrl = (raw, hasExplicitSsl) => {
1745
1796
  case "application_name":
1746
1797
  config.applicationName = value;
1747
1798
  break;
1799
+ case "options":
1800
+ config.options = value;
1801
+ break;
1748
1802
  case "connect_timeout":
1749
1803
  {
1750
1804
  const seconds = Number(value);
@@ -1766,8 +1820,8 @@ const parseUrl = (raw, hasExplicitSsl) => {
1766
1820
  break;
1767
1821
  case "prefer":
1768
1822
  case "allow":
1769
- if (hasExplicitSsl) break;
1770
- return EffectResult.fail(configError(`sslmode "${value}" is not supported: set ssl explicitly to true or false`));
1823
+ config.ssl = "prefer";
1824
+ break;
1771
1825
  default:
1772
1826
  return EffectResult.fail(configError(`Unrecognized sslmode in URL: "${value}"`));
1773
1827
  }