@juit/pgproxy-client 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.
package/src/client.ts ADDED
@@ -0,0 +1,172 @@
1
+ import { Registry, serialize } from '@juit/pgproxy-types'
2
+
3
+ import { assert } from './assert'
4
+ import { createProvider } from './provider'
5
+ import { PGResult } from './result'
6
+
7
+ import type { PGConnection, PGProvider } from './provider'
8
+
9
+ function serializeParams(params: any[]): (string | null)[] {
10
+ if (params.length == 0) return []
11
+
12
+ const result: (string | null)[] = new Array(params.length)
13
+ for (let i = 0; i < params.length; i ++) {
14
+ result[i] =
15
+ params[i] === undefined ? null :
16
+ params[i] === null ? null :
17
+ serialize(params[i])
18
+ }
19
+
20
+ return result
21
+ }
22
+
23
+ /** An interface for an object that can execute queries on a database */
24
+ export interface PGQueryable {
25
+ /**
26
+ * Execute a query on the database
27
+ *
28
+ * @param text - The SQL query to execute optionally containing placeholders.
29
+ * @param params - Any parameter replacement for `$x` placeholders.
30
+ */
31
+ query<
32
+ Row extends Record<string, any> = Record<string, any>,
33
+ Tuple extends readonly any[] = readonly any [],
34
+ >(text: string, params?: any[]): Promise<PGResult<Row, Tuple>>
35
+ }
36
+
37
+ /**
38
+ * An interface for an object that can execute queries _and transactions_
39
+ * on a database */
40
+ export interface PGTransactionable extends PGQueryable {
41
+ /** Start a transaction by issuing a `BEGIN` statement */
42
+ begin(): Promise<this>
43
+ /** Commit a transaction by issuing a `COMMIT` statement */
44
+ commit(): Promise<this>
45
+ /** Cancel a transaction by issuing a `ROLLBACK` statement */
46
+ rollback(): Promise<this>
47
+ }
48
+
49
+
50
+ /** A consumer for a {@link PGTransactionable} connection */
51
+ export type PGConsumer<T> = (connection: PGTransactionable) => T | PromiseLike<T>
52
+
53
+ /** The PostgreSQL client */
54
+ export interface PGClient extends PGQueryable {
55
+ /** The {@link @juit/pgproxy-types#Registry} used to parse results from PostgreSQL */
56
+ readonly registry: Registry
57
+
58
+ /**
59
+ * Execute a _single_ query on the database.
60
+ *
61
+ * Invoking the `query` method on a {@link (PGClient:interface)} does NOT guarantee that
62
+ * the query will be executed on the same connection, therefore things like
63
+ * _transactions_ will be immediately rolled back after the query.
64
+ *
65
+ * @param text - The SQL query to execute optionally containing placeholders.
66
+ * @param params - Any parameter replacement for `$x` placeholders.
67
+ */
68
+ query<
69
+ Row extends Record<string, any> = Record<string, any>,
70
+ Tuple extends readonly any[] = readonly any [],
71
+ >(text: string, params?: any[]): Promise<PGResult<Row, Tuple>>
72
+
73
+ /**
74
+ * Connect to the database to execute a number of different queries.
75
+ *
76
+ * The `consumer` will be passed a {@link PGTransactionable} instance backed
77
+ * by the _same_ connection to the database, therefore transactions can be
78
+ * safely executed in the context of the consumer function itself.
79
+ */
80
+ connect<T>(consumer: PGConsumer<T>): Promise<T>
81
+
82
+ /**
83
+ * Destroy any resource and underlying connection associated with this
84
+ * instance's {@link PGProvider}.
85
+ */
86
+ destroy(): Promise<void>
87
+ }
88
+
89
+ /** A constructor for {@link (PGClient:interface)} instances */
90
+ export interface PGClientConstructor {
91
+ new (url?: string | URL): PGClient
92
+ new (provider: PGProvider<PGConnection>): PGClient
93
+ }
94
+
95
+ /**
96
+ * The PostgreSQL client
97
+ *
98
+ * @constructor
99
+ */
100
+ export const PGClient: PGClientConstructor = class PGClientImpl implements PGClient {
101
+ readonly registry: Registry = new Registry()
102
+
103
+ private _provider: PGProvider<PGConnection>
104
+
105
+ constructor(url?: string | URL)
106
+ constructor(provider: PGProvider<PGConnection>)
107
+ constructor(urlOrProvider?: string | URL | PGProvider<PGConnection>) {
108
+ urlOrProvider = urlOrProvider || globalThis?.process?.env?.PGURL
109
+ assert(urlOrProvider, 'No URL to connect to (PGURL environment variable missing?)')
110
+ if (typeof urlOrProvider === 'string') urlOrProvider = new URL(urlOrProvider, 'psql:///')
111
+ assert(urlOrProvider, 'Missing URL or provider for client')
112
+
113
+ if (urlOrProvider instanceof URL) {
114
+ if (!(urlOrProvider.username || urlOrProvider.password)) {
115
+ const username = globalThis?.process?.env?.PGUSER || ''
116
+ const password = globalThis?.process?.env?.PGPASSWORD || ''
117
+ urlOrProvider.username = encodeURIComponent(username)
118
+ urlOrProvider.password = encodeURIComponent(password)
119
+ }
120
+ }
121
+
122
+ this._provider = urlOrProvider instanceof URL ?
123
+ createProvider(urlOrProvider) :
124
+ urlOrProvider
125
+ }
126
+
127
+ async query<
128
+ Row extends Record<string, any> = Record<string, any>,
129
+ Tuple extends readonly any[] = readonly any [],
130
+ >(text: string, params: any[] = []): Promise<PGResult<Row, Tuple>> {
131
+ const result = await this._provider.query(text, serializeParams(params))
132
+ return new PGResult<Row, Tuple>(result, this.registry)
133
+ }
134
+
135
+ async connect<T>(consumer: PGConsumer<T>): Promise<T> {
136
+ const connection = await this._provider.acquire()
137
+
138
+ try {
139
+ const registry = this.registry
140
+
141
+ const consumable: PGTransactionable = {
142
+ async query<
143
+ Row extends Record<string, any> = Record<string, any>,
144
+ Tuple extends readonly any[] = readonly any [],
145
+ >(text: string, params: any[] = []): Promise<PGResult<Row, Tuple>> {
146
+ const result = await connection.query(text, serializeParams(params))
147
+ return new PGResult(result, registry)
148
+ },
149
+ async begin(): Promise<PGTransactionable> {
150
+ await this.query('BEGIN')
151
+ return this
152
+ },
153
+ async commit(): Promise<PGTransactionable> {
154
+ await this.query('COMMIT')
155
+ return this
156
+ },
157
+ async rollback(): Promise<PGTransactionable> {
158
+ await this.query('ROLLBACK')
159
+ return this
160
+ },
161
+ }
162
+
163
+ return await consumer(consumable)
164
+ } finally {
165
+ await this._provider.release(connection)
166
+ }
167
+ }
168
+
169
+ async destroy(): Promise<void> {
170
+ return await this._provider.destroy()
171
+ }
172
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './assert'
2
+ export * from './client'
3
+ export * from './provider'
4
+ export * from './result'
5
+ export * from './websocket'
@@ -0,0 +1,80 @@
1
+ import { assert } from './assert'
2
+
3
+
4
+ /* ========================================================================== *
5
+ * EXPORTED TYPES *
6
+ * ========================================================================== */
7
+
8
+ /** Describes the result of a query from a {@link PGProvider} */
9
+ export interface PGConnectionResult {
10
+ /** The SQL command that generated this result (`SELECT`, `INSERT`, ...) */
11
+ command: string
12
+ /** Number of rows affected by this query (e.g. added rows in `INSERT`) */
13
+ rowCount: number
14
+ /** Fields description with `name` (column name) and `oid` (type) */
15
+ fields: [ name: string, oid: number ][]
16
+ /** Result rows, as an array of unparsed `string` results from `libpq` */
17
+ rows: (string | null)[][]
18
+ }
19
+
20
+ export interface PGConnection {
21
+ query(text: string, params: (string | null)[]): Promise<PGConnectionResult>
22
+ }
23
+
24
+ export interface PGProviderConstructor<Connection extends PGConnection> {
25
+ new (url: URL): PGProvider<Connection>
26
+ }
27
+
28
+ export interface PGProvider<Connection extends PGConnection> extends PGConnection {
29
+ acquire(): Promise<Connection>
30
+ release(connection: Connection): Promise<void>
31
+ destroy(): Promise<void>
32
+ }
33
+
34
+ /* ========================================================================== *
35
+ * ABSTRACT PROVIDER IMPLEMENTATION *
36
+ * ========================================================================== */
37
+
38
+ export abstract class AbstractPGProvider<Connection extends PGConnection>
39
+ implements PGProvider<Connection> {
40
+ abstract acquire(): Promise<Connection>
41
+ abstract release(connection: PGConnection): Promise<void>
42
+
43
+ async query(text: string, params: string[]): Promise<PGConnectionResult> {
44
+ const connection = await this.acquire()
45
+ try {
46
+ return await connection.query(text, params)
47
+ } finally {
48
+ await this.release(connection)
49
+ }
50
+ }
51
+
52
+ async destroy(): Promise<void> {
53
+ /* Nothing to do here... */
54
+ }
55
+ }
56
+
57
+ /* ========================================================================== *
58
+ * PROVIDERS REGISTRATION *
59
+ * ========================================================================== */
60
+
61
+ /** All known providers, mapped by protocol */
62
+ const providers = new Map<string, PGProviderConstructor<PGConnection>>()
63
+
64
+ /** Register a provider, associating it with the specified protocol */
65
+ export function registerProvider(
66
+ protocol: string,
67
+ constructor: PGProviderConstructor<PGConnection>,
68
+ ): void {
69
+ protocol = `${protocol}:` // URL always has protocol with _colon_
70
+ assert(! providers.has(protocol), `Connection provider for "${protocol}..." already registered`)
71
+ providers.set(protocol, constructor)
72
+ providers.set(protocol, constructor)
73
+ }
74
+
75
+ /** Create a new {@link PGProvider} instance for the specified URL */
76
+ export function createProvider(url: URL): PGProvider<PGConnection> {
77
+ const Provider = providers.get(url.protocol)
78
+ assert(Provider, `No connection provider registered for "${url.protocol}..."`)
79
+ return new Provider(url)
80
+ }
package/src/result.ts ADDED
@@ -0,0 +1,83 @@
1
+ import type { Registry } from '@juit/pgproxy-types'
2
+ import type { PGConnectionResult } from './provider'
3
+
4
+ /* ========================================================================== *
5
+ * EXPORTED TYPES *
6
+ * ========================================================================== */
7
+
8
+ /** The result of a database query */
9
+ export interface PGResult<
10
+ Row extends Record<string, any> = Record<string, any>,
11
+ Tuple extends readonly any[] = readonly any [],
12
+ > {
13
+ /** The SQL command that generated this result (`SELECT`, `INSERT`, ...) */
14
+ command: string
15
+ /** Result description describing column names and relative OIDs */
16
+ fields: { name: string, oid: number }[]
17
+ /**
18
+ * The number of rows affected by the query.
19
+ *
20
+ * This can be the number of lines returned in `rows` (for `SELECT`
21
+ * statements, for example) or the number of lines _affected_ by the query
22
+ * (the number of records inserted by an `INSERT` query).
23
+ */
24
+ rowCount: number
25
+ /** The rows returned by the database query, keyed by the column name. */
26
+ rows: Row[]
27
+ /** The tuples returned by the database query, keyed by the column index. */
28
+ tuples: Tuple[]
29
+ }
30
+
31
+ /** Constructor for {@link (PGResult:interface)} instances */
32
+ export interface PGResultConstructor {
33
+ new <
34
+ Row extends Record<string, any> = Record<string, any>,
35
+ Tuple extends readonly any[] = readonly any [],
36
+ >(result: PGConnectionResult, registry: Registry): PGResult<Row, Tuple>
37
+ }
38
+
39
+ /* ========================================================================== *
40
+ * PGRESULT IMPLEMENTATION *
41
+ * ========================================================================== */
42
+
43
+ /** The result of a database query */
44
+ export const PGResult: PGResultConstructor = class PGResultImpl<
45
+ Row extends Record<string, any> = Record<string, any>,
46
+ Tuple extends readonly any[] = readonly any [],
47
+ > implements PGResult<Row, Tuple> {
48
+ command: string
49
+ fields: { name: string, oid: number }[]
50
+ rowCount: number
51
+ rows: Row[]
52
+ tuples: Tuple[]
53
+
54
+ constructor(result: PGConnectionResult, registry: Registry) {
55
+ this.rowCount = result.rowCount
56
+ this.command = result.command
57
+ this.fields = result.fields.map(([ name, oid ]) => ({ name, oid }))
58
+
59
+ const rowCount = result.rows.length
60
+ const colCount = result.fields.length
61
+
62
+ const mappers = result.fields.map(([ name, oid ]) => ([
63
+ name, registry.getParser(oid),
64
+ ] as const))
65
+
66
+ const rows = this.rows = new Array(rowCount)
67
+ const tuples = this.tuples = new Array(rowCount)
68
+
69
+ for (let row = 0; row < rowCount; row ++) {
70
+ const tupleData = tuples[row] = new Array(colCount)
71
+ const rowData = rows[row] = {} as Record<string, any>
72
+
73
+ for (let col = 0; col < colCount; col ++) {
74
+ const [ name, parser ] = mappers[col]!
75
+ const value = result.rows[row]![col]!
76
+ tupleData[col] = rowData[name] =
77
+ value === null ? null :
78
+ value === undefined ? null :
79
+ parser(value)
80
+ }
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,269 @@
1
+ import { assert } from './assert'
2
+
3
+ import type { Request, Response } from '@juit/pgproxy-server'
4
+ import type { PGConnection, PGConnectionResult, PGProvider } from './provider'
5
+
6
+ /* ========================================================================== *
7
+ * WEBSOCKET TYPES: in order to work with both WHATWG WebSockets and NodeJS's *
8
+ * "ws" package, let's abstract our _minimal_ requirement for implementation *
9
+ * ensuring type compatibility between the two variants. *
10
+ * ========================================================================== */
11
+
12
+ const pgWebSocketReadyState = {
13
+ CONNECTING: 0,
14
+ OPEN: 1,
15
+ CLOSING: 2,
16
+ CLOSED: 3,
17
+ } as const
18
+
19
+ interface PGWebSocketCloseEvent {
20
+ readonly code: number;
21
+ readonly reason: string;
22
+ }
23
+
24
+ interface PGWebSocketMessageEvent {
25
+ readonly data?: any
26
+ }
27
+
28
+ interface PGWebSocketErrorEvent {
29
+ readonly error?: any
30
+ }
31
+
32
+ export interface PGWebSocket {
33
+ addEventListener(event: 'close', handler: (event: PGWebSocketCloseEvent) => void): void
34
+ addEventListener(event: 'error', handler: (event: PGWebSocketErrorEvent) => void): void
35
+ addEventListener(event: 'message', handler: (event: PGWebSocketMessageEvent) => void): void
36
+ addEventListener(event: 'open', handler: () => void): void
37
+ removeEventListener(event: 'close', handler: (event: PGWebSocketCloseEvent) => void): void
38
+ removeEventListener(event: 'error', handler: (event: PGWebSocketErrorEvent) => void): void
39
+ removeEventListener(event: 'message', handler: (event: PGWebSocketMessageEvent) => void): void
40
+ removeEventListener(event: 'open', handler: () => void): void
41
+
42
+ readonly readyState: number;
43
+
44
+ send(message: string): void
45
+ close(code?: number, reason?: string): void;
46
+ }
47
+
48
+ /* ========================================================================== *
49
+ * INTERNALS *
50
+ * ========================================================================== */
51
+
52
+ /* Return the specified message or the default one */
53
+ function msg(message: string | null | undefined, defaultMessage: string): string {
54
+ return message || defaultMessage
55
+ }
56
+
57
+ /** A request, simply an unwrapped {@link PGConnectionResult} promise */
58
+ class WebSocketRequest {
59
+ readonly promise: Promise<PGConnectionResult>
60
+ readonly resolve!: (result: PGConnectionResult) => void
61
+ readonly reject!: (reason: any) => void
62
+
63
+ constructor(public id: string) {
64
+ this.promise = new Promise((resolve, reject) => Object.defineProperties(this, {
65
+ resolve: { value: resolve },
66
+ reject: { value: reject },
67
+ }))
68
+ }
69
+ }
70
+
71
+ /** Connection implementation, wrapping a `WebSocket` */
72
+ class WebSocketConnectionImpl implements WebSocketConnection {
73
+ /** Open requests to correlate, keyed by their unique request id */
74
+ private _requests = new Map<string, WebSocketRequest>()
75
+ /** Our error, set also when the websocket is closed */
76
+ private _error?: any
77
+
78
+ constructor(private _socket: PGWebSocket, private _getRequestId: () => string) {
79
+ /* On close, set the error to "WebSocket Closed" if none was set before */
80
+ _socket.addEventListener('close', (event) => {
81
+ /* Keep the first error we received... */
82
+ if (! this._error) {
83
+ const reason = msg(event.reason, 'Unknown Reason')
84
+ const message = `WebSocket Closed (${event.code}): ${reason}`
85
+ this._error = new Error(message)
86
+ }
87
+
88
+ /* Reject all open/pending requests */
89
+ for (const req of this._requests.values()) req.reject(this._error)
90
+ this._requests.clear()
91
+ })
92
+
93
+ /* On errors, make sure that the websocket is closed */
94
+ _socket.addEventListener('error', (event) => {
95
+ if (event.error) this._error = event.error
96
+ else this._error = new Error('Unknown WebSocket Error')
97
+
98
+ /* Reject all open/pending requests */
99
+ for (const req of this._requests.values()) req.reject(this._error)
100
+ this._requests.clear()
101
+
102
+ /* Make sure that the websocket is closed */
103
+ this.close()
104
+ })
105
+
106
+ /* On messages, correlate the message with a request and resolve it */
107
+ _socket.addEventListener('message', (event) => {
108
+ try {
109
+ /* Make sure we have a _text_ message (yup, it's JSON) */
110
+ const data = event.data
111
+ assert(typeof data === 'string', 'Data not a "string"')
112
+
113
+ /* Parse the response */
114
+ let payload: Response
115
+ try {
116
+ payload = JSON.parse(data)
117
+ } catch (error) {
118
+ throw new Error('Unable to parse JSON payload')
119
+ }
120
+
121
+ /* Ensure the payload is a proper object */
122
+ assert(payload && (typeof payload === 'object'), 'JSON payload is not an object')
123
+
124
+ /* Correlate the response ID with a previous request */
125
+ const request = this._requests.get(payload.id)
126
+ assert(request, `Invalid response ID "${payload.id}"`)
127
+
128
+ /* Determine what kind of response we're dealing with */
129
+ if (payload.statusCode === 200) {
130
+ this._requests.delete(payload.id)
131
+ return request.resolve(payload)
132
+ } else if (payload.statusCode === 400) {
133
+ this._requests.delete(payload.id)
134
+ return request.reject(new Error(`${msg(payload.error, 'Unknown error')} (${payload.statusCode})`))
135
+ } else {
136
+ throw new Error(`${msg(payload.error, 'Unknown error')} (${payload.statusCode})`)
137
+ }
138
+ } catch (error: any) {
139
+ _socket.close(1003, msg(error.message, 'Uknown error'))
140
+ }
141
+ })
142
+ }
143
+
144
+ close(): void {
145
+ if (this._socket.readyState === pgWebSocketReadyState.CLOSED) return
146
+ /* coverage ignore if */
147
+ if (this._socket.readyState === pgWebSocketReadyState.CLOSING) return
148
+ this._socket.close(1000, 'Normal termination')
149
+ }
150
+
151
+ query(query: string, params: (string | null)[]): Promise<PGConnectionResult> {
152
+ /* The error is set also when the websocket is closed, soooooo... */
153
+ if (this._error) return Promise.reject(this._error)
154
+
155
+ /* Wrap sending into a promise, both request.promise and send can fail... */
156
+ return new Promise((resolve, reject) => {
157
+ /* Get a unique request ID, and prepare our request */
158
+ const id = this._getRequestId()
159
+ const request = new WebSocketRequest(id)
160
+ this._requests.set(id, request)
161
+
162
+ /* Handle responses from the request first */
163
+ request.promise.then(resolve, reject)
164
+
165
+ /* Send our message to the server after the request promise is handled */
166
+ try {
167
+ this._socket.send(JSON.stringify({ id, query, params } satisfies Request))
168
+ } catch (error) {
169
+ reject(error)
170
+ }
171
+ })
172
+ }
173
+ }
174
+
175
+ /* ========================================================================== *
176
+ * EXPORTED *
177
+ * ========================================================================== */
178
+
179
+ /** A connection to the database backed by a `WebSocket` */
180
+ export interface WebSocketConnection extends PGConnection {
181
+ /** Close this connection and the underlying `WebSocket` */
182
+ close(): void
183
+ }
184
+
185
+ /** An abstract provider implementing `connect(...)` via WHATWG WebSockets */
186
+ export abstract class WebSocketProvider implements PGProvider<WebSocketConnection> {
187
+ private readonly _connections = new Set<WebSocketConnection>()
188
+
189
+ abstract query(text: string, params: (string | null)[]): Promise<PGConnectionResult>
190
+
191
+ /** Return a unique request identifier to correlate responses */
192
+ protected abstract _getUniqueRequestId(): string
193
+
194
+ /**
195
+ * Create a new WebSocket.
196
+ *
197
+ * This method can be asynchronous and can return a `Promise`. This is
198
+ * due to the fact that in order to create our authentication token with the
199
+ * Web Cryptography API, we need to _await_ the resolution of our token.
200
+ *
201
+ * This method should call _synchronously_ the {@link WebSocketProvider._connectWebSocket}
202
+ * as soon as the WebSocket instance is created, in order to handle `open`,
203
+ * `close`, or `error` events before the event loop has a chance to resolve
204
+ * the `Promise` asynchronously.
205
+ */
206
+ protected abstract _getWebSocket(): Promise<PGWebSocket>
207
+
208
+ /**
209
+ * Handle the initial connection of a WebSocket.
210
+ *
211
+ * This method should be called _synchronously_ by {@link WebSocketProvider._getWebSocket} as
212
+ * soon as the WebSocket instance is created.
213
+ */
214
+ protected _connectWebSocket<S extends PGWebSocket>(socket: S): Promise<S> {
215
+ return new Promise<S>((resolve, reject) => {
216
+ /* The socket might have already connected (or failed connecting) in the
217
+ * time it takes for the event loop to resolve our promise... */
218
+ if (socket.readyState === pgWebSocketReadyState.OPEN) return resolve(socket)
219
+ if (socket.readyState !== pgWebSocketReadyState.CONNECTING) {
220
+ return reject(new Error(`Invalid WebSocket ready state ${socket.readyState}`))
221
+ }
222
+
223
+ const onopen = (): void => {
224
+ removeEventListeners()
225
+ resolve(socket)
226
+ }
227
+
228
+ const onerror = (event: PGWebSocketErrorEvent): void => {
229
+ removeEventListeners()
230
+ if ('error' in event) return reject(event.error)
231
+ reject(new Error('Uknown error opening WebSocket'))
232
+ }
233
+
234
+ const onclose = (event: PGWebSocketCloseEvent): void => {
235
+ removeEventListeners()
236
+ reject(new Error(`Connection closed with code ${event.code}: ${event.reason}`))
237
+ }
238
+
239
+ const removeEventListeners = (): void => {
240
+ socket.removeEventListener('open', onopen)
241
+ socket.removeEventListener('error', onerror)
242
+ socket.removeEventListener('close', onclose)
243
+ }
244
+
245
+ socket.addEventListener('open', onopen)
246
+ socket.addEventListener('error', onerror)
247
+ socket.addEventListener('close', onclose)
248
+ })
249
+ }
250
+
251
+ async acquire(): Promise<WebSocketConnection> {
252
+ const socket = await this._getWebSocket()
253
+
254
+ /* Wrap the WebSocket into a _connection_, register and return it */
255
+ const connection = new WebSocketConnectionImpl(socket, () => this._getUniqueRequestId())
256
+ this._connections.add(connection)
257
+ return connection
258
+ }
259
+
260
+ async release(connection: WebSocketConnection): Promise<void> {
261
+ this._connections.delete(connection)
262
+ connection.close()
263
+ }
264
+
265
+ async destroy(): Promise<void> {
266
+ this._connections.forEach((connection) => connection.close())
267
+ this._connections.clear()
268
+ }
269
+ }