@effect/sql-pg 4.0.0-beta.7 → 4.0.0-beta.70

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