@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.
@@ -34,6 +34,7 @@ import type { Duplex } from "node:stream"
34
34
  import * as Tls from "node:tls"
35
35
  import type { ConnectionOptions } from "node:tls"
36
36
  import { type ConnectionInternals, internalsKey } from "./internal/connection.ts"
37
+ import * as PasswordInternal from "./internal/password.ts"
37
38
  import { classifySqlState, validateChannelName } from "./internal/sqlError.ts"
38
39
  import * as PgAuth from "./PgAuth.ts"
39
40
  import * as PgProtocol from "./PgProtocol.ts"
@@ -66,6 +67,11 @@ export type TypeId = "~@effect/sql-pg/PgConnection"
66
67
  * socket path, while a `host` beginning with `/` is treated as a socket
67
68
  * directory and expands to `${host}/.s.PGSQL.${port}`.
68
69
  *
70
+ * URL modes `sslmode=prefer` and `sslmode=allow` try TLS first, falling back
71
+ * to plaintext only when the server answers `SSLRequest` with `N`. Unlike
72
+ * libpq, `allow` also tries TLS first. Certificate verification stays enabled
73
+ * unless explicitly disabled through `ssl` options.
74
+ *
69
75
  * Prepared statements are enabled by default and limited by
70
76
  * `preparedStatementCacheSize`. Disable them for statement-mode poolers or
71
77
  * workloads that generate unique SQL. Streams always use unnamed statements.
@@ -86,9 +92,31 @@ export interface Config {
86
92
  readonly ssl?: boolean | ConnectionOptions | undefined
87
93
  readonly database?: string | undefined
88
94
  readonly username?: string | undefined
89
- readonly password?: Redacted.Redacted | undefined
95
+ /**
96
+ * A static password or an Effect evaluated for each connection attempt.
97
+ * Providers must handle typed errors and require no services.
98
+ * {@link Effect.orDie} converts typed errors to defects, not retryable SQL errors.
99
+ */
100
+ readonly password?: Redacted.Redacted | Effect.Effect<Redacted.Redacted> | undefined
90
101
  readonly connectTimeout?: Duration.Input | undefined
102
+ /**
103
+ * Overrides `startupParameters.application_name`, the URL's `application_name`,
104
+ * and the default `"@effect/sql-pg"`, in that order.
105
+ */
91
106
  readonly applicationName?: string | undefined
107
+ /**
108
+ * Session defaults sent in every physical connection's startup packet.
109
+ * Names are lowercased; `user`, `database`, `replication`, and `options` are
110
+ * reserved. `client_encoding` only accepts UTF8 / UTF-8 (case-insensitive).
111
+ * Empty names and NUL bytes fail before connecting; PostgreSQL validates
112
+ * other settings. Do not set the same GUC here and in `startupOptions`.
113
+ */
114
+ readonly startupParameters?: Readonly<Record<string, string>> | undefined
115
+ /**
116
+ * Opaque PostgreSQL startup options, overriding the URL's `options` parameter.
117
+ * Forwarded without parsing `-c` flags or checking for duplicate GUCs.
118
+ */
119
+ readonly startupOptions?: string | undefined
92
120
  readonly stream?: (() => Duplex) | undefined
93
121
  readonly types?: PgTypes.Registry | undefined
94
122
  readonly multiplex?: boolean | undefined
@@ -171,13 +199,26 @@ export interface PgConnection {
171
199
  * ownership queue, while calls through the original connection wait.
172
200
  */
173
201
  readonly pin: Effect.Effect<PgConnection, never, Scope.Scope>
174
- /** Runs a query and returns rows keyed by column name. Pass `false` to skip the prepared statement cache. */
202
+ /**
203
+ * Runs a query and returns rows keyed by column name. Pass `false` to skip
204
+ * the prepared statement cache.
205
+ *
206
+ * On interruption, the connection drains to `ReadyForQuery` and sends a
207
+ * `CancelRequest` if needed. Unless the backend confirms cancellation with
208
+ * `57014`, a pool retires the session before its next checkout. A retained
209
+ * checkout and an unpooled session remain exposed to a late cancel.
210
+ * `statement_timeout` also reports `57014` and can be mistaken for
211
+ * confirmation.
212
+ */
175
213
  readonly query: (
176
214
  sql: string,
177
215
  params?: ReadonlyArray<unknown>,
178
216
  prepare?: boolean
179
217
  ) => Effect.Effect<Result, SqlError>
180
- /** Runs a query and returns positional rows. Pass `false` to skip the prepared statement cache. */
218
+ /**
219
+ * Runs a query and returns positional rows. Pass `false` to skip the
220
+ * prepared statement cache. Interruption behaves as for `query`.
221
+ */
181
222
  readonly queryValues: (
182
223
  sql: string,
183
224
  params?: ReadonlyArray<unknown>,
@@ -185,9 +226,8 @@ export interface PgConnection {
185
226
  ) => Effect.Effect<ReadonlyArray<ReadonlyArray<unknown>>, SqlError>
186
227
  /**
187
228
  * Streams rows without collecting the full result. The session is pinned for
188
- * the lifetime of the stream. Aborting the stream
189
- * before the result completes cancels the statement with a `CancelRequest`
190
- * and drains the connection back to `ReadyForQuery`.
229
+ * the lifetime of the stream. An early abort behaves like an interrupted
230
+ * `query`.
191
231
  */
192
232
  readonly stream: (
193
233
  sql: string,
@@ -198,10 +238,12 @@ export interface PgConnection {
198
238
  * PostgreSQL confirms `LISTEN`. The session stays pinned until the scope
199
239
  * closes, when it runs `UNLISTEN` and shuts down the queue. PostgreSQL
200
240
  * registration errors fail the acquiring effect.
241
+ * Connection failures after registration fail the queue with the original
242
+ * `SqlError`. Intentional scope closure interrupts consumers.
201
243
  */
202
244
  readonly listen: (
203
245
  channel: string
204
- ) => Effect.Effect<Queue.Dequeue<Notification>, SqlError, Scope.Scope>
246
+ ) => Effect.Effect<Queue.Dequeue<Notification, SqlError>, SqlError, Scope.Scope>
205
247
  /**
206
248
  * Attempts to cancel the active query through a side connection. This is a
207
249
  * no-op for an unpinned multiplexed connection because the active
@@ -223,14 +265,13 @@ export const PgConnection = Context.Service<PgConnection>("@effect/sql-pg/PgConn
223
265
  *
224
266
  * **Details**
225
267
  *
226
- * The transport, optional `SSLRequest`, startup, and authentication steps run
227
- * under `connectTimeout` (5 seconds by default). The effect resolves once the
228
- * backend sends `ReadyForQuery`. When the scope closes, the
229
- * session sends `Terminate` and ends the socket.
268
+ * Password resolution, transport, optional `SSLRequest`, startup, and
269
+ * authentication run under `connectTimeout` (5 seconds by default). The effect
270
+ * resolves when the backend sends `ReadyForQuery`. Closing the scope sends
271
+ * `Terminate` and ends the socket.
230
272
  *
231
- * When `ssl` is enabled, a server that rejects `SSLRequest` fails the
232
- * connection. Unix sockets and custom streams should set `ssl.servername`
233
- * explicitly.
273
+ * Use `sslmode=require` or explicit `ssl: true` to require encryption.
274
+ * Unix sockets and custom streams should set `ssl.servername` explicitly.
234
275
  *
235
276
  * @category constructors
236
277
  * @since 4.0.0
@@ -238,10 +279,11 @@ export const PgConnection = Context.Service<PgConnection>("@effect/sql-pg/PgConn
238
279
  export const make = (options: Config): Effect.Effect<PgConnection, SqlError, Scope.Scope> =>
239
280
  Effect.flatMap(resolveConfig(options), (config) =>
240
281
  Effect.acquireRelease(
241
- Effect.map(
242
- connect(config),
243
- (session) => new PgConnectionImpl(options, config, session, options.types)
244
- ),
282
+ Effect.gen(function*() {
283
+ const password = yield* PasswordInternal.resolve(config.password)
284
+ const session = yield* connect(config, password)
285
+ return new PgConnectionImpl(options, config, session, options.types)
286
+ }),
245
287
  (connection) => Effect.sync(() => connection.closeUnsafe()),
246
288
  { interruptible: true }
247
289
  ).pipe(
@@ -262,6 +304,7 @@ export const make = (options: Config): Effect.Effect<PgConnection, SqlError, Sco
262
304
 
263
305
  interface Session {
264
306
  readonly socket: Duplex
307
+ readonly encrypted: boolean
265
308
  readonly parser: PgProtocol.Parser<unknown>
266
309
  readonly processId: number
267
310
  readonly secretKey: number
@@ -286,6 +329,9 @@ interface PipelineEntry {
286
329
  }
287
330
 
288
331
  const abortDrainTimeoutMillis = 5000
332
+ const abortDrainGraceMillis = 10
333
+ /** PostgreSQL `query_canceled` SQLSTATE. */
334
+ const queryCanceledCode = "57014"
289
335
  const cancelRequestTimeoutMillis = 5000
290
336
  /** How many statements a multiplexed session keeps on the wire at once. */
291
337
  const maxPipelineDepth = 128
@@ -311,8 +357,9 @@ class PgConnectionImpl implements PgConnection {
311
357
  consumer: Consumer | undefined
312
358
  deadWith: SqlError | undefined
313
359
  closed = false
314
- readonly channels = new Map<string, Set<Queue.Queue<Notification>>>()
315
- readonly fatalHooks = new Set<() => void>()
360
+ cancelPending = false
361
+ readonly channels = new Map<string, Set<Queue.Queue<Notification, SqlError>>>()
362
+ readonly retireHooks = new Set<() => void>()
316
363
  /** Queued but not yet written; drained into `pipelineInFlight` on flush. */
317
364
  readonly pipelinePending: Array<PipelineEntry> = []
318
365
  /** Written and awaiting their `ReadyForQuery`, oldest first. */
@@ -336,7 +383,7 @@ class PgConnectionImpl implements PgConnection {
336
383
  this[internalsKey] = {
337
384
  base: this,
338
385
  deadError: () => this.deadWith,
339
- fatalHooks: this.fatalHooks
386
+ retireHooks: this.retireHooks
340
387
  }
341
388
  this.pinnedView = new PinnedPgConnection(this)
342
389
  session.socket.on("data", this.onData)
@@ -368,6 +415,15 @@ class PgConnectionImpl implements PgConnection {
368
415
  private readonly dispatch = (message: PgProtocol.BackendMessage<unknown>): void => {
369
416
  if (this.deadWith !== undefined) return
370
417
  switch (message._tag) {
418
+ case "ErrorResponse":
419
+ if (message.fields.code === queryCanceledCode) this.cancelPending = false
420
+ break
421
+ case "ReadyForQuery":
422
+ if (this.cancelPending) {
423
+ this.cancelPending = false
424
+ this.retire()
425
+ }
426
+ break
371
427
  case "NotificationResponse": {
372
428
  const queues = this.channels.get(message.channel)
373
429
  if (queues !== undefined) {
@@ -412,12 +468,16 @@ class PgConnectionImpl implements PgConnection {
412
468
  consumer?.onFatal(error)
413
469
  const sets = Array.from(this.channels.values())
414
470
  this.channels.clear()
471
+ const cause = this.closed ? Cause.interrupt() : Cause.fail(error)
415
472
  for (const set of sets) {
416
- for (const queue of set) Queue.failCauseUnsafe(queue, Cause.interrupt())
417
- }
418
- if (!this.closed) {
419
- for (const hook of this.fatalHooks) hook()
473
+ for (const queue of set) Queue.failCauseUnsafe(queue, cause)
420
474
  }
475
+ this.retire()
476
+ }
477
+
478
+ private retire(): void {
479
+ if (this.closed) return
480
+ for (const hook of this.retireHooks) hook()
421
481
  }
422
482
 
423
483
  closeUnsafe(): void {
@@ -663,7 +723,9 @@ class PgConnectionImpl implements PgConnection {
663
723
  /** Sends a `CancelRequest` for this session on a side connection. */
664
724
  readonly cancel: Effect.Effect<void> = Effect.suspend(() => {
665
725
  if (this.deadWith !== undefined) return Effect.void
666
- return sendCancelRequest(this.resolved, this.session.processId, this.session.secretKey)
726
+ this.cancelPending = true
727
+ if (this.consumer === undefined) this.retire()
728
+ return sendCancelRequest(this.resolved, this.session)
667
729
  })
668
730
 
669
731
  readonly pin: Effect.Effect<PgConnection, never, Scope.Scope> = Effect.suspend(() => {
@@ -719,7 +781,8 @@ class PgConnectionImpl implements PgConnection {
719
781
 
720
782
  readonly listen = (
721
783
  channel: string
722
- ): Effect.Effect<Queue.Dequeue<Notification>, SqlError, Scope.Scope> => listenChannel(this, this.pin, channel)
784
+ ): Effect.Effect<Queue.Dequeue<Notification, SqlError>, SqlError, Scope.Scope> =>
785
+ listenChannel(this, this.pin, channel)
723
786
 
724
787
  readonly interrupt: Effect.Effect<void> = Effect.suspend(() =>
725
788
  this.multiplex && !this.pinned ? Effect.void : this.cancel
@@ -768,7 +831,8 @@ class PinnedPgConnection implements PgConnection {
768
831
 
769
832
  readonly listen = (
770
833
  channel: string
771
- ): Effect.Effect<Queue.Dequeue<Notification>, SqlError, Scope.Scope> => listenChannel(this.base, this.pin, channel)
834
+ ): Effect.Effect<Queue.Dequeue<Notification, SqlError>, SqlError, Scope.Scope> =>
835
+ listenChannel(this.base, this.pin, channel)
772
836
  }
773
837
 
774
838
  interface QueryOutput {
@@ -809,11 +873,7 @@ const inferScalar = (value: unknown): PgTypes.Parameter => {
809
873
  // or timestamp column the way it did with the text-protocol drivers.
810
874
  return inferredParameter(0, value)
811
875
  }
812
- if (value instanceof Date) {
813
- const time = value.getTime()
814
- if (Number.isNaN(time)) throw new PgTypes.CodecError({ message: "Invalid Date parameter" })
815
- return inferredParameter(PgTypes.OID.timestamptz, time)
816
- }
876
+ if (value instanceof Date) return inferredParameter(PgTypes.OID.timestamptz, value)
817
877
  if (value instanceof Uint8Array) return inferredParameter(PgTypes.OID.bytea, value)
818
878
  if (value instanceof Int8Array) {
819
879
  return inferredParameter(
@@ -1047,6 +1107,7 @@ const encodeQuery = (
1047
1107
  class PreparedCache {
1048
1108
  readonly max: number
1049
1109
  private readonly statements = new Map<string, Prepared>()
1110
+ private readonly namespace = randomBytes(8).toString("hex")
1050
1111
  private closes: Array<Uint8Array> | undefined
1051
1112
  private counter = 0
1052
1113
 
@@ -1066,7 +1127,7 @@ class PreparedCache {
1066
1127
  return found
1067
1128
  }
1068
1129
  const prepared: Prepared = {
1069
- name: `effect${++this.counter}`,
1130
+ name: `effect_${this.namespace}_${++this.counter}`,
1070
1131
  key,
1071
1132
  ready: false,
1072
1133
  parsing: false,
@@ -1427,6 +1488,32 @@ class QueryMachine implements Consumer {
1427
1488
  const emptyRows: ReadonlyArray<Row> = []
1428
1489
  const emptyValues: ReadonlyArray<ReadonlyArray<unknown>> = []
1429
1490
 
1491
+ /** Drains an aborted statement, sending a cancel after a short grace period and failing if it stalls. */
1492
+ const drainAborted = (
1493
+ conn: PgConnectionImpl,
1494
+ timeoutMessage: string,
1495
+ abort: (onDrained: () => void) => void
1496
+ ): Effect.Effect<void> => {
1497
+ const drained = Deferred.makeUnsafe<void>()
1498
+ const timeout = setTimeout(
1499
+ () => conn.fatal(connectionQueryError(new Error(timeoutMessage), `PgConnection: ${timeoutMessage}`)),
1500
+ abortDrainTimeoutMillis
1501
+ )
1502
+ abort(() => {
1503
+ clearTimeout(timeout)
1504
+ Deferred.doneUnsafe(drained, Effect.void)
1505
+ })
1506
+ const grace = Effect.callback<void>((resume) => {
1507
+ const timer = setTimeout(() => resume(Effect.void), abortDrainGraceMillis)
1508
+ return Effect.sync(() => clearTimeout(timer))
1509
+ })
1510
+ const cancelAndDrain = Effect.andThen(conn.cancel, Deferred.await(drained))
1511
+ return Effect.andThen(
1512
+ Effect.raceFirst(Deferred.await(drained), grace),
1513
+ Effect.suspend(() => Deferred.isDoneUnsafe(drained) ? Effect.void : cancelAndDrain)
1514
+ )
1515
+ }
1516
+
1430
1517
  /** One cycle on a connection that carries a single statement at a time. */
1431
1518
  const runQuery = (
1432
1519
  conn: PgConnectionImpl,
@@ -1452,26 +1539,13 @@ const runQuery = (
1452
1539
  conn.fatal(connectionQueryError(cause, "PgConnection: Failed to write query"))
1453
1540
  }
1454
1541
 
1455
- // On interruption: cancel the statement and drain the connection back to
1456
- // ReadyForQuery so it stays usable, destroying it when the drain stalls.
1457
1542
  return Effect.suspend(() => {
1458
1543
  if (machine.isDone()) return Effect.void
1459
- const wait = Effect.callback<void>((resumeWait) => {
1460
- const timer = setTimeout(
1461
- () =>
1462
- conn.fatal(connectionQueryError(
1463
- new Error("Query cancellation timed out"),
1464
- "PgConnection: Query cancellation timed out"
1465
- )),
1466
- abortDrainTimeoutMillis
1467
- )
1544
+ return drainAborted(conn, "Query cancellation timed out", (onDrained) =>
1468
1545
  machine.abort(() => {
1469
- clearTimeout(timer)
1470
1546
  conn.consumer = undefined
1471
- resumeWait(Effect.void)
1472
- })
1473
- })
1474
- return Effect.andThen(conn.cancel, wait)
1547
+ onDrained()
1548
+ }))
1475
1549
  })
1476
1550
  })
1477
1551
 
@@ -1716,30 +1790,16 @@ const streamRows = (
1716
1790
  else if (buffer.length >= streamPauseThreshold) setPaused(true)
1717
1791
  }
1718
1792
 
1719
- // On early abort: cancel the statement and drain back to ReadyForQuery so
1720
- // the pinned connection stays usable, destroying it when the drain stalls.
1721
1793
  yield* Scope.addFinalizer(
1722
1794
  scope,
1723
1795
  Effect.suspend(() => {
1724
1796
  if (done) return Effect.void
1725
1797
  aborted = true
1726
- setPaused(false)
1727
- const wait = Effect.callback<void>((resumeWait) => {
1728
- if (done) return resumeWait(Effect.void)
1729
- const timer = setTimeout(
1730
- () =>
1731
- conn.fatal(connectionQueryError(
1732
- new Error("Stream cancellation timed out"),
1733
- "PgConnection: Stream cancellation timed out"
1734
- )),
1735
- abortDrainTimeoutMillis
1736
- )
1737
- drainDone = () => {
1738
- clearTimeout(timer)
1739
- resumeWait(Effect.void)
1740
- }
1798
+ const drain = drainAborted(conn, "Stream cancellation timed out", (onDrained) => {
1799
+ drainDone = onDrained
1741
1800
  })
1742
- return Effect.andThen(conn.cancel, wait)
1801
+ setPaused(false)
1802
+ return drain
1743
1803
  })
1744
1804
  )
1745
1805
 
@@ -1768,7 +1828,7 @@ const listenChannel = (
1768
1828
  conn: PgConnectionImpl,
1769
1829
  pin: Effect.Effect<PgConnection, never, Scope.Scope>,
1770
1830
  channel: string
1771
- ): Effect.Effect<Queue.Dequeue<Notification>, SqlError, Scope.Scope> =>
1831
+ ): Effect.Effect<Queue.Dequeue<Notification, SqlError>, SqlError, Scope.Scope> =>
1772
1832
  Effect.uninterruptibleMask((restore) =>
1773
1833
  Effect.gen(function*() {
1774
1834
  const channelError = validateChannelName(channel, "listen")
@@ -1778,7 +1838,7 @@ const listenChannel = (
1778
1838
  return yield* restore(Effect.gen(function*() {
1779
1839
  const pinned = yield* Scope.provide(pin, scope)
1780
1840
  if (conn.deadWith !== undefined) return yield* conn.deadWith
1781
- const queue = yield* Queue.unbounded<Notification>()
1841
+ const queue = yield* Queue.unbounded<Notification, SqlError>()
1782
1842
  const identifier = escapeIdentifier(channel)
1783
1843
  let queues = conn.channels.get(channel)
1784
1844
  if (queues === undefined) {
@@ -1808,7 +1868,7 @@ const listenChannel = (
1808
1868
  })
1809
1869
  )
1810
1870
 
1811
- const sendCancelRequest = (config: ResolvedConfig, pid: number, secret: number): Effect.Effect<void> =>
1871
+ const sendCancelRequest = (config: ResolvedConfig, session: Session): Effect.Effect<void> =>
1812
1872
  Effect.callback<void>((resume) => {
1813
1873
  let done = false
1814
1874
  let socket: Duplex
@@ -1820,7 +1880,7 @@ const sendCancelRequest = (config: ResolvedConfig, pid: number, secret: number):
1820
1880
  socket?.destroy()
1821
1881
  resume(Effect.void)
1822
1882
  }
1823
- const frame = PgProtocol.encodeCancelRequest({ pid, secret })
1883
+ const frame = PgProtocol.encodeCancelRequest({ pid: session.processId, secret: session.secretKey })
1824
1884
  // After the frame is written the server processes the request and closes
1825
1885
  // the connection, which lands in the `close` handler.
1826
1886
  const send = (): void => {
@@ -1830,8 +1890,8 @@ const sendCancelRequest = (config: ResolvedConfig, pid: number, secret: number):
1830
1890
  if (config.ssl === false) return send()
1831
1891
  socket.once("data", (chunk: Uint8Array) => {
1832
1892
  if (done) return
1833
- // Never send the cancel secret over a connection the server refused
1834
- // to upgrade.
1893
+ // A TLS session's cancel secret must stay encrypted.
1894
+ if (chunk.length === 1 && chunk[0] === 0x4e && config.sslOptional && !session.encrypted) return send()
1835
1895
  if (chunk.length !== 1 || chunk[0] !== 0x53) return finish()
1836
1896
  const raw = socket
1837
1897
  raw.off("error", finish)
@@ -1866,10 +1926,11 @@ const sendCancelRequest = (config: ResolvedConfig, pid: number, secret: number):
1866
1926
  return Effect.sync(finish)
1867
1927
  })
1868
1928
 
1869
- const connect = (config: ResolvedConfig): Effect.Effect<Session, SqlError> =>
1929
+ const connect = (config: ResolvedConfig, resolvedPassword: string | undefined): Effect.Effect<Session, SqlError> =>
1870
1930
  Effect.callback<Session, SqlError>((resume) => {
1871
1931
  let done = false
1872
1932
  let socket: Duplex
1933
+ let encrypted = false
1873
1934
  let parser: PgProtocol.Parser<unknown> | undefined
1874
1935
  let sslErrorParser: PgProtocol.Parser | undefined
1875
1936
  let scram: PgAuth.ScramState | undefined
@@ -1892,14 +1953,14 @@ const connect = (config: ResolvedConfig): Effect.Effect<Session, SqlError> =>
1892
1953
  failConnect(new Error("Connection closed unexpectedly"), "PgConnection: Connection closed during startup")
1893
1954
 
1894
1955
  const password = (): string | undefined => {
1895
- if (config.password === undefined) {
1956
+ if (resolvedPassword === undefined) {
1896
1957
  failAuth(
1897
1958
  new Error("The server requested password authentication"),
1898
1959
  "PgConnection: No password configured"
1899
1960
  )
1900
1961
  return undefined
1901
1962
  }
1902
- return config.password
1963
+ return resolvedPassword
1903
1964
  }
1904
1965
 
1905
1966
  const handleMessage = (message: PgProtocol.BackendMessage<unknown>): void => {
@@ -1991,7 +2052,7 @@ const connect = (config: ResolvedConfig): Effect.Effect<Session, SqlError> =>
1991
2052
  socket.off("error", onError)
1992
2053
  socket.off("close", onClose)
1993
2054
  socket.on("error", ignoreError)
1994
- resume(Effect.succeed({ socket, parser: parser!, processId, secretKey }))
2055
+ resume(Effect.succeed({ socket, encrypted, parser: parser!, processId, secretKey }))
1995
2056
  return
1996
2057
  default:
1997
2058
  return failConnect(
@@ -2017,11 +2078,7 @@ const connect = (config: ResolvedConfig): Effect.Effect<Session, SqlError> =>
2017
2078
  const startup = (): void => {
2018
2079
  parser = PgProtocol.makeParser<unknown>({ maxMessageSize: config.maxMessageSize })
2019
2080
  socket.on("data", onData)
2020
- socket.write(PgProtocol.encodeStartupMessage({
2021
- user: config.username,
2022
- database: config.database,
2023
- application_name: config.applicationName
2024
- }))
2081
+ socket.write(PgProtocol.encodeStartupMessage(config.startupParameters))
2025
2082
  }
2026
2083
 
2027
2084
  const onSslResponse = (chunk: Uint8Array): void => {
@@ -2057,6 +2114,7 @@ const connect = (config: ResolvedConfig): Effect.Effect<Session, SqlError> =>
2057
2114
  return failConnect(response.failure, "PgConnection: Invalid SSLRequest response")
2058
2115
  }
2059
2116
  if (response.success === "N") {
2117
+ if (config.sslOptional) return startup()
2060
2118
  return failConnect(new Error("The server does not support TLS"), "PgConnection: Server refused TLS")
2061
2119
  }
2062
2120
  const raw = socket
@@ -2070,7 +2128,10 @@ const connect = (config: ResolvedConfig): Effect.Effect<Session, SqlError> =>
2070
2128
  })
2071
2129
  socket.on("error", onError)
2072
2130
  socket.on("close", onClose)
2073
- socket.once("secureConnect", startup)
2131
+ socket.once("secureConnect", () => {
2132
+ encrypted = true
2133
+ startup()
2134
+ })
2074
2135
  }
2075
2136
 
2076
2137
  const begin = (): void => {
@@ -2117,11 +2178,11 @@ interface ResolvedConfig {
2117
2178
  readonly port: number
2118
2179
  readonly path: string | undefined
2119
2180
  readonly ssl: boolean | ConnectionOptions
2120
- readonly database: string | undefined
2181
+ readonly sslOptional: boolean
2121
2182
  readonly username: string
2122
- readonly password: string | undefined
2183
+ readonly password: Redacted.Redacted | Effect.Effect<Redacted.Redacted> | undefined
2123
2184
  readonly connectTimeout: Duration.Duration
2124
- readonly applicationName: string
2185
+ readonly startupParameters: PgProtocol.StartupParameters
2125
2186
  readonly stream: (() => Duplex) | undefined
2126
2187
  readonly maxMessageSize: number | undefined
2127
2188
  }
@@ -2138,7 +2199,7 @@ const configError = (message: string, cause?: unknown): SqlError =>
2138
2199
  const resolveConfig = (options: Config): Effect.Effect<ResolvedConfig, SqlError> =>
2139
2200
  Effect.suspend(() => {
2140
2201
  const parsed: EffectResult.Result<UrlConfig, SqlError> = options.url !== undefined
2141
- ? parseUrl(Redacted.value(options.url), options.ssl !== undefined)
2202
+ ? parseUrl(Redacted.value(options.url))
2142
2203
  : EffectResult.succeed({})
2143
2204
  if (EffectResult.isFailure(parsed)) return Effect.fail(parsed.failure)
2144
2205
  const url = parsed.success
@@ -2148,16 +2209,49 @@ const resolveConfig = (options: Config): Effect.Effect<ResolvedConfig, SqlError>
2148
2209
  if (username === undefined) {
2149
2210
  return Effect.fail(configError("No username configured"))
2150
2211
  }
2212
+ const named: Record<string, string> = Object.create(null)
2213
+ for (const [name, value] of Object.entries(options.startupParameters ?? {})) {
2214
+ const key = name.toLowerCase()
2215
+ if (key === "user" || key === "database" || key === "replication" || key === "options") {
2216
+ return Effect.fail(configError(`Reserved startup parameter: "${name}"`))
2217
+ }
2218
+ if (key === "client_encoding") {
2219
+ const encoding = value.toUpperCase()
2220
+ if (encoding !== "UTF8" && encoding !== "UTF-8") {
2221
+ return Effect.fail(configError("Startup parameter client_encoding must be UTF8 or UTF-8"))
2222
+ }
2223
+ named[key] = "UTF8"
2224
+ } else {
2225
+ named[key] = value
2226
+ }
2227
+ if (name === "" || name.includes("\0") || value.includes("\0")) {
2228
+ return Effect.fail(
2229
+ configError("Startup parameter names must be nonempty and names/values must not contain NUL")
2230
+ )
2231
+ }
2232
+ }
2233
+ const startupParameters: PgProtocol.StartupParameters = {
2234
+ ...named,
2235
+ user: username,
2236
+ database: options.database ?? url.database,
2237
+ application_name: options.applicationName ?? named.application_name ?? url.applicationName ?? "@effect/sql-pg",
2238
+ options: options.startupOptions ?? url.options
2239
+ }
2240
+ for (const [name, value] of Object.entries(startupParameters)) {
2241
+ if (value?.includes("\0")) {
2242
+ return Effect.fail(configError(`Startup parameter "${name}" must not contain NUL`))
2243
+ }
2244
+ }
2151
2245
  return Effect.succeed<ResolvedConfig>({
2152
2246
  host,
2153
2247
  port,
2154
2248
  path: options.path ?? (host.startsWith("/") ? `${host}/.s.PGSQL.${port}` : undefined),
2155
- ssl: options.ssl ?? url.ssl ?? false,
2156
- database: options.database ?? url.database,
2249
+ ssl: options.ssl ?? (url.ssl === "prefer" ? true : url.ssl ?? false),
2250
+ sslOptional: options.ssl === undefined && url.ssl === "prefer",
2157
2251
  username,
2158
- password: options.password !== undefined ? Redacted.value(options.password) : url.password,
2252
+ password: options.password ?? (url.password !== undefined ? Redacted.make(url.password) : undefined),
2159
2253
  connectTimeout: Duration.fromInputUnsafe(options.connectTimeout ?? url.connectTimeout ?? Duration.seconds(5)),
2160
- applicationName: options.applicationName ?? url.applicationName ?? "@effect/sql-pg",
2254
+ startupParameters,
2161
2255
  stream: options.stream,
2162
2256
  maxMessageSize: options.maxMessageSize
2163
2257
  })
@@ -2170,8 +2264,9 @@ interface UrlConfig {
2170
2264
  username?: string | undefined
2171
2265
  password?: string | undefined
2172
2266
  applicationName?: string | undefined
2267
+ options?: string | undefined
2173
2268
  connectTimeout?: Duration.Duration | undefined
2174
- ssl?: boolean | undefined
2269
+ ssl?: boolean | "prefer" | undefined
2175
2270
  }
2176
2271
 
2177
2272
  const decodeComponent = (value: string, what: string): EffectResult.Result<string, SqlError> => {
@@ -2189,7 +2284,7 @@ const parsePort = (value: string, what: string): EffectResult.Result<number, Sql
2189
2284
  : EffectResult.succeed(port)
2190
2285
  }
2191
2286
 
2192
- const parseUrl = (raw: string, hasExplicitSsl: boolean): EffectResult.Result<UrlConfig, SqlError> => {
2287
+ const parseUrl = (raw: string): EffectResult.Result<UrlConfig, SqlError> => {
2193
2288
  let url: URL
2194
2289
  try {
2195
2290
  url = new URL(raw)
@@ -2252,6 +2347,9 @@ const parseUrl = (raw: string, hasExplicitSsl: boolean): EffectResult.Result<Url
2252
2347
  case "application_name":
2253
2348
  config.applicationName = value
2254
2349
  break
2350
+ case "options":
2351
+ config.options = value
2352
+ break
2255
2353
  case "connect_timeout": {
2256
2354
  const seconds = Number(value)
2257
2355
  if (!Number.isInteger(seconds) || seconds < 0) {
@@ -2272,10 +2370,8 @@ const parseUrl = (raw: string, hasExplicitSsl: boolean): EffectResult.Result<Url
2272
2370
  break
2273
2371
  case "prefer":
2274
2372
  case "allow":
2275
- if (hasExplicitSsl) break
2276
- return EffectResult.fail(
2277
- configError(`sslmode "${value}" is not supported: set ssl explicitly to true or false`)
2278
- )
2373
+ config.ssl = "prefer"
2374
+ break
2279
2375
  default:
2280
2376
  return EffectResult.fail(configError(`Unrecognized sslmode in URL: "${value}"`))
2281
2377
  }