@effect/sql-pg 0.47.0 → 0.49.0

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
@@ -5,21 +5,25 @@ import * as Reactivity from "@effect/experimental/Reactivity"
5
5
  import * as Client from "@effect/sql/SqlClient"
6
6
  import type { Connection } from "@effect/sql/SqlConnection"
7
7
  import { SqlError } from "@effect/sql/SqlError"
8
- import type { Custom, Fragment, Primitive } from "@effect/sql/Statement"
8
+ import type { Custom, Fragment } from "@effect/sql/Statement"
9
9
  import * as Statement from "@effect/sql/Statement"
10
+ import * as Arr from "effect/Array"
10
11
  import * as Chunk from "effect/Chunk"
11
12
  import * as Config from "effect/Config"
12
- import type { ConfigError } from "effect/ConfigError"
13
+ import type * as ConfigError from "effect/ConfigError"
13
14
  import * as Context from "effect/Context"
14
15
  import * as Duration from "effect/Duration"
15
16
  import * as Effect from "effect/Effect"
17
+ import * as Fiber from "effect/Fiber"
16
18
  import * as Layer from "effect/Layer"
19
+ import * as Option from "effect/Option"
20
+ import * as RcRef from "effect/RcRef"
17
21
  import * as Redacted from "effect/Redacted"
18
- import type * as Scope from "effect/Scope"
22
+ import * as Scope from "effect/Scope"
19
23
  import * as Stream from "effect/Stream"
20
- import type * as NodeStream from "node:stream"
21
24
  import type { ConnectionOptions } from "node:tls"
22
- import postgres from "postgres"
25
+ import * as Pg from "pg"
26
+ import Cursor from "pg-cursor"
23
27
 
24
28
  const ATTR_DB_SYSTEM_NAME = "db.system.name"
25
29
  const ATTR_DB_NAMESPACE = "db.namespace"
@@ -30,13 +34,13 @@ const ATTR_SERVER_PORT = "server.port"
30
34
  * @category type ids
31
35
  * @since 1.0.0
32
36
  */
33
- export const TypeId: unique symbol = Symbol.for("@effect/sql-pg/PgClient")
37
+ export const TypeId: TypeId = "~@effect/sql-pg/PgClient"
34
38
 
35
39
  /**
36
40
  * @category type ids
37
41
  * @since 1.0.0
38
42
  */
39
- export type TypeId = typeof TypeId
43
+ export type TypeId = "~@effect/sql-pg/PgClient"
40
44
 
41
45
  /**
42
46
  * @category models
@@ -46,7 +50,6 @@ export interface PgClient extends Client.SqlClient {
46
50
  readonly [TypeId]: TypeId
47
51
  readonly config: PgClientConfig
48
52
  readonly json: (_: unknown) => Fragment
49
- readonly array: (_: ReadonlyArray<Primitive>) => Fragment
50
53
  readonly listen: (channel: string) => Stream.Stream<string, SqlError>
51
54
  readonly notify: (channel: string, payload: string) => Effect.Effect<void, SqlError>
52
55
  }
@@ -72,33 +75,11 @@ export interface PgClientConfig {
72
75
  readonly username?: string | undefined
73
76
  readonly password?: Redacted.Redacted | undefined
74
77
 
75
- /**
76
- * A function returning a custom socket to use. This parameter is not documented
77
- * in the postgres.js's type signature. See their
78
- * [readme](https://github.com/porsager/postgres?tab=readme-ov-file#connection-details) instead.
79
- *
80
- * @example
81
- * ```ts
82
- * import { AuthTypes, Connector } from "@google-cloud/cloud-sql-connector";
83
- * import { PgClient } from "@effect/sql-pg";
84
- * import { Config, Effect, Layer } from "effect"
85
- *
86
- * const layer = Effect.gen(function*() {
87
- * const connector = new Connector();
88
- * const clientOpts = yield* Effect.promise(() => connector.getOptions({
89
- * instanceConnectionName: "project:region:instance",
90
- * authType: AuthTypes.IAM,
91
- * }));
92
- * return PgClient.layer({ socket: clientOpts.stream, username: "iam-user" });
93
- * }).pipe(Layer.unwrapEffect)
94
- * ```
95
- */
96
- readonly socket?: (() => NodeStream.Duplex) | undefined
97
-
98
78
  readonly idleTimeout?: Duration.DurationInput | undefined
99
79
  readonly connectTimeout?: Duration.DurationInput | undefined
100
80
 
101
81
  readonly maxConnections?: number | undefined
82
+ readonly minConnections?: number | undefined
102
83
  readonly connectionTTL?: Duration.DurationInput | undefined
103
84
 
104
85
  readonly applicationName?: string | undefined
@@ -107,31 +88,7 @@ export interface PgClientConfig {
107
88
  readonly transformResultNames?: ((str: string) => string) | undefined
108
89
  readonly transformQueryNames?: ((str: string) => string) | undefined
109
90
  readonly transformJson?: boolean | undefined
110
- readonly fetchTypes?: boolean | undefined
111
- readonly prepare?: boolean | undefined
112
- /**
113
- * A callback when postgres has a notice, see
114
- * [readme](https://github.com/porsager/postgres?tab=readme-ov-file#connection-details).
115
- * By default, postgres.js logs these with console.log.
116
- * To silence notices, see the following example:
117
- * @example
118
- * ```ts
119
- * import { PgClient } from "@effect/sql-pg";
120
- * import { Config, Layer } from "effect"
121
- *
122
- * const layer = PgClient.layer({ onnotice: Config.succeed(() => {}) })
123
- * ```
124
- */
125
- readonly onnotice?: (notice: postgres.Notice) => void
126
- readonly types?: Record<string, postgres.PostgresType> | undefined
127
-
128
- readonly debug?: postgres.Options<{}>["debug"] | undefined
129
- }
130
-
131
- type PartialWithUndefined<T> = { [K in keyof T]?: T[K] | undefined }
132
-
133
- interface PostgresOptions extends postgres.Options<{}> {
134
- readonly socket?: (() => NodeStream.Duplex) | undefined
91
+ readonly types?: Pg.CustomTypesConfig | undefined
135
92
  }
136
93
 
137
94
  /**
@@ -153,53 +110,39 @@ export const make = (
153
110
  ).array :
154
111
  undefined
155
112
 
156
- const opts: PartialWithUndefined<PostgresOptions> = {
157
- max: options.maxConnections ?? 10,
158
- max_lifetime: options.connectionTTL
159
- ? Math.round(
160
- Duration.toMillis(Duration.decode(options.connectionTTL)) / 1000
161
- )
162
- : undefined,
163
- idle_timeout: options.idleTimeout
164
- ? Math.round(
165
- Duration.toMillis(Duration.decode(options.idleTimeout)) / 1000
166
- )
167
- : undefined,
168
- connect_timeout: options.connectTimeout
169
- ? Math.round(
170
- Duration.toMillis(Duration.decode(options.connectTimeout)) / 1000
171
- )
172
- : undefined,
173
-
113
+ const pool = new Pg.Pool({
114
+ connectionString: options.url ? Redacted.value(options.url) : undefined,
115
+ user: options.username,
174
116
  host: options.host,
175
- port: options.port,
176
- ssl: options.ssl,
177
- path: options.path,
178
117
  database: options.database,
179
- username: options.username,
180
118
  password: options.password ? Redacted.value(options.password) : undefined,
181
- fetch_types: options.fetchTypes ?? true,
182
- prepare: options.prepare ?? true,
183
- onnotice: options.onnotice,
184
- types: options.types,
185
- debug: options.debug,
186
- connection: {
187
- application_name: options.applicationName ?? "@effect/sql-pg"
188
- },
189
- socket: options.socket
190
- }
119
+ ssl: options.ssl,
120
+ port: options.port,
121
+ connectionTimeoutMillis: options.connectTimeout
122
+ ? Duration.toMillis(options.connectTimeout)
123
+ : undefined,
124
+ idleTimeoutMillis: options.idleTimeout
125
+ ? Duration.toMillis(options.idleTimeout)
126
+ : undefined,
127
+ max: options.maxConnections,
128
+ min: options.minConnections,
129
+ maxLifetimeSeconds: options.connectionTTL
130
+ ? Duration.toSeconds(options.connectionTTL)
131
+ : undefined,
132
+ application_name: options.applicationName ?? "@effect/sql-pg",
133
+ types: options.types
134
+ })
191
135
 
192
- const client = options.url
193
- ? postgres(Redacted.value(options.url), opts as any)
194
- : postgres(opts as any)
136
+ pool.on("error", (_err) => {
137
+ })
195
138
 
196
139
  yield* Effect.acquireRelease(
197
140
  Effect.tryPromise({
198
- try: () => client`select 1`,
141
+ try: () => pool.query("SELECT 1"),
199
142
  catch: (cause) => new SqlError({ cause, message: "PgClient: Failed to connect" })
200
143
  }),
201
144
  () =>
202
- Effect.promise(() => client.end()).pipe(
145
+ Effect.promise(() => pool.end()).pipe(
203
146
  Effect.interruptible,
204
147
  Effect.timeoutOption(1000)
205
148
  )
@@ -215,80 +158,133 @@ export const make = (
215
158
  )
216
159
 
217
160
  class ConnectionImpl implements Connection {
218
- constructor(private readonly pg: postgres.Sql<{}>) {}
161
+ readonly pg: Pg.Pool | Pg.PoolClient
162
+ constructor(pg: Pg.Pool | Pg.PoolClient) {
163
+ this.pg = pg
164
+ }
219
165
 
220
- private run(query: postgres.PendingQuery<any> | postgres.PendingValuesQuery<any>) {
166
+ private run(query: string, params: ReadonlyArray<unknown>) {
221
167
  return Effect.async<ReadonlyArray<any>, SqlError>((resume) => {
222
- query.then(
223
- (_) => resume(Effect.succeed(_)),
224
- (cause) => resume(new SqlError({ cause, message: "Failed to execute statement" }))
225
- )
226
- return Effect.sync(() => query.cancel())
168
+ this.pg.query(query, params as any, (err, result) => {
169
+ if (err) {
170
+ resume(Effect.fail(new SqlError({ cause: err, message: "Failed to execute statement" })))
171
+ } else {
172
+ // Multi-statement queries return an array of results
173
+ const rows = Array.isArray(result)
174
+ ? result.map((r) => r.rows ?? [])
175
+ : result.rows ?? []
176
+ resume(Effect.succeed(rows))
177
+ }
178
+ })
227
179
  })
228
180
  }
229
181
 
230
182
  execute(
231
183
  sql: string,
232
- params: ReadonlyArray<Primitive>,
184
+ params: ReadonlyArray<unknown>,
233
185
  transformRows: (<A extends object>(row: ReadonlyArray<A>) => ReadonlyArray<A>) | undefined
234
186
  ) {
235
187
  return transformRows
236
- ? Effect.map(this.run(this.pg.unsafe(sql, params as any)), transformRows)
237
- : this.run(this.pg.unsafe(sql, params as any))
188
+ ? Effect.map(this.run(sql, params), transformRows)
189
+ : this.run(sql, params)
238
190
  }
239
- executeRaw(sql: string, params: ReadonlyArray<Primitive>) {
240
- return this.run(this.pg.unsafe(sql, params as any))
191
+ executeRaw(sql: string, params: ReadonlyArray<unknown>) {
192
+ return Effect.async<Pg.Result, SqlError>((resume) => {
193
+ this.pg.query(sql, params as any, (err, result) => {
194
+ if (err) {
195
+ resume(Effect.fail(new SqlError({ cause: err, message: "Failed to execute statement" })))
196
+ } else {
197
+ resume(Effect.succeed(result))
198
+ }
199
+ })
200
+ })
241
201
  }
242
- executeWithoutTransform(sql: string, params: ReadonlyArray<Primitive>) {
243
- return this.run(this.pg.unsafe(sql, params as any))
202
+ executeWithoutTransform(sql: string, params: ReadonlyArray<unknown>) {
203
+ return this.run(sql, params)
244
204
  }
245
- executeValues(sql: string, params: ReadonlyArray<Primitive>) {
246
- return this.run(this.pg.unsafe(sql, params as any).values())
205
+ executeValues(sql: string, params: ReadonlyArray<unknown>) {
206
+ return Effect.async<ReadonlyArray<any>, SqlError>((resume) => {
207
+ this.pg.query(
208
+ {
209
+ text: sql,
210
+ rowMode: "array",
211
+ values: params as Array<string>
212
+ },
213
+ (err, result) => {
214
+ if (err) {
215
+ resume(Effect.fail(new SqlError({ cause: err, message: "Failed to execute statement" })))
216
+ } else {
217
+ resume(Effect.succeed(result.rows))
218
+ }
219
+ }
220
+ )
221
+ })
247
222
  }
248
223
  executeUnprepared(
249
224
  sql: string,
250
- params: ReadonlyArray<Primitive>,
225
+ params: ReadonlyArray<unknown>,
251
226
  transformRows: (<A extends object>(row: ReadonlyArray<A>) => ReadonlyArray<A>) | undefined
252
227
  ) {
253
228
  return this.execute(sql, params, transformRows)
254
229
  }
255
230
  executeStream(
256
231
  sql: string,
257
- params: ReadonlyArray<Primitive>,
232
+ params: ReadonlyArray<unknown>,
258
233
  transformRows: (<A extends object>(row: ReadonlyArray<A>) => ReadonlyArray<A>) | undefined
259
234
  ) {
260
- return Stream.mapChunks(
261
- Stream.fromAsyncIterable(
262
- this.pg.unsafe(sql, params as any).cursor(16) as AsyncIterable<
263
- Array<any>
264
- >,
265
- (cause) => new SqlError({ cause, message: "Failed to execute statement" })
266
- ),
267
- Chunk.flatMap((rows) => Chunk.unsafeFromArray(transformRows ? transformRows(rows) : rows))
235
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
236
+ const self = this
237
+ return Effect.gen(function*() {
238
+ const cursor = yield* Effect.acquireRelease(
239
+ Effect.sync(() => self.pg.query(new Cursor(sql, params as any))),
240
+ (cursor) => Effect.sync(() => cursor.close())
241
+ )
242
+ const pull = Effect.async<Chunk.Chunk<any>, Option.Option<SqlError>>((resume) => {
243
+ cursor.read(128, (err, rows) => {
244
+ if (err) {
245
+ resume(Effect.fail(Option.some(new SqlError({ cause: err, message: "Failed to execute statement" }))))
246
+ } else if (Arr.isNonEmptyArray(rows)) {
247
+ resume(Effect.succeed(Chunk.unsafeFromArray(transformRows ? transformRows(rows) as any : rows)))
248
+ } else {
249
+ resume(Effect.fail(Option.none()))
250
+ }
251
+ })
252
+ })
253
+ return Stream.repeatEffectChunkOption(pull)
254
+ }).pipe(
255
+ Stream.unwrapScoped
268
256
  )
269
257
  }
270
258
  }
271
259
 
260
+ const reserveRaw = Effect.async<Pg.PoolClient, SqlError, Scope.Scope>((resume) => {
261
+ const fiber = Option.getOrThrow(Fiber.getCurrentFiber())
262
+ const scope = Context.unsafeGet(fiber.currentContext, Scope.Scope)
263
+ pool.connect((err, client, release) => {
264
+ if (err) {
265
+ resume(Effect.fail(new SqlError({ cause: err, message: "Failed to acquire connection for transaction" })))
266
+ } else {
267
+ resume(Effect.as(Scope.addFinalizer(scope, Effect.sync(release)), client!))
268
+ }
269
+ })
270
+ })
271
+ const reserve = Effect.map(reserveRaw, (client) => new ConnectionImpl(client))
272
+
273
+ const listenClient = yield* RcRef.make({
274
+ acquire: reserveRaw
275
+ })
276
+
272
277
  return Object.assign(
273
278
  yield* Client.make({
274
- acquirer: Effect.succeed(new ConnectionImpl(client)),
275
- transactionAcquirer: Effect.map(
276
- Effect.acquireRelease(
277
- Effect.tryPromise({
278
- try: () => client.reserve(),
279
- catch: (cause) => new SqlError({ cause, message: "Failed to reserve connection" })
280
- }),
281
- (pg) => Effect.sync(() => pg.release())
282
- ),
283
- (_) => new ConnectionImpl(_)
284
- ),
279
+ acquirer: Effect.succeed(new ConnectionImpl(pool)),
280
+ transactionAcquirer: reserve,
285
281
  compiler,
286
282
  spanAttributes: [
287
283
  ...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
288
284
  [ATTR_DB_SYSTEM_NAME, "postgresql"],
289
- [ATTR_DB_NAMESPACE, opts.database ?? options.username ?? "postgres"],
290
- [ATTR_SERVER_ADDRESS, opts.host ?? "localhost"],
291
- [ATTR_SERVER_PORT, opts.port ?? 5432]
285
+ [ATTR_DB_NAMESPACE, options.database ?? options.username ?? "postgres"],
286
+ [ATTR_SERVER_ADDRESS, options.host ?? "localhost"],
287
+ [ATTR_SERVER_PORT, options.port ?? 5432]
292
288
  ],
293
289
  transformRows
294
290
  }),
@@ -296,28 +292,42 @@ export const make = (
296
292
  [TypeId]: TypeId as TypeId,
297
293
  config: {
298
294
  ...options,
299
- host: client.options.host[0] ?? undefined,
300
- port: client.options.port[0] ?? undefined,
301
- username: client.options.user,
302
- password: client.options.pass ? Redacted.make(client.options.pass) : undefined,
303
- database: client.options.database
295
+ host: pool.options.host,
296
+ port: pool.options.port,
297
+ username: pool.options.user,
298
+ password: typeof pool.options.password === "string" ? Redacted.make(pool.options.password) : undefined,
299
+ database: pool.options.database
304
300
  },
305
301
  json: (_: unknown) => PgJson(_),
306
- array: (_: ReadonlyArray<Primitive>) => PgArray(_),
307
302
  listen: (channel: string) =>
308
- Stream.asyncPush<string, SqlError>((emit) =>
309
- Effect.acquireRelease(
310
- Effect.tryPromise({
311
- try: () => client.listen(channel, (payload) => emit.single(payload)),
312
- catch: (cause) => new SqlError({ cause, message: "Failed to listen" })
313
- }),
314
- ({ unlisten }) => Effect.promise(() => unlisten())
303
+ Stream.asyncPush<string, SqlError>(Effect.fnUntraced(function*(emit) {
304
+ const client = yield* RcRef.get(listenClient)
305
+ function onNotification(msg: Pg.Notification) {
306
+ if (msg.channel === channel && msg.payload) {
307
+ emit.single(msg.payload)
308
+ }
309
+ }
310
+ yield* Effect.addFinalizer(() =>
311
+ Effect.promise(() => {
312
+ client.off("notification", onNotification)
313
+ return client.query(`UNLISTEN ${Pg.escapeIdentifier(channel)}`)
314
+ })
315
315
  )
316
- ),
316
+ yield* Effect.tryPromise({
317
+ try: () => client.query(`LISTEN ${Pg.escapeIdentifier(channel)}`),
318
+ catch: (cause) => new SqlError({ cause, message: "Failed to listen" })
319
+ })
320
+ client.on("notification", onNotification)
321
+ })),
317
322
  notify: (channel: string, payload: string) =>
318
- Effect.tryPromise({
319
- try: () => client.notify(channel, payload),
320
- catch: (cause) => new SqlError({ cause, message: "Failed to notify" })
323
+ Effect.async<void, SqlError>((resume) => {
324
+ pool.query(`NOTIFY ${Pg.escapeIdentifier(channel)}, $1`, [payload], (err) => {
325
+ if (err) {
326
+ resume(Effect.fail(new SqlError({ cause: err, message: "Failed to notify" })))
327
+ } else {
328
+ resume(Effect.void)
329
+ }
330
+ })
321
331
  })
322
332
  }
323
333
  )
@@ -329,7 +339,7 @@ export const make = (
329
339
  */
330
340
  export const layerConfig = (
331
341
  config: Config.Config.Wrap<PgClientConfig>
332
- ): Layer.Layer<PgClient | Client.SqlClient, ConfigError | SqlError> =>
342
+ ): Layer.Layer<PgClient | Client.SqlClient, ConfigError.ConfigError | SqlError> =>
333
343
  Layer.scopedContext(
334
344
  Config.unwrap(config).pipe(
335
345
  Effect.flatMap(make),
@@ -347,7 +357,7 @@ export const layerConfig = (
347
357
  */
348
358
  export const layer = (
349
359
  config: PgClientConfig
350
- ): Layer.Layer<PgClient | Client.SqlClient, ConfigError | SqlError> =>
360
+ ): Layer.Layer<PgClient | Client.SqlClient, SqlError> =>
351
361
  Layer.scopedContext(
352
362
  Effect.map(make(config), (client) =>
353
363
  Context.make(PgClient, client).pipe(
@@ -363,8 +373,6 @@ export const makeCompiler = (
363
373
  transform?: (_: string) => string,
364
374
  transformJson = true
365
375
  ): Statement.Compiler => {
366
- const pg = postgres({ max: 0 })
367
-
368
376
  const transformValue = transformJson && transform
369
377
  ? Statement.defaultTransforms(transform).value
370
378
  : undefined
@@ -393,33 +401,12 @@ export const makeCompiler = (
393
401
  return [
394
402
  placeholder(undefined),
395
403
  [
396
- pg.json(
397
- withoutTransform || transformValue === undefined
398
- ? type.i0
399
- : transformValue(type.i0)
400
- ) as any
404
+ withoutTransform || transformValue === undefined
405
+ ? type.i0
406
+ : transformValue(type.i0)
401
407
  ]
402
408
  ]
403
409
  }
404
- case "PgArray": {
405
- const param = pg.array(type.i0 as any) as any
406
- const first = type.i0[0]
407
- switch (typeof first) {
408
- case "boolean": {
409
- param.type = 1000
410
- break
411
- }
412
- case "number": {
413
- param.type = 1022
414
- break
415
- }
416
- default: {
417
- param.type = 1009
418
- break
419
- }
420
- }
421
- return [placeholder(undefined), [param]]
422
- }
423
410
  }
424
411
  }
425
412
  })
@@ -431,7 +418,7 @@ const escape = Statement.defaultEscape("\"")
431
418
  * @category custom types
432
419
  * @since 1.0.0
433
420
  */
434
- export type PgCustom = PgJson | PgArray
421
+ export type PgCustom = PgJson
435
422
 
436
423
  /**
437
424
  * @category custom types
@@ -443,14 +430,3 @@ interface PgJson extends Custom<"PgJson", unknown> {}
443
430
  * @since 1.0.0
444
431
  */
445
432
  const PgJson = Statement.custom<PgJson>("PgJson")
446
-
447
- /**
448
- * @category custom types
449
- * @since 1.0.0
450
- */
451
- interface PgArray extends Custom<"PgArray", ReadonlyArray<Primitive>> {}
452
- /**
453
- * @category custom types
454
- * @since 1.0.0
455
- */
456
- const PgArray = Statement.custom<PgArray>("PgArray")