@effect/sql-pg 4.0.0-rc.111 → 4.0.0-rc.113

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 (60) hide show
  1. package/AGENTS.md +24 -9
  2. package/CLAUDE.md +24 -9
  3. package/ai-docs/package.json +2 -2
  4. package/ai-docs/src/01_effect/01_basics/02_effect-fn.ts +18 -5
  5. package/ai-docs/src/01_effect/01_basics/index.md +5 -3
  6. package/ai-docs/src/01_effect/03_services/20_layer-composition.ts +1 -1
  7. package/ai-docs/src/01_effect/03_services/20_layer-unwrap.ts +2 -2
  8. package/ai-docs/src/01_effect/05_resources/10_acquire-release.ts +2 -2
  9. package/ai-docs/src/03_stream/30_encoding.ts +5 -7
  10. package/ai-docs/src/08_observability/10_logging.ts +1 -1
  11. package/ai-docs/src/70_cli/10_basics.ts +7 -7
  12. package/ai-docs/src/71_ai/10_language-model.ts +2 -2
  13. package/ai-docs/src/71_ai/20_tools.ts +1 -1
  14. package/ai-docs/src/71_ai/30_chat.ts +1 -1
  15. package/dist/PgAuth.d.ts +93 -0
  16. package/dist/PgAuth.d.ts.map +1 -0
  17. package/dist/PgAuth.js +229 -0
  18. package/dist/PgAuth.js.map +1 -0
  19. package/dist/PgClient.d.ts +49 -82
  20. package/dist/PgClient.d.ts.map +1 -1
  21. package/dist/PgClient.js +58 -510
  22. package/dist/PgClient.js.map +1 -1
  23. package/dist/PgConnection.d.ts +177 -0
  24. package/dist/PgConnection.d.ts.map +1 -0
  25. package/dist/PgConnection.js +1786 -0
  26. package/dist/PgConnection.js.map +1 -0
  27. package/dist/PgPool.d.ts +107 -0
  28. package/dist/PgPool.d.ts.map +1 -0
  29. package/dist/PgPool.js +124 -0
  30. package/dist/PgPool.js.map +1 -0
  31. package/dist/PgProtocol.d.ts +783 -0
  32. package/dist/PgProtocol.d.ts.map +1 -0
  33. package/dist/PgProtocol.js +1202 -0
  34. package/dist/PgProtocol.js.map +1 -0
  35. package/dist/PgTypes.d.ts +429 -0
  36. package/dist/PgTypes.d.ts.map +1 -0
  37. package/dist/PgTypes.js +1511 -0
  38. package/dist/PgTypes.js.map +1 -0
  39. package/dist/index.d.ts +20 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +20 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/internal/connection.d.ts +2 -0
  44. package/dist/internal/connection.d.ts.map +1 -0
  45. package/dist/internal/connection.js +5 -0
  46. package/dist/internal/connection.js.map +1 -0
  47. package/dist/internal/sqlError.d.ts +10 -0
  48. package/dist/internal/sqlError.d.ts.map +1 -0
  49. package/dist/internal/sqlError.js +57 -0
  50. package/dist/internal/sqlError.js.map +1 -0
  51. package/package.json +8 -12
  52. package/src/PgAuth.ts +318 -0
  53. package/src/PgClient.ts +138 -681
  54. package/src/PgConnection.ts +2294 -0
  55. package/src/PgPool.ts +231 -0
  56. package/src/PgProtocol.ts +1863 -0
  57. package/src/PgTypes.ts +1998 -0
  58. package/src/index.ts +25 -0
  59. package/src/internal/connection.ts +26 -0
  60. package/src/internal/sqlError.ts +75 -0
@@ -0,0 +1,2294 @@
1
+ /**
2
+ * Native PostgreSQL sessions built on the `PgProtocol` wire codec.
3
+ *
4
+ * Sessions support queries, streaming, `LISTEN`/`NOTIFY`, cancellation, and
5
+ * exclusive ownership for transactions.
6
+ *
7
+ * @since 4.0.0
8
+ */
9
+ import type * as Arr from "effect/Array"
10
+ import * as Cause from "effect/Cause"
11
+ import * as Channel from "effect/Channel"
12
+ import * as Context from "effect/Context"
13
+ import * as Deferred from "effect/Deferred"
14
+ import * as Duration from "effect/Duration"
15
+ import * as Effect from "effect/Effect"
16
+ import * as Exit from "effect/Exit"
17
+ import * as Queue from "effect/Queue"
18
+ import * as Redacted from "effect/Redacted"
19
+ import * as EffectResult from "effect/Result"
20
+ import * as Scope from "effect/Scope"
21
+ import * as Semaphore from "effect/Semaphore"
22
+ import * as Stream from "effect/Stream"
23
+ import {
24
+ AuthenticationError,
25
+ ConnectionError,
26
+ SqlError,
27
+ type SqlErrorReason,
28
+ UnknownError
29
+ } from "effect/unstable/sql/SqlError"
30
+ import { Buffer } from "node:buffer"
31
+ import { randomBytes } from "node:crypto"
32
+ import * as Net from "node:net"
33
+ import type { Duplex } from "node:stream"
34
+ import * as Tls from "node:tls"
35
+ import type { ConnectionOptions } from "node:tls"
36
+ import { type ConnectionInternals, internalsKey } from "./internal/connection.ts"
37
+ import { classifySqlState, validateChannelName } from "./internal/sqlError.ts"
38
+ import * as PgAuth from "./PgAuth.ts"
39
+ import * as PgProtocol from "./PgProtocol.ts"
40
+ import * as PgTypes from "./PgTypes.ts"
41
+
42
+ /**
43
+ * The runtime type identifier for `PgConnection`.
44
+ *
45
+ * @category type IDs
46
+ * @since 4.0.0
47
+ */
48
+ export const TypeId: TypeId = "~@effect/sql-pg/PgConnection"
49
+
50
+ /**
51
+ * The type-level identifier for `PgConnection`.
52
+ *
53
+ * @category type IDs
54
+ * @since 4.0.0
55
+ */
56
+ export type TypeId = "~@effect/sql-pg/PgConnection"
57
+
58
+ /**
59
+ * Connection settings for a PostgreSQL session.
60
+ *
61
+ * **Details**
62
+ *
63
+ * A `url` is parsed as a libpq URI (`postgres://` or `postgresql://`);
64
+ * explicit fields win over anything the URL carries. A `stream` factory wins
65
+ * over `host`, `port`, and `path`. A `path` is used verbatim as the unix
66
+ * socket path, while a `host` beginning with `/` is treated as a socket
67
+ * directory and expands to `${host}/.s.PGSQL.${port}`.
68
+ *
69
+ * Prepared statements are enabled by default and limited by
70
+ * `preparedStatementCacheSize`. Disable them for statement-mode poolers or
71
+ * workloads that generate unique SQL. Streams always use unnamed statements.
72
+ *
73
+ * With `multiplex` enabled, unpinned queries from multiple fibers are
74
+ * pipelined. Transactions, streams, and listeners remain exclusive. An
75
+ * unpinned multiplexed connection cannot be interrupted because cancellation
76
+ * could affect another fiber's query.
77
+ *
78
+ * @category models
79
+ * @since 4.0.0
80
+ */
81
+ export interface Config {
82
+ readonly url?: Redacted.Redacted | undefined
83
+ readonly host?: string | undefined
84
+ readonly port?: number | undefined
85
+ readonly path?: string | undefined
86
+ readonly ssl?: boolean | ConnectionOptions | undefined
87
+ readonly database?: string | undefined
88
+ readonly username?: string | undefined
89
+ readonly password?: Redacted.Redacted | undefined
90
+ readonly connectTimeout?: Duration.Input | undefined
91
+ readonly applicationName?: string | undefined
92
+ readonly stream?: (() => Duplex) | undefined
93
+ readonly types?: PgTypes.Registry | undefined
94
+ readonly multiplex?: boolean | undefined
95
+ readonly prepare?: boolean | undefined
96
+ readonly preparedStatementCacheSize?: number | undefined
97
+ /** Maximum backend message size in bytes. Defaults to 16 MiB. */
98
+ readonly maxMessageSize?: number | undefined
99
+ }
100
+
101
+ /**
102
+ * Builds the prepared-statement cache a session should use, or `undefined`
103
+ * when `prepare` is off or the cache is sized to nothing.
104
+ */
105
+ const preparedCacheFor = (config: Config): PreparedCache | undefined => {
106
+ if (config.prepare === false) return undefined
107
+ const max = config.preparedStatementCacheSize ?? defaultPreparedStatements
108
+ return max > 0 ? new PreparedCache(max) : undefined
109
+ }
110
+
111
+ /**
112
+ * An object result row keyed by column name.
113
+ *
114
+ * @category models
115
+ * @since 4.0.0
116
+ */
117
+ export interface Row {
118
+ readonly [column: string]: unknown
119
+ }
120
+
121
+ /**
122
+ * Metadata for one result column.
123
+ *
124
+ * @category models
125
+ * @since 4.0.0
126
+ */
127
+ export interface Field {
128
+ readonly name: string
129
+ readonly dataTypeId: number
130
+ }
131
+
132
+ /**
133
+ * The result of a query.
134
+ *
135
+ * @category models
136
+ * @since 4.0.0
137
+ */
138
+ export interface Result {
139
+ readonly command: string
140
+ readonly rowCount: number
141
+ readonly oid: number | null
142
+ readonly rows: ReadonlyArray<Row>
143
+ readonly fields: ReadonlyArray<Field>
144
+ }
145
+
146
+ /**
147
+ * A `NOTIFY` message received while listening on a channel.
148
+ *
149
+ * @category models
150
+ * @since 4.0.0
151
+ */
152
+ export interface Notification {
153
+ readonly processId: number
154
+ readonly channel: string
155
+ readonly payload: string
156
+ }
157
+
158
+ /**
159
+ * A connected and authenticated PostgreSQL session.
160
+ *
161
+ * @category models
162
+ * @since 4.0.0
163
+ */
164
+ export interface PgConnection {
165
+ readonly [TypeId]: TypeId
166
+ readonly config: Config
167
+ readonly processId: number
168
+ /**
169
+ * Reserves the session for exclusive use until the scope closes. Pinning is
170
+ * reentrant. Calls through the returned connection skip the
171
+ * ownership queue, while calls through the original connection wait.
172
+ */
173
+ 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. */
175
+ readonly query: (
176
+ sql: string,
177
+ params?: ReadonlyArray<unknown>,
178
+ prepare?: boolean
179
+ ) => Effect.Effect<Result, SqlError>
180
+ /** Runs a query and returns positional rows. Pass `false` to skip the prepared statement cache. */
181
+ readonly queryValues: (
182
+ sql: string,
183
+ params?: ReadonlyArray<unknown>,
184
+ prepare?: boolean
185
+ ) => Effect.Effect<ReadonlyArray<ReadonlyArray<unknown>>, SqlError>
186
+ /**
187
+ * 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`.
191
+ */
192
+ readonly stream: (
193
+ sql: string,
194
+ params?: ReadonlyArray<unknown>
195
+ ) => Stream.Stream<Row, SqlError>
196
+ /**
197
+ * Registers a channel listener and returns its notification queue after
198
+ * PostgreSQL confirms `LISTEN`. The session stays pinned until the scope
199
+ * closes, when it runs `UNLISTEN` and shuts down the queue. PostgreSQL
200
+ * registration errors fail the acquiring effect.
201
+ */
202
+ readonly listen: (
203
+ channel: string
204
+ ) => Effect.Effect<Queue.Dequeue<Notification>, SqlError, Scope.Scope>
205
+ /**
206
+ * Attempts to cancel the active query through a side connection. This is a
207
+ * no-op for an unpinned multiplexed connection because the active
208
+ * query may belong to another fiber. The effect never fails.
209
+ */
210
+ readonly interrupt: Effect.Effect<void>
211
+ }
212
+
213
+ /**
214
+ * The service tag for `PgConnection`.
215
+ *
216
+ * @category services
217
+ * @since 4.0.0
218
+ */
219
+ export const PgConnection = Context.Service<PgConnection>("@effect/sql-pg/PgConnection")
220
+
221
+ /**
222
+ * Connects and authenticates a single PostgreSQL session.
223
+ *
224
+ * **Details**
225
+ *
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.
230
+ *
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.
234
+ *
235
+ * @category constructors
236
+ * @since 4.0.0
237
+ */
238
+ export const make = (options: Config): Effect.Effect<PgConnection, SqlError, Scope.Scope> =>
239
+ Effect.flatMap(resolveConfig(options), (config) =>
240
+ Effect.acquireRelease(
241
+ Effect.map(
242
+ connect(config),
243
+ (session) => new PgConnectionImpl(options, config, session, options.types)
244
+ ),
245
+ (connection) => Effect.sync(() => connection.closeUnsafe()),
246
+ { interruptible: true }
247
+ ).pipe(
248
+ Effect.timeoutOrElse({
249
+ duration: config.connectTimeout,
250
+ orElse: () =>
251
+ Effect.fail(
252
+ new SqlError({
253
+ reason: new ConnectionError({
254
+ cause: new Error("Connection timed out"),
255
+ message: "PgConnection: Connection timed out",
256
+ operation: "connect"
257
+ })
258
+ })
259
+ )
260
+ })
261
+ ))
262
+
263
+ interface Session {
264
+ readonly socket: Duplex
265
+ readonly parser: PgProtocol.Parser<unknown>
266
+ readonly processId: number
267
+ readonly secretKey: number
268
+ }
269
+
270
+ /**
271
+ * The active protocol consumer: the state machine of the statement currently
272
+ * on the wire. Messages the pump does not handle itself are forwarded here.
273
+ */
274
+ interface Consumer {
275
+ readonly onMessage: (message: PgProtocol.BackendMessage<unknown>) => void
276
+ readonly onBatchEnd?: (() => void) | undefined
277
+ readonly onFatal: (error: SqlError) => void
278
+ }
279
+
280
+ /** One statement waiting in, or travelling through, a multiplexed pipeline. */
281
+ interface PipelineEntry {
282
+ readonly plan: Plan
283
+ readonly deferred: Deferred.Deferred<QueryOutput, SqlError>
284
+ readonly machine: QueryMachine
285
+ abandoned: boolean
286
+ }
287
+
288
+ const abortDrainTimeoutMillis = 5000
289
+ const cancelRequestTimeoutMillis = 5000
290
+ /** How many statements a multiplexed session keeps on the wire at once. */
291
+ const maxPipelineDepth = 128
292
+ const streamPauseThreshold = 512
293
+
294
+ class PgConnectionImpl implements PgConnection {
295
+ readonly [TypeId]: TypeId = TypeId
296
+ readonly config: Config
297
+ readonly processId: number
298
+ readonly session: Session
299
+ /** The custom codec registry, or `undefined` for the builtin catalogue. */
300
+ readonly registry: PgTypes.Registry | undefined
301
+ readonly bindEncoder: BindEncoder
302
+ /** The statements this session has named, or `undefined` when disabled. */
303
+ readonly prepared: PreparedCache | undefined
304
+ readonly resolved: ResolvedConfig
305
+ readonly multiplex: boolean
306
+ /** Serializes statements: one in-flight extended-query cycle. */
307
+ readonly wire = Semaphore.makeUnsafe(1)
308
+ /** Exclusive-ownership queue used by `pin` and unpinned statements. */
309
+ readonly owner = Semaphore.makeUnsafe(1)
310
+ pinned = false
311
+ consumer: Consumer | undefined
312
+ deadWith: SqlError | undefined
313
+ closed = false
314
+ readonly channels = new Map<string, Set<Queue.Queue<Notification>>>()
315
+ readonly fatalHooks = new Set<() => void>()
316
+ /** Queued but not yet written; drained into `pipelineInFlight` on flush. */
317
+ readonly pipelinePending: Array<PipelineEntry> = []
318
+ /** Written and awaiting their `ReadyForQuery`, oldest first. */
319
+ readonly pipelineInFlight: Array<PipelineEntry> = []
320
+ pipelineHead = 0
321
+ pipelineFlushScheduled = false
322
+ readonly pipelineIdleWaiters = new Set<() => void>()
323
+ readonly pinnedView: PgConnection
324
+ readonly [internalsKey]: ConnectionInternals
325
+
326
+ constructor(config: Config, resolved: ResolvedConfig, session: Session, registry: PgTypes.Registry | undefined) {
327
+ this.config = config
328
+ this.resolved = resolved
329
+ this.session = session
330
+ this.processId = session.processId
331
+ this.registry = registry
332
+ this.bindEncoder = makeBindEncoder(registry)
333
+ this.prepared = preparedCacheFor(config)
334
+ this.multiplex = config.multiplex ?? false
335
+ // Before the pinned view, which copies it.
336
+ this[internalsKey] = {
337
+ base: this,
338
+ deadError: () => this.deadWith,
339
+ fatalHooks: this.fatalHooks
340
+ }
341
+ this.pinnedView = new PinnedPgConnection(this)
342
+ session.socket.on("data", this.onData)
343
+ session.socket.on("error", this.onSocketError)
344
+ session.socket.on("close", this.onSocketClose)
345
+ }
346
+
347
+ private readonly onData = (chunk: Uint8Array): void => {
348
+ try {
349
+ this.session.parser.pushEach(chunk, this.dispatch)
350
+ } catch (cause) {
351
+ // A field reader that threw reports the row it could not decode; anything
352
+ // else came out of the framing itself.
353
+ return this.fatal(
354
+ cause instanceof PgProtocol.ParseError
355
+ ? connectionQueryError(cause, "PgConnection: Failed to parse server messages")
356
+ : queryError(cause, "PgConnection: Failed to decode row")
357
+ )
358
+ }
359
+ if (this.deadWith === undefined) this.consumer?.onBatchEnd?.()
360
+ }
361
+
362
+ private readonly onSocketError = (cause: Error): void =>
363
+ this.fatal(connectionQueryError(cause, "PgConnection: Socket error"))
364
+
365
+ private readonly onSocketClose = (): void =>
366
+ this.fatal(connectionQueryError(new Error("Connection closed"), "PgConnection: Connection closed"))
367
+
368
+ private readonly dispatch = (message: PgProtocol.BackendMessage<unknown>): void => {
369
+ if (this.deadWith !== undefined) return
370
+ switch (message._tag) {
371
+ case "NotificationResponse": {
372
+ const queues = this.channels.get(message.channel)
373
+ if (queues !== undefined) {
374
+ const notification: Notification = {
375
+ processId: message.pid,
376
+ channel: message.channel,
377
+ payload: message.payload
378
+ }
379
+ for (const queue of queues) Queue.offerUnsafe(queue, notification)
380
+ }
381
+ return
382
+ }
383
+ case "ParameterStatus":
384
+ case "NoticeResponse":
385
+ return
386
+ }
387
+ if (this.consumer !== undefined) return this.consumer.onMessage(message)
388
+ if (message._tag === "ErrorResponse") {
389
+ return this.fatal(
390
+ new SqlError({
391
+ reason: classifyFields(message.fields, "PgConnection: The server reported an error", "query")
392
+ })
393
+ )
394
+ }
395
+ this.fatal(
396
+ connectionQueryError(
397
+ new Error(`Unexpected ${message._tag} while idle`),
398
+ `PgConnection: Unexpected ${message._tag} while idle`
399
+ )
400
+ )
401
+ }
402
+
403
+ /** Marks the session dead: optionally destroys the socket, fails the active statement
404
+ * and every listen queue, and notifies pool hooks unless the session's own
405
+ * scope is being released. */
406
+ fatal(error: SqlError, destroySocket = true): void {
407
+ if (this.deadWith !== undefined) return
408
+ this.deadWith = error
409
+ if (destroySocket) this.session.socket.destroy()
410
+ const consumer = this.consumer
411
+ this.consumer = undefined
412
+ consumer?.onFatal(error)
413
+ const sets = Array.from(this.channels.values())
414
+ this.channels.clear()
415
+ 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()
420
+ }
421
+ }
422
+
423
+ closeUnsafe(): void {
424
+ this.closed = true
425
+ let destroySocket = true
426
+ if (this.deadWith === undefined && this.session.socket.writable) {
427
+ try {
428
+ this.session.socket.end(PgProtocol.encodeTerminate())
429
+ destroySocket = false
430
+ } catch {
431
+ // Fall back to the fatal path's immediate destroy below.
432
+ }
433
+ }
434
+ this.fatal(
435
+ new SqlError({
436
+ reason: new ConnectionError({
437
+ cause: new Error("Connection is closed"),
438
+ message: "PgConnection: Connection is closed",
439
+ operation: "query"
440
+ })
441
+ }),
442
+ destroySocket
443
+ )
444
+ }
445
+
446
+ /** Plans one execution. `cache` is `undefined` to force the unnamed path. */
447
+ readonly encodeQuery = (
448
+ sql: string,
449
+ params: ReadonlyArray<unknown>,
450
+ cache: PreparedCache | undefined
451
+ ): Plan => encodeQuery(sql, params, this.registry, this.bindEncoder, cache)
452
+
453
+ /**
454
+ * Routes backend messages to the oldest statement still on the wire.
455
+ *
456
+ * Every cycle carries its own `Sync`, so the backend answers them in order
457
+ * and finishes each with a `ReadyForQuery`. That boundary is what advances
458
+ * the queue, and it is also why a statement the backend rejects only skips
459
+ * the rest of its own cycle: the ones queued behind it still run.
460
+ */
461
+ private readonly pipelineConsumer: Consumer = {
462
+ onMessage: (message) => {
463
+ const entry = this.pipelineInFlight[this.pipelineHead]
464
+ if (entry === undefined) {
465
+ return this.fatal(connectionQueryError(
466
+ new Error(`Unexpected ${message._tag} without an in-flight query`),
467
+ `PgConnection: Unexpected ${message._tag} without an in-flight query`
468
+ ))
469
+ }
470
+ entry.machine.onMessage(message)
471
+ },
472
+ onBatchEnd: () => this.flushPipeline(),
473
+ onFatal: (error) => {
474
+ const entries = [
475
+ ...this.pipelineInFlight.slice(this.pipelineHead),
476
+ ...this.pipelinePending
477
+ ]
478
+ this.pipelineInFlight.length = 0
479
+ this.pipelineHead = 0
480
+ this.pipelinePending.length = 0
481
+ this.pipelineFlushScheduled = false
482
+ for (const entry of entries) {
483
+ if (!entry.abandoned) Deferred.doneUnsafe(entry.deferred, Effect.fail(error))
484
+ }
485
+ this.notifyPipelineIdle()
486
+ }
487
+ }
488
+
489
+ private readonly pipelineDepth = (): number => this.pipelineInFlight.length - this.pipelineHead
490
+
491
+ private readonly pipelineIsIdle = (): boolean => this.pipelineDepth() === 0 && this.pipelinePending.length === 0
492
+
493
+ private readonly notifyPipelineIdle = (): void => {
494
+ if (!this.pipelineIsIdle()) return
495
+ this.consumer = undefined
496
+ this.session.parser.readField = undefined
497
+ const waiters = Array.from(this.pipelineIdleWaiters)
498
+ this.pipelineIdleWaiters.clear()
499
+ for (const waiter of waiters) waiter()
500
+ }
501
+
502
+ /** Resolves once nothing is on the wire, so `pin` can take the connection. */
503
+ readonly waitPipelineIdle: Effect.Effect<void> = Effect.callback((resume) => {
504
+ if (this.pipelineIsIdle()) return resume(Effect.void)
505
+ const waiter = () => resume(Effect.void)
506
+ this.pipelineIdleWaiters.add(waiter)
507
+ return Effect.sync(() => this.pipelineIdleWaiters.delete(waiter))
508
+ })
509
+
510
+ private readonly finishPipelineEntry = (
511
+ entry: PipelineEntry,
512
+ result: Effect.Effect<QueryOutput, SqlError>
513
+ ): void => {
514
+ if (this.pipelineInFlight[this.pipelineHead] !== entry) {
515
+ return this.fatal(connectionQueryError(
516
+ new Error("A pipelined query completed out of order"),
517
+ "PgConnection: A pipelined query completed out of order"
518
+ ))
519
+ }
520
+ this.pipelineHead++
521
+ // The next statement's rows can be in this same chunk, so its reader has to
522
+ // be in place before the parser reads on.
523
+ this.session.parser.readField = this.pipelineInFlight[this.pipelineHead]?.machine.readField
524
+ if (!entry.abandoned) Deferred.doneUnsafe(entry.deferred, result)
525
+ if (this.pipelineHead === this.pipelineInFlight.length) {
526
+ this.pipelineInFlight.length = 0
527
+ this.pipelineHead = 0
528
+ }
529
+ }
530
+
531
+ /** Writes everything queued since the last flush as one batch. */
532
+ private readonly flushPipeline = (): void => {
533
+ this.pipelineFlushScheduled = false
534
+ if (this.deadWith !== undefined) return
535
+ let capacity = maxPipelineDepth - this.pipelineDepth()
536
+ if (capacity <= 0) return
537
+ const wasEmpty = this.pipelineDepth() === 0
538
+ const batch: Array<PipelineEntry> = []
539
+ let index = 0
540
+ while (capacity > 0 && index < this.pipelinePending.length) {
541
+ const entry = this.pipelinePending[index++]
542
+ if (entry.abandoned) {
543
+ if (entry.plan.parses && entry.plan.prepared !== undefined) {
544
+ entry.plan.prepared.parsing = false
545
+ }
546
+ continue
547
+ }
548
+ batch.push(entry)
549
+ this.pipelineInFlight.push(entry)
550
+ capacity--
551
+ }
552
+ this.pipelinePending.splice(0, index)
553
+ if (batch.length === 0) {
554
+ this.notifyPipelineIdle()
555
+ return
556
+ }
557
+ if (wasEmpty) this.session.parser.readField = batch[0].machine.readField
558
+ const frame = batch.length === 1
559
+ ? batch[0].plan.frame
560
+ : concat(batch.map((entry) => entry.plan.frame))
561
+ try {
562
+ this.session.socket.write(frame)
563
+ } catch (cause) {
564
+ this.fatal(connectionQueryError(cause, "PgConnection: Failed to write query batch"))
565
+ }
566
+ }
567
+
568
+ private readonly schedulePipelineFlush = (): void => {
569
+ if (this.pipelineFlushScheduled || this.pipelineDepth() >= maxPipelineDepth) return
570
+ this.pipelineFlushScheduled = true
571
+ // A microtask is what lets fibers submitted in the same tick share a write.
572
+ queueMicrotask(this.flushPipeline)
573
+ }
574
+
575
+ /**
576
+ * One cycle on a multiplexed connection. The `owner` permit is only taken to
577
+ * queue the statement, so a pin still orders against submissions without
578
+ * serializing them.
579
+ */
580
+ private readonly pipelineCycle = (
581
+ sql: string,
582
+ params: ReadonlyArray<unknown>,
583
+ wantRows: boolean,
584
+ cache: PreparedCache | undefined
585
+ ): Effect.Effect<QueryOutput, SqlError> =>
586
+ Effect.flatMap(
587
+ this.owner.withPermit(Effect.suspend(() => {
588
+ if (this.deadWith !== undefined) return Effect.fail(this.deadWith)
589
+ let plan: Plan
590
+ try {
591
+ plan = this.encodeQuery(sql, params, cache)
592
+ } catch (cause) {
593
+ return Effect.fail(queryError(cause, "PgConnection: Failed to encode query"))
594
+ }
595
+ const deferred = Deferred.makeUnsafe<QueryOutput, SqlError>()
596
+ let entry: PipelineEntry
597
+ const machine = new QueryMachine(this, plan, wantRows, (result) => this.finishPipelineEntry(entry, result))
598
+ entry = { plan, deferred, machine, abandoned: false }
599
+ this.pipelinePending.push(entry)
600
+ this.consumer = this.pipelineConsumer
601
+ this.schedulePipelineFlush()
602
+ return Effect.succeed(entry)
603
+ })),
604
+ (entry) => {
605
+ const awaited = Deferred.await(entry.deferred).pipe(
606
+ Effect.onInterrupt(() =>
607
+ Effect.sync(() => {
608
+ entry.abandoned = true
609
+ })
610
+ )
611
+ )
612
+ if (cache === undefined || entry.plan.parses) return awaited
613
+ return retryStale(entry.plan, cache, awaited, () => this.pipelineCycle(sql, params, wantRows, undefined))
614
+ }
615
+ )
616
+
617
+ /**
618
+ * One extended-query cycle for a caller that shares the session with others:
619
+ * the fibers inside a transaction, and a stream running beside them. The
620
+ * wire permit is what serializes them.
621
+ */
622
+ readonly cycle = (
623
+ sql: string,
624
+ params: ReadonlyArray<unknown>,
625
+ wantRows: boolean,
626
+ cache: PreparedCache | undefined
627
+ ): Effect.Effect<QueryOutput, SqlError> => this.wire.withPermit(this.cycleOwned(sql, params, wantRows, cache))
628
+
629
+ /**
630
+ * Runs one cycle for a caller that owns the session. No wire permit is needed
631
+ * because competing pinned work and streams must acquire `owner` first.
632
+ */
633
+ readonly cycleOwned = (
634
+ sql: string,
635
+ params: ReadonlyArray<unknown>,
636
+ wantRows: boolean,
637
+ cache: PreparedCache | undefined
638
+ ): Effect.Effect<QueryOutput, SqlError> => Effect.suspend(() => this.attempt(sql, params, wantRows, cache))
639
+
640
+ /**
641
+ * Runs one cycle. A reused statement the backend has since dropped, or whose
642
+ * plan no longer matches its columns, is parsed again on a second attempt;
643
+ * that attempt skips the cache, so it cannot loop.
644
+ */
645
+ private readonly attempt = (
646
+ sql: string,
647
+ params: ReadonlyArray<unknown>,
648
+ wantRows: boolean,
649
+ cache: PreparedCache | undefined
650
+ ): Effect.Effect<QueryOutput, SqlError> => {
651
+ if (this.deadWith !== undefined) return Effect.fail(this.deadWith)
652
+ let plan: Plan
653
+ try {
654
+ plan = this.encodeQuery(sql, params, cache)
655
+ } catch (cause) {
656
+ return Effect.fail(queryError(cause, "PgConnection: Failed to encode query"))
657
+ }
658
+ const run = runQuery(this, plan, wantRows)
659
+ if (cache === undefined || plan.parses) return run
660
+ return retryStale(plan, cache, run, () => this.attempt(sql, params, wantRows, undefined))
661
+ }
662
+
663
+ /** Sends a `CancelRequest` for this session on a side connection. */
664
+ readonly cancel: Effect.Effect<void> = Effect.suspend(() => {
665
+ if (this.deadWith !== undefined) return Effect.void
666
+ return sendCancelRequest(this.resolved, this.session.processId, this.session.secretKey)
667
+ })
668
+
669
+ readonly pin: Effect.Effect<PgConnection, never, Scope.Scope> = Effect.suspend(() => {
670
+ const reserve = this[internalsKey].reserve
671
+ return reserve === undefined ? this.pinExclusive : Effect.andThen(reserve, this.pinExclusive)
672
+ })
673
+
674
+ private readonly pinExclusive: Effect.Effect<PgConnection, never, Scope.Scope> = Effect.acquireRelease(
675
+ Effect.flatMap(this.owner.take(1), () =>
676
+ // Holding `owner` stops new submissions; a pipeline already on the wire
677
+ // still has to drain before this fiber owns the connection.
678
+ Effect.as(
679
+ Effect.tap(
680
+ Effect.onInterrupt(this.waitPipelineIdle, () => this.owner.release(1)),
681
+ () =>
682
+ Effect.sync(() => {
683
+ this.pinned = true
684
+ })
685
+ ),
686
+ this.pinnedView
687
+ )),
688
+ () =>
689
+ Effect.suspend(() => {
690
+ this.pinned = false
691
+ return this.owner.release(1)
692
+ })
693
+ )
694
+
695
+ private readonly run = (
696
+ sql: string,
697
+ params: ReadonlyArray<unknown>,
698
+ wantRows: boolean,
699
+ prepare: boolean
700
+ ): Effect.Effect<QueryOutput, SqlError> => {
701
+ const cache = prepare ? this.prepared : undefined
702
+ return this.multiplex
703
+ ? this.pipelineCycle(sql, params, wantRows, cache)
704
+ : this.owner.withPermit(this.cycleOwned(sql, params, wantRows, cache))
705
+ }
706
+
707
+ readonly query = (sql: string, params?: ReadonlyArray<unknown>, prepare = true): Effect.Effect<Result, SqlError> =>
708
+ Effect.map(this.run(sql, params ?? emptyParams, true, prepare), takeResult)
709
+
710
+ readonly queryValues = (
711
+ sql: string,
712
+ params?: ReadonlyArray<unknown>,
713
+ prepare = true
714
+ ): Effect.Effect<ReadonlyArray<ReadonlyArray<unknown>>, SqlError> =>
715
+ Effect.map(this.run(sql, params ?? emptyParams, false, prepare), takeValues)
716
+
717
+ readonly stream = (sql: string, params?: ReadonlyArray<unknown>): Stream.Stream<Row, SqlError> =>
718
+ streamRows(this, this.pin, sql, params ?? emptyParams)
719
+
720
+ readonly listen = (
721
+ channel: string
722
+ ): Effect.Effect<Queue.Dequeue<Notification>, SqlError, Scope.Scope> => listenChannel(this, this.pin, channel)
723
+
724
+ readonly interrupt: Effect.Effect<void> = Effect.suspend(() =>
725
+ this.multiplex && !this.pinned ? Effect.void : this.cancel
726
+ )
727
+ }
728
+
729
+ /**
730
+ * The view of a session returned by `pin`: statements skip the ownership
731
+ * queue and re-pinning is a no-op, making `pin` reentrant for `stream` and
732
+ * `listen` running inside a transaction.
733
+ */
734
+ class PinnedPgConnection implements PgConnection {
735
+ readonly [TypeId]: TypeId = TypeId
736
+ readonly base: PgConnectionImpl
737
+ readonly [internalsKey]: ConnectionInternals
738
+ readonly pin: Effect.Effect<PgConnection, never, Scope.Scope>
739
+ readonly interrupt: Effect.Effect<void>
740
+
741
+ constructor(base: PgConnectionImpl) {
742
+ this.base = base
743
+ this[internalsKey] = base[internalsKey]
744
+ this.pin = Effect.succeed(this)
745
+ this.interrupt = base.cancel
746
+ }
747
+
748
+ get config(): Config {
749
+ return this.base.config
750
+ }
751
+
752
+ get processId(): number {
753
+ return this.base.processId
754
+ }
755
+
756
+ readonly query = (sql: string, params?: ReadonlyArray<unknown>, prepare = true): Effect.Effect<Result, SqlError> =>
757
+ Effect.map(this.base.cycle(sql, params ?? emptyParams, true, prepare ? this.base.prepared : undefined), takeResult)
758
+
759
+ readonly queryValues = (
760
+ sql: string,
761
+ params?: ReadonlyArray<unknown>,
762
+ prepare = true
763
+ ): Effect.Effect<ReadonlyArray<ReadonlyArray<unknown>>, SqlError> =>
764
+ Effect.map(this.base.cycle(sql, params ?? emptyParams, false, prepare ? this.base.prepared : undefined), takeValues)
765
+
766
+ readonly stream = (sql: string, params?: ReadonlyArray<unknown>): Stream.Stream<Row, SqlError> =>
767
+ streamRows(this.base, this.pin, sql, params ?? emptyParams)
768
+
769
+ readonly listen = (
770
+ channel: string
771
+ ): Effect.Effect<Queue.Dequeue<Notification>, SqlError, Scope.Scope> => listenChannel(this.base, this.pin, channel)
772
+ }
773
+
774
+ interface QueryOutput {
775
+ readonly result: Result
776
+ readonly values: ReadonlyArray<ReadonlyArray<unknown>>
777
+ }
778
+
779
+ const INT32_MIN = -2147483648
780
+ const INT32_MAX = 2147483647
781
+
782
+ const inferredParameter = (oid: number, value: unknown): PgTypes.Parameter => ({
783
+ [PgTypes.ParameterTypeId]: PgTypes.ParameterTypeId,
784
+ oid,
785
+ value
786
+ })
787
+
788
+ const inferScalar = (value: unknown): PgTypes.Parameter => {
789
+ if (PgTypes.isParameter(value)) return value
790
+ if (value === null || value === undefined) return inferredParameter(0, null)
791
+ switch (typeof value) {
792
+ case "boolean":
793
+ return inferredParameter(PgTypes.OID.bool, value)
794
+ case "bigint":
795
+ return inferredParameter(PgTypes.OID.int8, value)
796
+ case "number":
797
+ if (Number.isInteger(value)) {
798
+ if (value >= INT32_MIN && value <= INT32_MAX) {
799
+ return inferredParameter(PgTypes.OID.int4, value)
800
+ }
801
+ if (Number.isSafeInteger(value)) {
802
+ return inferredParameter(PgTypes.OID.int8, BigInt(value))
803
+ }
804
+ }
805
+ return inferredParameter(PgTypes.OID.float8, value)
806
+ case "string":
807
+ // Bound with no concrete type, as a text-format literal, so the backend
808
+ // derives the type from the statement: a string works against a bigint
809
+ // or timestamp column the way it did with the text-protocol drivers.
810
+ return inferredParameter(0, value)
811
+ }
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
+ }
817
+ if (value instanceof Uint8Array) return inferredParameter(PgTypes.OID.bytea, value)
818
+ if (value instanceof Int8Array) {
819
+ return inferredParameter(
820
+ PgTypes.OID.bytea,
821
+ new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
822
+ )
823
+ }
824
+ throw new PgTypes.CodecError({ message: `Cannot infer a PostgreSQL type for ${String(value)}` })
825
+ }
826
+
827
+ const inferParameter = (value: unknown, registry: PgTypes.Registry | undefined): PgTypes.Parameter => {
828
+ if (!Array.isArray(value)) return inferScalar(value)
829
+ if (value.length === 0) {
830
+ throw new PgTypes.CodecError({ message: "Cannot infer the type of an empty array; use PgTypes.array" })
831
+ }
832
+ let elementOid: number | undefined
833
+ const values: Array<unknown> = new Array(value.length)
834
+ for (let index = 0; index < value.length; index++) {
835
+ const element = value[index]
836
+ if (Array.isArray(element)) {
837
+ throw new PgTypes.CodecError({ message: "Nested array parameters are not supported" })
838
+ }
839
+ const parameter = inferScalar(element)
840
+ // A scalar string binds untyped, but an array names its element type.
841
+ let oid = parameter.oid
842
+ if (oid === 0) {
843
+ if (parameter.value === null) {
844
+ values[index] = null
845
+ continue
846
+ }
847
+ oid = PgTypes.OID.text
848
+ }
849
+ if (Array.isArray(parameter.value)) {
850
+ throw new PgTypes.CodecError({ message: "Nested array parameters are not supported" })
851
+ }
852
+ if (elementOid === undefined) elementOid = oid
853
+ else if (elementOid !== oid) {
854
+ throw new PgTypes.CodecError({ message: "Array parameter elements must have the same inferred OID" })
855
+ }
856
+ values[index] = parameter.value
857
+ }
858
+ if (elementOid === undefined) {
859
+ throw new PgTypes.CodecError({ message: "Cannot infer the type of an array containing only null values" })
860
+ }
861
+ const arrayOid = PgTypes.arrayOidFor(elementOid, registry)
862
+ if (arrayOid === undefined) {
863
+ throw new PgTypes.CodecError({ message: `No array type known for element OID ${elementOid}` })
864
+ }
865
+ return inferredParameter(arrayOid, values)
866
+ }
867
+
868
+ /**
869
+ * Joins the parts of a frame into the buffer that goes on the wire.
870
+ *
871
+ * The buffer comes from Node's pool and can be passed directly to
872
+ * `socket.write`. Every byte is initialized before the buffer is returned.
873
+ */
874
+ const concat = (chunks: ReadonlyArray<Uint8Array>): Uint8Array => {
875
+ let length = 0
876
+ for (let index = 0; index < chunks.length; index++) length += chunks[index].length
877
+ const output = Buffer.allocUnsafe(length)
878
+ let offset = 0
879
+ for (let index = 0; index < chunks.length; index++) {
880
+ output.set(chunks[index], offset)
881
+ offset += chunks[index].length
882
+ }
883
+ return output
884
+ }
885
+
886
+ const emptyParams: ReadonlyArray<unknown> = []
887
+
888
+ const takeResult = (output: QueryOutput): Result => output.result
889
+ const takeValues = (output: QueryOutput): ReadonlyArray<ReadonlyArray<unknown>> => output.values
890
+
891
+ /**
892
+ * The tail of every extended-query frame. `Describe` names the unnamed portal,
893
+ * `Execute` runs it without a row limit, and `Sync` closes the cycle; none of
894
+ * the three carries per-query state, so the bytes are encoded once.
895
+ */
896
+ const describeExecuteSync: Uint8Array = concat([
897
+ PgProtocol.encodeDescribe({ target: "portal", name: "" }),
898
+ PgProtocol.encodeExecute({ portal: "", maxRows: 0 }),
899
+ PgProtocol.encodeSync()
900
+ ])
901
+
902
+ /** The same tail for a statement whose columns are already known. */
903
+ const executeSync: Uint8Array = concat([
904
+ PgProtocol.encodeExecute({ portal: "", maxRows: 0 }),
905
+ PgProtocol.encodeSync()
906
+ ])
907
+
908
+ /** The default number of statements a connection keeps prepared. */
909
+ const defaultPreparedStatements = 100
910
+
911
+ /**
912
+ * A statement the backend has parsed and holds under a name.
913
+ *
914
+ * `ready` turns true once `ParseComplete` confirms the backend has it and the
915
+ * first execution has reported its columns. Until then the entry is treated as
916
+ * a miss, which is safe because one cycle runs at a time. A `ready` entry with
917
+ * no `description` describes a statement that returns no rows.
918
+ */
919
+ interface Prepared {
920
+ readonly name: string
921
+ readonly key: string
922
+ ready: boolean
923
+ /** A cycle carrying this statement's `Parse` is on the wire. */
924
+ parsing: boolean
925
+ description: Description | undefined
926
+ }
927
+
928
+ /** One planned execution: the bytes to write and what to expect back. */
929
+ interface Plan {
930
+ readonly frame: Uint8Array
931
+ /** `CloseComplete` messages to consume before the cycle proper. */
932
+ readonly closes: number
933
+ /** Whether the frame carries a `Parse`. */
934
+ readonly parses: boolean
935
+ /** Whether the frame carries a `Describe`, so the columns arrive on the wire. */
936
+ readonly describes: boolean
937
+ /** The statement being filled in or reused, if this execution names one. */
938
+ readonly prepared: Prepared | undefined
939
+ /** Set when the columns were already known, so no `RowDescription` is coming. */
940
+ readonly description: Description | undefined
941
+ /** Set by the cycle when the backend rejected the name or the cached plan. */
942
+ stale: boolean
943
+ }
944
+
945
+ /** The unnamed path: `Parse` / `Bind` / `Describe` / `Execute` / `Sync`. */
946
+ const encodeUnnamed = (
947
+ sql: string,
948
+ parameters: ReadonlyArray<PgTypes.Parameter>,
949
+ parameterTypes: ReadonlyArray<number>,
950
+ encodeBind: BindEncoder
951
+ ): Plan => {
952
+ const parse = PgProtocol.encodeParse({ name: "", query: sql, parameterTypes })
953
+ if (EffectResult.isFailure(parse)) throw parse.failure
954
+ const bind = encodeBind({ portal: "", statement: "", parameters })
955
+ if (EffectResult.isFailure(bind)) throw bind.failure
956
+ return {
957
+ frame: concat([parse.success, bind.success, describeExecuteSync]),
958
+ closes: 0,
959
+ parses: true,
960
+ describes: true,
961
+ prepared: undefined,
962
+ description: undefined,
963
+ stale: false
964
+ }
965
+ }
966
+
967
+ /**
968
+ * Builds one extended-query cycle as a single buffer, so a statement costs one
969
+ * socket write.
970
+ *
971
+ * A statement the backend already holds under a name needs only
972
+ * `Bind` / `Execute` / `Sync`: no `Parse` for the backend to plan and no
973
+ * `Describe`, because the columns came back the first time and are cached with
974
+ * the name. Statements evicted from the cache ride along as `Close` messages
975
+ * rather than paying a round trip of their own.
976
+ */
977
+ const encodeQuery = (
978
+ sql: string,
979
+ params: ReadonlyArray<unknown>,
980
+ registry: PgTypes.Registry | undefined,
981
+ encodeBind: BindEncoder,
982
+ cache: PreparedCache | undefined
983
+ ): Plan => {
984
+ const count = params.length
985
+ const parameters: Array<PgTypes.Parameter> = new Array(count)
986
+ const parameterTypes: Array<number> = new Array(count)
987
+ for (let index = 0; index < count; index++) {
988
+ const parameter = inferParameter(params[index], registry)
989
+ parameters[index] = parameter
990
+ parameterTypes[index] = parameter.oid
991
+ }
992
+
993
+ if (cache === undefined) {
994
+ return encodeUnnamed(sql, parameters, parameterTypes, encodeBind)
995
+ }
996
+
997
+ const prepared = cache.get(sql, parameterTypes)
998
+ if (!prepared.ready && prepared.parsing) {
999
+ // Another cycle is already on the wire carrying this name's `Parse`.
1000
+ // Naming it again would collide, and reusing it would mean binding to
1001
+ // columns nobody has seen yet, so this execution goes unnamed.
1002
+ return encodeUnnamed(sql, parameters, parameterTypes, encodeBind)
1003
+ }
1004
+ const bind = encodeBind({ portal: "", statement: prepared.name, parameters })
1005
+ if (EffectResult.isFailure(bind)) throw bind.failure
1006
+ const parse = prepared.ready ? undefined : PgProtocol.encodeParse({ name: prepared.name, query: sql, parameterTypes })
1007
+ if (parse !== undefined && EffectResult.isFailure(parse)) throw parse.failure
1008
+ const closeFrames = cache.takeCloses()
1009
+
1010
+ if (parse === undefined) {
1011
+ return {
1012
+ frame: closeFrames === undefined
1013
+ ? concat([bind.success, executeSync])
1014
+ : concat([closeFrames.frames, bind.success, executeSync]),
1015
+ closes: closeFrames?.count ?? 0,
1016
+ parses: false,
1017
+ describes: false,
1018
+ prepared,
1019
+ description: prepared.description,
1020
+ stale: false
1021
+ }
1022
+ }
1023
+
1024
+ prepared.parsing = true
1025
+ return {
1026
+ frame: closeFrames === undefined
1027
+ ? concat([parse.success, bind.success, describeExecuteSync])
1028
+ : concat([closeFrames.frames, parse.success, bind.success, describeExecuteSync]),
1029
+ closes: closeFrames?.count ?? 0,
1030
+ parses: true,
1031
+ describes: true,
1032
+ prepared,
1033
+ description: undefined,
1034
+ stale: false
1035
+ }
1036
+ }
1037
+
1038
+ /**
1039
+ * The statements one connection has prepared, keyed by SQL text and the
1040
+ * parameter OIDs inferred for it: the same text with differently typed
1041
+ * parameters is a different statement to the backend.
1042
+ *
1043
+ * The cache is bounded and evicts least-recently-used. An evicted statement is
1044
+ * closed on the backend, but its `Close` waits for the next statement to go
1045
+ * out rather than taking a round trip of its own.
1046
+ */
1047
+ class PreparedCache {
1048
+ readonly max: number
1049
+ private readonly statements = new Map<string, Prepared>()
1050
+ private closes: Array<Uint8Array> | undefined
1051
+ private counter = 0
1052
+
1053
+ constructor(max: number) {
1054
+ this.max = max
1055
+ }
1056
+
1057
+ get(sql: string, parameterTypes: ReadonlyArray<number>): Prepared {
1058
+ const key = parameterTypes.length === 0 ? sql : `${sql}\u0000${parameterTypes.join(",")}`
1059
+ const found = this.statements.get(key)
1060
+ if (found !== undefined) {
1061
+ // Re-insert to move it to the end: `Map` iterates in insertion order, so
1062
+ // the first key is the least recently used one.
1063
+ this.statements.delete(key)
1064
+ this.statements.set(key, found)
1065
+ this.trim(found)
1066
+ return found
1067
+ }
1068
+ const prepared: Prepared = {
1069
+ name: `effect${++this.counter}`,
1070
+ key,
1071
+ ready: false,
1072
+ parsing: false,
1073
+ description: undefined
1074
+ }
1075
+ this.statements.set(key, prepared)
1076
+ this.trim(prepared)
1077
+ return prepared
1078
+ }
1079
+
1080
+ /**
1081
+ * A statement still being parsed cannot be closed yet: its `Parse` may not
1082
+ * have reached the socket. Let the cache exceed its bound temporarily, then
1083
+ * discard abandoned entries and close excess ready statements on access.
1084
+ */
1085
+ private trim(protectedStatement?: Prepared): void {
1086
+ while (this.statements.size > this.max) {
1087
+ let evicted: Prepared | undefined
1088
+ for (const prepared of this.statements.values()) {
1089
+ if (prepared !== protectedStatement && (prepared.ready || !prepared.parsing)) {
1090
+ evicted = prepared
1091
+ break
1092
+ }
1093
+ }
1094
+ if (evicted === undefined) return
1095
+ this.statements.delete(evicted.key)
1096
+ if (evicted.ready) this.close(evicted.name)
1097
+ }
1098
+ }
1099
+
1100
+ /**
1101
+ * Drops a statement the backend no longer holds, or whose plan went stale.
1102
+ *
1103
+ * The name is closed either way. A plan that went stale is still held under
1104
+ * it, and re-parsing would collide; a name the backend has already lost
1105
+ * ignores the `Close`, which Postgres treats as a success.
1106
+ */
1107
+ evict(prepared: Prepared): void {
1108
+ if (prepared.parsing) return
1109
+ if (this.statements.delete(prepared.key) && prepared.ready) this.close(prepared.name)
1110
+ }
1111
+
1112
+ /**
1113
+ * Drops a statement whose parsing cycle failed before its columns arrived.
1114
+ *
1115
+ * A `Parse` that completed before the error outlives the failed cycle, so
1116
+ * the backend may hold the name while the entry can never become ready. The
1117
+ * name is closed either way: closing one the backend never registered is a
1118
+ * no-op.
1119
+ */
1120
+ evictFailed(prepared: Prepared): void {
1121
+ this.statements.delete(prepared.key)
1122
+ this.close(prepared.name)
1123
+ }
1124
+
1125
+ private close(name: string): void {
1126
+ ;(this.closes ??= []).push(PgProtocol.encodeClose({ target: "statement", name }))
1127
+ }
1128
+
1129
+ takeCloses(): { readonly frames: Uint8Array; readonly count: number } | undefined {
1130
+ const closes = this.closes
1131
+ if (closes === undefined) return undefined
1132
+ this.closes = undefined
1133
+ return { frames: closes.length === 1 ? closes[0] : concat(closes), count: closes.length }
1134
+ }
1135
+ }
1136
+
1137
+ /**
1138
+ * SQLSTATEs that mean "this name is no longer usable": the backend lost the
1139
+ * statement, or the plan behind it no longer matches the columns it was
1140
+ * prepared for. Both are recovered by parsing the statement again.
1141
+ */
1142
+ const isStalePreparedStatement = (code: string | undefined): boolean => code === "26000" || code === "0A000"
1143
+
1144
+ type BindEncoder = (options: {
1145
+ readonly portal: string
1146
+ readonly statement: string
1147
+ readonly parameters: ReadonlyArray<PgTypes.Parameter>
1148
+ }) => EffectResult.Result<Uint8Array, PgProtocol.EncodeError | PgTypes.CodecError>
1149
+
1150
+ /**
1151
+ * The default builtin-catalogue encoder, shared by every connection without a
1152
+ * custom registry. Passing `PgTypes.writeParameter` itself engages
1153
+ * `makeBindEncoder`'s unsafe fast path.
1154
+ */
1155
+ const defaultBindEncoder: BindEncoder = PgProtocol.makeBindEncoder(PgTypes.writeParameter, PgTypes.isTextFormat)
1156
+
1157
+ /** One bind encoder per registry, since building one allocates a closure. */
1158
+ const makeBindEncoder = (registry: PgTypes.Registry | undefined): BindEncoder =>
1159
+ registry === undefined
1160
+ ? defaultBindEncoder
1161
+ : PgProtocol.makeBindEncoder(
1162
+ (sink: PgProtocol.ValueSink, parameter: PgTypes.Parameter) => PgTypes.writeParameter(sink, parameter, registry),
1163
+ PgTypes.isTextFormat
1164
+ )
1165
+
1166
+ const queryError = (cause: unknown, message: string): SqlError =>
1167
+ new SqlError({ reason: new UnknownError({ cause, message, operation: "query" }) })
1168
+
1169
+ /**
1170
+ * Retries a cycle whose named statement the backend no longer honors: the
1171
+ * statement is dropped from the cache and the retry runs unnamed, so it cannot
1172
+ * loop.
1173
+ */
1174
+ const retryStale = (
1175
+ plan: Plan,
1176
+ cache: PreparedCache,
1177
+ run: Effect.Effect<QueryOutput, SqlError>,
1178
+ rerun: () => Effect.Effect<QueryOutput, SqlError>
1179
+ ): Effect.Effect<QueryOutput, SqlError> =>
1180
+ Effect.catchCause(run, (cause) => {
1181
+ if (!plan.stale) return Effect.failCause(cause)
1182
+ cache.evict(plan.prepared!)
1183
+ return rerun()
1184
+ })
1185
+
1186
+ const connectionQueryError = (cause: unknown, message: string): SqlError =>
1187
+ new SqlError({ reason: new ConnectionError({ cause, message, operation: "query" }) })
1188
+
1189
+ const escapeIdentifier = (identifier: string): string => `"${identifier.replaceAll("\"", "\"\"")}"`
1190
+
1191
+ type QueryPhase = "close" | "parse" | "bind" | "describe" | "rows" | "complete" | "error"
1192
+
1193
+ /**
1194
+ * Splits a command tag such as `SELECT 3` or `INSERT 0 1`. Reading the two
1195
+ * spaces directly keeps a completed statement from allocating the parts array
1196
+ * that splitting on every space would.
1197
+ */
1198
+ const parseCommandTag = (tag: string): { command: string; rowCount: number; oid: number | null } => {
1199
+ const firstSpace = tag.indexOf(" ")
1200
+ if (firstSpace < 0) return { command: tag, oid: null, rowCount: 0 }
1201
+ const command = tag.slice(0, firstSpace)
1202
+ const secondSpace = tag.indexOf(" ", firstSpace + 1)
1203
+ if (command === "INSERT" && secondSpace > 0) {
1204
+ return {
1205
+ command,
1206
+ oid: Number(tag.slice(firstSpace + 1, secondSpace)),
1207
+ rowCount: Number(tag.slice(secondSpace + 1))
1208
+ }
1209
+ }
1210
+ const last = tag.slice((secondSpace < 0 ? firstSpace : secondSpace) + 1)
1211
+ return { command, oid: null, rowCount: isDigits(last) ? Number(last) : 0 }
1212
+ }
1213
+
1214
+ const isDigits = (value: string): boolean => {
1215
+ if (value.length === 0) return false
1216
+ for (let index = 0; index < value.length; index++) {
1217
+ const code = value.charCodeAt(index)
1218
+ if (code < 48 || code > 57) return false
1219
+ }
1220
+ return true
1221
+ }
1222
+
1223
+ /**
1224
+ * Tracks one query cycle while the connection routes backend messages to it.
1225
+ * Each pipelined cycle keeps its own parser state and completes once.
1226
+ */
1227
+ class QueryMachine implements Consumer {
1228
+ private readonly conn: PgConnectionImpl
1229
+ private readonly plan: Plan
1230
+ private readonly finish: (effect: Effect.Effect<QueryOutput, SqlError>) => void
1231
+ /** Installed on the parser while this machine is at the head of the queue. */
1232
+ readonly readField: PgProtocol.FieldReader<unknown> | undefined
1233
+ /** Object rows, or `undefined` when the caller asked for positional ones. */
1234
+ private readonly rows: Array<Row> | undefined
1235
+ private readonly values: Array<ReadonlyArray<unknown>> | undefined
1236
+
1237
+ private closes: number
1238
+ private phase: QueryPhase
1239
+ private fieldCount: number
1240
+ private resultFields: ReadonlyArray<Field>
1241
+ private rowBuilder: RowBuilder | undefined
1242
+ private command = ""
1243
+ private rowCount = 0
1244
+ private oid: number | null = null
1245
+ private failure: SqlError | undefined
1246
+ private done = false
1247
+ private aborted = false
1248
+ private drainDone: (() => void) | undefined
1249
+
1250
+ constructor(
1251
+ conn: PgConnectionImpl,
1252
+ plan: Plan,
1253
+ wantRows: boolean,
1254
+ finish: (effect: Effect.Effect<QueryOutput, SqlError>) => void
1255
+ ) {
1256
+ this.conn = conn
1257
+ this.plan = plan
1258
+ this.finish = finish
1259
+ this.rows = wantRows ? [] : undefined
1260
+ this.values = wantRows ? undefined : []
1261
+ this.closes = plan.closes
1262
+ this.phase = plan.closes > 0 ? "close" : plan.parses ? "parse" : "bind"
1263
+ const description = plan.description
1264
+ this.readField = description?.readField
1265
+ this.fieldCount = description?.resultFields.length ?? 0
1266
+ this.resultFields = description?.resultFields ?? emptyFields
1267
+ this.rowBuilder = description?.rowBuilder
1268
+ }
1269
+
1270
+ isDone(): boolean {
1271
+ return this.done
1272
+ }
1273
+
1274
+ abort(onDrained: () => void): void {
1275
+ if (this.done) return onDrained()
1276
+ this.aborted = true
1277
+ this.drainDone = onDrained
1278
+ }
1279
+
1280
+ private complete(effect: Effect.Effect<QueryOutput, SqlError>): void {
1281
+ if (this.done) return
1282
+ this.done = true
1283
+ // Whether it parsed or not, this cycle no longer holds the name: success
1284
+ // has already marked it ready, failure leaves it to be parsed again.
1285
+ const prepared = this.plan.prepared
1286
+ if (prepared !== undefined) prepared.parsing = false
1287
+ this.finish(effect)
1288
+ }
1289
+
1290
+ private failDesync(message: string): void {
1291
+ this.conn.fatal(connectionQueryError(new Error(message), `PgConnection: ${message}`))
1292
+ }
1293
+
1294
+ onFatal(error: SqlError): void {
1295
+ if (this.done) return
1296
+ this.done = true
1297
+ const prepared = this.plan.prepared
1298
+ if (prepared !== undefined) prepared.parsing = false
1299
+ if (this.aborted) this.drainDone?.()
1300
+ else this.finish(Effect.fail(error))
1301
+ }
1302
+
1303
+ onMessage(message: PgProtocol.BackendMessage<unknown>): void {
1304
+ if (this.aborted) {
1305
+ if (message._tag === "ReadyForQuery") {
1306
+ this.done = true
1307
+ this.drainDone?.()
1308
+ }
1309
+ return
1310
+ }
1311
+ if (this.phase === "error") {
1312
+ if (message._tag === "ReadyForQuery") return this.complete(Effect.fail(this.failure!))
1313
+ return this.failDesync(`Unexpected ${message._tag} after ErrorResponse`)
1314
+ }
1315
+ switch (message._tag) {
1316
+ case "CloseComplete":
1317
+ if (this.phase !== "close") return this.failDesync(`Unexpected CloseComplete during ${this.phase}`)
1318
+ if (--this.closes === 0) this.phase = this.plan.parses ? "parse" : "bind"
1319
+ return
1320
+ case "ParseComplete":
1321
+ if (this.phase !== "parse") return this.failDesync(`Unexpected ParseComplete during ${this.phase}`)
1322
+ this.phase = "bind"
1323
+ return
1324
+ case "BindComplete":
1325
+ if (this.phase !== "bind") return this.failDesync(`Unexpected BindComplete during ${this.phase}`)
1326
+ this.phase = this.plan.describes ? "describe" : "rows"
1327
+ return
1328
+ case "RowDescription": {
1329
+ if (this.phase !== "describe") return this.failDesync(`Unexpected RowDescription during ${this.phase}`)
1330
+ const description = describe(message.fields, this.conn.registry)
1331
+ if (EffectResult.isFailure(description)) {
1332
+ return this.conn.fatal(queryError(description.failure, "PgConnection: Failed to decode row"))
1333
+ }
1334
+ this.fieldCount = message.fields.length
1335
+ this.resultFields = description.success.resultFields
1336
+ this.rowBuilder = description.success.rowBuilder
1337
+ // `pushEach` hands this description over before it reads the rows
1338
+ // behind it, including rows that arrived in the same chunk.
1339
+ this.conn.session.parser.readField = description.success.readField
1340
+ const prepared = this.plan.prepared
1341
+ if (prepared !== undefined) {
1342
+ prepared.description = description.success
1343
+ prepared.ready = true
1344
+ }
1345
+ this.phase = "rows"
1346
+ return
1347
+ }
1348
+ case "NoData": {
1349
+ if (this.phase !== "describe") return this.failDesync(`Unexpected NoData during ${this.phase}`)
1350
+ const prepared = this.plan.prepared
1351
+ if (prepared !== undefined) {
1352
+ prepared.description = undefined
1353
+ prepared.ready = true
1354
+ }
1355
+ this.phase = "rows"
1356
+ return
1357
+ }
1358
+ case "DataRow": {
1359
+ if (this.phase !== "rows" || this.rowBuilder === undefined) {
1360
+ return this.failDesync(`Unexpected DataRow during ${this.phase}`)
1361
+ }
1362
+ const rowValues = message.values
1363
+ if (rowValues.length !== this.fieldCount) {
1364
+ return this.failDesync(`DataRow has ${rowValues.length} values for ${this.fieldCount} fields`)
1365
+ }
1366
+ if (this.rows !== undefined) this.rows.push(this.rowBuilder(rowValues))
1367
+ else this.values!.push(rowValues)
1368
+ return
1369
+ }
1370
+ case "CommandComplete": {
1371
+ if (this.phase !== "rows") return this.failDesync(`Unexpected CommandComplete during ${this.phase}`)
1372
+ const parsed = parseCommandTag(message.commandTag)
1373
+ this.command = parsed.command
1374
+ this.rowCount = parsed.rowCount
1375
+ this.oid = parsed.oid
1376
+ this.phase = "complete"
1377
+ return
1378
+ }
1379
+ case "EmptyQueryResponse":
1380
+ if (this.phase !== "rows") return this.failDesync(`Unexpected EmptyQueryResponse during ${this.phase}`)
1381
+ this.phase = "complete"
1382
+ return
1383
+ case "ErrorResponse": {
1384
+ if (isStalePreparedStatement(message.fields.code)) this.plan.stale = true
1385
+ // A cycle that carried this statement's `Parse` but failed before its
1386
+ // columns arrived leaves an entry that can never become ready while
1387
+ // the backend may still hold the name: the `Parse` outlives the
1388
+ // failed cycle. Drop the entry and close the name, so the next
1389
+ // execution parses fresh under a new one.
1390
+ const prepared = this.plan.prepared
1391
+ if (this.plan.parses && prepared !== undefined && !prepared.ready) {
1392
+ this.conn.prepared?.evictFailed(prepared)
1393
+ }
1394
+ // Every phase drains the same way: the backend skips the rest of the
1395
+ // cycle and sends `ReadyForQuery` after the `Sync` that closes it, so
1396
+ // a statement it refused to parse leaves the session usable.
1397
+ this.failure = new SqlError({
1398
+ reason: classifyFields(message.fields, "PgConnection: Query failed", "query")
1399
+ })
1400
+ this.phase = "error"
1401
+ return
1402
+ }
1403
+ case "ReadyForQuery":
1404
+ if (this.phase !== "complete") return this.failDesync(`Unexpected ReadyForQuery during ${this.phase}`)
1405
+ return this.complete(Effect.succeed({
1406
+ result: {
1407
+ command: this.command,
1408
+ rowCount: this.rowCount,
1409
+ oid: this.oid,
1410
+ rows: this.rows ?? emptyRows,
1411
+ fields: this.resultFields
1412
+ },
1413
+ values: this.values ?? emptyValues
1414
+ }))
1415
+ case "CopyInResponse":
1416
+ case "CopyOutResponse":
1417
+ case "CopyBothResponse":
1418
+ case "CopyData":
1419
+ case "CopyDone":
1420
+ return this.failDesync(`Unexpected ${message._tag}; COPY is not supported`)
1421
+ default:
1422
+ return this.failDesync(`Unexpected ${message._tag} during ${this.phase}`)
1423
+ }
1424
+ }
1425
+ }
1426
+
1427
+ const emptyRows: ReadonlyArray<Row> = []
1428
+ const emptyValues: ReadonlyArray<ReadonlyArray<unknown>> = []
1429
+
1430
+ /** One cycle on a connection that carries a single statement at a time. */
1431
+ const runQuery = (
1432
+ conn: PgConnectionImpl,
1433
+ plan: Plan,
1434
+ wantRows: boolean
1435
+ ): Effect.Effect<QueryOutput, SqlError> =>
1436
+ Effect.callback<QueryOutput, SqlError>((resume) => {
1437
+ if (conn.deadWith !== undefined) {
1438
+ resume(Effect.fail(conn.deadWith))
1439
+ return
1440
+ }
1441
+ const machine = new QueryMachine(conn, plan, wantRows, (effect) => {
1442
+ conn.consumer = undefined
1443
+ resume(effect)
1444
+ })
1445
+ conn.consumer = machine
1446
+ // A reused statement has no `RowDescription` coming, so its reader has to
1447
+ // be in place before the rows are.
1448
+ conn.session.parser.readField = machine.readField
1449
+ try {
1450
+ conn.session.socket.write(plan.frame)
1451
+ } catch (cause) {
1452
+ conn.fatal(connectionQueryError(cause, "PgConnection: Failed to write query"))
1453
+ }
1454
+
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
+ return Effect.suspend(() => {
1458
+ 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
+ )
1468
+ machine.abort(() => {
1469
+ clearTimeout(timer)
1470
+ conn.consumer = undefined
1471
+ resumeWait(Effect.void)
1472
+ })
1473
+ })
1474
+ return Effect.andThen(conn.cancel, wait)
1475
+ })
1476
+ })
1477
+
1478
+ /** Turns one decoded row into an object keyed by column name. */
1479
+ type RowBuilder = (rowValues: ReadonlyArray<unknown>) => Row
1480
+
1481
+ /** Everything a query needs from one `RowDescription`. */
1482
+ interface Description {
1483
+ readonly readField: PgProtocol.FieldReader<unknown>
1484
+ readonly rowBuilder: RowBuilder
1485
+ readonly resultFields: ReadonlyArray<Field>
1486
+ }
1487
+
1488
+ const emptyFields: ReadonlyArray<Field> = []
1489
+
1490
+ /** Derives the per-column readers and the row constructor for a description. */
1491
+ const describe = (
1492
+ fields: ReadonlyArray<PgProtocol.FieldDescription>,
1493
+ registry: PgTypes.Registry | undefined
1494
+ ): EffectResult.Result<Description, PgTypes.CodecError> => {
1495
+ const reader = PgTypes.makeFieldReader(fields, registry)
1496
+ if (EffectResult.isFailure(reader)) return EffectResult.fail(reader.failure)
1497
+ const resultFields: Array<Field> = new Array(fields.length)
1498
+ for (let index = 0; index < fields.length; index++) {
1499
+ resultFields[index] = { name: fields[index].name, dataTypeId: fields[index].dataTypeOid }
1500
+ }
1501
+ return EffectResult.succeed({
1502
+ readField: reader.success,
1503
+ rowBuilder: makeRowBuilder(fields),
1504
+ resultFields
1505
+ })
1506
+ }
1507
+
1508
+ /**
1509
+ * Builds the row constructor for one `RowDescription`.
1510
+ *
1511
+ * Assignment is an order of magnitude faster than `Object.defineProperty` and
1512
+ * stores the same own, enumerable, writable, configurable property - except
1513
+ * for `__proto__`, which assignment routes to the prototype setter instead. A
1514
+ * description carrying that column name falls back to the slow spelling.
1515
+ */
1516
+ const makeRowBuilder = (fields: ReadonlyArray<PgProtocol.FieldDescription>): RowBuilder => {
1517
+ const names: Array<string> = new Array(fields.length)
1518
+ let hasProto = false
1519
+ for (let index = 0; index < fields.length; index++) {
1520
+ const name = fields[index].name
1521
+ names[index] = name
1522
+ if (name === "__proto__") hasProto = true
1523
+ }
1524
+ if (hasProto) {
1525
+ return (rowValues) => {
1526
+ const row: Record<string, unknown> = {}
1527
+ for (let index = 0; index < names.length; index++) {
1528
+ Object.defineProperty(row, names[index], {
1529
+ value: rowValues[index],
1530
+ enumerable: true,
1531
+ configurable: true,
1532
+ writable: true
1533
+ })
1534
+ }
1535
+ return row
1536
+ }
1537
+ }
1538
+ return (rowValues) => {
1539
+ const row: Record<string, unknown> = {}
1540
+ for (let index = 0; index < names.length; index++) {
1541
+ row[names[index]] = rowValues[index]
1542
+ }
1543
+ return row
1544
+ }
1545
+ }
1546
+
1547
+ const streamRows = (
1548
+ conn: PgConnectionImpl,
1549
+ pin: Effect.Effect<PgConnection, never, Scope.Scope>,
1550
+ sql: string,
1551
+ params: ReadonlyArray<unknown>
1552
+ ): Stream.Stream<Row, SqlError> =>
1553
+ Stream.fromChannel(Channel.fromTransform(Effect.fnUntraced(function*(_, scope) {
1554
+ yield* Scope.provide(pin, scope)
1555
+ yield* Scope.provide(
1556
+ Effect.acquireRelease(conn.wire.take(1), () => conn.wire.release(1)),
1557
+ scope
1558
+ )
1559
+ if (conn.deadWith !== undefined) return yield* conn.deadWith
1560
+ // Streams stay on the unnamed path: a stream pays its setup once over the
1561
+ // whole result, so naming the statement buys little and would need the
1562
+ // stale-plan retry to unwind rows already delivered.
1563
+ const plan = yield* Effect.try({
1564
+ try: () => conn.encodeQuery(sql, params, undefined),
1565
+ catch: (cause) => queryError(cause, "PgConnection: Failed to encode query")
1566
+ })
1567
+ const frame = plan.frame
1568
+
1569
+ const socket = conn.session.socket
1570
+ const parser = conn.session.parser
1571
+ let phase: QueryPhase = "parse"
1572
+ let fieldCount = 0
1573
+ let rowBuilder: RowBuilder | undefined
1574
+ let buffer: Array<Row> = []
1575
+ let failure: SqlError | undefined
1576
+ let finished = false
1577
+ let done = false
1578
+ let aborted = false
1579
+ let paused = false
1580
+ let pending:
1581
+ | ((effect: Effect.Effect<Arr.NonEmptyReadonlyArray<Row>, SqlError | Cause.Done>) => void)
1582
+ | undefined
1583
+ let drainDone: (() => void) | undefined
1584
+
1585
+ const setPaused = (value: boolean): void => {
1586
+ if (paused === value) return
1587
+ paused = value
1588
+ if (value) socket.pause()
1589
+ else socket.resume()
1590
+ }
1591
+
1592
+ const deliver = (): void => {
1593
+ if (pending === undefined) return
1594
+ const resume = pending
1595
+ if (buffer.length > 0) {
1596
+ const chunk = buffer as Arr.NonEmptyArray<Row>
1597
+ buffer = []
1598
+ pending = undefined
1599
+ resume(Effect.succeed(chunk))
1600
+ } else if (finished) {
1601
+ pending = undefined
1602
+ resume(failure !== undefined ? Effect.fail(failure) : Cause.done())
1603
+ }
1604
+ }
1605
+
1606
+ const onFatal = (error: SqlError): void => {
1607
+ if (done) return
1608
+ done = true
1609
+ finished = true
1610
+ if (failure === undefined) failure = error
1611
+ drainDone?.()
1612
+ deliver()
1613
+ }
1614
+ // `conn.fatal` notifies the registered consumer; the direct `onFatal` call
1615
+ // covers the case where the connection was already dead.
1616
+ const failFatal = (error: SqlError): void => {
1617
+ conn.fatal(error)
1618
+ onFatal(error)
1619
+ }
1620
+ const failDesync = (message: string): void =>
1621
+ failFatal(connectionQueryError(new Error(message), `PgConnection: ${message}`))
1622
+
1623
+ const onMessage = (message: PgProtocol.BackendMessage<unknown>): void => {
1624
+ if (aborted) {
1625
+ if (message._tag === "ReadyForQuery") {
1626
+ done = true
1627
+ conn.consumer = undefined
1628
+ drainDone?.()
1629
+ }
1630
+ return
1631
+ }
1632
+ if (phase === "error") {
1633
+ switch (message._tag) {
1634
+ case "ReadyForQuery":
1635
+ finished = true
1636
+ done = true
1637
+ conn.consumer = undefined
1638
+ deliver()
1639
+ return
1640
+ default:
1641
+ return failDesync(`Unexpected ${message._tag} after ErrorResponse`)
1642
+ }
1643
+ }
1644
+ switch (message._tag) {
1645
+ case "ParseComplete":
1646
+ if (phase !== "parse") return failDesync(`Unexpected ParseComplete during ${phase}`)
1647
+ phase = "bind"
1648
+ return
1649
+ case "BindComplete":
1650
+ if (phase !== "bind") return failDesync(`Unexpected BindComplete during ${phase}`)
1651
+ phase = "describe"
1652
+ return
1653
+ case "RowDescription": {
1654
+ if (phase !== "describe") return failDesync(`Unexpected RowDescription during ${phase}`)
1655
+ const description = describe(message.fields, conn.registry)
1656
+ if (EffectResult.isFailure(description)) {
1657
+ return failFatal(queryError(description.failure, "PgConnection: Failed to decode row"))
1658
+ }
1659
+ fieldCount = message.fields.length
1660
+ rowBuilder = description.success.rowBuilder
1661
+ parser.readField = description.success.readField
1662
+ phase = "rows"
1663
+ return
1664
+ }
1665
+ case "NoData":
1666
+ if (phase !== "describe") return failDesync(`Unexpected NoData during ${phase}`)
1667
+ phase = "rows"
1668
+ return
1669
+ case "DataRow": {
1670
+ if (phase !== "rows" || rowBuilder === undefined) {
1671
+ return failDesync(`Unexpected DataRow during ${phase}`)
1672
+ }
1673
+ const rowValues = message.values
1674
+ if (rowValues.length !== fieldCount) {
1675
+ return failDesync(`DataRow has ${rowValues.length} values for ${fieldCount} fields`)
1676
+ }
1677
+ buffer.push(rowBuilder(rowValues))
1678
+ return
1679
+ }
1680
+ case "CommandComplete":
1681
+ if (phase !== "rows") return failDesync(`Unexpected CommandComplete during ${phase}`)
1682
+ phase = "complete"
1683
+ return
1684
+ case "EmptyQueryResponse":
1685
+ if (phase !== "rows") return failDesync(`Unexpected EmptyQueryResponse during ${phase}`)
1686
+ phase = "complete"
1687
+ return
1688
+ case "ErrorResponse": {
1689
+ failure = new SqlError({
1690
+ reason: classifyFields(message.fields, "PgConnection: Query failed", "query")
1691
+ })
1692
+ phase = "error"
1693
+ return
1694
+ }
1695
+ case "ReadyForQuery":
1696
+ if (phase !== "complete") return failDesync(`Unexpected ReadyForQuery during ${phase}`)
1697
+ finished = true
1698
+ done = true
1699
+ conn.consumer = undefined
1700
+ deliver()
1701
+ return
1702
+ case "CopyInResponse":
1703
+ case "CopyOutResponse":
1704
+ case "CopyBothResponse":
1705
+ case "CopyData":
1706
+ case "CopyDone":
1707
+ return failDesync(`Unexpected ${message._tag}; COPY is not supported`)
1708
+ default:
1709
+ return failDesync(`Unexpected ${message._tag} during ${phase}`)
1710
+ }
1711
+ }
1712
+
1713
+ const onBatchEnd = (): void => {
1714
+ if (done || aborted) return
1715
+ if (pending !== undefined) deliver()
1716
+ else if (buffer.length >= streamPauseThreshold) setPaused(true)
1717
+ }
1718
+
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
+ yield* Scope.addFinalizer(
1722
+ scope,
1723
+ Effect.suspend(() => {
1724
+ if (done) return Effect.void
1725
+ 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
+ }
1741
+ })
1742
+ return Effect.andThen(conn.cancel, wait)
1743
+ })
1744
+ )
1745
+
1746
+ conn.consumer = { onMessage, onBatchEnd, onFatal }
1747
+ parser.readField = undefined
1748
+ // @effect-diagnostics-next-line tryCatchInEffectGen:off
1749
+ try {
1750
+ socket.write(frame)
1751
+ } catch (cause) {
1752
+ failFatal(connectionQueryError(cause, "PgConnection: Failed to write query"))
1753
+ }
1754
+
1755
+ // @effect-diagnostics-next-line returnEffectInGen:off
1756
+ return Effect.callback<Arr.NonEmptyReadonlyArray<Row>, SqlError | Cause.Done>((resume) => {
1757
+ pending = resume
1758
+ setPaused(false)
1759
+ deliver()
1760
+ if (pending === undefined) return
1761
+ return Effect.sync(() => {
1762
+ if (pending === resume) pending = undefined
1763
+ })
1764
+ })
1765
+ })))
1766
+
1767
+ const listenChannel = (
1768
+ conn: PgConnectionImpl,
1769
+ pin: Effect.Effect<PgConnection, never, Scope.Scope>,
1770
+ channel: string
1771
+ ): Effect.Effect<Queue.Dequeue<Notification>, SqlError, Scope.Scope> =>
1772
+ Effect.uninterruptibleMask((restore) =>
1773
+ Effect.gen(function*() {
1774
+ const channelError = validateChannelName(channel, "listen")
1775
+ if (channelError !== undefined) return yield* Effect.fail(channelError)
1776
+ const parentScope = yield* Scope.Scope
1777
+ const scope = yield* Scope.fork(parentScope)
1778
+ return yield* restore(Effect.gen(function*() {
1779
+ const pinned = yield* Scope.provide(pin, scope)
1780
+ if (conn.deadWith !== undefined) return yield* conn.deadWith
1781
+ const queue = yield* Queue.unbounded<Notification>()
1782
+ const identifier = escapeIdentifier(channel)
1783
+ let queues = conn.channels.get(channel)
1784
+ if (queues === undefined) {
1785
+ queues = new Set()
1786
+ conn.channels.set(channel, queues)
1787
+ }
1788
+ queues.add(queue)
1789
+ yield* Scope.addFinalizer(
1790
+ scope,
1791
+ Effect.suspend(() => {
1792
+ const current = conn.channels.get(channel)
1793
+ if (current === undefined) return Queue.shutdown(queue)
1794
+ current.delete(queue)
1795
+ if (current.size > 0) return Queue.shutdown(queue)
1796
+ conn.channels.delete(channel)
1797
+ const unlisten = conn.deadWith === undefined
1798
+ ? Effect.ignore(pinned.query(`UNLISTEN ${identifier}`))
1799
+ : Effect.void
1800
+ return Effect.andThen(unlisten, Queue.shutdown(queue))
1801
+ })
1802
+ )
1803
+ yield* pinned.query(`LISTEN ${identifier}`)
1804
+ return queue
1805
+ })).pipe(
1806
+ Effect.tapCause((cause) => Scope.close(scope, Exit.failCause(cause)))
1807
+ )
1808
+ })
1809
+ )
1810
+
1811
+ const sendCancelRequest = (config: ResolvedConfig, pid: number, secret: number): Effect.Effect<void> =>
1812
+ Effect.callback<void>((resume) => {
1813
+ let done = false
1814
+ let socket: Duplex
1815
+ let timer: ReturnType<typeof setTimeout> | undefined
1816
+ const finish = (): void => {
1817
+ if (done) return
1818
+ done = true
1819
+ if (timer !== undefined) clearTimeout(timer)
1820
+ socket?.destroy()
1821
+ resume(Effect.void)
1822
+ }
1823
+ const frame = PgProtocol.encodeCancelRequest({ pid, secret })
1824
+ // After the frame is written the server processes the request and closes
1825
+ // the connection, which lands in the `close` handler.
1826
+ const send = (): void => {
1827
+ socket.write(frame)
1828
+ }
1829
+ const begin = (): void => {
1830
+ if (config.ssl === false) return send()
1831
+ socket.once("data", (chunk: Uint8Array) => {
1832
+ if (done) return
1833
+ // Never send the cancel secret over a connection the server refused
1834
+ // to upgrade.
1835
+ if (chunk.length !== 1 || chunk[0] !== 0x53) return finish()
1836
+ const raw = socket
1837
+ raw.off("error", finish)
1838
+ raw.off("close", finish)
1839
+ socket = Tls.connect({
1840
+ host: config.host,
1841
+ ...(typeof config.ssl === "object" ? config.ssl : {}),
1842
+ socket: raw as Net.Socket
1843
+ })
1844
+ socket.on("error", finish)
1845
+ socket.on("close", finish)
1846
+ socket.once("secureConnect", send)
1847
+ })
1848
+ socket.write(PgProtocol.encodeSslRequest())
1849
+ }
1850
+ try {
1851
+ socket = config.stream !== undefined
1852
+ ? config.stream()
1853
+ : config.path !== undefined
1854
+ ? Net.connect({ path: config.path })
1855
+ : Net.connect({ host: config.host, port: config.port, noDelay: true })
1856
+ } catch {
1857
+ resume(Effect.void)
1858
+ return
1859
+ }
1860
+ timer = setTimeout(finish, cancelRequestTimeoutMillis)
1861
+ socket.on("error", finish)
1862
+ socket.on("close", finish)
1863
+ if (config.stream !== undefined) begin()
1864
+ else socket.once("connect", begin)
1865
+ return Effect.sync(finish)
1866
+ })
1867
+
1868
+ const connect = (config: ResolvedConfig): Effect.Effect<Session, SqlError> =>
1869
+ Effect.callback<Session, SqlError>((resume) => {
1870
+ let done = false
1871
+ let socket: Duplex
1872
+ let parser: PgProtocol.Parser<unknown> | undefined
1873
+ let sslErrorParser: PgProtocol.Parser | undefined
1874
+ let scram: PgAuth.ScramState | undefined
1875
+ let processId = 0
1876
+ let secretKey = 0
1877
+
1878
+ const fail = (reason: SqlErrorReason): void => {
1879
+ if (done) return
1880
+ done = true
1881
+ socket?.destroy()
1882
+ resume(Effect.fail(new SqlError({ reason })))
1883
+ }
1884
+ const failConnect = (cause: unknown, message: string): void =>
1885
+ fail(new ConnectionError({ cause, message, operation: "connect" }))
1886
+ const failAuth = (cause: unknown, message: string): void =>
1887
+ fail(new AuthenticationError({ cause, message, operation: "connect" }))
1888
+
1889
+ const onError = (cause: Error) => failConnect(cause, "PgConnection: Failed to connect")
1890
+ const onClose = () =>
1891
+ failConnect(new Error("Connection closed unexpectedly"), "PgConnection: Connection closed during startup")
1892
+
1893
+ const password = (): string | undefined => {
1894
+ if (config.password === undefined) {
1895
+ failAuth(
1896
+ new Error("The server requested password authentication"),
1897
+ "PgConnection: No password configured"
1898
+ )
1899
+ return undefined
1900
+ }
1901
+ return config.password
1902
+ }
1903
+
1904
+ const handleMessage = (message: PgProtocol.BackendMessage<unknown>): void => {
1905
+ switch (message._tag) {
1906
+ case "AuthenticationOk":
1907
+ case "NoticeResponse":
1908
+ case "NegotiateProtocolVersion":
1909
+ case "ParameterStatus":
1910
+ return
1911
+ case "AuthenticationCleartextPassword": {
1912
+ const secret = password()
1913
+ if (secret === undefined) return
1914
+ socket.write(PgProtocol.encodePasswordMessage({ password: secret }))
1915
+ return
1916
+ }
1917
+ case "AuthenticationMD5Password": {
1918
+ const secret = password()
1919
+ if (secret === undefined) return
1920
+ const hashed = PgAuth.md5Password({ user: config.username, password: secret, salt: message.salt })
1921
+ if (EffectResult.isFailure(hashed)) {
1922
+ return failAuth(hashed.failure, "PgConnection: MD5 authentication failed")
1923
+ }
1924
+ socket.write(PgProtocol.encodePasswordMessage({ password: hashed.success }))
1925
+ return
1926
+ }
1927
+ case "AuthenticationSASL": {
1928
+ if (!message.mechanisms.includes(PgAuth.SCRAM_SHA_256)) {
1929
+ return failAuth(
1930
+ new Error(`Unsupported SASL mechanisms: ${message.mechanisms.join(", ")}`),
1931
+ `PgConnection: Only ${PgAuth.SCRAM_SHA_256} is supported`
1932
+ )
1933
+ }
1934
+ const secret = password()
1935
+ if (secret === undefined) return
1936
+ const init = PgAuth.scramInit({ password: secret, nonce: randomBytes(18).toString("base64") })
1937
+ if (EffectResult.isFailure(init)) {
1938
+ return failAuth(init.failure, "PgConnection: SCRAM authentication failed")
1939
+ }
1940
+ scram = init.success.state
1941
+ socket.write(PgProtocol.encodeSASLInitialResponse({
1942
+ mechanism: PgAuth.SCRAM_SHA_256,
1943
+ initialResponse: init.success.response
1944
+ }))
1945
+ return
1946
+ }
1947
+ case "AuthenticationSASLContinue": {
1948
+ if (scram === undefined || scram._tag !== "ScramFirst") {
1949
+ return failConnect(new Error("Unexpected AuthenticationSASLContinue"), "PgConnection: Protocol desync")
1950
+ }
1951
+ const next = PgAuth.scramContinue(scram, message.data)
1952
+ if (EffectResult.isFailure(next)) {
1953
+ return failAuth(next.failure, "PgConnection: SCRAM authentication failed")
1954
+ }
1955
+ scram = next.success.state
1956
+ socket.write(PgProtocol.encodeSASLResponse({ data: next.success.response }))
1957
+ return
1958
+ }
1959
+ case "AuthenticationSASLFinal": {
1960
+ if (scram === undefined || scram._tag !== "ScramFinal") {
1961
+ return failConnect(new Error("Unexpected AuthenticationSASLFinal"), "PgConnection: Protocol desync")
1962
+ }
1963
+ const verified = PgAuth.scramFinish(scram, message.data)
1964
+ if (EffectResult.isFailure(verified)) {
1965
+ return failAuth(verified.failure, "PgConnection: SCRAM server verification failed")
1966
+ }
1967
+ scram = undefined
1968
+ return
1969
+ }
1970
+ case "AuthenticationUnsupported":
1971
+ return failAuth(
1972
+ new Error(`Authentication method ${message.method} is not supported`),
1973
+ "PgConnection: Unsupported authentication method"
1974
+ )
1975
+ case "BackendKeyData":
1976
+ processId = message.pid
1977
+ secretKey = message.secret
1978
+ return
1979
+ case "ErrorResponse":
1980
+ return fail(classifyFields(message.fields, "PgConnection: Failed to connect", "connect"))
1981
+ case "ReadyForQuery":
1982
+ if (scram !== undefined) {
1983
+ return failAuth(
1984
+ new Error("The server completed authentication without proving its identity"),
1985
+ "PgConnection: SCRAM exchange did not complete"
1986
+ )
1987
+ }
1988
+ done = true
1989
+ socket.off("data", onData)
1990
+ socket.off("error", onError)
1991
+ socket.off("close", onClose)
1992
+ socket.on("error", ignoreError)
1993
+ resume(Effect.succeed({ socket, parser: parser!, processId, secretKey }))
1994
+ return
1995
+ default:
1996
+ return failConnect(
1997
+ new Error(`Unexpected ${message._tag} message during startup`),
1998
+ "PgConnection: Protocol desync"
1999
+ )
2000
+ }
2001
+ }
2002
+
2003
+ const onData = (chunk: Uint8Array): void => {
2004
+ let messages: ReadonlyArray<PgProtocol.BackendMessage<unknown>>
2005
+ try {
2006
+ messages = parser!.push(chunk)
2007
+ } catch (cause) {
2008
+ return failConnect(cause, "PgConnection: Failed to parse server response")
2009
+ }
2010
+ for (const message of messages) {
2011
+ if (done) return
2012
+ handleMessage(message)
2013
+ }
2014
+ }
2015
+
2016
+ const startup = (): void => {
2017
+ parser = PgProtocol.makeParser<unknown>({ maxMessageSize: config.maxMessageSize })
2018
+ socket.on("data", onData)
2019
+ socket.write(PgProtocol.encodeStartupMessage({
2020
+ user: config.username,
2021
+ database: config.database,
2022
+ application_name: config.applicationName
2023
+ }))
2024
+ }
2025
+
2026
+ const onSslResponse = (chunk: Uint8Array): void => {
2027
+ if (done) return
2028
+ if (sslErrorParser !== undefined || chunk[0] === 0x45) {
2029
+ sslErrorParser ??= PgProtocol.makeParser({ maxMessageSize: config.maxMessageSize })
2030
+ let messages: ReadonlyArray<PgProtocol.BackendMessage>
2031
+ try {
2032
+ messages = sslErrorParser.push(chunk)
2033
+ } catch (cause) {
2034
+ return failConnect(cause, "PgConnection: Failed to parse SSLRequest error response")
2035
+ }
2036
+ if (messages.length === 0) return
2037
+ socket.off("data", onSslResponse)
2038
+ const message = messages[0]
2039
+ if (messages.length !== 1 || message._tag !== "ErrorResponse") {
2040
+ return failConnect(
2041
+ new Error("Expected one ErrorResponse after SSLRequest"),
2042
+ "PgConnection: Invalid SSLRequest response"
2043
+ )
2044
+ }
2045
+ return fail(classifyFields(message.fields, "PgConnection: Failed to negotiate TLS", "connect"))
2046
+ }
2047
+ socket.off("data", onSslResponse)
2048
+ if (chunk.length !== 1) {
2049
+ return failConnect(
2050
+ new Error(`Received ${chunk.length} bytes in response to SSLRequest`),
2051
+ "PgConnection: Invalid SSLRequest response"
2052
+ )
2053
+ }
2054
+ const response = PgProtocol.decodeSslResponse(chunk[0])
2055
+ if (EffectResult.isFailure(response)) {
2056
+ return failConnect(response.failure, "PgConnection: Invalid SSLRequest response")
2057
+ }
2058
+ if (response.success === "N") {
2059
+ return failConnect(new Error("The server does not support TLS"), "PgConnection: Server refused TLS")
2060
+ }
2061
+ const raw = socket
2062
+ raw.off("error", onError)
2063
+ raw.off("close", onClose)
2064
+ socket = Tls.connect({
2065
+ host: config.host,
2066
+ ...(typeof config.ssl === "object" ? config.ssl : {}),
2067
+ socket: raw as Net.Socket
2068
+ })
2069
+ socket.on("error", onError)
2070
+ socket.on("close", onClose)
2071
+ socket.once("secureConnect", startup)
2072
+ }
2073
+
2074
+ const begin = (): void => {
2075
+ if (config.ssl === false) return startup()
2076
+ socket.on("data", onSslResponse)
2077
+ socket.write(PgProtocol.encodeSslRequest())
2078
+ }
2079
+
2080
+ try {
2081
+ socket = config.stream !== undefined
2082
+ ? config.stream()
2083
+ : config.path !== undefined
2084
+ ? Net.connect({ path: config.path })
2085
+ // A statement is one write and then a wait for its answer, so Nagle has
2086
+ // nothing to coalesce and only holds the write back.
2087
+ : Net.connect({ host: config.host, port: config.port, noDelay: true })
2088
+ } catch (cause) {
2089
+ resume(Effect.fail(
2090
+ new SqlError({
2091
+ reason: new ConnectionError({ cause, message: "PgConnection: Failed to connect", operation: "connect" })
2092
+ })
2093
+ ))
2094
+ return
2095
+ }
2096
+ socket.on("error", onError)
2097
+ socket.on("close", onClose)
2098
+ if (config.stream !== undefined) {
2099
+ begin()
2100
+ } else {
2101
+ socket.once("connect", begin)
2102
+ }
2103
+
2104
+ return Effect.sync(() => {
2105
+ if (done) return
2106
+ done = true
2107
+ socket.destroy()
2108
+ })
2109
+ })
2110
+
2111
+ const ignoreError = (_: Error) => {}
2112
+
2113
+ interface ResolvedConfig {
2114
+ readonly host: string
2115
+ readonly port: number
2116
+ readonly path: string | undefined
2117
+ readonly ssl: boolean | ConnectionOptions
2118
+ readonly database: string | undefined
2119
+ readonly username: string
2120
+ readonly password: string | undefined
2121
+ readonly connectTimeout: Duration.Duration
2122
+ readonly applicationName: string
2123
+ readonly stream: (() => Duplex) | undefined
2124
+ readonly maxMessageSize: number | undefined
2125
+ }
2126
+
2127
+ const configError = (message: string, cause?: unknown): SqlError =>
2128
+ new SqlError({
2129
+ reason: new ConnectionError({
2130
+ cause: cause ?? new Error(message),
2131
+ message: `PgConnection: ${message}`,
2132
+ operation: "connect"
2133
+ })
2134
+ })
2135
+
2136
+ const resolveConfig = (options: Config): Effect.Effect<ResolvedConfig, SqlError> =>
2137
+ Effect.suspend(() => {
2138
+ const parsed: EffectResult.Result<UrlConfig, SqlError> = options.url !== undefined
2139
+ ? parseUrl(Redacted.value(options.url), options.ssl !== undefined)
2140
+ : EffectResult.succeed({})
2141
+ if (EffectResult.isFailure(parsed)) return Effect.fail(parsed.failure)
2142
+ const url = parsed.success
2143
+ const host = options.host ?? url.host ?? "localhost"
2144
+ const port = options.port ?? url.port ?? 5432
2145
+ const username = options.username ?? url.username ?? process.env.USER ?? process.env.USERNAME
2146
+ if (username === undefined) {
2147
+ return Effect.fail(configError("No username configured"))
2148
+ }
2149
+ return Effect.succeed<ResolvedConfig>({
2150
+ host,
2151
+ port,
2152
+ path: options.path ?? (host.startsWith("/") ? `${host}/.s.PGSQL.${port}` : undefined),
2153
+ ssl: options.ssl ?? url.ssl ?? false,
2154
+ database: options.database ?? url.database,
2155
+ username,
2156
+ password: options.password !== undefined ? Redacted.value(options.password) : url.password,
2157
+ connectTimeout: Duration.fromInputUnsafe(options.connectTimeout ?? url.connectTimeout ?? Duration.seconds(5)),
2158
+ applicationName: options.applicationName ?? url.applicationName ?? "@effect/sql-pg",
2159
+ stream: options.stream,
2160
+ maxMessageSize: options.maxMessageSize
2161
+ })
2162
+ })
2163
+
2164
+ interface UrlConfig {
2165
+ host?: string | undefined
2166
+ port?: number | undefined
2167
+ database?: string | undefined
2168
+ username?: string | undefined
2169
+ password?: string | undefined
2170
+ applicationName?: string | undefined
2171
+ connectTimeout?: Duration.Duration | undefined
2172
+ ssl?: boolean | undefined
2173
+ }
2174
+
2175
+ const decodeComponent = (value: string, what: string): EffectResult.Result<string, SqlError> => {
2176
+ try {
2177
+ return EffectResult.succeed(decodeURIComponent(value))
2178
+ } catch {
2179
+ return EffectResult.fail(configError(`Invalid percent-encoding in URL ${what}`))
2180
+ }
2181
+ }
2182
+
2183
+ const parsePort = (value: string, what: string): EffectResult.Result<number, SqlError> => {
2184
+ const port = Number(value)
2185
+ return !Number.isInteger(port) || port < 1 || port > 65535
2186
+ ? EffectResult.fail(configError(`Invalid port in URL ${what}: "${value}"`))
2187
+ : EffectResult.succeed(port)
2188
+ }
2189
+
2190
+ const parseUrl = (raw: string, hasExplicitSsl: boolean): EffectResult.Result<UrlConfig, SqlError> => {
2191
+ let url: URL
2192
+ try {
2193
+ url = new URL(raw)
2194
+ } catch (cause) {
2195
+ return EffectResult.fail(configError("Invalid connection URL", cause))
2196
+ }
2197
+ if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") {
2198
+ return EffectResult.fail(configError(`Unsupported connection URL protocol: "${url.protocol}"`))
2199
+ }
2200
+
2201
+ const config: UrlConfig = {}
2202
+ if (url.hostname !== "") {
2203
+ if (url.hostname.startsWith("[") && url.hostname.endsWith("]")) {
2204
+ config.host = url.hostname.slice(1, -1)
2205
+ } else {
2206
+ const host = decodeComponent(url.hostname, "host")
2207
+ if (EffectResult.isFailure(host)) return EffectResult.fail(host.failure)
2208
+ config.host = host.success
2209
+ }
2210
+ }
2211
+ if (url.port !== "") {
2212
+ const port = parsePort(url.port, "authority")
2213
+ if (EffectResult.isFailure(port)) return EffectResult.fail(port.failure)
2214
+ config.port = port.success
2215
+ }
2216
+ if (url.username !== "") {
2217
+ const username = decodeComponent(url.username, "username")
2218
+ if (EffectResult.isFailure(username)) return EffectResult.fail(username.failure)
2219
+ config.username = username.success
2220
+ }
2221
+ if (url.password !== "") {
2222
+ const password = decodeComponent(url.password, "password")
2223
+ if (EffectResult.isFailure(password)) return EffectResult.fail(password.failure)
2224
+ config.password = password.success
2225
+ }
2226
+ const database = decodeComponent(url.pathname.replace(/^\//, ""), "database")
2227
+ if (EffectResult.isFailure(database)) return EffectResult.fail(database.failure)
2228
+ if (database.success !== "") config.database = database.success
2229
+
2230
+ for (const [key, value] of url.searchParams) {
2231
+ switch (key) {
2232
+ case "host":
2233
+ config.host = value
2234
+ break
2235
+ case "port": {
2236
+ const port = parsePort(value, "port parameter")
2237
+ if (EffectResult.isFailure(port)) return EffectResult.fail(port.failure)
2238
+ config.port = port.success
2239
+ break
2240
+ }
2241
+ case "user":
2242
+ config.username = value
2243
+ break
2244
+ case "password":
2245
+ config.password = value
2246
+ break
2247
+ case "dbname":
2248
+ config.database = value
2249
+ break
2250
+ case "application_name":
2251
+ config.applicationName = value
2252
+ break
2253
+ case "connect_timeout": {
2254
+ const seconds = Number(value)
2255
+ if (!Number.isInteger(seconds) || seconds < 0) {
2256
+ return EffectResult.fail(configError(`Invalid connect_timeout in URL: "${value}"`))
2257
+ }
2258
+ config.connectTimeout = seconds === 0 ? Duration.infinity : Duration.seconds(seconds)
2259
+ break
2260
+ }
2261
+ case "sslmode":
2262
+ switch (value) {
2263
+ case "disable":
2264
+ config.ssl = false
2265
+ break
2266
+ case "require":
2267
+ case "verify-ca":
2268
+ case "verify-full":
2269
+ config.ssl = true
2270
+ break
2271
+ case "prefer":
2272
+ case "allow":
2273
+ if (hasExplicitSsl) break
2274
+ return EffectResult.fail(
2275
+ configError(`sslmode "${value}" is not supported: set ssl explicitly to true or false`)
2276
+ )
2277
+ default:
2278
+ return EffectResult.fail(configError(`Unrecognized sslmode in URL: "${value}"`))
2279
+ }
2280
+ break
2281
+ // Unknown query parameters are ignored, matching libpq.
2282
+ }
2283
+ }
2284
+ return EffectResult.succeed(config)
2285
+ }
2286
+
2287
+ const classifyFields = (
2288
+ fields: PgProtocol.ErrorFields,
2289
+ message: string,
2290
+ operation: string
2291
+ ): SqlErrorReason => {
2292
+ const cause = Object.assign(new Error(fields.message ?? "Unknown PostgreSQL error"), fields)
2293
+ return classifySqlState(fields.code, fields.constraint, { cause, message, operation })
2294
+ }