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

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