@juit/pgproxy-pool 1.0.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.
@@ -0,0 +1,494 @@
1
+ import assert from 'node:assert'
2
+ import { randomUUID } from 'node:crypto'
3
+
4
+ import { Emitter } from './events'
5
+ import { LibPQ } from './libpq'
6
+ import { Queue } from './queue'
7
+
8
+ import type { Logger } from './index'
9
+
10
+ /* ========================================================================== *
11
+ * INTERNALS *
12
+ * ========================================================================== */
13
+
14
+ /** Mappings to convert our options into LibPQ's own keys */
15
+ const optionKeys = {
16
+ address: 'hostaddr',
17
+ applicationName: 'application_name',
18
+ connectTimeout: 'connect_timeout',
19
+ database: 'dbname',
20
+ gssLibrary: 'gsslib',
21
+ host: 'host',
22
+ keepalives: 'keepalives',
23
+ keepalivesCount: 'keepalives_count',
24
+ keepalivesIdle: 'keepalives_idle',
25
+ keepalivesInterval: 'keepalives_interval',
26
+ kerberosServiceName: 'krbsrvname',
27
+ password: 'password',
28
+ port: 'port',
29
+ sslCertFile: 'sslcert',
30
+ sslCompression: 'sslcompression',
31
+ sslCrlFile: 'sslcrl',
32
+ sslKeyFile: 'sslkey',
33
+ sslMode: 'sslmode',
34
+ sslRootCertFile: 'sslrootcert',
35
+ user: 'user',
36
+ } as const satisfies Record<keyof ConnectionOptions, string>
37
+
38
+ /** Quote a parameter value for options */
39
+ function quoteParamValue(value: string): string {
40
+ value = value.replace(/\\/g, '\\\\').replace(/'/g, '\\\'')
41
+ return `'${value}'`
42
+ }
43
+
44
+ /** The {@link FinalizationRegistry} ensuring LibPQ gets finalized */
45
+ const finalizer = new FinalizationRegistry<LibPQ>( /* coverage ignore next */ (pq) => {
46
+ pq.finish()
47
+ })
48
+
49
+ /* ========================================================================== *
50
+ * TYPES *
51
+ * ========================================================================== */
52
+
53
+ /**
54
+ * Connection options
55
+ *
56
+ * See https://www.postgresql.org/docs/9.3/libpq-connect.html#LIBPQ-PARAMKEYWORDS
57
+ */
58
+ export interface ConnectionOptions {
59
+ /** The database name. */
60
+ database?: string
61
+
62
+ /** Name of host to connect to. */
63
+ host?: string,
64
+
65
+ /** IPv4 or IPv6 numeric IP address of host to connect to. */
66
+ address?: string,
67
+
68
+ /** Port number to connect to at the server host. */
69
+ port?: number
70
+
71
+ /** PostgreSQL user name to connect as. */
72
+ user?: string
73
+
74
+ /** Password to be used if the server demands password authentication. */
75
+ password?: string
76
+
77
+ /** Maximum wait for connection, in seconds. */
78
+ connectTimeout?: number
79
+
80
+ /** The `application_name` as it will appear in `pg_stat_activity`. */
81
+ applicationName?: string
82
+
83
+ /** Controls whether client-side TCP keepalives are used. */
84
+ keepalives?: boolean
85
+
86
+ /** The number of seconds of inactivity after which TCP should send a keepalive message to the server. */
87
+ keepalivesIdle?: number
88
+
89
+ /** The number of seconds after which a TCP keepalive message that is not acknowledged by the server should be retransmitted. */
90
+ keepalivesInterval?: number
91
+
92
+ /** The number of TCP keepalives that can be lost before the client's connection to the server is considered dead. */
93
+ keepalivesCount?: number
94
+
95
+ /**
96
+ * This option determines whether or with what priority a secure SSL TCP/IP
97
+ * connection will be negotiated with the server. There are six modes:
98
+ * * `disable`: only try a non-SSL connection
99
+ * * `allow`: first try a non-SSL connection; if that fails, try an SSL connection
100
+ * * `prefer` _(default)_: first try an SSL connection; if that fails, try a non-SSL connection
101
+ * * `require`: only try an SSL connection. If a root CA file is present, verify the certificate in the same way as if verify-ca was specified
102
+ * * `verify-ca`: only try an SSL connection, and verify that the server certificate is issued by a trusted certificate authority (CA)
103
+ * * `verify-full`: only try an SSL connection, verify that the server certificate is issued by a trusted CA and that the server host name matches that in the certificate
104
+ */
105
+ sslMode?:
106
+ | 'disable'
107
+ | 'allow'
108
+ | 'prefer'
109
+ | 'require'
110
+ | 'verify-ca'
111
+ | 'verify-full'
112
+
113
+ /** If set to `true` (default), data sent over SSL connections will be compressed */
114
+ sslCompression?: boolean
115
+
116
+ /** The file name of the client SSL certificate */
117
+ sslCertFile?: string
118
+
119
+ /** The location for the secret key used for the client certificate. */
120
+ sslKeyFile?: string
121
+
122
+ /** The name of a file containing SSL certificate authority (CA) certificate(s). */
123
+ sslRootCertFile?: string
124
+
125
+ /** The file name of the SSL certificate revocation list (CRL). */
126
+ sslCrlFile?: string
127
+
128
+ /** Kerberos service name to use when authenticating with Kerberos 5 or GSSAPI. */
129
+ kerberosServiceName?: string
130
+
131
+ /** GSS library to use for GSSAPI authentication. Only used on Windows. */
132
+ gssLibrary?: 'gssapi'
133
+ }
134
+
135
+ /** Describes the result of a PostgreSQL query */
136
+ export interface ConnectionQueryResult {
137
+ /** Command executed (normally `SELECT`, or `INSERT`, ...) */
138
+ command: string
139
+ /** Number of rows affected by this query (e.g. added rows in `INSERT`) */
140
+ rowCount: number
141
+ /** Fields description with `name` (column name) and `oid` (type) */
142
+ fields: [ name: string, oid: number ][]
143
+ /** Result rows, as an array of unparsed `string` results from `libpq` */
144
+ rows: (string | null)[][]
145
+ }
146
+
147
+ /** The type identifying */
148
+ export type ConnectionQueryParams = (string | number | bigint | null)[]
149
+
150
+ /** Events generated by our {@link Connection} */
151
+ interface ConnectionEvents {
152
+ error: (error: Error) => unknown
153
+ connected: () => unknown
154
+ destroyed: () => unknown
155
+ }
156
+
157
+ /* ========================================================================== *
158
+ * CONNECTION OPTIONS *
159
+ * ========================================================================== */
160
+
161
+ /** Convert our options into a string suitable for LibPQ */
162
+ export function convertOptions(options: ConnectionOptions): string {
163
+ const params: string[] = []
164
+ for (const [ option, value ] of Object.entries(options)) {
165
+ if (value == null) continue
166
+
167
+ const key = optionKeys[option as keyof ConnectionOptions]
168
+ if (! key) continue
169
+
170
+ const string =
171
+ typeof value === 'boolean' ? value ? '1' : '0' :
172
+ typeof value === 'number' ? value.toString() :
173
+ typeof value === 'string' ? value :
174
+ /* coverage ignore next */
175
+ assert.fail(`Invalid type for option ${option}`)
176
+
177
+ if (string.length === 0) continue
178
+
179
+ params.push(`${key}=${quoteParamValue(string)}`)
180
+ }
181
+ return params.join(' ')
182
+ }
183
+
184
+ /* ========================================================================== *
185
+ * CONNECTION *
186
+ * ========================================================================== */
187
+
188
+ /** Our *minimalistic* PostgreSQL connection wrapping `libpq`. */
189
+ export class Connection extends Emitter<ConnectionEvents> {
190
+ /** The unique ID of this connection */
191
+ public id: string
192
+
193
+ /** Queue for serializing queries to the database */
194
+ private readonly _queue: Queue = new Queue()
195
+ /** Option string to use when calling `connect` */
196
+ private readonly _options: string
197
+
198
+ /** Current instance of `libpq` */
199
+ private _pq: LibPQ
200
+ /** A flag indicating that `destroy()` has been invoked... */
201
+ private _destroyed: boolean = false
202
+
203
+ /** Create a connection with the specified `LibPQ` parameters string */
204
+ constructor(logger: Logger, params?: string)
205
+ /** Create a connection with the specified configuration options */
206
+ constructor(logger: Logger, options?: ConnectionOptions)
207
+ /* Overloaded constructor */
208
+ constructor(logger: Logger, options: string | ConnectionOptions = {}) {
209
+ super(logger)
210
+
211
+ this.id = randomUUID()
212
+ this._logger = logger
213
+ const params = typeof options === 'string' ? options : convertOptions(options)
214
+ this._options = `fallback_application_name='pool:${this.id}' ${params}`
215
+
216
+ this._pq = new LibPQ()
217
+ finalizer.register(this, this._pq, this._pq)
218
+
219
+ this.on('error', () => {
220
+ finalizer.unregister(this._pq)
221
+ this._pq.finish()
222
+ this._destroyed = true
223
+ })
224
+
225
+ logger.debug(`Connection "${this.id}" created`)
226
+ }
227
+
228
+ /* ===== GETTERS ========================================================== */
229
+
230
+ /** Check whether this {@link Connection} is connected or not */
231
+ get connected(): boolean {
232
+ return !! this._pq.connected
233
+ }
234
+
235
+ /** Check whether this {@link Connection} is destroyed or not */
236
+ get destroyed(): boolean {
237
+ return this._destroyed
238
+ }
239
+
240
+ /** Return the version of the server we're connected to */
241
+ get serverVersion(): string {
242
+ assert(this._pq.connected, 'Not connected')
243
+ const version = this._pq.serverVersion()
244
+ return `${Math.floor(version / 10000)}.${version % 10000}`
245
+ }
246
+
247
+ /* ===== PUBLIC =========================================================== */
248
+
249
+ /** Connect this {@link Connection} (fails if connected already) */
250
+ async connect(): Promise<Connection> {
251
+ assert(! this._pq.connected, `Connection "${this.id}" already connected`)
252
+ assert(! this._destroyed, `Connection "${this.id}" already destroyed`)
253
+
254
+ this._logger.debug(`Connection "${this.id}" connecting`)
255
+
256
+ /* Turn LibPQ's own `connect` function into a promise */
257
+ const promise = new Promise<boolean>((resolve, reject) => {
258
+ this._pq.connect(this._options, (error) => {
259
+ /* On error, simply finish (regardless) and fail cleaning up the error */
260
+ if (error) {
261
+ return reject(new Error(error.message.trim() || 'Unknown connection error'))
262
+ }
263
+
264
+ /* Ensure that our connection is setup as non-blocking, fail otherwise */
265
+ if (! this._pq.setNonBlocking(true)) {
266
+ return reject(new Error(`Unable to set connection "${this.id}" as non-blocking`))
267
+ }
268
+
269
+ /* Done, return LibPQ's connected status */
270
+ return resolve(this._pq.connected)
271
+ })
272
+ })
273
+
274
+ /* Rewrap the promise into an async/await to fix error stack traces */
275
+ try {
276
+ const connected = await promise
277
+
278
+ if (this._destroyed) throw new Error(`Connection "${this.id}" aborted`)
279
+ if (! connected) throw new Error(`Connection "${this.id}" not connected`)
280
+
281
+ this._logger.info(`Connection "${this.id}" connected (server version ${this.serverVersion})`)
282
+ this._emit('connected')
283
+ return this
284
+ } catch (error: any) {
285
+ if (error instanceof Error) Error.captureStackTrace(error)
286
+
287
+ finalizer.unregister(this._pq)
288
+ this._pq.finish()
289
+ this._destroyed = true
290
+
291
+ this._emit('error', error)
292
+ throw error
293
+ }
294
+ }
295
+
296
+ /** Destroy this {@link Connection} releasing all related resources */
297
+ destroy(): void {
298
+ if (this._destroyed) return
299
+
300
+ finalizer.unregister(this._pq)
301
+ this._pq.finish()
302
+ this._destroyed = true
303
+
304
+ this._emit('destroyed')
305
+ }
306
+
307
+ /** ===== QUERY INTERFACE ================================================= */
308
+
309
+ /** Execute a (possibly parameterised) query with this {@link Connection} */
310
+ async query(text: string, params?: ConnectionQueryParams): Promise<ConnectionQueryResult> {
311
+ /* Enqueue a new query, and return a Promise to its result */
312
+ const promise = this._queue.enqueue(() => {
313
+ assert(this._pq.connected, `Connection "${this.id}" not connected`)
314
+
315
+ /* Create a new "Query" handling all I/O and run it, wrapping the call
316
+ * in an async/await to properly contextualize error stack traces */
317
+ return new Query(this._pq, this._logger)
318
+ .on('error', (error) => this._emit('error', error))
319
+ .run(text, params)
320
+ })
321
+
322
+ try {
323
+ return await promise
324
+ } catch (error: any) {
325
+ if (error instanceof Error) Error.captureStackTrace(error)
326
+ throw error
327
+ } finally {
328
+ /* Forget the connection if the query terminated it */
329
+ if (! this._pq.connected) this.destroy()
330
+ }
331
+ }
332
+
333
+ /** Cancel (if possible) the currently running query */
334
+ cancel(): void {
335
+ assert(this._pq.connected, `Connection "${this.id}" not connected`)
336
+
337
+ /* Remember, PQcancel creates a temporary connection to issue the cancel
338
+ * so it doesn't affect the current query (must still be read in full!) */
339
+ const cancel = this._pq.cancel()
340
+ if (cancel === true) return
341
+
342
+ /* coverage ignore next */
343
+ throw new Error(cancel || 'Unknown error canceling')
344
+ }
345
+ }
346
+
347
+ /* ========================================================================== *
348
+ * QUERY INTERFACE *
349
+ * ========================================================================== */
350
+
351
+ /** Internal implementation of a query, sending and awaiting a result */
352
+ class Query extends Emitter {
353
+ constructor(private _pq: LibPQ, logger: Logger) {
354
+ super(logger)
355
+ }
356
+
357
+ /** Run a query, sending it and flushing it, then reading results */
358
+ run(text: string, params: ConnectionQueryParams = []): Promise<ConnectionQueryResult> {
359
+ return new Promise<ConnectionQueryResult>((resolve, reject) => {
360
+ /* Send the query to the server and check it was sent */
361
+ const sent = params.length > 0 ?
362
+ this._pq.sendQueryParams(text, params as any[]) :
363
+ this._pq.sendQuery(text)
364
+
365
+ if (! sent) throw this._fail('sendQuery', 'Unable to send query')
366
+
367
+ /* Make sure the query is flushed all the way without errors */
368
+ this._flushQuery((error) => {
369
+ if (error) return reject(error)
370
+
371
+ /* Prepare the callback to be executed when the connection is readable */
372
+ const readableCallback = (): void => this._read(onResult)
373
+
374
+ /* Prepare the callback to be executed when results are fully read */
375
+ const onResult = (error?: Error): void => {
376
+ /* Regardless on whether there was an error, stop reading... */
377
+ this._pq.stopReader()
378
+ this._pq.off('readable', readableCallback)
379
+
380
+ /* If there was an error, simply reject our result */
381
+ if (error) {
382
+ this._pq.clear()
383
+ return reject(error)
384
+ }
385
+
386
+ /* If successful, prepare the result and resolve */
387
+ const result = this._createResult()
388
+ this._pq.clear()
389
+ resolve(result)
390
+ }
391
+
392
+ /* Start the reading loop, */
393
+ this._pq.on('readable', readableCallback)
394
+ this._pq.startReader()
395
+ })
396
+ })
397
+ }
398
+
399
+ /* === ERRORS ============================================================= */
400
+
401
+ private _fail(syscall: string, message: string): Error {
402
+ const text = (this._pq.errorMessage() || '').trim() || message
403
+ const error = Object.assign(new Error(`${text} (${syscall})`), { syscall })
404
+ this._pq.finish()
405
+
406
+ this._emit('error', error)
407
+ return error
408
+ }
409
+
410
+ /* === INTERNALS ========================================================== */
411
+
412
+ private _error?: Error
413
+
414
+ private _flushQuery(cb: (error?: Error) => void): void {
415
+ const result = this._pq.flush()
416
+
417
+ /* No errors, continue */
418
+ if (result === 0) return cb() // 0 is "success"
419
+
420
+ /* Error flushing the query */
421
+ if (result === -1) cb(this._fail('flush', 'Unable to flush query'))
422
+
423
+ /* Not flushed yet, wait and retry */
424
+ this._pq.writable(/* coverage ignore next */ () => this._flushQuery(cb))
425
+ }
426
+
427
+ private _read(onResult: (error?: Error) => void): void {
428
+ /* Read waiting data from the socket */
429
+ if (! this._pq.consumeInput()) {
430
+ return onResult(this._fail('consumeInput', 'Unable to consume input'))
431
+ }
432
+
433
+ /* Check if there is still outstanding data, if so, wait for it all to come in */
434
+ if (this._pq.isBusy()) /* coverage ignore next */ return
435
+
436
+ /* Load our result object */
437
+ while (this._pq.getResult()) {
438
+ /* Check the status of the result */
439
+ const status = this._pq.resultStatus()
440
+ switch (status) {
441
+ case 'PGRES_FATAL_ERROR':
442
+ this._error = new Error(`SQL Fatal Error: ${this._pq.resultErrorMessage().trim()}`)
443
+ break
444
+
445
+ case 'PGRES_TUPLES_OK':
446
+ case 'PGRES_COMMAND_OK':
447
+ case 'PGRES_EMPTY_QUERY':
448
+ break
449
+
450
+ default:
451
+ return onResult(this._fail('resultStatus', `Unrecognized status ${status}`))
452
+ }
453
+
454
+ /* If reading multiple results, sometimes the following results might
455
+ * cause a blocking read. in this scenario yield back off the reader
456
+ * until libpq is readable */
457
+ if (this._pq.isBusy()) /* coverage ignore next */ return
458
+ }
459
+
460
+ /* All done, invoke our callback for completion! */
461
+ onResult(this._error)
462
+ }
463
+
464
+ /* === RESULT ============================================================= */
465
+
466
+ /** Create a {@link ConnectionQueryResult} from the data currently held by `libpq` */
467
+ private _createResult(): ConnectionQueryResult {
468
+ const command = this._pq.cmdStatus().split(' ')[0]!
469
+ const rowCount = parseInt(this._pq.cmdTuples() || '0')
470
+
471
+ const nfields = this._pq.nfields()
472
+ const ntuples = this._pq.ntuples()
473
+
474
+ const fields: ConnectionQueryResult['fields'] = new Array(nfields)
475
+ const rows: ConnectionQueryResult['rows'] = new Array(ntuples)
476
+
477
+ /* Looad up all the fields (name & type) from the query results */
478
+ for (let i = 0; i < nfields; i++) {
479
+ fields[i] = [ this._pq.fname(i), this._pq.ftype(i) ]
480
+ }
481
+
482
+ /* Load up all the results, row-by-row, column-by-column */
483
+ for (let i = 0; i < ntuples; i++) {
484
+ const row: (string | null)[] = rows[i] = new Array(nfields)
485
+ for (let j = 0; j < nfields; j++) {
486
+ const value = this._pq.getvalue(i, j)
487
+ row[j] = (value === '') && (this._pq.getisnull(i, j)) ? null : value
488
+ }
489
+ }
490
+
491
+ /* All done */
492
+ return { command, rowCount, fields, rows }
493
+ }
494
+ }
package/src/events.ts ADDED
@@ -0,0 +1,42 @@
1
+ import { EventEmitter } from 'node:events'
2
+
3
+ import type { Logger } from './index'
4
+
5
+ interface Events {
6
+ error: (error: Error) => unknown
7
+ }
8
+
9
+ type EventParams<E, K extends keyof E> =
10
+ E[K] extends ((...args: any[]) => unknown) ? Parameters<E[K]> : never
11
+
12
+ type EventCallback<E, K extends keyof E> =
13
+ E[K] extends ((...args: any[]) => unknown) ? E[K] : never
14
+
15
+ export class Emitter<E = Events> {
16
+ private _emitter = new EventEmitter()
17
+
18
+ constructor(protected _logger: Logger) {}
19
+
20
+ protected _emit<K extends keyof E>(event: K & string, ...args: EventParams<E, K>): void {
21
+ try {
22
+ this._emitter.emit(event, ...args)
23
+ } catch (error) {
24
+ this._logger.error(`Error in "${event}" handler`, error)
25
+ }
26
+ }
27
+
28
+ on<K extends keyof E>(event: K & string, callback: EventCallback<E, K>): this {
29
+ this._emitter.on(event, callback)
30
+ return this
31
+ }
32
+
33
+ once<K extends keyof E>(event: K & string, callback: EventCallback<E, K>): this {
34
+ this._emitter.once(event, callback)
35
+ return this
36
+ }
37
+
38
+ off<K extends keyof E>(event: K & string, callback: EventCallback<E, K>): this {
39
+ this._emitter.off(event, callback)
40
+ return this
41
+ }
42
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ export { Connection } from './connection'
2
+ export { ConnectionPool } from './pool'
3
+
4
+ export type { ConnectionOptions, ConnectionQueryResult as ConnectionQueryResult } from './connection'
5
+ export type { ConnectionPoolOptions, ConnectionPoolStats } from './pool'
6
+
7
+ /** A base Logger class that can be used to inject */
8
+ export interface Logger {
9
+ /** Log a message at `DEBUG` level */
10
+ readonly debug: (...args: any[]) => void
11
+ /** Log a message at `INFO` level */
12
+ readonly info: (...args: any[]) => void
13
+ /** Log a message at `WARN` level */
14
+ readonly warn: (...args: any[]) => void
15
+ /** Log a message at `ERROR` level */
16
+ readonly error: (...args: any[]) => void
17
+ }
package/src/libpq.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { createRequire } from 'node:module'
2
+
3
+ import type libpq from 'libpq'
4
+
5
+ // LibPQ has a nasty tendency to emit the path of its source directory when
6
+ // the parent module is not specified, and this happens *always* in ESM mode.
7
+ // By manually creating the require function, we can avoid this (aesthetics)
8
+ export type LibPQ = libpq
9
+ export type LibPQConstructor = { new(): LibPQ }
10
+ export const LibPQ: LibPQConstructor = createRequire(__fileurl)('libpq')