@effect/sql-pg 4.0.0-rc.115 → 4.0.0-rc.117
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/README.md +125 -1
- package/ai-docs/package.json +3 -3
- package/dist/PgClient.d.ts +27 -3
- package/dist/PgClient.d.ts.map +1 -1
- package/dist/PgClient.js +1 -0
- package/dist/PgClient.js.map +1 -1
- package/dist/PgConnection.d.ts +54 -14
- package/dist/PgConnection.d.ts.map +1 -1
- package/dist/PgConnection.js +121 -67
- package/dist/PgConnection.js.map +1 -1
- package/dist/PgMigrator.d.ts.map +1 -1
- package/dist/PgMigrator.js +15 -10
- package/dist/PgMigrator.js.map +1 -1
- package/dist/PgPool.d.ts.map +1 -1
- package/dist/PgPool.js +4 -7
- package/dist/PgPool.js.map +1 -1
- package/dist/PgTypes.d.ts +20 -10
- package/dist/PgTypes.d.ts.map +1 -1
- package/dist/PgTypes.js +64 -44
- package/dist/PgTypes.js.map +1 -1
- package/dist/internal/password.d.ts +4 -0
- package/dist/internal/password.d.ts.map +1 -0
- package/dist/internal/password.js +4 -0
- package/dist/internal/password.js.map +1 -0
- package/package.json +5 -5
- package/src/PgClient.ts +29 -4
- package/src/PgConnection.ts +194 -98
- package/src/PgMigrator.ts +18 -19
- package/src/PgPool.ts +4 -7
- package/src/PgTypes.ts +71 -50
- package/src/internal/connection.ts +2 -2
- package/src/internal/password.ts +11 -0
package/src/PgConnection.ts
CHANGED
|
@@ -34,6 +34,7 @@ import type { Duplex } from "node:stream"
|
|
|
34
34
|
import * as Tls from "node:tls"
|
|
35
35
|
import type { ConnectionOptions } from "node:tls"
|
|
36
36
|
import { type ConnectionInternals, internalsKey } from "./internal/connection.ts"
|
|
37
|
+
import * as PasswordInternal from "./internal/password.ts"
|
|
37
38
|
import { classifySqlState, validateChannelName } from "./internal/sqlError.ts"
|
|
38
39
|
import * as PgAuth from "./PgAuth.ts"
|
|
39
40
|
import * as PgProtocol from "./PgProtocol.ts"
|
|
@@ -66,6 +67,11 @@ export type TypeId = "~@effect/sql-pg/PgConnection"
|
|
|
66
67
|
* socket path, while a `host` beginning with `/` is treated as a socket
|
|
67
68
|
* directory and expands to `${host}/.s.PGSQL.${port}`.
|
|
68
69
|
*
|
|
70
|
+
* URL modes `sslmode=prefer` and `sslmode=allow` try TLS first, falling back
|
|
71
|
+
* to plaintext only when the server answers `SSLRequest` with `N`. Unlike
|
|
72
|
+
* libpq, `allow` also tries TLS first. Certificate verification stays enabled
|
|
73
|
+
* unless explicitly disabled through `ssl` options.
|
|
74
|
+
*
|
|
69
75
|
* Prepared statements are enabled by default and limited by
|
|
70
76
|
* `preparedStatementCacheSize`. Disable them for statement-mode poolers or
|
|
71
77
|
* workloads that generate unique SQL. Streams always use unnamed statements.
|
|
@@ -86,9 +92,31 @@ export interface Config {
|
|
|
86
92
|
readonly ssl?: boolean | ConnectionOptions | undefined
|
|
87
93
|
readonly database?: string | undefined
|
|
88
94
|
readonly username?: string | undefined
|
|
89
|
-
|
|
95
|
+
/**
|
|
96
|
+
* A static password or an Effect evaluated for each connection attempt.
|
|
97
|
+
* Providers must handle typed errors and require no services.
|
|
98
|
+
* {@link Effect.orDie} converts typed errors to defects, not retryable SQL errors.
|
|
99
|
+
*/
|
|
100
|
+
readonly password?: Redacted.Redacted | Effect.Effect<Redacted.Redacted> | undefined
|
|
90
101
|
readonly connectTimeout?: Duration.Input | undefined
|
|
102
|
+
/**
|
|
103
|
+
* Overrides `startupParameters.application_name`, the URL's `application_name`,
|
|
104
|
+
* and the default `"@effect/sql-pg"`, in that order.
|
|
105
|
+
*/
|
|
91
106
|
readonly applicationName?: string | undefined
|
|
107
|
+
/**
|
|
108
|
+
* Session defaults sent in every physical connection's startup packet.
|
|
109
|
+
* Names are lowercased; `user`, `database`, `replication`, and `options` are
|
|
110
|
+
* reserved. `client_encoding` only accepts UTF8 / UTF-8 (case-insensitive).
|
|
111
|
+
* Empty names and NUL bytes fail before connecting; PostgreSQL validates
|
|
112
|
+
* other settings. Do not set the same GUC here and in `startupOptions`.
|
|
113
|
+
*/
|
|
114
|
+
readonly startupParameters?: Readonly<Record<string, string>> | undefined
|
|
115
|
+
/**
|
|
116
|
+
* Opaque PostgreSQL startup options, overriding the URL's `options` parameter.
|
|
117
|
+
* Forwarded without parsing `-c` flags or checking for duplicate GUCs.
|
|
118
|
+
*/
|
|
119
|
+
readonly startupOptions?: string | undefined
|
|
92
120
|
readonly stream?: (() => Duplex) | undefined
|
|
93
121
|
readonly types?: PgTypes.Registry | undefined
|
|
94
122
|
readonly multiplex?: boolean | undefined
|
|
@@ -171,13 +199,26 @@ export interface PgConnection {
|
|
|
171
199
|
* ownership queue, while calls through the original connection wait.
|
|
172
200
|
*/
|
|
173
201
|
readonly pin: Effect.Effect<PgConnection, never, Scope.Scope>
|
|
174
|
-
/**
|
|
202
|
+
/**
|
|
203
|
+
* Runs a query and returns rows keyed by column name. Pass `false` to skip
|
|
204
|
+
* the prepared statement cache.
|
|
205
|
+
*
|
|
206
|
+
* On interruption, the connection drains to `ReadyForQuery` and sends a
|
|
207
|
+
* `CancelRequest` if needed. Unless the backend confirms cancellation with
|
|
208
|
+
* `57014`, a pool retires the session before its next checkout. A retained
|
|
209
|
+
* checkout and an unpooled session remain exposed to a late cancel.
|
|
210
|
+
* `statement_timeout` also reports `57014` and can be mistaken for
|
|
211
|
+
* confirmation.
|
|
212
|
+
*/
|
|
175
213
|
readonly query: (
|
|
176
214
|
sql: string,
|
|
177
215
|
params?: ReadonlyArray<unknown>,
|
|
178
216
|
prepare?: boolean
|
|
179
217
|
) => Effect.Effect<Result, SqlError>
|
|
180
|
-
/**
|
|
218
|
+
/**
|
|
219
|
+
* Runs a query and returns positional rows. Pass `false` to skip the
|
|
220
|
+
* prepared statement cache. Interruption behaves as for `query`.
|
|
221
|
+
*/
|
|
181
222
|
readonly queryValues: (
|
|
182
223
|
sql: string,
|
|
183
224
|
params?: ReadonlyArray<unknown>,
|
|
@@ -185,9 +226,8 @@ export interface PgConnection {
|
|
|
185
226
|
) => Effect.Effect<ReadonlyArray<ReadonlyArray<unknown>>, SqlError>
|
|
186
227
|
/**
|
|
187
228
|
* Streams rows without collecting the full result. The session is pinned for
|
|
188
|
-
* the lifetime of the stream.
|
|
189
|
-
*
|
|
190
|
-
* and drains the connection back to `ReadyForQuery`.
|
|
229
|
+
* the lifetime of the stream. An early abort behaves like an interrupted
|
|
230
|
+
* `query`.
|
|
191
231
|
*/
|
|
192
232
|
readonly stream: (
|
|
193
233
|
sql: string,
|
|
@@ -198,10 +238,12 @@ export interface PgConnection {
|
|
|
198
238
|
* PostgreSQL confirms `LISTEN`. The session stays pinned until the scope
|
|
199
239
|
* closes, when it runs `UNLISTEN` and shuts down the queue. PostgreSQL
|
|
200
240
|
* registration errors fail the acquiring effect.
|
|
241
|
+
* Connection failures after registration fail the queue with the original
|
|
242
|
+
* `SqlError`. Intentional scope closure interrupts consumers.
|
|
201
243
|
*/
|
|
202
244
|
readonly listen: (
|
|
203
245
|
channel: string
|
|
204
|
-
) => Effect.Effect<Queue.Dequeue<Notification>, SqlError, Scope.Scope>
|
|
246
|
+
) => Effect.Effect<Queue.Dequeue<Notification, SqlError>, SqlError, Scope.Scope>
|
|
205
247
|
/**
|
|
206
248
|
* Attempts to cancel the active query through a side connection. This is a
|
|
207
249
|
* no-op for an unpinned multiplexed connection because the active
|
|
@@ -223,14 +265,13 @@ export const PgConnection = Context.Service<PgConnection>("@effect/sql-pg/PgConn
|
|
|
223
265
|
*
|
|
224
266
|
* **Details**
|
|
225
267
|
*
|
|
226
|
-
*
|
|
227
|
-
* under `connectTimeout` (5 seconds by default). The effect
|
|
228
|
-
* backend sends `ReadyForQuery`.
|
|
229
|
-
*
|
|
268
|
+
* Password resolution, transport, optional `SSLRequest`, startup, and
|
|
269
|
+
* authentication run under `connectTimeout` (5 seconds by default). The effect
|
|
270
|
+
* resolves when the backend sends `ReadyForQuery`. Closing the scope sends
|
|
271
|
+
* `Terminate` and ends the socket.
|
|
230
272
|
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
* explicitly.
|
|
273
|
+
* Use `sslmode=require` or explicit `ssl: true` to require encryption.
|
|
274
|
+
* Unix sockets and custom streams should set `ssl.servername` explicitly.
|
|
234
275
|
*
|
|
235
276
|
* @category constructors
|
|
236
277
|
* @since 4.0.0
|
|
@@ -238,10 +279,11 @@ export const PgConnection = Context.Service<PgConnection>("@effect/sql-pg/PgConn
|
|
|
238
279
|
export const make = (options: Config): Effect.Effect<PgConnection, SqlError, Scope.Scope> =>
|
|
239
280
|
Effect.flatMap(resolveConfig(options), (config) =>
|
|
240
281
|
Effect.acquireRelease(
|
|
241
|
-
Effect.
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
282
|
+
Effect.gen(function*() {
|
|
283
|
+
const password = yield* PasswordInternal.resolve(config.password)
|
|
284
|
+
const session = yield* connect(config, password)
|
|
285
|
+
return new PgConnectionImpl(options, config, session, options.types)
|
|
286
|
+
}),
|
|
245
287
|
(connection) => Effect.sync(() => connection.closeUnsafe()),
|
|
246
288
|
{ interruptible: true }
|
|
247
289
|
).pipe(
|
|
@@ -262,6 +304,7 @@ export const make = (options: Config): Effect.Effect<PgConnection, SqlError, Sco
|
|
|
262
304
|
|
|
263
305
|
interface Session {
|
|
264
306
|
readonly socket: Duplex
|
|
307
|
+
readonly encrypted: boolean
|
|
265
308
|
readonly parser: PgProtocol.Parser<unknown>
|
|
266
309
|
readonly processId: number
|
|
267
310
|
readonly secretKey: number
|
|
@@ -286,6 +329,9 @@ interface PipelineEntry {
|
|
|
286
329
|
}
|
|
287
330
|
|
|
288
331
|
const abortDrainTimeoutMillis = 5000
|
|
332
|
+
const abortDrainGraceMillis = 10
|
|
333
|
+
/** PostgreSQL `query_canceled` SQLSTATE. */
|
|
334
|
+
const queryCanceledCode = "57014"
|
|
289
335
|
const cancelRequestTimeoutMillis = 5000
|
|
290
336
|
/** How many statements a multiplexed session keeps on the wire at once. */
|
|
291
337
|
const maxPipelineDepth = 128
|
|
@@ -311,8 +357,9 @@ class PgConnectionImpl implements PgConnection {
|
|
|
311
357
|
consumer: Consumer | undefined
|
|
312
358
|
deadWith: SqlError | undefined
|
|
313
359
|
closed = false
|
|
314
|
-
|
|
315
|
-
readonly
|
|
360
|
+
cancelPending = false
|
|
361
|
+
readonly channels = new Map<string, Set<Queue.Queue<Notification, SqlError>>>()
|
|
362
|
+
readonly retireHooks = new Set<() => void>()
|
|
316
363
|
/** Queued but not yet written; drained into `pipelineInFlight` on flush. */
|
|
317
364
|
readonly pipelinePending: Array<PipelineEntry> = []
|
|
318
365
|
/** Written and awaiting their `ReadyForQuery`, oldest first. */
|
|
@@ -336,7 +383,7 @@ class PgConnectionImpl implements PgConnection {
|
|
|
336
383
|
this[internalsKey] = {
|
|
337
384
|
base: this,
|
|
338
385
|
deadError: () => this.deadWith,
|
|
339
|
-
|
|
386
|
+
retireHooks: this.retireHooks
|
|
340
387
|
}
|
|
341
388
|
this.pinnedView = new PinnedPgConnection(this)
|
|
342
389
|
session.socket.on("data", this.onData)
|
|
@@ -368,6 +415,15 @@ class PgConnectionImpl implements PgConnection {
|
|
|
368
415
|
private readonly dispatch = (message: PgProtocol.BackendMessage<unknown>): void => {
|
|
369
416
|
if (this.deadWith !== undefined) return
|
|
370
417
|
switch (message._tag) {
|
|
418
|
+
case "ErrorResponse":
|
|
419
|
+
if (message.fields.code === queryCanceledCode) this.cancelPending = false
|
|
420
|
+
break
|
|
421
|
+
case "ReadyForQuery":
|
|
422
|
+
if (this.cancelPending) {
|
|
423
|
+
this.cancelPending = false
|
|
424
|
+
this.retire()
|
|
425
|
+
}
|
|
426
|
+
break
|
|
371
427
|
case "NotificationResponse": {
|
|
372
428
|
const queues = this.channels.get(message.channel)
|
|
373
429
|
if (queues !== undefined) {
|
|
@@ -412,12 +468,16 @@ class PgConnectionImpl implements PgConnection {
|
|
|
412
468
|
consumer?.onFatal(error)
|
|
413
469
|
const sets = Array.from(this.channels.values())
|
|
414
470
|
this.channels.clear()
|
|
471
|
+
const cause = this.closed ? Cause.interrupt() : Cause.fail(error)
|
|
415
472
|
for (const set of sets) {
|
|
416
|
-
for (const queue of set) Queue.failCauseUnsafe(queue,
|
|
417
|
-
}
|
|
418
|
-
if (!this.closed) {
|
|
419
|
-
for (const hook of this.fatalHooks) hook()
|
|
473
|
+
for (const queue of set) Queue.failCauseUnsafe(queue, cause)
|
|
420
474
|
}
|
|
475
|
+
this.retire()
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
private retire(): void {
|
|
479
|
+
if (this.closed) return
|
|
480
|
+
for (const hook of this.retireHooks) hook()
|
|
421
481
|
}
|
|
422
482
|
|
|
423
483
|
closeUnsafe(): void {
|
|
@@ -663,7 +723,9 @@ class PgConnectionImpl implements PgConnection {
|
|
|
663
723
|
/** Sends a `CancelRequest` for this session on a side connection. */
|
|
664
724
|
readonly cancel: Effect.Effect<void> = Effect.suspend(() => {
|
|
665
725
|
if (this.deadWith !== undefined) return Effect.void
|
|
666
|
-
|
|
726
|
+
this.cancelPending = true
|
|
727
|
+
if (this.consumer === undefined) this.retire()
|
|
728
|
+
return sendCancelRequest(this.resolved, this.session)
|
|
667
729
|
})
|
|
668
730
|
|
|
669
731
|
readonly pin: Effect.Effect<PgConnection, never, Scope.Scope> = Effect.suspend(() => {
|
|
@@ -719,7 +781,8 @@ class PgConnectionImpl implements PgConnection {
|
|
|
719
781
|
|
|
720
782
|
readonly listen = (
|
|
721
783
|
channel: string
|
|
722
|
-
): Effect.Effect<Queue.Dequeue<Notification>, SqlError, Scope.Scope> =>
|
|
784
|
+
): Effect.Effect<Queue.Dequeue<Notification, SqlError>, SqlError, Scope.Scope> =>
|
|
785
|
+
listenChannel(this, this.pin, channel)
|
|
723
786
|
|
|
724
787
|
readonly interrupt: Effect.Effect<void> = Effect.suspend(() =>
|
|
725
788
|
this.multiplex && !this.pinned ? Effect.void : this.cancel
|
|
@@ -768,7 +831,8 @@ class PinnedPgConnection implements PgConnection {
|
|
|
768
831
|
|
|
769
832
|
readonly listen = (
|
|
770
833
|
channel: string
|
|
771
|
-
): Effect.Effect<Queue.Dequeue<Notification>, SqlError, Scope.Scope> =>
|
|
834
|
+
): Effect.Effect<Queue.Dequeue<Notification, SqlError>, SqlError, Scope.Scope> =>
|
|
835
|
+
listenChannel(this.base, this.pin, channel)
|
|
772
836
|
}
|
|
773
837
|
|
|
774
838
|
interface QueryOutput {
|
|
@@ -809,11 +873,7 @@ const inferScalar = (value: unknown): PgTypes.Parameter => {
|
|
|
809
873
|
// or timestamp column the way it did with the text-protocol drivers.
|
|
810
874
|
return inferredParameter(0, value)
|
|
811
875
|
}
|
|
812
|
-
if (value instanceof Date)
|
|
813
|
-
const time = value.getTime()
|
|
814
|
-
if (Number.isNaN(time)) throw new PgTypes.CodecError({ message: "Invalid Date parameter" })
|
|
815
|
-
return inferredParameter(PgTypes.OID.timestamptz, time)
|
|
816
|
-
}
|
|
876
|
+
if (value instanceof Date) return inferredParameter(PgTypes.OID.timestamptz, value)
|
|
817
877
|
if (value instanceof Uint8Array) return inferredParameter(PgTypes.OID.bytea, value)
|
|
818
878
|
if (value instanceof Int8Array) {
|
|
819
879
|
return inferredParameter(
|
|
@@ -1047,6 +1107,7 @@ const encodeQuery = (
|
|
|
1047
1107
|
class PreparedCache {
|
|
1048
1108
|
readonly max: number
|
|
1049
1109
|
private readonly statements = new Map<string, Prepared>()
|
|
1110
|
+
private readonly namespace = randomBytes(8).toString("hex")
|
|
1050
1111
|
private closes: Array<Uint8Array> | undefined
|
|
1051
1112
|
private counter = 0
|
|
1052
1113
|
|
|
@@ -1066,7 +1127,7 @@ class PreparedCache {
|
|
|
1066
1127
|
return found
|
|
1067
1128
|
}
|
|
1068
1129
|
const prepared: Prepared = {
|
|
1069
|
-
name: `
|
|
1130
|
+
name: `effect_${this.namespace}_${++this.counter}`,
|
|
1070
1131
|
key,
|
|
1071
1132
|
ready: false,
|
|
1072
1133
|
parsing: false,
|
|
@@ -1427,6 +1488,32 @@ class QueryMachine implements Consumer {
|
|
|
1427
1488
|
const emptyRows: ReadonlyArray<Row> = []
|
|
1428
1489
|
const emptyValues: ReadonlyArray<ReadonlyArray<unknown>> = []
|
|
1429
1490
|
|
|
1491
|
+
/** Drains an aborted statement, sending a cancel after a short grace period and failing if it stalls. */
|
|
1492
|
+
const drainAborted = (
|
|
1493
|
+
conn: PgConnectionImpl,
|
|
1494
|
+
timeoutMessage: string,
|
|
1495
|
+
abort: (onDrained: () => void) => void
|
|
1496
|
+
): Effect.Effect<void> => {
|
|
1497
|
+
const drained = Deferred.makeUnsafe<void>()
|
|
1498
|
+
const timeout = setTimeout(
|
|
1499
|
+
() => conn.fatal(connectionQueryError(new Error(timeoutMessage), `PgConnection: ${timeoutMessage}`)),
|
|
1500
|
+
abortDrainTimeoutMillis
|
|
1501
|
+
)
|
|
1502
|
+
abort(() => {
|
|
1503
|
+
clearTimeout(timeout)
|
|
1504
|
+
Deferred.doneUnsafe(drained, Effect.void)
|
|
1505
|
+
})
|
|
1506
|
+
const grace = Effect.callback<void>((resume) => {
|
|
1507
|
+
const timer = setTimeout(() => resume(Effect.void), abortDrainGraceMillis)
|
|
1508
|
+
return Effect.sync(() => clearTimeout(timer))
|
|
1509
|
+
})
|
|
1510
|
+
const cancelAndDrain = Effect.andThen(conn.cancel, Deferred.await(drained))
|
|
1511
|
+
return Effect.andThen(
|
|
1512
|
+
Effect.raceFirst(Deferred.await(drained), grace),
|
|
1513
|
+
Effect.suspend(() => Deferred.isDoneUnsafe(drained) ? Effect.void : cancelAndDrain)
|
|
1514
|
+
)
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1430
1517
|
/** One cycle on a connection that carries a single statement at a time. */
|
|
1431
1518
|
const runQuery = (
|
|
1432
1519
|
conn: PgConnectionImpl,
|
|
@@ -1452,26 +1539,13 @@ const runQuery = (
|
|
|
1452
1539
|
conn.fatal(connectionQueryError(cause, "PgConnection: Failed to write query"))
|
|
1453
1540
|
}
|
|
1454
1541
|
|
|
1455
|
-
// On interruption: cancel the statement and drain the connection back to
|
|
1456
|
-
// ReadyForQuery so it stays usable, destroying it when the drain stalls.
|
|
1457
1542
|
return Effect.suspend(() => {
|
|
1458
1543
|
if (machine.isDone()) return Effect.void
|
|
1459
|
-
|
|
1460
|
-
const timer = setTimeout(
|
|
1461
|
-
() =>
|
|
1462
|
-
conn.fatal(connectionQueryError(
|
|
1463
|
-
new Error("Query cancellation timed out"),
|
|
1464
|
-
"PgConnection: Query cancellation timed out"
|
|
1465
|
-
)),
|
|
1466
|
-
abortDrainTimeoutMillis
|
|
1467
|
-
)
|
|
1544
|
+
return drainAborted(conn, "Query cancellation timed out", (onDrained) =>
|
|
1468
1545
|
machine.abort(() => {
|
|
1469
|
-
clearTimeout(timer)
|
|
1470
1546
|
conn.consumer = undefined
|
|
1471
|
-
|
|
1472
|
-
})
|
|
1473
|
-
})
|
|
1474
|
-
return Effect.andThen(conn.cancel, wait)
|
|
1547
|
+
onDrained()
|
|
1548
|
+
}))
|
|
1475
1549
|
})
|
|
1476
1550
|
})
|
|
1477
1551
|
|
|
@@ -1716,30 +1790,16 @@ const streamRows = (
|
|
|
1716
1790
|
else if (buffer.length >= streamPauseThreshold) setPaused(true)
|
|
1717
1791
|
}
|
|
1718
1792
|
|
|
1719
|
-
// On early abort: cancel the statement and drain back to ReadyForQuery so
|
|
1720
|
-
// the pinned connection stays usable, destroying it when the drain stalls.
|
|
1721
1793
|
yield* Scope.addFinalizer(
|
|
1722
1794
|
scope,
|
|
1723
1795
|
Effect.suspend(() => {
|
|
1724
1796
|
if (done) return Effect.void
|
|
1725
1797
|
aborted = true
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
if (done) return resumeWait(Effect.void)
|
|
1729
|
-
const timer = setTimeout(
|
|
1730
|
-
() =>
|
|
1731
|
-
conn.fatal(connectionQueryError(
|
|
1732
|
-
new Error("Stream cancellation timed out"),
|
|
1733
|
-
"PgConnection: Stream cancellation timed out"
|
|
1734
|
-
)),
|
|
1735
|
-
abortDrainTimeoutMillis
|
|
1736
|
-
)
|
|
1737
|
-
drainDone = () => {
|
|
1738
|
-
clearTimeout(timer)
|
|
1739
|
-
resumeWait(Effect.void)
|
|
1740
|
-
}
|
|
1798
|
+
const drain = drainAborted(conn, "Stream cancellation timed out", (onDrained) => {
|
|
1799
|
+
drainDone = onDrained
|
|
1741
1800
|
})
|
|
1742
|
-
|
|
1801
|
+
setPaused(false)
|
|
1802
|
+
return drain
|
|
1743
1803
|
})
|
|
1744
1804
|
)
|
|
1745
1805
|
|
|
@@ -1768,7 +1828,7 @@ const listenChannel = (
|
|
|
1768
1828
|
conn: PgConnectionImpl,
|
|
1769
1829
|
pin: Effect.Effect<PgConnection, never, Scope.Scope>,
|
|
1770
1830
|
channel: string
|
|
1771
|
-
): Effect.Effect<Queue.Dequeue<Notification>, SqlError, Scope.Scope> =>
|
|
1831
|
+
): Effect.Effect<Queue.Dequeue<Notification, SqlError>, SqlError, Scope.Scope> =>
|
|
1772
1832
|
Effect.uninterruptibleMask((restore) =>
|
|
1773
1833
|
Effect.gen(function*() {
|
|
1774
1834
|
const channelError = validateChannelName(channel, "listen")
|
|
@@ -1778,7 +1838,7 @@ const listenChannel = (
|
|
|
1778
1838
|
return yield* restore(Effect.gen(function*() {
|
|
1779
1839
|
const pinned = yield* Scope.provide(pin, scope)
|
|
1780
1840
|
if (conn.deadWith !== undefined) return yield* conn.deadWith
|
|
1781
|
-
const queue = yield* Queue.unbounded<Notification>()
|
|
1841
|
+
const queue = yield* Queue.unbounded<Notification, SqlError>()
|
|
1782
1842
|
const identifier = escapeIdentifier(channel)
|
|
1783
1843
|
let queues = conn.channels.get(channel)
|
|
1784
1844
|
if (queues === undefined) {
|
|
@@ -1808,7 +1868,7 @@ const listenChannel = (
|
|
|
1808
1868
|
})
|
|
1809
1869
|
)
|
|
1810
1870
|
|
|
1811
|
-
const sendCancelRequest = (config: ResolvedConfig,
|
|
1871
|
+
const sendCancelRequest = (config: ResolvedConfig, session: Session): Effect.Effect<void> =>
|
|
1812
1872
|
Effect.callback<void>((resume) => {
|
|
1813
1873
|
let done = false
|
|
1814
1874
|
let socket: Duplex
|
|
@@ -1820,7 +1880,7 @@ const sendCancelRequest = (config: ResolvedConfig, pid: number, secret: number):
|
|
|
1820
1880
|
socket?.destroy()
|
|
1821
1881
|
resume(Effect.void)
|
|
1822
1882
|
}
|
|
1823
|
-
const frame = PgProtocol.encodeCancelRequest({ pid, secret })
|
|
1883
|
+
const frame = PgProtocol.encodeCancelRequest({ pid: session.processId, secret: session.secretKey })
|
|
1824
1884
|
// After the frame is written the server processes the request and closes
|
|
1825
1885
|
// the connection, which lands in the `close` handler.
|
|
1826
1886
|
const send = (): void => {
|
|
@@ -1830,8 +1890,8 @@ const sendCancelRequest = (config: ResolvedConfig, pid: number, secret: number):
|
|
|
1830
1890
|
if (config.ssl === false) return send()
|
|
1831
1891
|
socket.once("data", (chunk: Uint8Array) => {
|
|
1832
1892
|
if (done) return
|
|
1833
|
-
//
|
|
1834
|
-
|
|
1893
|
+
// A TLS session's cancel secret must stay encrypted.
|
|
1894
|
+
if (chunk.length === 1 && chunk[0] === 0x4e && config.sslOptional && !session.encrypted) return send()
|
|
1835
1895
|
if (chunk.length !== 1 || chunk[0] !== 0x53) return finish()
|
|
1836
1896
|
const raw = socket
|
|
1837
1897
|
raw.off("error", finish)
|
|
@@ -1866,10 +1926,11 @@ const sendCancelRequest = (config: ResolvedConfig, pid: number, secret: number):
|
|
|
1866
1926
|
return Effect.sync(finish)
|
|
1867
1927
|
})
|
|
1868
1928
|
|
|
1869
|
-
const connect = (config: ResolvedConfig): Effect.Effect<Session, SqlError> =>
|
|
1929
|
+
const connect = (config: ResolvedConfig, resolvedPassword: string | undefined): Effect.Effect<Session, SqlError> =>
|
|
1870
1930
|
Effect.callback<Session, SqlError>((resume) => {
|
|
1871
1931
|
let done = false
|
|
1872
1932
|
let socket: Duplex
|
|
1933
|
+
let encrypted = false
|
|
1873
1934
|
let parser: PgProtocol.Parser<unknown> | undefined
|
|
1874
1935
|
let sslErrorParser: PgProtocol.Parser | undefined
|
|
1875
1936
|
let scram: PgAuth.ScramState | undefined
|
|
@@ -1892,14 +1953,14 @@ const connect = (config: ResolvedConfig): Effect.Effect<Session, SqlError> =>
|
|
|
1892
1953
|
failConnect(new Error("Connection closed unexpectedly"), "PgConnection: Connection closed during startup")
|
|
1893
1954
|
|
|
1894
1955
|
const password = (): string | undefined => {
|
|
1895
|
-
if (
|
|
1956
|
+
if (resolvedPassword === undefined) {
|
|
1896
1957
|
failAuth(
|
|
1897
1958
|
new Error("The server requested password authentication"),
|
|
1898
1959
|
"PgConnection: No password configured"
|
|
1899
1960
|
)
|
|
1900
1961
|
return undefined
|
|
1901
1962
|
}
|
|
1902
|
-
return
|
|
1963
|
+
return resolvedPassword
|
|
1903
1964
|
}
|
|
1904
1965
|
|
|
1905
1966
|
const handleMessage = (message: PgProtocol.BackendMessage<unknown>): void => {
|
|
@@ -1991,7 +2052,7 @@ const connect = (config: ResolvedConfig): Effect.Effect<Session, SqlError> =>
|
|
|
1991
2052
|
socket.off("error", onError)
|
|
1992
2053
|
socket.off("close", onClose)
|
|
1993
2054
|
socket.on("error", ignoreError)
|
|
1994
|
-
resume(Effect.succeed({ socket, parser: parser!, processId, secretKey }))
|
|
2055
|
+
resume(Effect.succeed({ socket, encrypted, parser: parser!, processId, secretKey }))
|
|
1995
2056
|
return
|
|
1996
2057
|
default:
|
|
1997
2058
|
return failConnect(
|
|
@@ -2017,11 +2078,7 @@ const connect = (config: ResolvedConfig): Effect.Effect<Session, SqlError> =>
|
|
|
2017
2078
|
const startup = (): void => {
|
|
2018
2079
|
parser = PgProtocol.makeParser<unknown>({ maxMessageSize: config.maxMessageSize })
|
|
2019
2080
|
socket.on("data", onData)
|
|
2020
|
-
socket.write(PgProtocol.encodeStartupMessage(
|
|
2021
|
-
user: config.username,
|
|
2022
|
-
database: config.database,
|
|
2023
|
-
application_name: config.applicationName
|
|
2024
|
-
}))
|
|
2081
|
+
socket.write(PgProtocol.encodeStartupMessage(config.startupParameters))
|
|
2025
2082
|
}
|
|
2026
2083
|
|
|
2027
2084
|
const onSslResponse = (chunk: Uint8Array): void => {
|
|
@@ -2057,6 +2114,7 @@ const connect = (config: ResolvedConfig): Effect.Effect<Session, SqlError> =>
|
|
|
2057
2114
|
return failConnect(response.failure, "PgConnection: Invalid SSLRequest response")
|
|
2058
2115
|
}
|
|
2059
2116
|
if (response.success === "N") {
|
|
2117
|
+
if (config.sslOptional) return startup()
|
|
2060
2118
|
return failConnect(new Error("The server does not support TLS"), "PgConnection: Server refused TLS")
|
|
2061
2119
|
}
|
|
2062
2120
|
const raw = socket
|
|
@@ -2070,7 +2128,10 @@ const connect = (config: ResolvedConfig): Effect.Effect<Session, SqlError> =>
|
|
|
2070
2128
|
})
|
|
2071
2129
|
socket.on("error", onError)
|
|
2072
2130
|
socket.on("close", onClose)
|
|
2073
|
-
socket.once("secureConnect",
|
|
2131
|
+
socket.once("secureConnect", () => {
|
|
2132
|
+
encrypted = true
|
|
2133
|
+
startup()
|
|
2134
|
+
})
|
|
2074
2135
|
}
|
|
2075
2136
|
|
|
2076
2137
|
const begin = (): void => {
|
|
@@ -2117,11 +2178,11 @@ interface ResolvedConfig {
|
|
|
2117
2178
|
readonly port: number
|
|
2118
2179
|
readonly path: string | undefined
|
|
2119
2180
|
readonly ssl: boolean | ConnectionOptions
|
|
2120
|
-
readonly
|
|
2181
|
+
readonly sslOptional: boolean
|
|
2121
2182
|
readonly username: string
|
|
2122
|
-
readonly password:
|
|
2183
|
+
readonly password: Redacted.Redacted | Effect.Effect<Redacted.Redacted> | undefined
|
|
2123
2184
|
readonly connectTimeout: Duration.Duration
|
|
2124
|
-
readonly
|
|
2185
|
+
readonly startupParameters: PgProtocol.StartupParameters
|
|
2125
2186
|
readonly stream: (() => Duplex) | undefined
|
|
2126
2187
|
readonly maxMessageSize: number | undefined
|
|
2127
2188
|
}
|
|
@@ -2138,7 +2199,7 @@ const configError = (message: string, cause?: unknown): SqlError =>
|
|
|
2138
2199
|
const resolveConfig = (options: Config): Effect.Effect<ResolvedConfig, SqlError> =>
|
|
2139
2200
|
Effect.suspend(() => {
|
|
2140
2201
|
const parsed: EffectResult.Result<UrlConfig, SqlError> = options.url !== undefined
|
|
2141
|
-
? parseUrl(Redacted.value(options.url)
|
|
2202
|
+
? parseUrl(Redacted.value(options.url))
|
|
2142
2203
|
: EffectResult.succeed({})
|
|
2143
2204
|
if (EffectResult.isFailure(parsed)) return Effect.fail(parsed.failure)
|
|
2144
2205
|
const url = parsed.success
|
|
@@ -2148,16 +2209,49 @@ const resolveConfig = (options: Config): Effect.Effect<ResolvedConfig, SqlError>
|
|
|
2148
2209
|
if (username === undefined) {
|
|
2149
2210
|
return Effect.fail(configError("No username configured"))
|
|
2150
2211
|
}
|
|
2212
|
+
const named: Record<string, string> = Object.create(null)
|
|
2213
|
+
for (const [name, value] of Object.entries(options.startupParameters ?? {})) {
|
|
2214
|
+
const key = name.toLowerCase()
|
|
2215
|
+
if (key === "user" || key === "database" || key === "replication" || key === "options") {
|
|
2216
|
+
return Effect.fail(configError(`Reserved startup parameter: "${name}"`))
|
|
2217
|
+
}
|
|
2218
|
+
if (key === "client_encoding") {
|
|
2219
|
+
const encoding = value.toUpperCase()
|
|
2220
|
+
if (encoding !== "UTF8" && encoding !== "UTF-8") {
|
|
2221
|
+
return Effect.fail(configError("Startup parameter client_encoding must be UTF8 or UTF-8"))
|
|
2222
|
+
}
|
|
2223
|
+
named[key] = "UTF8"
|
|
2224
|
+
} else {
|
|
2225
|
+
named[key] = value
|
|
2226
|
+
}
|
|
2227
|
+
if (name === "" || name.includes("\0") || value.includes("\0")) {
|
|
2228
|
+
return Effect.fail(
|
|
2229
|
+
configError("Startup parameter names must be nonempty and names/values must not contain NUL")
|
|
2230
|
+
)
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
const startupParameters: PgProtocol.StartupParameters = {
|
|
2234
|
+
...named,
|
|
2235
|
+
user: username,
|
|
2236
|
+
database: options.database ?? url.database,
|
|
2237
|
+
application_name: options.applicationName ?? named.application_name ?? url.applicationName ?? "@effect/sql-pg",
|
|
2238
|
+
options: options.startupOptions ?? url.options
|
|
2239
|
+
}
|
|
2240
|
+
for (const [name, value] of Object.entries(startupParameters)) {
|
|
2241
|
+
if (value?.includes("\0")) {
|
|
2242
|
+
return Effect.fail(configError(`Startup parameter "${name}" must not contain NUL`))
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2151
2245
|
return Effect.succeed<ResolvedConfig>({
|
|
2152
2246
|
host,
|
|
2153
2247
|
port,
|
|
2154
2248
|
path: options.path ?? (host.startsWith("/") ? `${host}/.s.PGSQL.${port}` : undefined),
|
|
2155
|
-
ssl: options.ssl ?? url.ssl ?? false,
|
|
2156
|
-
|
|
2249
|
+
ssl: options.ssl ?? (url.ssl === "prefer" ? true : url.ssl ?? false),
|
|
2250
|
+
sslOptional: options.ssl === undefined && url.ssl === "prefer",
|
|
2157
2251
|
username,
|
|
2158
|
-
password: options.password !== undefined ? Redacted.
|
|
2252
|
+
password: options.password ?? (url.password !== undefined ? Redacted.make(url.password) : undefined),
|
|
2159
2253
|
connectTimeout: Duration.fromInputUnsafe(options.connectTimeout ?? url.connectTimeout ?? Duration.seconds(5)),
|
|
2160
|
-
|
|
2254
|
+
startupParameters,
|
|
2161
2255
|
stream: options.stream,
|
|
2162
2256
|
maxMessageSize: options.maxMessageSize
|
|
2163
2257
|
})
|
|
@@ -2170,8 +2264,9 @@ interface UrlConfig {
|
|
|
2170
2264
|
username?: string | undefined
|
|
2171
2265
|
password?: string | undefined
|
|
2172
2266
|
applicationName?: string | undefined
|
|
2267
|
+
options?: string | undefined
|
|
2173
2268
|
connectTimeout?: Duration.Duration | undefined
|
|
2174
|
-
ssl?: boolean | undefined
|
|
2269
|
+
ssl?: boolean | "prefer" | undefined
|
|
2175
2270
|
}
|
|
2176
2271
|
|
|
2177
2272
|
const decodeComponent = (value: string, what: string): EffectResult.Result<string, SqlError> => {
|
|
@@ -2189,7 +2284,7 @@ const parsePort = (value: string, what: string): EffectResult.Result<number, Sql
|
|
|
2189
2284
|
: EffectResult.succeed(port)
|
|
2190
2285
|
}
|
|
2191
2286
|
|
|
2192
|
-
const parseUrl = (raw: string
|
|
2287
|
+
const parseUrl = (raw: string): EffectResult.Result<UrlConfig, SqlError> => {
|
|
2193
2288
|
let url: URL
|
|
2194
2289
|
try {
|
|
2195
2290
|
url = new URL(raw)
|
|
@@ -2252,6 +2347,9 @@ const parseUrl = (raw: string, hasExplicitSsl: boolean): EffectResult.Result<Url
|
|
|
2252
2347
|
case "application_name":
|
|
2253
2348
|
config.applicationName = value
|
|
2254
2349
|
break
|
|
2350
|
+
case "options":
|
|
2351
|
+
config.options = value
|
|
2352
|
+
break
|
|
2255
2353
|
case "connect_timeout": {
|
|
2256
2354
|
const seconds = Number(value)
|
|
2257
2355
|
if (!Number.isInteger(seconds) || seconds < 0) {
|
|
@@ -2272,10 +2370,8 @@ const parseUrl = (raw: string, hasExplicitSsl: boolean): EffectResult.Result<Url
|
|
|
2272
2370
|
break
|
|
2273
2371
|
case "prefer":
|
|
2274
2372
|
case "allow":
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
configError(`sslmode "${value}" is not supported: set ssl explicitly to true or false`)
|
|
2278
|
-
)
|
|
2373
|
+
config.ssl = "prefer"
|
|
2374
|
+
break
|
|
2279
2375
|
default:
|
|
2280
2376
|
return EffectResult.fail(configError(`Unrecognized sslmode in URL: "${value}"`))
|
|
2281
2377
|
}
|