@effect/sql-pg 4.0.0-beta.8 → 4.0.0-beta.80

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.
package/src/PgClient.ts CHANGED
@@ -1,25 +1,51 @@
1
1
  /**
2
- * @since 1.0.0
2
+ * Connects Effect SQL to PostgreSQL using the `pg` package.
3
+ *
4
+ * This module provides constructors and layers for building a PostgreSQL
5
+ * client from pool settings, a managed `pg.Client`, an existing `pg.Pool`, or
6
+ * custom connection code. The client runs Effect SQL queries against
7
+ * PostgreSQL, including transactions and streamed results, and adds helpers for
8
+ * JSON values and LISTEN/NOTIFY messages. It also maps common PostgreSQL
9
+ * failures, such as connection, authentication, constraint, timeout, and
10
+ * deadlock errors, into Effect SQL errors.
11
+ *
12
+ * @since 4.0.0
3
13
  */
4
14
  import * as Arr from "effect/Array"
5
15
  import * as Cause from "effect/Cause"
6
16
  import * as Channel from "effect/Channel"
7
17
  import * as Config from "effect/Config"
18
+ import * as Context from "effect/Context"
8
19
  import * as Duration from "effect/Duration"
9
20
  import * as Effect from "effect/Effect"
10
21
  import * as Fiber from "effect/Fiber"
11
22
  import * as Layer from "effect/Layer"
12
23
  import * as Number from "effect/Number"
24
+ import * as Option from "effect/Option"
13
25
  import * as Queue from "effect/Queue"
14
26
  import * as RcRef from "effect/RcRef"
15
27
  import * as Redacted from "effect/Redacted"
16
28
  import * as Scope from "effect/Scope"
17
- import * as ServiceMap from "effect/ServiceMap"
29
+ import * as Semaphore from "effect/Semaphore"
18
30
  import * as Stream from "effect/Stream"
19
31
  import * as Reactivity from "effect/unstable/reactivity/Reactivity"
20
32
  import * as Client from "effect/unstable/sql/SqlClient"
21
33
  import type { Connection } from "effect/unstable/sql/SqlConnection"
22
- import { SqlError } from "effect/unstable/sql/SqlError"
34
+ import type * as SqlConnection from "effect/unstable/sql/SqlConnection"
35
+ import {
36
+ AuthenticationError,
37
+ AuthorizationError,
38
+ ConnectionError,
39
+ ConstraintError,
40
+ DeadlockError,
41
+ LockTimeoutError,
42
+ SerializationError,
43
+ SqlError,
44
+ SqlSyntaxError,
45
+ StatementTimeoutError,
46
+ UniqueViolation,
47
+ UnknownError
48
+ } from "effect/unstable/sql/SqlError"
23
49
  import type { Custom, Fragment } from "effect/unstable/sql/Statement"
24
50
  import * as Statement from "effect/unstable/sql/Statement"
25
51
  import type { Duplex } from "node:stream"
@@ -28,26 +54,27 @@ import * as Pg from "pg"
28
54
  import * as PgConnString from "pg-connection-string"
29
55
  import Cursor from "pg-cursor"
30
56
 
31
- const ATTR_DB_SYSTEM_NAME = "db.system.name"
32
- const ATTR_DB_NAMESPACE = "db.namespace"
33
- const ATTR_SERVER_ADDRESS = "server.address"
34
- const ATTR_SERVER_PORT = "server.port"
35
-
36
57
  /**
37
- * @category type ids
38
- * @since 1.0.0
58
+ * Runtime type identifier used to mark `PgClient` values.
59
+ *
60
+ * @category type IDs
61
+ * @since 4.0.0
39
62
  */
40
63
  export const TypeId: TypeId = "~@effect/sql-pg/PgClient"
41
64
 
42
65
  /**
43
- * @category type ids
44
- * @since 1.0.0
66
+ * Type-level identifier used to mark `PgClient` values.
67
+ *
68
+ * @category type IDs
69
+ * @since 4.0.0
45
70
  */
46
71
  export type TypeId = "~@effect/sql-pg/PgClient"
47
72
 
48
73
  /**
74
+ * PostgreSQL client service, extending `SqlClient` with JSON parameter fragments and LISTEN/NOTIFY helpers.
75
+ *
49
76
  * @category models
50
- * @since 1.0.0
77
+ * @since 4.0.0
51
78
  */
52
79
  export interface PgClient extends Client.SqlClient {
53
80
  readonly [TypeId]: TypeId
@@ -58,14 +85,22 @@ export interface PgClient extends Client.SqlClient {
58
85
  }
59
86
 
60
87
  /**
61
- * @category tags
62
- * @since 1.0.0
88
+ * Service tag for the PostgreSQL client service.
89
+ *
90
+ * **When to use**
91
+ *
92
+ * Use to access or provide a PostgreSQL client through the Effect context.
93
+ *
94
+ * @category services
95
+ * @since 4.0.0
63
96
  */
64
- export const PgClient = ServiceMap.Service<PgClient>("@effect/sql-pg/PgClient")
97
+ export const PgClient = Context.Service<PgClient>("@effect/sql-pg/PgClient")
65
98
 
66
99
  /**
100
+ * Configuration for a PostgreSQL client, including connection, TLS, custom stream, application name, type parser, JSON transform, and query/result name transform options.
101
+ *
67
102
  * @category constructors
68
- * @since 1.0.0
103
+ * @since 4.0.0
69
104
  */
70
105
  export interface PgClientConfig {
71
106
  readonly url?: Redacted.Redacted | undefined
@@ -78,14 +113,9 @@ export interface PgClientConfig {
78
113
  readonly username?: string | undefined
79
114
  readonly password?: Redacted.Redacted | undefined
80
115
 
81
- readonly stream?: (() => Duplex) | undefined
82
-
83
- readonly idleTimeout?: Duration.Input | undefined
84
116
  readonly connectTimeout?: Duration.Input | undefined
85
117
 
86
- readonly maxConnections?: number | undefined
87
- readonly minConnections?: number | undefined
88
- readonly connectionTTL?: Duration.Input | undefined
118
+ readonly stream?: (() => Duplex) | undefined
89
119
 
90
120
  readonly applicationName?: string | undefined
91
121
  readonly spanAttributes?: Record<string, unknown> | undefined
@@ -97,12 +127,26 @@ export interface PgClientConfig {
97
127
  }
98
128
 
99
129
  /**
130
+ * PostgreSQL pool configuration, extending `PgClientConfig` with idle timeout, pool size, and connection lifetime settings.
131
+ *
100
132
  * @category constructors
101
- * @since 1.0.0
133
+ * @since 4.0.0
102
134
  */
103
- export const make = (
104
- options: PgClientConfig
105
- ): Effect.Effect<PgClient, SqlError, Scope.Scope | Reactivity.Reactivity> =>
135
+ export interface PgPoolConfig extends PgClientConfig {
136
+ readonly idleTimeout?: Duration.Input | undefined
137
+
138
+ readonly maxConnections?: number | undefined
139
+ readonly minConnections?: number | undefined
140
+ readonly connectionTTL?: Duration.Input | undefined
141
+ }
142
+
143
+ /**
144
+ * Creates a scoped PostgreSQL client backed by a managed `pg` connection pool.
145
+ *
146
+ * @category constructors
147
+ * @since 4.0.0
148
+ */
149
+ export const make = (options: PgPoolConfig): Effect.Effect<PgClient, SqlError, Scope.Scope | Reactivity.Reactivity> =>
106
150
  fromPool({
107
151
  ...options,
108
152
  acquire: Effect.gen(function*() {
@@ -135,20 +179,24 @@ export const make = (
135
179
  yield* Effect.acquireRelease(
136
180
  Effect.tryPromise({
137
181
  try: () => pool.query("SELECT 1"),
138
- catch: (cause) => new SqlError({ cause, message: "PgClient: Failed to connect" })
182
+ catch: (cause) => new SqlError({ reason: classifyError(cause, "PgClient: Failed to connect", "connect") })
139
183
  }),
140
184
  () =>
141
185
  Effect.promise(() => pool.end()).pipe(
142
186
  Effect.timeoutOption(1000)
143
- )
187
+ ),
188
+ { interruptible: true }
144
189
  ).pipe(
145
190
  Effect.timeoutOrElse({
146
191
  duration: options.connectTimeout ?? Duration.seconds(5),
147
- onTimeout: () =>
192
+ orElse: () =>
148
193
  Effect.fail(
149
194
  new SqlError({
150
- cause: new Error("Connection timed out"),
151
- message: "PgClient: Connection timed out"
195
+ reason: new ConnectionError({
196
+ cause: new Error("Connection timed out"),
197
+ message: "PgClient: Connection timed out",
198
+ operation: "connect"
199
+ })
152
200
  })
153
201
  )
154
202
  })
@@ -159,8 +207,70 @@ export const make = (
159
207
  })
160
208
 
161
209
  /**
210
+ * Creates a scoped PostgreSQL client backed by a managed single `pg` client, optionally acquiring a separate client for streaming and LISTEN operations.
211
+ *
162
212
  * @category constructors
163
- * @since 1.0.0
213
+ * @since 4.0.0
214
+ */
215
+ export const makeClient = (
216
+ options: PgClientConfig & {
217
+ /**
218
+ * Whether to acquire a separate client for each sql.stream / sql.listen
219
+ */
220
+ readonly acquireForStream?: boolean | undefined
221
+ }
222
+ ): Effect.Effect<PgClient, SqlError, Scope.Scope | Reactivity.Reactivity> =>
223
+ fromClient({
224
+ ...options,
225
+ acquire: Effect.gen(function*() {
226
+ const client = new Pg.Client({
227
+ connectionString: options.url ? Redacted.value(options.url) : undefined,
228
+ user: options.username,
229
+ host: options.host,
230
+ database: options.database,
231
+ password: options.password ? Redacted.value(options.password) : undefined,
232
+ ssl: options.ssl,
233
+ port: options.port,
234
+ ...(options.stream ? { stream: options.stream } : {}),
235
+ application_name: options.applicationName ?? "@effect/sql-pg",
236
+ types: options.types
237
+ })
238
+ yield* Effect.acquireRelease(
239
+ Effect.tryPromise({
240
+ try: () => client.query("SELECT 1"),
241
+ catch: (cause) => new SqlError({ reason: classifyError(cause, "PgClient: Failed to connect", "connect") })
242
+ }),
243
+ () =>
244
+ Effect.promise(() => client.end()).pipe(
245
+ Effect.timeoutOption(1000)
246
+ ),
247
+ { interruptible: true }
248
+ ).pipe(
249
+ Effect.timeoutOrElse({
250
+ duration: options.connectTimeout ?? Duration.seconds(5),
251
+ orElse: () =>
252
+ Effect.fail(
253
+ new SqlError({
254
+ reason: new ConnectionError({
255
+ cause: new Error("Connection timed out"),
256
+ message: "PgClient: Connection timed out",
257
+ operation: "connect"
258
+ })
259
+ })
260
+ )
261
+ })
262
+ )
263
+
264
+ return client
265
+ }),
266
+ acquireForStream: options.acquireForStream ?? false
267
+ })
268
+
269
+ /**
270
+ * Builds a PostgreSQL client from a scoped `pg` pool acquisition effect, deriving transaction, streaming, and LISTEN/NOTIFY support from that pool.
271
+ *
272
+ * @category constructors
273
+ * @since 4.0.0
164
274
  */
165
275
  export const fromPool = Effect.fnUntraced(function*(
166
276
  options: {
@@ -175,197 +285,149 @@ export const fromPool = Effect.fnUntraced(function*(
175
285
  readonly types?: Pg.CustomTypesConfig | undefined
176
286
  }
177
287
  ): Effect.fn.Return<PgClient, SqlError, Scope.Scope | Reactivity.Reactivity> {
178
- const compiler = makeCompiler(
179
- options.transformQueryNames,
180
- options.transformJson
181
- )
182
- const transformRows = options.transformResultNames ?
183
- Statement.defaultTransforms(
184
- options.transformResultNames,
185
- options.transformJson
186
- ).array :
187
- undefined
188
-
189
288
  const pool = yield* options.acquire
190
289
 
191
- class ConnectionImpl implements Connection {
192
- readonly pg: Pg.PoolClient | undefined
193
- constructor(pg?: Pg.PoolClient) {
194
- this.pg = pg
195
- }
196
-
197
- private runWithClient<A>(f: (client: Pg.PoolClient, resume: (_: Effect.Effect<A, SqlError>) => void) => void) {
198
- if (this.pg !== undefined) {
199
- return Effect.callback<A, SqlError>((resume) => {
200
- f(this.pg!, resume)
201
- return makeCancel(pool, this.pg!)
202
- })
203
- }
204
- return Effect.callback<A, SqlError>((resume) => {
205
- let done = false
206
- let cancel: Effect.Effect<void> | undefined = undefined
207
- let client: Pg.PoolClient | undefined = undefined
208
- function onError(cause: Error) {
209
- cleanup(cause)
210
- resume(Effect.fail(new SqlError({ cause, message: "Connection error" })))
211
- }
212
- function cleanup(cause?: Error) {
213
- if (!done) client?.release(cause)
214
- done = true
215
- client?.off("error", onError)
216
- }
217
- pool.connect((cause, client_) => {
218
- if (cause) {
219
- return resume(Effect.fail(new SqlError({ cause, message: "Failed to acquire connection" })))
220
- } else if (!client_) {
221
- return resume(
222
- Effect.fail(
223
- new SqlError({ message: "Failed to acquire connection", cause: new Error("No client returned") })
224
- )
225
- )
226
- } else if (done) {
227
- client_.release()
228
- return
229
- }
230
- client = client_
231
- client.once("error", onError)
232
- cancel = makeCancel(pool, client)
233
- f(client, (eff) => {
234
- cleanup()
235
- resume(eff)
290
+ const makeConection = (client?: Pg.PoolClient) =>
291
+ new ConnectionImpl(
292
+ function runWithClient<A>(f: (client: Pg.ClientBase, resume: (_: Effect.Effect<A, SqlError>) => void) => void) {
293
+ if (client !== undefined) {
294
+ return Effect.callback<A, SqlError>((resume) => {
295
+ f(client!, resume)
296
+ return makeCancel(pool, client!)
236
297
  })
237
- })
238
- return Effect.suspend(() => {
239
- if (!cancel) {
240
- cleanup()
241
- return Effect.void
242
- }
243
- return Effect.ensuring(cancel, Effect.sync(cleanup))
244
- })
245
- })
246
- }
247
-
248
- private run(query: string, params: ReadonlyArray<unknown>) {
249
- return this.runWithClient<ReadonlyArray<any>>((client, resume) => {
250
- client.query(query, params as any, (err, result) => {
251
- if (err) {
252
- resume(Effect.fail(new SqlError({ cause: err, message: "Failed to execute statement" })))
253
- } else {
254
- // Multi-statement queries return an array of results
255
- resume(Effect.succeed(
256
- Array.isArray(result)
257
- ? result.map((r) => r.rows ?? [])
258
- : result.rows ?? []
259
- ))
298
+ }
299
+ return Effect.callback<A, SqlError>((resume) => {
300
+ let done = false
301
+ let cancel: Effect.Effect<void> | undefined = undefined
302
+ let client: Pg.PoolClient | undefined = undefined
303
+ function onError(cause: Error) {
304
+ cleanup(cause)
305
+ resume(Effect.fail(new SqlError({ reason: classifyError(cause, "Connection error", "acquireConnection") })))
260
306
  }
261
- })
262
- })
263
- }
264
-
265
- execute(
266
- sql: string,
267
- params: ReadonlyArray<unknown>,
268
- transformRows: (<A extends object>(row: ReadonlyArray<A>) => ReadonlyArray<A>) | undefined
269
- ) {
270
- return transformRows
271
- ? Effect.map(this.run(sql, params), transformRows)
272
- : this.run(sql, params)
273
- }
274
- executeRaw(sql: string, params: ReadonlyArray<unknown>) {
275
- return this.runWithClient<Pg.Result>((client, resume) => {
276
- client.query(sql, params as any, (err, result) => {
277
- if (err) {
278
- resume(Effect.fail(new SqlError({ cause: err, message: "Failed to execute statement" })))
279
- } else {
280
- resume(Effect.succeed(result))
307
+ function cleanup(cause?: Error) {
308
+ if (!done) client?.release(cause)
309
+ done = true
310
+ client?.off("error", onError)
281
311
  }
282
- })
283
- })
284
- }
285
- executeWithoutTransform(sql: string, params: ReadonlyArray<unknown>) {
286
- return this.run(sql, params)
287
- }
288
- executeValues(sql: string, params: ReadonlyArray<unknown>) {
289
- return this.runWithClient<ReadonlyArray<any>>((client, resume) => {
290
- client.query(
291
- {
292
- text: sql,
293
- rowMode: "array",
294
- values: params as Array<string>
295
- },
296
- (err, result) => {
297
- if (err) {
298
- resume(Effect.fail(new SqlError({ cause: err, message: "Failed to execute statement" })))
299
- } else {
300
- resume(Effect.succeed(result.rows))
312
+ pool.connect((cause, client_) => {
313
+ if (cause) {
314
+ return resume(
315
+ Effect.fail(
316
+ new SqlError({
317
+ reason: classifyError(cause, "Failed to acquire connection", "acquireConnection")
318
+ })
319
+ )
320
+ )
321
+ } else if (!client_) {
322
+ return resume(
323
+ Effect.fail(
324
+ new SqlError({
325
+ reason: new ConnectionError({
326
+ message: "Failed to acquire connection",
327
+ cause: new Error("No client returned"),
328
+ operation: "acquireConnection"
329
+ })
330
+ })
331
+ )
332
+ )
333
+ } else if (done) {
334
+ client_.release()
335
+ return
301
336
  }
302
- }
303
- )
304
- })
305
- }
306
- executeUnprepared(
307
- sql: string,
308
- params: ReadonlyArray<unknown>,
309
- transformRows: (<A extends object>(row: ReadonlyArray<A>) => ReadonlyArray<A>) | undefined
310
- ) {
311
- return this.execute(sql, params, transformRows)
312
- }
313
- executeStream(
314
- sql: string,
315
- params: ReadonlyArray<unknown>,
316
- transformRows: (<A extends object>(row: ReadonlyArray<A>) => ReadonlyArray<A>) | undefined
317
- ) {
318
- // oxlint-disable-next-line @typescript-eslint/no-this-alias
319
- const self = this
320
- return Stream.fromChannel(Channel.fromTransform(Effect.fnUntraced(function*(_, scope) {
321
- const client = self.pg ?? (yield* Scope.provide(reserveRaw, scope))
322
- yield* Scope.addFinalizer(scope, Effect.promise(() => cursor.close()))
323
- const cursor = client.query(new Cursor(sql, params as any))
324
- // @effect-diagnostics-next-line returnEffectInGen:off
325
- return Effect.callback<Arr.NonEmptyReadonlyArray<any>, SqlError | Cause.Done>((resume) => {
326
- cursor.read(128, (err, rows) => {
327
- if (err) {
328
- resume(Effect.fail(new SqlError({ cause: err, message: "Failed to execute statement" })))
329
- } else if (Arr.isArrayNonEmpty(rows)) {
330
- resume(Effect.succeed(transformRows ? transformRows(rows) as any : rows))
331
- } else {
332
- resume(Cause.done())
337
+ client = client_
338
+ client.once("error", onError)
339
+ cancel = makeCancel(pool, client)
340
+ f(client, (eff) => {
341
+ cleanup()
342
+ resume(eff)
343
+ })
344
+ })
345
+ return Effect.suspend(() => {
346
+ if (!cancel) {
347
+ cleanup()
348
+ return Effect.void
333
349
  }
350
+ return Effect.ensuring(cancel, Effect.sync(cleanup))
334
351
  })
335
352
  })
336
- })))
337
- }
338
- }
353
+ },
354
+ client ? Effect.succeed(client) : reserveRaw
355
+ )
339
356
 
340
357
  const reserveRaw = Effect.callback<Pg.PoolClient, SqlError, Scope.Scope>((resume) => {
341
358
  const fiber = Fiber.getCurrent()!
342
- const scope = ServiceMap.getUnsafe(fiber.services, Scope.Scope)
359
+ const scope = Context.getUnsafe(fiber.context, Scope.Scope)
343
360
  let cause: Error | undefined = undefined
361
+ function onError(cause_: Error) {
362
+ cause = cause_
363
+ }
344
364
  pool.connect((err, client, release) => {
345
365
  if (err) {
346
- resume(Effect.fail(new SqlError({ cause: err, message: "Failed to acquire connection for transaction" })))
347
- } else {
348
- resume(Effect.as(
349
- Scope.addFinalizer(
350
- scope,
351
- Effect.sync(() => {
352
- client!.off("error", onError)
353
- release(cause)
366
+ return resume(
367
+ Effect.fail(
368
+ new SqlError({
369
+ reason: classifyError(
370
+ err,
371
+ "Failed to acquire connection for transaction",
372
+ "acquireConnection"
373
+ )
354
374
  })
355
- ),
356
- client!
357
- ))
358
- }
359
- function onError(cause_: Error) {
360
- cause = cause_
375
+ )
376
+ )
377
+ } else if (!client) {
378
+ return resume(
379
+ Effect.fail(
380
+ new SqlError({
381
+ reason: new ConnectionError({
382
+ message: "Failed to acquire connection for transaction",
383
+ cause: new Error("No client returned"),
384
+ operation: "acquireConnection"
385
+ })
386
+ })
387
+ )
388
+ )
361
389
  }
362
- client!.on("error", onError)
390
+ client.on("error", onError)
391
+ resume(Effect.as(
392
+ Scope.addFinalizer(
393
+ scope,
394
+ Effect.sync(() => {
395
+ client.off("error", onError)
396
+ release(cause)
397
+ })
398
+ ),
399
+ client
400
+ ))
363
401
  })
364
402
  })
365
- const reserve = Effect.map(reserveRaw, (client) => new ConnectionImpl(client))
403
+ const reserve = Effect.map(reserveRaw, makeConection)
366
404
 
367
- const listenClient = yield* RcRef.make({
368
- acquire: reserveRaw
405
+ const onListenClientError = (_: Error) => {
406
+ }
407
+
408
+ const listenAcquirer = yield* RcRef.make({
409
+ acquire: Effect.acquireRelease(
410
+ Effect.tryPromise({
411
+ try: async () => {
412
+ const client = new Pg.Client(pool.options)
413
+ await client.connect()
414
+ client.on("error", onListenClientError)
415
+ return client
416
+ },
417
+ catch: (cause) =>
418
+ new SqlError({
419
+ reason: classifyError(cause, "Failed to acquire connection for listen", "acquireConnection")
420
+ })
421
+ }),
422
+ (client) =>
423
+ Effect.promise(() => {
424
+ client.off("error", onListenClientError)
425
+ return client.end()
426
+ }).pipe(
427
+ Effect.timeoutOption(1000)
428
+ ),
429
+ { interruptible: true }
430
+ )
369
431
  })
370
432
 
371
433
  let config: PgClientConfig = {
@@ -386,7 +448,7 @@ export const fromPool = Effect.fnUntraced(function*(
386
448
  config = {
387
449
  ...config,
388
450
  host: config.host ?? parsed.host ?? undefined,
389
- port: config.port ?? (parsed.port ? Number.parse(parsed.port) : undefined),
451
+ port: config.port ?? (parsed.port ? Option.getOrUndefined(Number.parse(parsed.port)) : undefined),
390
452
  username: config.username ?? parsed.user ?? undefined,
391
453
  password: config.password ?? (parsed.password ? Redacted.make(parsed.password) : undefined),
392
454
  database: config.database ?? parsed.database ?? undefined
@@ -396,10 +458,136 @@ export const fromPool = Effect.fnUntraced(function*(
396
458
  }
397
459
  }
398
460
 
461
+ return yield* makeWith({
462
+ acquirer: Effect.succeed(makeConection()),
463
+ transactionAcquirer: reserve,
464
+ listenAcquirer: RcRef.get(listenAcquirer),
465
+ config,
466
+ spanAttributes: options.spanAttributes,
467
+ transformResultNames: options.transformResultNames,
468
+ transformQueryNames: options.transformQueryNames,
469
+ transformJson: options.transformJson
470
+ })
471
+ })
472
+
473
+ /**
474
+ * Builds a PostgreSQL client from a scoped `pg` client acquisition effect, serializing access when sharing the client and optionally using separate clients for streams and LISTEN.
475
+ *
476
+ * @category constructors
477
+ * @since 4.0.0
478
+ */
479
+ export const fromClient = Effect.fnUntraced(function*(
480
+ options: {
481
+ readonly acquire: Effect.Effect<Pg.Client, SqlError, Scope.Scope>
482
+
483
+ /**
484
+ * Whether to acquire a separate client for each sql.stream / sql.listen.
485
+ */
486
+ readonly acquireForStream: boolean
487
+
488
+ readonly applicationName?: string | undefined
489
+ readonly spanAttributes?: Record<string, unknown> | undefined
490
+
491
+ readonly transformResultNames?: ((str: string) => string) | undefined
492
+ readonly transformQueryNames?: ((str: string) => string) | undefined
493
+ readonly transformJson?: boolean | undefined
494
+ readonly types?: Pg.CustomTypesConfig | undefined
495
+ }
496
+ ): Effect.fn.Return<PgClient, SqlError, Scope.Scope | Reactivity.Reactivity> {
497
+ function onError() {}
498
+ const acquireWithErrorHandler = options.acquire.pipe(
499
+ Effect.tap((client) => {
500
+ client.on("error", onError)
501
+ return Effect.addFinalizer(() => {
502
+ client.off("error", onError)
503
+ return Effect.void
504
+ })
505
+ })
506
+ )
507
+ const client = yield* acquireWithErrorHandler
508
+
509
+ const semaphore = Semaphore.makeUnsafe(1)
510
+ let streamClient = options.acquireForStream ? acquireWithErrorHandler : Effect.acquireRelease(
511
+ Effect.as(semaphore.take(1), client),
512
+ () => semaphore.release(1)
513
+ )
514
+
515
+ const makeConection = (client: Pg.Client) =>
516
+ new ConnectionImpl(
517
+ function runWithClient<A>(f: (client: Pg.ClientBase, resume: (_: Effect.Effect<A, SqlError>) => void) => void) {
518
+ return Effect.callback<A, SqlError>((resume) => {
519
+ f(client, resume)
520
+ })
521
+ },
522
+ streamClient
523
+ )
524
+ const connection = makeConection(client)
525
+ const acquirer = semaphore.withPermit(Effect.succeed(connection))
526
+
527
+ const config: PgClientConfig = {
528
+ ...options,
529
+ host: client.host,
530
+ port: client.port,
531
+ database: client.database,
532
+ username: client.user,
533
+ password: typeof client.password === "string" ? Redacted.make(client.password) : undefined,
534
+ ssl: client.ssl
535
+ }
536
+
537
+ return yield* makeWith({
538
+ acquirer,
539
+ transactionAcquirer: acquirer,
540
+ listenAcquirer: streamClient,
541
+ config,
542
+ spanAttributes: options.spanAttributes,
543
+ transformResultNames: options.transformResultNames,
544
+ transformQueryNames: options.transformQueryNames,
545
+ transformJson: options.transformJson
546
+ })
547
+ })
548
+
549
+ /**
550
+ * Creates a `PgClient` from SQL connection acquirers, a LISTEN acquirer, client configuration, and transformation options.
551
+ *
552
+ * **When to use**
553
+ *
554
+ * Use to build a PostgreSQL client from custom connection acquisition logic
555
+ * instead of the built-in pool or single-client constructors.
556
+ *
557
+ * @category constructors
558
+ * @since 4.0.0
559
+ */
560
+ export const makeWith = Effect.fnUntraced(function*(
561
+ options: {
562
+ readonly acquirer: SqlConnection.Acquirer
563
+ readonly transactionAcquirer: SqlConnection.Acquirer
564
+ readonly listenAcquirer: Effect.Effect<Pg.ClientBase, SqlError, Scope.Scope>
565
+
566
+ readonly config: PgClientConfig
567
+ readonly spanAttributes?: Record<string, unknown> | undefined
568
+
569
+ readonly transformResultNames?: ((str: string) => string) | undefined
570
+ readonly transformQueryNames?: ((str: string) => string) | undefined
571
+ readonly transformJson?: boolean | undefined
572
+ }
573
+ ): Effect.fn.Return<PgClient, SqlError, Scope.Scope | Reactivity.Reactivity> {
574
+ const compiler = makeCompiler(
575
+ options.transformQueryNames,
576
+ options.transformJson
577
+ )
578
+ const transformRows = options.transformResultNames ?
579
+ Statement.defaultTransforms(
580
+ options.transformResultNames,
581
+ options.transformJson
582
+ ).array :
583
+ undefined
584
+
585
+ const config = options.config
586
+
399
587
  return Object.assign(
400
588
  yield* Client.make({
401
- acquirer: Effect.succeed(new ConnectionImpl()),
402
- transactionAcquirer: reserve,
589
+ acquirer: options.acquirer,
590
+ transactionAcquirer: options.transactionAcquirer,
403
591
  compiler,
404
592
  spanAttributes: [
405
593
  ...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
@@ -412,11 +600,11 @@ export const fromPool = Effect.fnUntraced(function*(
412
600
  }),
413
601
  {
414
602
  [TypeId]: TypeId as TypeId,
415
- config,
603
+ config: options.config,
416
604
  json: (_: unknown) => Statement.fragment([PgJson(_)]),
417
605
  listen: (channel: string) =>
418
606
  Stream.callback<string, SqlError>(Effect.fnUntraced(function*(queue) {
419
- const client = yield* RcRef.get(listenClient)
607
+ const client = yield* options.listenAcquirer
420
608
  function onNotification(msg: Pg.Notification) {
421
609
  if (msg.channel === channel && msg.payload) {
422
610
  Queue.offerUnsafe(queue, msg.payload)
@@ -430,24 +618,133 @@ export const fromPool = Effect.fnUntraced(function*(
430
618
  )
431
619
  yield* Effect.tryPromise({
432
620
  try: () => client.query(`LISTEN ${Pg.escapeIdentifier(channel)}`),
433
- catch: (cause) => new SqlError({ cause, message: "Failed to listen" })
621
+ catch: (cause) => new SqlError({ reason: classifyError(cause, "Failed to listen", "listen") })
434
622
  })
435
623
  client.on("notification", onNotification)
436
624
  })),
437
625
  notify: (channel: string, payload: string) =>
438
- Effect.callback<void, SqlError>((resume) => {
439
- pool.query(`NOTIFY ${Pg.escapeIdentifier(channel)}, $1`, [payload], (err) => {
440
- if (err) {
441
- resume(Effect.fail(new SqlError({ cause: err, message: "Failed to notify" })))
442
- } else {
443
- resume(Effect.void)
444
- }
445
- })
446
- })
626
+ Effect.asVoid(Effect.scoped(Effect.flatMap(
627
+ options.acquirer,
628
+ (conn) => conn.executeRaw(`SELECT pg_notify($1, $2)`, [channel, payload])
629
+ )))
447
630
  }
448
631
  )
449
632
  })
450
633
 
634
+ class ConnectionImpl implements Connection {
635
+ constructor(
636
+ runWithClient: <A>(
637
+ f: (client: Pg.ClientBase, resume: (_: Effect.Effect<A, SqlError>) => void) => void
638
+ ) => Effect.Effect<A, SqlError>,
639
+ reserve: Effect.Effect<Pg.ClientBase, SqlError, Scope.Scope>
640
+ ) {
641
+ this.runWithClient = runWithClient
642
+ this.reserve = reserve
643
+ }
644
+
645
+ private readonly runWithClient: <A>(
646
+ f: (client: Pg.ClientBase, resume: (_: Effect.Effect<A, SqlError>) => void) => void
647
+ ) => Effect.Effect<A, SqlError>
648
+ private readonly reserve: Effect.Effect<Pg.ClientBase, SqlError, Scope.Scope>
649
+
650
+ private run(query: string, params: ReadonlyArray<unknown>) {
651
+ return this.runWithClient<ReadonlyArray<any>>((client, resume) => {
652
+ client.query(query, params as any, (err, result) => {
653
+ if (err) {
654
+ resume(
655
+ Effect.fail(new SqlError({ reason: classifyError(err, "Failed to execute statement", "execute") }))
656
+ )
657
+ } else {
658
+ // Multi-statement queries return an array of results
659
+ resume(Effect.succeed(
660
+ Array.isArray(result)
661
+ ? result.map((r) => r.rows ?? [])
662
+ : result.rows ?? []
663
+ ))
664
+ }
665
+ })
666
+ })
667
+ }
668
+
669
+ execute(
670
+ sql: string,
671
+ params: ReadonlyArray<unknown>,
672
+ transformRows: (<A extends object>(row: ReadonlyArray<A>) => ReadonlyArray<A>) | undefined
673
+ ) {
674
+ return transformRows
675
+ ? Effect.map(this.run(sql, params), transformRows)
676
+ : this.run(sql, params)
677
+ }
678
+ executeRaw(sql: string, params: ReadonlyArray<unknown>) {
679
+ return this.runWithClient<Pg.Result>((client, resume) => {
680
+ client.query(sql, params as any, (err, result) => {
681
+ if (err) {
682
+ resume(
683
+ Effect.fail(new SqlError({ reason: classifyError(err, "Failed to execute statement", "execute") }))
684
+ )
685
+ } else {
686
+ resume(Effect.succeed(result))
687
+ }
688
+ })
689
+ })
690
+ }
691
+ executeWithoutTransform(sql: string, params: ReadonlyArray<unknown>) {
692
+ return this.run(sql, params)
693
+ }
694
+ executeValues(sql: string, params: ReadonlyArray<unknown>) {
695
+ return this.runWithClient<ReadonlyArray<any>>((client, resume) => {
696
+ client.query(
697
+ {
698
+ text: sql,
699
+ rowMode: "array",
700
+ values: params as Array<string>
701
+ },
702
+ (err, result) => {
703
+ if (err) {
704
+ resume(
705
+ Effect.fail(new SqlError({ reason: classifyError(err, "Failed to execute statement", "execute") }))
706
+ )
707
+ } else {
708
+ resume(Effect.succeed(result.rows))
709
+ }
710
+ }
711
+ )
712
+ })
713
+ }
714
+ executeUnprepared(
715
+ sql: string,
716
+ params: ReadonlyArray<unknown>,
717
+ transformRows: (<A extends object>(row: ReadonlyArray<A>) => ReadonlyArray<A>) | undefined
718
+ ) {
719
+ return this.execute(sql, params, transformRows)
720
+ }
721
+ executeStream(
722
+ sql: string,
723
+ params: ReadonlyArray<unknown>,
724
+ transformRows: (<A extends object>(row: ReadonlyArray<A>) => ReadonlyArray<A>) | undefined
725
+ ) {
726
+ // oxlint-disable-next-line @typescript-eslint/no-this-alias
727
+ const self = this
728
+ return Stream.fromChannel(Channel.fromTransform(Effect.fnUntraced(function*(_, scope) {
729
+ const client = yield* Scope.provide(self.reserve, scope)
730
+ yield* Scope.addFinalizer(scope, Effect.promise(() => cursor.close()))
731
+ const cursor = client.query(new Cursor(sql, params as any))
732
+ // @effect-diagnostics-next-line returnEffectInGen:off
733
+ return Effect.callback<Arr.NonEmptyReadonlyArray<any>, SqlError | Cause.Done>((resume) => {
734
+ cursor.read(128, (err, rows) => {
735
+ if (err) {
736
+ resume(Effect.fail(new SqlError({ reason: classifyError(err, "Failed to execute statement", "stream") })))
737
+ } else if (Arr.isArrayNonEmpty(rows)) {
738
+ resume(Effect.succeed(transformRows ? transformRows(rows) as any : rows))
739
+ } else {
740
+ resume(Cause.done())
741
+ }
742
+ })
743
+ })
744
+ })))
745
+ }
746
+ }
747
+
451
748
  const cancelEffects = new WeakMap<Pg.PoolClient, Effect.Effect<void> | undefined>()
452
749
  const makeCancel = (pool: Pg.Pool, client: Pg.PoolClient) => {
453
750
  if (cancelEffects.has(client)) {
@@ -471,64 +768,52 @@ const makeCancel = (pool: Pg.Pool, client: Pg.PoolClient) => {
471
768
  }
472
769
 
473
770
  /**
771
+ * Creates a layer from an effect that acquires a `PgClient`, providing both `PgClient` and `SqlClient`.
772
+ *
474
773
  * @category layers
475
- * @since 1.0.0
774
+ * @since 4.0.0
476
775
  */
477
- export const layerConfig: (
478
- config: Config.Wrap<PgClientConfig>
479
- ) => Layer.Layer<PgClient | Client.SqlClient, Config.ConfigError | SqlError> = (
480
- config: Config.Wrap<PgClientConfig>
481
- ): Layer.Layer<PgClient | Client.SqlClient, Config.ConfigError | SqlError> =>
482
- Layer.effectServices(
483
- Config.unwrap(config).asEffect().pipe(
484
- Effect.flatMap(make),
485
- Effect.map((client) =>
486
- ServiceMap.make(PgClient, client).pipe(
487
- ServiceMap.add(Client.SqlClient, client)
488
- )
489
- )
490
- )
491
- ).pipe(Layer.provide(Reactivity.layer))
776
+ export const layerFrom = <E, R>(
777
+ acquire: Effect.Effect<PgClient, E, R>
778
+ ): Layer.Layer<PgClient | Client.SqlClient, E, Exclude<R, Scope.Scope | Reactivity.Reactivity>> =>
779
+ Layer.effectContext(
780
+ Effect.map(acquire, (client) =>
781
+ Context.make(PgClient, client).pipe(
782
+ Context.add(Client.SqlClient, client)
783
+ ))
784
+ ).pipe(Layer.provide(Reactivity.layer)) as any
492
785
 
493
786
  /**
787
+ * Creates a layer from a `Config`-wrapped PostgreSQL pool configuration, providing both `PgClient` and `SqlClient`.
788
+ *
494
789
  * @category layers
495
- * @since 1.0.0
790
+ * @since 4.0.0
496
791
  */
497
- export const layer = (
498
- config: PgClientConfig
499
- ): Layer.Layer<PgClient | Client.SqlClient, SqlError> =>
500
- Layer.effectServices(
501
- Effect.map(make(config), (client) =>
502
- ServiceMap.make(PgClient, client).pipe(
503
- ServiceMap.add(Client.SqlClient, client)
504
- ))
505
- ).pipe(Layer.provide(Reactivity.layer))
792
+ export const layerConfig: (
793
+ config: Config.Wrap<PgPoolConfig>
794
+ ) => Layer.Layer<PgClient | Client.SqlClient, Config.ConfigError | SqlError> = (
795
+ config: Config.Wrap<PgPoolConfig>
796
+ ): Layer.Layer<PgClient | Client.SqlClient, Config.ConfigError | SqlError> =>
797
+ layerFrom(Effect.flatMap(
798
+ Config.unwrap(config),
799
+ make
800
+ ))
506
801
 
507
802
  /**
803
+ * Creates a layer from a concrete PostgreSQL pool configuration, providing both `PgClient` and `SqlClient`.
804
+ *
508
805
  * @category layers
509
- * @since 1.0.0
806
+ * @since 4.0.0
510
807
  */
511
- export const layerFromPool = (options: {
512
- readonly acquire: Effect.Effect<Pg.Pool, SqlError, Scope.Scope>
513
-
514
- readonly applicationName?: string | undefined
515
- readonly spanAttributes?: Record<string, unknown> | undefined
516
-
517
- readonly transformResultNames?: ((str: string) => string) | undefined
518
- readonly transformQueryNames?: ((str: string) => string) | undefined
519
- readonly transformJson?: boolean | undefined
520
- readonly types?: Pg.CustomTypesConfig | undefined
521
- }): Layer.Layer<PgClient | Client.SqlClient, SqlError> =>
522
- Layer.effectServices(
523
- Effect.map(fromPool(options), (client) =>
524
- ServiceMap.make(PgClient, client).pipe(
525
- ServiceMap.add(Client.SqlClient, client)
526
- ))
527
- ).pipe(Layer.provide(Reactivity.layer))
808
+ export const layer = (
809
+ config: PgPoolConfig
810
+ ): Layer.Layer<PgClient | Client.SqlClient, SqlError> => layerFrom(make(config))
528
811
 
529
812
  /**
530
- * @category constructor
531
- * @since 1.0.0
813
+ * Creates the PostgreSQL statement compiler, using `$1` placeholders, double-quoted identifiers, PostgreSQL returning clauses, and optional JSON value transformation.
814
+ *
815
+ * @category constructors
816
+ * @since 4.0.0
532
817
  */
533
818
  export const makeCompiler = (
534
819
  transform?: (_: string) => string,
@@ -576,18 +861,87 @@ export const makeCompiler = (
576
861
  const escape = Statement.defaultEscape("\"")
577
862
 
578
863
  /**
864
+ * PostgreSQL-specific custom statement fragments supported by the compiler, currently JSON parameter fragments.
865
+ *
579
866
  * @category custom types
580
- * @since 1.0.0
867
+ * @since 4.0.0
581
868
  */
582
869
  export type PgCustom = PgJson
583
870
 
584
871
  /**
585
872
  * @category custom types
586
- * @since 1.0.0
873
+ * @since 4.0.0
587
874
  */
588
875
  interface PgJson extends Custom<"PgJson", unknown> {}
589
876
  /**
590
877
  * @category custom types
591
- * @since 1.0.0
878
+ * @since 4.0.0
592
879
  */
593
880
  const PgJson = Statement.custom<PgJson>("PgJson")
881
+
882
+ const ATTR_DB_SYSTEM_NAME = "db.system.name"
883
+ const ATTR_DB_NAMESPACE = "db.namespace"
884
+ const ATTR_SERVER_ADDRESS = "server.address"
885
+ const ATTR_SERVER_PORT = "server.port"
886
+
887
+ const pgCodeFromCause = (cause: unknown): string | undefined => {
888
+ if (typeof cause !== "object" || cause === null || !("code" in cause)) {
889
+ return undefined
890
+ }
891
+ const code = cause.code
892
+ return typeof code === "string" ? code : undefined
893
+ }
894
+
895
+ const pgConstraintFromCause = (cause: unknown): string => {
896
+ if (typeof cause !== "object" || cause === null || !("constraint" in cause)) {
897
+ return "unknown"
898
+ }
899
+ const constraint = cause.constraint
900
+ if (typeof constraint !== "string") {
901
+ return "unknown"
902
+ }
903
+ const normalized = constraint.trim()
904
+ return normalized.length === 0 ? "unknown" : normalized
905
+ }
906
+
907
+ const classifyError = (
908
+ cause: unknown,
909
+ message: string,
910
+ operation: string
911
+ ) => {
912
+ const props = { cause, message, operation }
913
+ const code = pgCodeFromCause(cause)
914
+ if (code !== undefined) {
915
+ if (code.startsWith("08")) {
916
+ return new ConnectionError(props)
917
+ }
918
+ if (code.startsWith("28")) {
919
+ return new AuthenticationError(props)
920
+ }
921
+ if (code === "42501") {
922
+ return new AuthorizationError(props)
923
+ }
924
+ if (code.startsWith("42")) {
925
+ return new SqlSyntaxError(props)
926
+ }
927
+ if (code === "23505") {
928
+ return new UniqueViolation({ ...props, constraint: pgConstraintFromCause(cause) })
929
+ }
930
+ if (code.startsWith("23")) {
931
+ return new ConstraintError(props)
932
+ }
933
+ if (code === "40P01") {
934
+ return new DeadlockError(props)
935
+ }
936
+ if (code === "40001") {
937
+ return new SerializationError(props)
938
+ }
939
+ if (code === "55P03") {
940
+ return new LockTimeoutError(props)
941
+ }
942
+ if (code === "57014") {
943
+ return new StatementTimeoutError(props)
944
+ }
945
+ }
946
+ return new UnknownError(props)
947
+ }