@effect/sql-pg 4.0.0-beta.1 → 4.0.0-beta.100

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.DurationInput | undefined
84
- readonly connectTimeout?: Duration.DurationInput | undefined
116
+ readonly connectTimeout?: Duration.Input | undefined
85
117
 
86
- readonly maxConnections?: number | undefined
87
- readonly minConnections?: number | undefined
88
- readonly connectionTTL?: Duration.DurationInput | 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*() {
@@ -116,15 +160,15 @@ export const make = (
116
160
  port: options.port,
117
161
  ...(options.stream ? { stream: options.stream } : {}),
118
162
  connectionTimeoutMillis: options.connectTimeout
119
- ? Duration.toMillis(Duration.fromDurationInputUnsafe(options.connectTimeout))
163
+ ? Duration.toMillis(Duration.fromInputUnsafe(options.connectTimeout))
120
164
  : undefined,
121
165
  idleTimeoutMillis: options.idleTimeout
122
- ? Duration.toMillis(Duration.fromDurationInputUnsafe(options.idleTimeout))
166
+ ? Duration.toMillis(Duration.fromInputUnsafe(options.idleTimeout))
123
167
  : undefined,
124
168
  max: options.maxConnections,
125
169
  min: options.minConnections,
126
170
  maxLifetimeSeconds: options.connectionTTL
127
- ? Duration.toSeconds(Duration.fromDurationInputUnsafe(options.connectionTTL))
171
+ ? Duration.toSeconds(Duration.fromInputUnsafe(options.connectionTTL))
128
172
  : undefined,
129
173
  application_name: options.applicationName ?? "@effect/sql-pg",
130
174
  types: options.types
@@ -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,69 @@ 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
+ *
212
+ * @category constructors
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.acquireRelease(
226
+ Effect.tryPromise({
227
+ try: async () => {
228
+ const client = new Pg.Client({
229
+ connectionString: options.url ? Redacted.value(options.url) : undefined,
230
+ user: options.username,
231
+ host: options.host,
232
+ database: options.database,
233
+ password: options.password ? Redacted.value(options.password) : undefined,
234
+ ssl: options.ssl,
235
+ port: options.port,
236
+ ...(options.stream ? { stream: options.stream } : {}),
237
+ application_name: options.applicationName ?? "@effect/sql-pg",
238
+ types: options.types
239
+ })
240
+ await client.connect()
241
+ return client
242
+ },
243
+ catch: (cause) => new SqlError({ reason: classifyError(cause, "PgClient: Failed to connect", "connect") })
244
+ }),
245
+ (client) =>
246
+ Effect.promise(() => client.end()).pipe(
247
+ Effect.timeoutOption(1000)
248
+ ),
249
+ { interruptible: true }
250
+ ).pipe(
251
+ Effect.timeoutOrElse({
252
+ duration: options.connectTimeout ?? Duration.seconds(5),
253
+ orElse: () =>
254
+ Effect.fail(
255
+ new SqlError({
256
+ reason: new ConnectionError({
257
+ cause: new Error("Connection timed out"),
258
+ message: "PgClient: Connection timed out",
259
+ operation: "connect"
260
+ })
261
+ })
262
+ )
263
+ })
264
+ ),
265
+ acquireForStream: options.acquireForStream ?? false
266
+ })
267
+
268
+ /**
269
+ * Builds a PostgreSQL client from a scoped `pg` pool acquisition effect, deriving transaction, streaming, and LISTEN/NOTIFY support from that pool.
270
+ *
162
271
  * @category constructors
163
- * @since 1.0.0
272
+ * @since 4.0.0
164
273
  */
165
274
  export const fromPool = Effect.fnUntraced(function*(
166
275
  options: {
@@ -175,197 +284,149 @@ export const fromPool = Effect.fnUntraced(function*(
175
284
  readonly types?: Pg.CustomTypesConfig | undefined
176
285
  }
177
286
  ): 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
287
  const pool = yield* options.acquire
190
288
 
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)
289
+ const makeConection = (client?: Pg.PoolClient) =>
290
+ new ConnectionImpl(
291
+ function runWithClient<A>(f: (client: Pg.ClientBase, resume: (_: Effect.Effect<A, SqlError>) => void) => void) {
292
+ if (client !== undefined) {
293
+ return Effect.callback<A, SqlError>((resume) => {
294
+ f(client!, resume)
295
+ return makeCancel(pool, client!)
236
296
  })
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
- ))
297
+ }
298
+ return Effect.callback<A, SqlError>((resume) => {
299
+ let done = false
300
+ let cancel: Effect.Effect<void> | undefined = undefined
301
+ let client: Pg.PoolClient | undefined = undefined
302
+ function onError(cause: Error) {
303
+ cleanup(cause)
304
+ resume(Effect.fail(new SqlError({ reason: classifyError(cause, "Connection error", "acquireConnection") })))
260
305
  }
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))
306
+ function cleanup(cause?: Error) {
307
+ if (!done) client?.release(cause)
308
+ done = true
309
+ client?.off("error", onError)
281
310
  }
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))
311
+ pool.connect((cause, client_) => {
312
+ if (cause) {
313
+ return resume(
314
+ Effect.fail(
315
+ new SqlError({
316
+ reason: classifyError(cause, "Failed to acquire connection", "acquireConnection")
317
+ })
318
+ )
319
+ )
320
+ } else if (!client_) {
321
+ return resume(
322
+ Effect.fail(
323
+ new SqlError({
324
+ reason: new ConnectionError({
325
+ message: "Failed to acquire connection",
326
+ cause: new Error("No client returned"),
327
+ operation: "acquireConnection"
328
+ })
329
+ })
330
+ )
331
+ )
332
+ } else if (done) {
333
+ client_.release()
334
+ return
301
335
  }
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())
336
+ client = client_
337
+ client.once("error", onError)
338
+ cancel = makeCancel(pool, client)
339
+ f(client, (eff) => {
340
+ cleanup()
341
+ resume(eff)
342
+ })
343
+ })
344
+ return Effect.suspend(() => {
345
+ if (!cancel) {
346
+ cleanup()
347
+ return Effect.void
333
348
  }
349
+ return Effect.ensuring(cancel, Effect.sync(cleanup))
334
350
  })
335
351
  })
336
- })))
337
- }
338
- }
352
+ },
353
+ client ? Effect.succeed(client) : reserveRaw
354
+ )
339
355
 
340
356
  const reserveRaw = Effect.callback<Pg.PoolClient, SqlError, Scope.Scope>((resume) => {
341
357
  const fiber = Fiber.getCurrent()!
342
- const scope = ServiceMap.getUnsafe(fiber.services, Scope.Scope)
358
+ const scope = Context.getUnsafe(fiber.context, Scope.Scope)
343
359
  let cause: Error | undefined = undefined
360
+ function onError(cause_: Error) {
361
+ cause = cause_
362
+ }
344
363
  pool.connect((err, client, release) => {
345
364
  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)
365
+ return resume(
366
+ Effect.fail(
367
+ new SqlError({
368
+ reason: classifyError(
369
+ err,
370
+ "Failed to acquire connection for transaction",
371
+ "acquireConnection"
372
+ )
354
373
  })
355
- ),
356
- client!
357
- ))
358
- }
359
- function onError(cause_: Error) {
360
- cause = cause_
374
+ )
375
+ )
376
+ } else if (!client) {
377
+ return resume(
378
+ Effect.fail(
379
+ new SqlError({
380
+ reason: new ConnectionError({
381
+ message: "Failed to acquire connection for transaction",
382
+ cause: new Error("No client returned"),
383
+ operation: "acquireConnection"
384
+ })
385
+ })
386
+ )
387
+ )
361
388
  }
362
- client!.on("error", onError)
389
+ client.on("error", onError)
390
+ resume(Effect.as(
391
+ Scope.addFinalizer(
392
+ scope,
393
+ Effect.sync(() => {
394
+ client.off("error", onError)
395
+ release(cause)
396
+ })
397
+ ),
398
+ client
399
+ ))
363
400
  })
364
401
  })
365
- const reserve = Effect.map(reserveRaw, (client) => new ConnectionImpl(client))
402
+ const reserve = Effect.map(reserveRaw, makeConection)
403
+
404
+ const onListenClientError = (_: Error) => {
405
+ }
366
406
 
367
- const listenClient = yield* RcRef.make({
368
- acquire: reserveRaw
407
+ const listenAcquirer = yield* RcRef.make({
408
+ acquire: Effect.acquireRelease(
409
+ Effect.tryPromise({
410
+ try: async () => {
411
+ const client = new Pg.Client(pool.options)
412
+ await client.connect()
413
+ client.on("error", onListenClientError)
414
+ return client
415
+ },
416
+ catch: (cause) =>
417
+ new SqlError({
418
+ reason: classifyError(cause, "Failed to acquire connection for listen", "acquireConnection")
419
+ })
420
+ }),
421
+ (client) =>
422
+ Effect.promise(() => {
423
+ client.off("error", onListenClientError)
424
+ return client.end()
425
+ }).pipe(
426
+ Effect.timeoutOption(1000)
427
+ ),
428
+ { interruptible: true }
429
+ )
369
430
  })
370
431
 
371
432
  let config: PgClientConfig = {
@@ -386,7 +447,7 @@ export const fromPool = Effect.fnUntraced(function*(
386
447
  config = {
387
448
  ...config,
388
449
  host: config.host ?? parsed.host ?? undefined,
389
- port: config.port ?? (parsed.port ? Number.parse(parsed.port) : undefined),
450
+ port: config.port ?? (parsed.port ? Option.getOrUndefined(Number.parse(parsed.port)) : undefined),
390
451
  username: config.username ?? parsed.user ?? undefined,
391
452
  password: config.password ?? (parsed.password ? Redacted.make(parsed.password) : undefined),
392
453
  database: config.database ?? parsed.database ?? undefined
@@ -396,10 +457,136 @@ export const fromPool = Effect.fnUntraced(function*(
396
457
  }
397
458
  }
398
459
 
460
+ return yield* makeWith({
461
+ acquirer: Effect.succeed(makeConection()),
462
+ transactionAcquirer: reserve,
463
+ listenAcquirer: RcRef.get(listenAcquirer),
464
+ config,
465
+ spanAttributes: options.spanAttributes,
466
+ transformResultNames: options.transformResultNames,
467
+ transformQueryNames: options.transformQueryNames,
468
+ transformJson: options.transformJson
469
+ })
470
+ })
471
+
472
+ /**
473
+ * 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.
474
+ *
475
+ * @category constructors
476
+ * @since 4.0.0
477
+ */
478
+ export const fromClient = Effect.fnUntraced(function*(
479
+ options: {
480
+ readonly acquire: Effect.Effect<Pg.Client, SqlError, Scope.Scope>
481
+
482
+ /**
483
+ * Whether to acquire a separate client for each sql.stream / sql.listen.
484
+ */
485
+ readonly acquireForStream: boolean
486
+
487
+ readonly applicationName?: string | undefined
488
+ readonly spanAttributes?: Record<string, unknown> | undefined
489
+
490
+ readonly transformResultNames?: ((str: string) => string) | undefined
491
+ readonly transformQueryNames?: ((str: string) => string) | undefined
492
+ readonly transformJson?: boolean | undefined
493
+ readonly types?: Pg.CustomTypesConfig | undefined
494
+ }
495
+ ): Effect.fn.Return<PgClient, SqlError, Scope.Scope | Reactivity.Reactivity> {
496
+ function onError() {}
497
+ const acquireWithErrorHandler = options.acquire.pipe(
498
+ Effect.tap((client) => {
499
+ client.on("error", onError)
500
+ return Effect.addFinalizer(() => {
501
+ client.off("error", onError)
502
+ return Effect.void
503
+ })
504
+ })
505
+ )
506
+ const client = yield* acquireWithErrorHandler
507
+
508
+ const semaphore = Semaphore.makeUnsafe(1)
509
+ let streamClient = options.acquireForStream ? acquireWithErrorHandler : Effect.acquireRelease(
510
+ Effect.as(semaphore.take(1), client),
511
+ () => semaphore.release(1)
512
+ )
513
+
514
+ const makeConection = (client: Pg.Client) =>
515
+ new ConnectionImpl(
516
+ function runWithClient<A>(f: (client: Pg.ClientBase, resume: (_: Effect.Effect<A, SqlError>) => void) => void) {
517
+ return Effect.callback<A, SqlError>((resume) => {
518
+ f(client, resume)
519
+ })
520
+ },
521
+ streamClient
522
+ )
523
+ const connection = makeConection(client)
524
+ const acquirer = semaphore.withPermit(Effect.succeed(connection))
525
+
526
+ const config: PgClientConfig = {
527
+ ...options,
528
+ host: client.host,
529
+ port: client.port,
530
+ database: client.database,
531
+ username: client.user,
532
+ password: typeof client.password === "string" ? Redacted.make(client.password) : undefined,
533
+ ssl: client.ssl
534
+ }
535
+
536
+ return yield* makeWith({
537
+ acquirer,
538
+ transactionAcquirer: acquirer,
539
+ listenAcquirer: streamClient,
540
+ config,
541
+ spanAttributes: options.spanAttributes,
542
+ transformResultNames: options.transformResultNames,
543
+ transformQueryNames: options.transformQueryNames,
544
+ transformJson: options.transformJson
545
+ })
546
+ })
547
+
548
+ /**
549
+ * Creates a `PgClient` from SQL connection acquirers, a LISTEN acquirer, client configuration, and transformation options.
550
+ *
551
+ * **When to use**
552
+ *
553
+ * Use to build a PostgreSQL client from custom connection acquisition logic
554
+ * instead of the built-in pool or single-client constructors.
555
+ *
556
+ * @category constructors
557
+ * @since 4.0.0
558
+ */
559
+ export const makeWith = Effect.fnUntraced(function*(
560
+ options: {
561
+ readonly acquirer: SqlConnection.Acquirer
562
+ readonly transactionAcquirer: SqlConnection.Acquirer
563
+ readonly listenAcquirer: Effect.Effect<Pg.ClientBase, SqlError, Scope.Scope>
564
+
565
+ readonly config: PgClientConfig
566
+ readonly spanAttributes?: Record<string, unknown> | undefined
567
+
568
+ readonly transformResultNames?: ((str: string) => string) | undefined
569
+ readonly transformQueryNames?: ((str: string) => string) | undefined
570
+ readonly transformJson?: boolean | undefined
571
+ }
572
+ ): Effect.fn.Return<PgClient, SqlError, Scope.Scope | Reactivity.Reactivity> {
573
+ const compiler = makeCompiler(
574
+ options.transformQueryNames,
575
+ options.transformJson
576
+ )
577
+ const transformRows = options.transformResultNames ?
578
+ Statement.defaultTransforms(
579
+ options.transformResultNames,
580
+ options.transformJson
581
+ ).array :
582
+ undefined
583
+
584
+ const config = options.config
585
+
399
586
  return Object.assign(
400
587
  yield* Client.make({
401
- acquirer: Effect.succeed(new ConnectionImpl()),
402
- transactionAcquirer: reserve,
588
+ acquirer: options.acquirer,
589
+ transactionAcquirer: options.transactionAcquirer,
403
590
  compiler,
404
591
  spanAttributes: [
405
592
  ...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
@@ -412,11 +599,11 @@ export const fromPool = Effect.fnUntraced(function*(
412
599
  }),
413
600
  {
414
601
  [TypeId]: TypeId as TypeId,
415
- config,
602
+ config: options.config,
416
603
  json: (_: unknown) => Statement.fragment([PgJson(_)]),
417
604
  listen: (channel: string) =>
418
605
  Stream.callback<string, SqlError>(Effect.fnUntraced(function*(queue) {
419
- const client = yield* RcRef.get(listenClient)
606
+ const client = yield* options.listenAcquirer
420
607
  function onNotification(msg: Pg.Notification) {
421
608
  if (msg.channel === channel && msg.payload) {
422
609
  Queue.offerUnsafe(queue, msg.payload)
@@ -430,24 +617,136 @@ export const fromPool = Effect.fnUntraced(function*(
430
617
  )
431
618
  yield* Effect.tryPromise({
432
619
  try: () => client.query(`LISTEN ${Pg.escapeIdentifier(channel)}`),
433
- catch: (cause) => new SqlError({ cause, message: "Failed to listen" })
620
+ catch: (cause) => new SqlError({ reason: classifyError(cause, "Failed to listen", "listen") })
434
621
  })
435
622
  client.on("notification", onNotification)
436
623
  })),
437
624
  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
- })
625
+ Effect.asVoid(Effect.scoped(Effect.flatMap(
626
+ options.acquirer,
627
+ (conn) => conn.executeRaw(`SELECT pg_notify($1, $2)`, [channel, payload])
628
+ )))
447
629
  }
448
630
  )
449
631
  })
450
632
 
633
+ class ConnectionImpl implements Connection {
634
+ constructor(
635
+ runWithClient: <A>(
636
+ f: (client: Pg.ClientBase, resume: (_: Effect.Effect<A, SqlError>) => void) => void
637
+ ) => Effect.Effect<A, SqlError>,
638
+ reserve: Effect.Effect<Pg.ClientBase, SqlError, Scope.Scope>
639
+ ) {
640
+ this.runWithClient = runWithClient
641
+ this.reserve = reserve
642
+ }
643
+
644
+ private readonly runWithClient: <A>(
645
+ f: (client: Pg.ClientBase, resume: (_: Effect.Effect<A, SqlError>) => void) => void
646
+ ) => Effect.Effect<A, SqlError>
647
+ private readonly reserve: Effect.Effect<Pg.ClientBase, SqlError, Scope.Scope>
648
+
649
+ private run(query: string, params: ReadonlyArray<unknown>) {
650
+ return this.runWithClient<ReadonlyArray<any>>((client, resume) => {
651
+ client.query(query, params as any, (err, result) => {
652
+ if (err) {
653
+ resume(
654
+ Effect.fail(new SqlError({ reason: classifyError(err, "Failed to execute statement", "execute") }))
655
+ )
656
+ } else {
657
+ // Multi-statement queries return an array of results
658
+ resume(Effect.succeed(
659
+ Array.isArray(result)
660
+ ? result.map((r) => r.rows ?? [])
661
+ : result.rows ?? []
662
+ ))
663
+ }
664
+ })
665
+ })
666
+ }
667
+
668
+ execute(
669
+ sql: string,
670
+ params: ReadonlyArray<unknown>,
671
+ transformRows: (<A extends object>(row: ReadonlyArray<A>) => ReadonlyArray<A>) | undefined
672
+ ) {
673
+ return transformRows
674
+ ? Effect.map(this.run(sql, params), transformRows)
675
+ : this.run(sql, params)
676
+ }
677
+ executeRaw(sql: string, params: ReadonlyArray<unknown>) {
678
+ return this.runWithClient<Pg.Result>((client, resume) => {
679
+ client.query(sql, params as any, (err, result) => {
680
+ if (err) {
681
+ resume(
682
+ Effect.fail(new SqlError({ reason: classifyError(err, "Failed to execute statement", "execute") }))
683
+ )
684
+ } else {
685
+ resume(Effect.succeed(result))
686
+ }
687
+ })
688
+ })
689
+ }
690
+ executeWithoutTransform(sql: string, params: ReadonlyArray<unknown>) {
691
+ return this.run(sql, params)
692
+ }
693
+ executeValues(sql: string, params: ReadonlyArray<unknown>) {
694
+ return this.runWithClient<ReadonlyArray<any>>((client, resume) => {
695
+ client.query(
696
+ {
697
+ text: sql,
698
+ rowMode: "array",
699
+ values: params as Array<string>
700
+ },
701
+ (err, result) => {
702
+ if (err) {
703
+ resume(
704
+ Effect.fail(new SqlError({ reason: classifyError(err, "Failed to execute statement", "execute") }))
705
+ )
706
+ } else {
707
+ resume(Effect.succeed(result.rows))
708
+ }
709
+ }
710
+ )
711
+ })
712
+ }
713
+ executeValuesUnprepared(sql: string, params: ReadonlyArray<unknown>) {
714
+ return this.executeValues(sql, params)
715
+ }
716
+ executeUnprepared(
717
+ sql: string,
718
+ params: ReadonlyArray<unknown>,
719
+ transformRows: (<A extends object>(row: ReadonlyArray<A>) => ReadonlyArray<A>) | undefined
720
+ ) {
721
+ return this.execute(sql, params, transformRows)
722
+ }
723
+ executeStream(
724
+ sql: string,
725
+ params: ReadonlyArray<unknown>,
726
+ transformRows: (<A extends object>(row: ReadonlyArray<A>) => ReadonlyArray<A>) | undefined
727
+ ) {
728
+ // oxlint-disable-next-line @typescript-eslint/no-this-alias
729
+ const self = this
730
+ return Stream.fromChannel(Channel.fromTransform(Effect.fnUntraced(function*(_, scope) {
731
+ const client = yield* Scope.provide(self.reserve, scope)
732
+ yield* Scope.addFinalizer(scope, Effect.promise(() => cursor.close()))
733
+ const cursor = client.query(new Cursor(sql, params as any))
734
+ // @effect-diagnostics-next-line returnEffectInGen:off
735
+ return Effect.callback<Arr.NonEmptyReadonlyArray<any>, SqlError | Cause.Done>((resume) => {
736
+ cursor.read(128, (err, rows) => {
737
+ if (err) {
738
+ resume(Effect.fail(new SqlError({ reason: classifyError(err, "Failed to execute statement", "stream") })))
739
+ } else if (Arr.isArrayNonEmpty(rows)) {
740
+ resume(Effect.succeed(transformRows ? transformRows(rows) as any : rows))
741
+ } else {
742
+ resume(Cause.done())
743
+ }
744
+ })
745
+ })
746
+ })))
747
+ }
748
+ }
749
+
451
750
  const cancelEffects = new WeakMap<Pg.PoolClient, Effect.Effect<void> | undefined>()
452
751
  const makeCancel = (pool: Pg.Pool, client: Pg.PoolClient) => {
453
752
  if (cancelEffects.has(client)) {
@@ -471,64 +770,52 @@ const makeCancel = (pool: Pg.Pool, client: Pg.PoolClient) => {
471
770
  }
472
771
 
473
772
  /**
773
+ * Creates a layer from an effect that acquires a `PgClient`, providing both `PgClient` and `SqlClient`.
774
+ *
474
775
  * @category layers
475
- * @since 1.0.0
776
+ * @since 4.0.0
476
777
  */
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))
778
+ export const layerFrom = <E, R>(
779
+ acquire: Effect.Effect<PgClient, E, R>
780
+ ): Layer.Layer<PgClient | Client.SqlClient, E, Exclude<R, Scope.Scope | Reactivity.Reactivity>> =>
781
+ Layer.effectContext(
782
+ Effect.map(acquire, (client) =>
783
+ Context.make(PgClient, client).pipe(
784
+ Context.add(Client.SqlClient, client)
785
+ ))
786
+ ).pipe(Layer.provide(Reactivity.layer)) as any
492
787
 
493
788
  /**
789
+ * Creates a layer from a `Config`-wrapped PostgreSQL pool configuration, providing both `PgClient` and `SqlClient`.
790
+ *
494
791
  * @category layers
495
- * @since 1.0.0
792
+ * @since 4.0.0
496
793
  */
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))
794
+ export const layerConfig: (
795
+ config: Config.Wrap<PgPoolConfig>
796
+ ) => Layer.Layer<PgClient | Client.SqlClient, Config.ConfigError | SqlError> = (
797
+ config: Config.Wrap<PgPoolConfig>
798
+ ): Layer.Layer<PgClient | Client.SqlClient, Config.ConfigError | SqlError> =>
799
+ layerFrom(Effect.flatMap(
800
+ Config.unwrap(config),
801
+ make
802
+ ))
506
803
 
507
804
  /**
805
+ * Creates a layer from a concrete PostgreSQL pool configuration, providing both `PgClient` and `SqlClient`.
806
+ *
508
807
  * @category layers
509
- * @since 1.0.0
808
+ * @since 4.0.0
510
809
  */
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))
810
+ export const layer = (
811
+ config: PgPoolConfig
812
+ ): Layer.Layer<PgClient | Client.SqlClient, SqlError> => layerFrom(make(config))
528
813
 
529
814
  /**
530
- * @category constructor
531
- * @since 1.0.0
815
+ * Creates the PostgreSQL statement compiler, using `$1` placeholders, double-quoted identifiers, PostgreSQL returning clauses, and optional JSON value transformation.
816
+ *
817
+ * @category constructors
818
+ * @since 4.0.0
532
819
  */
533
820
  export const makeCompiler = (
534
821
  transform?: (_: string) => string,
@@ -576,18 +863,87 @@ export const makeCompiler = (
576
863
  const escape = Statement.defaultEscape("\"")
577
864
 
578
865
  /**
866
+ * PostgreSQL-specific custom statement fragments supported by the compiler, currently JSON parameter fragments.
867
+ *
579
868
  * @category custom types
580
- * @since 1.0.0
869
+ * @since 4.0.0
581
870
  */
582
871
  export type PgCustom = PgJson
583
872
 
584
873
  /**
585
874
  * @category custom types
586
- * @since 1.0.0
875
+ * @since 4.0.0
587
876
  */
588
877
  interface PgJson extends Custom<"PgJson", unknown> {}
589
878
  /**
590
879
  * @category custom types
591
- * @since 1.0.0
880
+ * @since 4.0.0
592
881
  */
593
882
  const PgJson = Statement.custom<PgJson>("PgJson")
883
+
884
+ const ATTR_DB_SYSTEM_NAME = "db.system.name"
885
+ const ATTR_DB_NAMESPACE = "db.namespace"
886
+ const ATTR_SERVER_ADDRESS = "server.address"
887
+ const ATTR_SERVER_PORT = "server.port"
888
+
889
+ const pgCodeFromCause = (cause: unknown): string | undefined => {
890
+ if (typeof cause !== "object" || cause === null || !("code" in cause)) {
891
+ return undefined
892
+ }
893
+ const code = cause.code
894
+ return typeof code === "string" ? code : undefined
895
+ }
896
+
897
+ const pgConstraintFromCause = (cause: unknown): string => {
898
+ if (typeof cause !== "object" || cause === null || !("constraint" in cause)) {
899
+ return "unknown"
900
+ }
901
+ const constraint = cause.constraint
902
+ if (typeof constraint !== "string") {
903
+ return "unknown"
904
+ }
905
+ const normalized = constraint.trim()
906
+ return normalized.length === 0 ? "unknown" : normalized
907
+ }
908
+
909
+ const classifyError = (
910
+ cause: unknown,
911
+ message: string,
912
+ operation: string
913
+ ) => {
914
+ const props = { cause, message, operation }
915
+ const code = pgCodeFromCause(cause)
916
+ if (code !== undefined) {
917
+ if (code.startsWith("08")) {
918
+ return new ConnectionError(props)
919
+ }
920
+ if (code.startsWith("28")) {
921
+ return new AuthenticationError(props)
922
+ }
923
+ if (code === "42501") {
924
+ return new AuthorizationError(props)
925
+ }
926
+ if (code.startsWith("42")) {
927
+ return new SqlSyntaxError(props)
928
+ }
929
+ if (code === "23505") {
930
+ return new UniqueViolation({ ...props, constraint: pgConstraintFromCause(cause) })
931
+ }
932
+ if (code.startsWith("23")) {
933
+ return new ConstraintError(props)
934
+ }
935
+ if (code === "40P01") {
936
+ return new DeadlockError(props)
937
+ }
938
+ if (code === "40001") {
939
+ return new SerializationError(props)
940
+ }
941
+ if (code === "55P03") {
942
+ return new LockTimeoutError(props)
943
+ }
944
+ if (code === "57014") {
945
+ return new StatementTimeoutError(props)
946
+ }
947
+ }
948
+ return new UnknownError(props)
949
+ }