@effect/sql-pg 4.0.0-rc.115 → 4.0.0-rc.116
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 +1 -1
- package/dist/PgClient.d.ts +27 -3
- package/dist/PgClient.d.ts.map +1 -1
- package/dist/PgClient.js.map +1 -1
- package/dist/PgConnection.d.ts +37 -9
- package/dist/PgConnection.d.ts.map +1 -1
- package/dist/PgConnection.js +74 -39
- 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/PgTypes.d.ts +18 -10
- package/dist/PgTypes.d.ts.map +1 -1
- package/dist/PgTypes.js +48 -31
- 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 +28 -4
- package/src/PgConnection.ts +119 -53
- package/src/PgMigrator.ts +18 -19
- package/src/PgTypes.ts +49 -31
- 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
|
|
@@ -198,10 +226,12 @@ export interface PgConnection {
|
|
|
198
226
|
* PostgreSQL confirms `LISTEN`. The session stays pinned until the scope
|
|
199
227
|
* closes, when it runs `UNLISTEN` and shuts down the queue. PostgreSQL
|
|
200
228
|
* registration errors fail the acquiring effect.
|
|
229
|
+
* Connection failures after registration fail the queue with the original
|
|
230
|
+
* `SqlError`. Intentional scope closure interrupts consumers.
|
|
201
231
|
*/
|
|
202
232
|
readonly listen: (
|
|
203
233
|
channel: string
|
|
204
|
-
) => Effect.Effect<Queue.Dequeue<Notification>, SqlError, Scope.Scope>
|
|
234
|
+
) => Effect.Effect<Queue.Dequeue<Notification, SqlError>, SqlError, Scope.Scope>
|
|
205
235
|
/**
|
|
206
236
|
* Attempts to cancel the active query through a side connection. This is a
|
|
207
237
|
* no-op for an unpinned multiplexed connection because the active
|
|
@@ -223,14 +253,13 @@ export const PgConnection = Context.Service<PgConnection>("@effect/sql-pg/PgConn
|
|
|
223
253
|
*
|
|
224
254
|
* **Details**
|
|
225
255
|
*
|
|
226
|
-
*
|
|
227
|
-
* under `connectTimeout` (5 seconds by default). The effect
|
|
228
|
-
* backend sends `ReadyForQuery`.
|
|
229
|
-
*
|
|
256
|
+
* Password resolution, transport, optional `SSLRequest`, startup, and
|
|
257
|
+
* authentication run under `connectTimeout` (5 seconds by default). The effect
|
|
258
|
+
* resolves when the backend sends `ReadyForQuery`. Closing the scope sends
|
|
259
|
+
* `Terminate` and ends the socket.
|
|
230
260
|
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
* explicitly.
|
|
261
|
+
* Use `sslmode=require` or explicit `ssl: true` to require encryption.
|
|
262
|
+
* Unix sockets and custom streams should set `ssl.servername` explicitly.
|
|
234
263
|
*
|
|
235
264
|
* @category constructors
|
|
236
265
|
* @since 4.0.0
|
|
@@ -238,10 +267,11 @@ export const PgConnection = Context.Service<PgConnection>("@effect/sql-pg/PgConn
|
|
|
238
267
|
export const make = (options: Config): Effect.Effect<PgConnection, SqlError, Scope.Scope> =>
|
|
239
268
|
Effect.flatMap(resolveConfig(options), (config) =>
|
|
240
269
|
Effect.acquireRelease(
|
|
241
|
-
Effect.
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
270
|
+
Effect.gen(function*() {
|
|
271
|
+
const password = yield* PasswordInternal.resolve(config.password)
|
|
272
|
+
const session = yield* connect(config, password)
|
|
273
|
+
return new PgConnectionImpl(options, config, session, options.types)
|
|
274
|
+
}),
|
|
245
275
|
(connection) => Effect.sync(() => connection.closeUnsafe()),
|
|
246
276
|
{ interruptible: true }
|
|
247
277
|
).pipe(
|
|
@@ -262,6 +292,7 @@ export const make = (options: Config): Effect.Effect<PgConnection, SqlError, Sco
|
|
|
262
292
|
|
|
263
293
|
interface Session {
|
|
264
294
|
readonly socket: Duplex
|
|
295
|
+
readonly encrypted: boolean
|
|
265
296
|
readonly parser: PgProtocol.Parser<unknown>
|
|
266
297
|
readonly processId: number
|
|
267
298
|
readonly secretKey: number
|
|
@@ -311,7 +342,7 @@ class PgConnectionImpl implements PgConnection {
|
|
|
311
342
|
consumer: Consumer | undefined
|
|
312
343
|
deadWith: SqlError | undefined
|
|
313
344
|
closed = false
|
|
314
|
-
readonly channels = new Map<string, Set<Queue.Queue<Notification>>>()
|
|
345
|
+
readonly channels = new Map<string, Set<Queue.Queue<Notification, SqlError>>>()
|
|
315
346
|
readonly fatalHooks = new Set<() => void>()
|
|
316
347
|
/** Queued but not yet written; drained into `pipelineInFlight` on flush. */
|
|
317
348
|
readonly pipelinePending: Array<PipelineEntry> = []
|
|
@@ -412,8 +443,9 @@ class PgConnectionImpl implements PgConnection {
|
|
|
412
443
|
consumer?.onFatal(error)
|
|
413
444
|
const sets = Array.from(this.channels.values())
|
|
414
445
|
this.channels.clear()
|
|
446
|
+
const cause = this.closed ? Cause.interrupt() : Cause.fail(error)
|
|
415
447
|
for (const set of sets) {
|
|
416
|
-
for (const queue of set) Queue.failCauseUnsafe(queue,
|
|
448
|
+
for (const queue of set) Queue.failCauseUnsafe(queue, cause)
|
|
417
449
|
}
|
|
418
450
|
if (!this.closed) {
|
|
419
451
|
for (const hook of this.fatalHooks) hook()
|
|
@@ -663,7 +695,7 @@ class PgConnectionImpl implements PgConnection {
|
|
|
663
695
|
/** Sends a `CancelRequest` for this session on a side connection. */
|
|
664
696
|
readonly cancel: Effect.Effect<void> = Effect.suspend(() => {
|
|
665
697
|
if (this.deadWith !== undefined) return Effect.void
|
|
666
|
-
return sendCancelRequest(this.resolved, this.session
|
|
698
|
+
return sendCancelRequest(this.resolved, this.session)
|
|
667
699
|
})
|
|
668
700
|
|
|
669
701
|
readonly pin: Effect.Effect<PgConnection, never, Scope.Scope> = Effect.suspend(() => {
|
|
@@ -719,7 +751,8 @@ class PgConnectionImpl implements PgConnection {
|
|
|
719
751
|
|
|
720
752
|
readonly listen = (
|
|
721
753
|
channel: string
|
|
722
|
-
): Effect.Effect<Queue.Dequeue<Notification>, SqlError, Scope.Scope> =>
|
|
754
|
+
): Effect.Effect<Queue.Dequeue<Notification, SqlError>, SqlError, Scope.Scope> =>
|
|
755
|
+
listenChannel(this, this.pin, channel)
|
|
723
756
|
|
|
724
757
|
readonly interrupt: Effect.Effect<void> = Effect.suspend(() =>
|
|
725
758
|
this.multiplex && !this.pinned ? Effect.void : this.cancel
|
|
@@ -768,7 +801,8 @@ class PinnedPgConnection implements PgConnection {
|
|
|
768
801
|
|
|
769
802
|
readonly listen = (
|
|
770
803
|
channel: string
|
|
771
|
-
): Effect.Effect<Queue.Dequeue<Notification>, SqlError, Scope.Scope> =>
|
|
804
|
+
): Effect.Effect<Queue.Dequeue<Notification, SqlError>, SqlError, Scope.Scope> =>
|
|
805
|
+
listenChannel(this.base, this.pin, channel)
|
|
772
806
|
}
|
|
773
807
|
|
|
774
808
|
interface QueryOutput {
|
|
@@ -809,11 +843,7 @@ const inferScalar = (value: unknown): PgTypes.Parameter => {
|
|
|
809
843
|
// or timestamp column the way it did with the text-protocol drivers.
|
|
810
844
|
return inferredParameter(0, value)
|
|
811
845
|
}
|
|
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
|
-
}
|
|
846
|
+
if (value instanceof Date) return inferredParameter(PgTypes.OID.timestamptz, value)
|
|
817
847
|
if (value instanceof Uint8Array) return inferredParameter(PgTypes.OID.bytea, value)
|
|
818
848
|
if (value instanceof Int8Array) {
|
|
819
849
|
return inferredParameter(
|
|
@@ -1768,7 +1798,7 @@ const listenChannel = (
|
|
|
1768
1798
|
conn: PgConnectionImpl,
|
|
1769
1799
|
pin: Effect.Effect<PgConnection, never, Scope.Scope>,
|
|
1770
1800
|
channel: string
|
|
1771
|
-
): Effect.Effect<Queue.Dequeue<Notification>, SqlError, Scope.Scope> =>
|
|
1801
|
+
): Effect.Effect<Queue.Dequeue<Notification, SqlError>, SqlError, Scope.Scope> =>
|
|
1772
1802
|
Effect.uninterruptibleMask((restore) =>
|
|
1773
1803
|
Effect.gen(function*() {
|
|
1774
1804
|
const channelError = validateChannelName(channel, "listen")
|
|
@@ -1778,7 +1808,7 @@ const listenChannel = (
|
|
|
1778
1808
|
return yield* restore(Effect.gen(function*() {
|
|
1779
1809
|
const pinned = yield* Scope.provide(pin, scope)
|
|
1780
1810
|
if (conn.deadWith !== undefined) return yield* conn.deadWith
|
|
1781
|
-
const queue = yield* Queue.unbounded<Notification>()
|
|
1811
|
+
const queue = yield* Queue.unbounded<Notification, SqlError>()
|
|
1782
1812
|
const identifier = escapeIdentifier(channel)
|
|
1783
1813
|
let queues = conn.channels.get(channel)
|
|
1784
1814
|
if (queues === undefined) {
|
|
@@ -1808,7 +1838,7 @@ const listenChannel = (
|
|
|
1808
1838
|
})
|
|
1809
1839
|
)
|
|
1810
1840
|
|
|
1811
|
-
const sendCancelRequest = (config: ResolvedConfig,
|
|
1841
|
+
const sendCancelRequest = (config: ResolvedConfig, session: Session): Effect.Effect<void> =>
|
|
1812
1842
|
Effect.callback<void>((resume) => {
|
|
1813
1843
|
let done = false
|
|
1814
1844
|
let socket: Duplex
|
|
@@ -1820,7 +1850,7 @@ const sendCancelRequest = (config: ResolvedConfig, pid: number, secret: number):
|
|
|
1820
1850
|
socket?.destroy()
|
|
1821
1851
|
resume(Effect.void)
|
|
1822
1852
|
}
|
|
1823
|
-
const frame = PgProtocol.encodeCancelRequest({ pid, secret })
|
|
1853
|
+
const frame = PgProtocol.encodeCancelRequest({ pid: session.processId, secret: session.secretKey })
|
|
1824
1854
|
// After the frame is written the server processes the request and closes
|
|
1825
1855
|
// the connection, which lands in the `close` handler.
|
|
1826
1856
|
const send = (): void => {
|
|
@@ -1830,8 +1860,8 @@ const sendCancelRequest = (config: ResolvedConfig, pid: number, secret: number):
|
|
|
1830
1860
|
if (config.ssl === false) return send()
|
|
1831
1861
|
socket.once("data", (chunk: Uint8Array) => {
|
|
1832
1862
|
if (done) return
|
|
1833
|
-
//
|
|
1834
|
-
|
|
1863
|
+
// A TLS session's cancel secret must stay encrypted.
|
|
1864
|
+
if (chunk.length === 1 && chunk[0] === 0x4e && config.sslOptional && !session.encrypted) return send()
|
|
1835
1865
|
if (chunk.length !== 1 || chunk[0] !== 0x53) return finish()
|
|
1836
1866
|
const raw = socket
|
|
1837
1867
|
raw.off("error", finish)
|
|
@@ -1866,10 +1896,11 @@ const sendCancelRequest = (config: ResolvedConfig, pid: number, secret: number):
|
|
|
1866
1896
|
return Effect.sync(finish)
|
|
1867
1897
|
})
|
|
1868
1898
|
|
|
1869
|
-
const connect = (config: ResolvedConfig): Effect.Effect<Session, SqlError> =>
|
|
1899
|
+
const connect = (config: ResolvedConfig, resolvedPassword: string | undefined): Effect.Effect<Session, SqlError> =>
|
|
1870
1900
|
Effect.callback<Session, SqlError>((resume) => {
|
|
1871
1901
|
let done = false
|
|
1872
1902
|
let socket: Duplex
|
|
1903
|
+
let encrypted = false
|
|
1873
1904
|
let parser: PgProtocol.Parser<unknown> | undefined
|
|
1874
1905
|
let sslErrorParser: PgProtocol.Parser | undefined
|
|
1875
1906
|
let scram: PgAuth.ScramState | undefined
|
|
@@ -1892,14 +1923,14 @@ const connect = (config: ResolvedConfig): Effect.Effect<Session, SqlError> =>
|
|
|
1892
1923
|
failConnect(new Error("Connection closed unexpectedly"), "PgConnection: Connection closed during startup")
|
|
1893
1924
|
|
|
1894
1925
|
const password = (): string | undefined => {
|
|
1895
|
-
if (
|
|
1926
|
+
if (resolvedPassword === undefined) {
|
|
1896
1927
|
failAuth(
|
|
1897
1928
|
new Error("The server requested password authentication"),
|
|
1898
1929
|
"PgConnection: No password configured"
|
|
1899
1930
|
)
|
|
1900
1931
|
return undefined
|
|
1901
1932
|
}
|
|
1902
|
-
return
|
|
1933
|
+
return resolvedPassword
|
|
1903
1934
|
}
|
|
1904
1935
|
|
|
1905
1936
|
const handleMessage = (message: PgProtocol.BackendMessage<unknown>): void => {
|
|
@@ -1991,7 +2022,7 @@ const connect = (config: ResolvedConfig): Effect.Effect<Session, SqlError> =>
|
|
|
1991
2022
|
socket.off("error", onError)
|
|
1992
2023
|
socket.off("close", onClose)
|
|
1993
2024
|
socket.on("error", ignoreError)
|
|
1994
|
-
resume(Effect.succeed({ socket, parser: parser!, processId, secretKey }))
|
|
2025
|
+
resume(Effect.succeed({ socket, encrypted, parser: parser!, processId, secretKey }))
|
|
1995
2026
|
return
|
|
1996
2027
|
default:
|
|
1997
2028
|
return failConnect(
|
|
@@ -2017,11 +2048,7 @@ const connect = (config: ResolvedConfig): Effect.Effect<Session, SqlError> =>
|
|
|
2017
2048
|
const startup = (): void => {
|
|
2018
2049
|
parser = PgProtocol.makeParser<unknown>({ maxMessageSize: config.maxMessageSize })
|
|
2019
2050
|
socket.on("data", onData)
|
|
2020
|
-
socket.write(PgProtocol.encodeStartupMessage(
|
|
2021
|
-
user: config.username,
|
|
2022
|
-
database: config.database,
|
|
2023
|
-
application_name: config.applicationName
|
|
2024
|
-
}))
|
|
2051
|
+
socket.write(PgProtocol.encodeStartupMessage(config.startupParameters))
|
|
2025
2052
|
}
|
|
2026
2053
|
|
|
2027
2054
|
const onSslResponse = (chunk: Uint8Array): void => {
|
|
@@ -2057,6 +2084,7 @@ const connect = (config: ResolvedConfig): Effect.Effect<Session, SqlError> =>
|
|
|
2057
2084
|
return failConnect(response.failure, "PgConnection: Invalid SSLRequest response")
|
|
2058
2085
|
}
|
|
2059
2086
|
if (response.success === "N") {
|
|
2087
|
+
if (config.sslOptional) return startup()
|
|
2060
2088
|
return failConnect(new Error("The server does not support TLS"), "PgConnection: Server refused TLS")
|
|
2061
2089
|
}
|
|
2062
2090
|
const raw = socket
|
|
@@ -2070,7 +2098,10 @@ const connect = (config: ResolvedConfig): Effect.Effect<Session, SqlError> =>
|
|
|
2070
2098
|
})
|
|
2071
2099
|
socket.on("error", onError)
|
|
2072
2100
|
socket.on("close", onClose)
|
|
2073
|
-
socket.once("secureConnect",
|
|
2101
|
+
socket.once("secureConnect", () => {
|
|
2102
|
+
encrypted = true
|
|
2103
|
+
startup()
|
|
2104
|
+
})
|
|
2074
2105
|
}
|
|
2075
2106
|
|
|
2076
2107
|
const begin = (): void => {
|
|
@@ -2117,11 +2148,11 @@ interface ResolvedConfig {
|
|
|
2117
2148
|
readonly port: number
|
|
2118
2149
|
readonly path: string | undefined
|
|
2119
2150
|
readonly ssl: boolean | ConnectionOptions
|
|
2120
|
-
readonly
|
|
2151
|
+
readonly sslOptional: boolean
|
|
2121
2152
|
readonly username: string
|
|
2122
|
-
readonly password:
|
|
2153
|
+
readonly password: Redacted.Redacted | Effect.Effect<Redacted.Redacted> | undefined
|
|
2123
2154
|
readonly connectTimeout: Duration.Duration
|
|
2124
|
-
readonly
|
|
2155
|
+
readonly startupParameters: PgProtocol.StartupParameters
|
|
2125
2156
|
readonly stream: (() => Duplex) | undefined
|
|
2126
2157
|
readonly maxMessageSize: number | undefined
|
|
2127
2158
|
}
|
|
@@ -2138,7 +2169,7 @@ const configError = (message: string, cause?: unknown): SqlError =>
|
|
|
2138
2169
|
const resolveConfig = (options: Config): Effect.Effect<ResolvedConfig, SqlError> =>
|
|
2139
2170
|
Effect.suspend(() => {
|
|
2140
2171
|
const parsed: EffectResult.Result<UrlConfig, SqlError> = options.url !== undefined
|
|
2141
|
-
? parseUrl(Redacted.value(options.url)
|
|
2172
|
+
? parseUrl(Redacted.value(options.url))
|
|
2142
2173
|
: EffectResult.succeed({})
|
|
2143
2174
|
if (EffectResult.isFailure(parsed)) return Effect.fail(parsed.failure)
|
|
2144
2175
|
const url = parsed.success
|
|
@@ -2148,16 +2179,49 @@ const resolveConfig = (options: Config): Effect.Effect<ResolvedConfig, SqlError>
|
|
|
2148
2179
|
if (username === undefined) {
|
|
2149
2180
|
return Effect.fail(configError("No username configured"))
|
|
2150
2181
|
}
|
|
2182
|
+
const named: Record<string, string> = Object.create(null)
|
|
2183
|
+
for (const [name, value] of Object.entries(options.startupParameters ?? {})) {
|
|
2184
|
+
const key = name.toLowerCase()
|
|
2185
|
+
if (key === "user" || key === "database" || key === "replication" || key === "options") {
|
|
2186
|
+
return Effect.fail(configError(`Reserved startup parameter: "${name}"`))
|
|
2187
|
+
}
|
|
2188
|
+
if (key === "client_encoding") {
|
|
2189
|
+
const encoding = value.toUpperCase()
|
|
2190
|
+
if (encoding !== "UTF8" && encoding !== "UTF-8") {
|
|
2191
|
+
return Effect.fail(configError("Startup parameter client_encoding must be UTF8 or UTF-8"))
|
|
2192
|
+
}
|
|
2193
|
+
named[key] = "UTF8"
|
|
2194
|
+
} else {
|
|
2195
|
+
named[key] = value
|
|
2196
|
+
}
|
|
2197
|
+
if (name === "" || name.includes("\0") || value.includes("\0")) {
|
|
2198
|
+
return Effect.fail(
|
|
2199
|
+
configError("Startup parameter names must be nonempty and names/values must not contain NUL")
|
|
2200
|
+
)
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
const startupParameters: PgProtocol.StartupParameters = {
|
|
2204
|
+
...named,
|
|
2205
|
+
user: username,
|
|
2206
|
+
database: options.database ?? url.database,
|
|
2207
|
+
application_name: options.applicationName ?? named.application_name ?? url.applicationName ?? "@effect/sql-pg",
|
|
2208
|
+
options: options.startupOptions ?? url.options
|
|
2209
|
+
}
|
|
2210
|
+
for (const [name, value] of Object.entries(startupParameters)) {
|
|
2211
|
+
if (value?.includes("\0")) {
|
|
2212
|
+
return Effect.fail(configError(`Startup parameter "${name}" must not contain NUL`))
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2151
2215
|
return Effect.succeed<ResolvedConfig>({
|
|
2152
2216
|
host,
|
|
2153
2217
|
port,
|
|
2154
2218
|
path: options.path ?? (host.startsWith("/") ? `${host}/.s.PGSQL.${port}` : undefined),
|
|
2155
|
-
ssl: options.ssl ?? url.ssl ?? false,
|
|
2156
|
-
|
|
2219
|
+
ssl: options.ssl ?? (url.ssl === "prefer" ? true : url.ssl ?? false),
|
|
2220
|
+
sslOptional: options.ssl === undefined && url.ssl === "prefer",
|
|
2157
2221
|
username,
|
|
2158
|
-
password: options.password !== undefined ? Redacted.
|
|
2222
|
+
password: options.password ?? (url.password !== undefined ? Redacted.make(url.password) : undefined),
|
|
2159
2223
|
connectTimeout: Duration.fromInputUnsafe(options.connectTimeout ?? url.connectTimeout ?? Duration.seconds(5)),
|
|
2160
|
-
|
|
2224
|
+
startupParameters,
|
|
2161
2225
|
stream: options.stream,
|
|
2162
2226
|
maxMessageSize: options.maxMessageSize
|
|
2163
2227
|
})
|
|
@@ -2170,8 +2234,9 @@ interface UrlConfig {
|
|
|
2170
2234
|
username?: string | undefined
|
|
2171
2235
|
password?: string | undefined
|
|
2172
2236
|
applicationName?: string | undefined
|
|
2237
|
+
options?: string | undefined
|
|
2173
2238
|
connectTimeout?: Duration.Duration | undefined
|
|
2174
|
-
ssl?: boolean | undefined
|
|
2239
|
+
ssl?: boolean | "prefer" | undefined
|
|
2175
2240
|
}
|
|
2176
2241
|
|
|
2177
2242
|
const decodeComponent = (value: string, what: string): EffectResult.Result<string, SqlError> => {
|
|
@@ -2189,7 +2254,7 @@ const parsePort = (value: string, what: string): EffectResult.Result<number, Sql
|
|
|
2189
2254
|
: EffectResult.succeed(port)
|
|
2190
2255
|
}
|
|
2191
2256
|
|
|
2192
|
-
const parseUrl = (raw: string
|
|
2257
|
+
const parseUrl = (raw: string): EffectResult.Result<UrlConfig, SqlError> => {
|
|
2193
2258
|
let url: URL
|
|
2194
2259
|
try {
|
|
2195
2260
|
url = new URL(raw)
|
|
@@ -2252,6 +2317,9 @@ const parseUrl = (raw: string, hasExplicitSsl: boolean): EffectResult.Result<Url
|
|
|
2252
2317
|
case "application_name":
|
|
2253
2318
|
config.applicationName = value
|
|
2254
2319
|
break
|
|
2320
|
+
case "options":
|
|
2321
|
+
config.options = value
|
|
2322
|
+
break
|
|
2255
2323
|
case "connect_timeout": {
|
|
2256
2324
|
const seconds = Number(value)
|
|
2257
2325
|
if (!Number.isInteger(seconds) || seconds < 0) {
|
|
@@ -2272,10 +2340,8 @@ const parseUrl = (raw: string, hasExplicitSsl: boolean): EffectResult.Result<Url
|
|
|
2272
2340
|
break
|
|
2273
2341
|
case "prefer":
|
|
2274
2342
|
case "allow":
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
configError(`sslmode "${value}" is not supported: set ssl explicitly to true or false`)
|
|
2278
|
-
)
|
|
2343
|
+
config.ssl = "prefer"
|
|
2344
|
+
break
|
|
2279
2345
|
default:
|
|
2280
2346
|
return EffectResult.fail(configError(`Unrecognized sslmode in URL: "${value}"`))
|
|
2281
2347
|
}
|
package/src/PgMigrator.ts
CHANGED
|
@@ -13,12 +13,12 @@ import * as Effect from "effect/Effect"
|
|
|
13
13
|
import * as FileSystem from "effect/FileSystem"
|
|
14
14
|
import * as Layer from "effect/Layer"
|
|
15
15
|
import * as Path from "effect/Path"
|
|
16
|
-
import * as Redacted from "effect/Redacted"
|
|
17
16
|
import * as ChildProcess from "effect/unstable/process/ChildProcess"
|
|
18
17
|
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"
|
|
19
18
|
import * as Migrator from "effect/unstable/sql/Migrator"
|
|
20
19
|
import type { SqlClient } from "effect/unstable/sql/SqlClient"
|
|
21
20
|
import type { SqlError } from "effect/unstable/sql/SqlError"
|
|
21
|
+
import * as Password from "./internal/password.ts"
|
|
22
22
|
import { PgClient } from "./PgClient.ts"
|
|
23
23
|
|
|
24
24
|
/**
|
|
@@ -45,7 +45,7 @@ export const run: <R2 = never>(
|
|
|
45
45
|
| R2
|
|
46
46
|
> = Migrator.make({
|
|
47
47
|
dumpSchema(path, table) {
|
|
48
|
-
const pgDump = (args: Array<string
|
|
48
|
+
const pgDump = (args: Array<string>, password: string | undefined) =>
|
|
49
49
|
Effect.gen(function*() {
|
|
50
50
|
const sql = yield* PgClient
|
|
51
51
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
@@ -55,9 +55,7 @@ export const run: <R2 = never>(
|
|
|
55
55
|
PGHOST: sql.config.host,
|
|
56
56
|
PGPORT: sql.config.port?.toString(),
|
|
57
57
|
PGUSER: sql.config.username,
|
|
58
|
-
PGPASSWORD:
|
|
59
|
-
? Redacted.value(sql.config.password)
|
|
60
|
-
: undefined,
|
|
58
|
+
PGPASSWORD: password,
|
|
61
59
|
PGDATABASE: sql.config.database,
|
|
62
60
|
PGSSLMODE: sql.config.ssl ? "require" : "prefer"
|
|
63
61
|
}
|
|
@@ -69,21 +67,20 @@ export const run: <R2 = never>(
|
|
|
69
67
|
.replace(/\n{2,}/gm, "\n\n")
|
|
70
68
|
.trim();
|
|
71
69
|
}).pipe(
|
|
72
|
-
Effect.mapError((error) =>
|
|
70
|
+
Effect.mapError((error) =>
|
|
71
|
+
new Migrator.MigrationError({ kind: "Failed", message: error.message, cause: error })
|
|
72
|
+
)
|
|
73
73
|
)
|
|
74
74
|
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
Effect.all([pgDumpSchema, pgDumpMigrations], { concurrency: 2 }),
|
|
85
|
-
([schema, migrations]) => schema + "\n\n" + migrations
|
|
86
|
-
)
|
|
75
|
+
const pgDumpAll = Effect.gen(function*() {
|
|
76
|
+
const sql = yield* PgClient
|
|
77
|
+
const password = yield* Password.resolve(sql.config.password)
|
|
78
|
+
const [schema, migrations] = yield* Effect.all([
|
|
79
|
+
pgDump(["--schema-only"], password),
|
|
80
|
+
pgDump(["--column-inserts", "--data-only", `--table=${table}`], password)
|
|
81
|
+
], { concurrency: 2 })
|
|
82
|
+
return schema + "\n\n" + migrations
|
|
83
|
+
})
|
|
87
84
|
|
|
88
85
|
const pgDumpFile = (path: string) =>
|
|
89
86
|
Effect.gen(function*() {
|
|
@@ -93,7 +90,9 @@ export const run: <R2 = never>(
|
|
|
93
90
|
yield* fs.makeDirectory(path_.dirname(path), { recursive: true })
|
|
94
91
|
yield* fs.writeFileString(path, dump)
|
|
95
92
|
}).pipe(
|
|
96
|
-
Effect.mapError((error) =>
|
|
93
|
+
Effect.mapError((error) =>
|
|
94
|
+
new Migrator.MigrationError({ kind: "Failed", message: error.message, cause: error })
|
|
95
|
+
)
|
|
97
96
|
)
|
|
98
97
|
|
|
99
98
|
return pgDumpFile(path)
|
package/src/PgTypes.ts
CHANGED
|
@@ -10,9 +10,21 @@
|
|
|
10
10
|
* There is no `typeof` inference: an OID is always supplied, either directly
|
|
11
11
|
* or through a constructor such as `int4` that carries it.
|
|
12
12
|
*
|
|
13
|
-
* `timestamp`
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* `timestamp` and `timestamptz` values, including array elements, decode to
|
|
14
|
+
* `Date`. Encoders accept `Date` or epoch milliseconds.
|
|
15
|
+
* Decoding truncates to milliseconds toward zero relative to the PostgreSQL
|
|
16
|
+
* epoch. To restore numeric decoding, override the codecs with `register`
|
|
17
|
+
* or a client `Registry`.
|
|
18
|
+
*
|
|
19
|
+
* `infinity`, `-infinity` and values outside the JavaScript `Date` range
|
|
20
|
+
* (±8.64e15 epoch milliseconds) decode to an invalid `Date`. Numeric
|
|
21
|
+
* `±Infinity` encodes the PostgreSQL sentinels; encoding an invalid `Date` fails.
|
|
22
|
+
*
|
|
23
|
+
* The `timestamp` codec maps wall-clock fields to UTC fields of a `Date`.
|
|
24
|
+
* Date parameters bind as `timestamptz`, so inserting one into a `timestamp`
|
|
25
|
+
* column applies the session `TimeZone`. Use UTC or `timestamp(value)` to
|
|
26
|
+
* preserve its UTC fields. `timestamptz` round trips preserve the instant
|
|
27
|
+
* regardless of session timezone.
|
|
16
28
|
*
|
|
17
29
|
* @since 4.0.0
|
|
18
30
|
*/
|
|
@@ -992,20 +1004,22 @@ const readTimeMicros = (bytes: Uint8Array, offset: number): number => {
|
|
|
992
1004
|
return micros
|
|
993
1005
|
}
|
|
994
1006
|
|
|
995
|
-
/** The two halves of the int64 `timestampInt64` last produced. */
|
|
996
1007
|
let timestampHigh = 0
|
|
997
1008
|
let timestampLow = 0
|
|
998
1009
|
|
|
999
1010
|
/**
|
|
1000
|
-
*
|
|
1001
|
-
* paths read them from here rather than from a returned pair, so neither
|
|
1002
|
-
* allocates.
|
|
1011
|
+
* Writes the wire int64 to timestampHigh/Low, avoiding a pair allocation.
|
|
1003
1012
|
*/
|
|
1004
1013
|
const timestampInt64 = (value: unknown): void => {
|
|
1005
|
-
|
|
1006
|
-
if (
|
|
1007
|
-
|
|
1008
|
-
|
|
1014
|
+
let ms: number
|
|
1015
|
+
if (value instanceof Date) {
|
|
1016
|
+
ms = value.getTime()
|
|
1017
|
+
if (Number.isNaN(ms)) fail("timestamp cannot be an invalid Date")
|
|
1018
|
+
} else {
|
|
1019
|
+
ms = typeof value === "number" ? value : fail("Expected a Date or number for timestamp")
|
|
1020
|
+
if (Number.isNaN(ms)) fail("timestamp cannot be NaN")
|
|
1021
|
+
}
|
|
1022
|
+
if (ms === Number.POSITIVE_INFINITY) {
|
|
1009
1023
|
timestampHigh = INT32_MAX
|
|
1010
1024
|
timestampLow = -1
|
|
1011
1025
|
} else if (ms === Number.NEGATIVE_INFINITY) {
|
|
@@ -1037,17 +1051,14 @@ const timestampCodec: UnsafeCodec<any> = codecOf(
|
|
|
1037
1051
|
requireSize(size, 8, "timestamp")
|
|
1038
1052
|
const high = readInt32(bytes, offset)
|
|
1039
1053
|
if (high >= -MAX_EXACT_HIGH && high < MAX_EXACT_HIGH) {
|
|
1040
|
-
//
|
|
1041
|
-
// allocates no BigInt. Everything outside them, the sentinels included,
|
|
1042
|
-
// needs the exact 64-bit value.
|
|
1054
|
+
// These bounds allow exact conversion without BigInt.
|
|
1043
1055
|
const micros = high * 4294967296 + readUint32(bytes, offset + 4)
|
|
1044
|
-
return (micros - micros % 1000) / 1000 + PG_EPOCH_MS
|
|
1056
|
+
return new Date((micros - micros % 1000) / 1000 + PG_EPOCH_MS)
|
|
1045
1057
|
}
|
|
1046
1058
|
stage8(bytes, offset)
|
|
1047
1059
|
const micros = scratchView8.getBigInt64(0)
|
|
1048
|
-
if (micros === INT64_MAX) return Number.
|
|
1049
|
-
|
|
1050
|
-
return Number(micros / THOUSAND) + PG_EPOCH_MS
|
|
1060
|
+
if (micros === INT64_MAX || micros === INT64_MIN) return new Date(Number.NaN)
|
|
1061
|
+
return new Date(Number(micros / THOUSAND) + PG_EPOCH_MS)
|
|
1051
1062
|
},
|
|
1052
1063
|
(value) => {
|
|
1053
1064
|
timestampInt64(value)
|
|
@@ -1056,7 +1067,6 @@ const timestampCodec: UnsafeCodec<any> = codecOf(
|
|
|
1056
1067
|
writeInt32(bytes, 4, timestampLow)
|
|
1057
1068
|
return bytes
|
|
1058
1069
|
},
|
|
1059
|
-
// Two int32s are the int64, so the sink needs nothing of its own for it.
|
|
1060
1070
|
(sink, value) => {
|
|
1061
1071
|
timestampInt64(value)
|
|
1062
1072
|
sink.int32(timestampHigh)
|
|
@@ -1399,6 +1409,13 @@ const lookupFor = (registry: Registry | undefined): Lookup =>
|
|
|
1399
1409
|
* Registers a binary codec for an OID the built-in catalogue does not cover,
|
|
1400
1410
|
* or overrides a built-in one. Registered codecs take precedence.
|
|
1401
1411
|
*
|
|
1412
|
+
* **Details**
|
|
1413
|
+
*
|
|
1414
|
+
* Unregistered OIDs decode as UTF-8 text. Register binary user-defined types
|
|
1415
|
+
* to avoid garbled output or codec errors, which close the connection when
|
|
1416
|
+
* reading rows. For arrays, use `makeRegistry().register` with
|
|
1417
|
+
* `RegisterOptions.arrayOid` and pass the registry as the client's `types` option.
|
|
1418
|
+
*
|
|
1402
1419
|
* @category registry
|
|
1403
1420
|
* @since 4.0.0
|
|
1404
1421
|
*/
|
|
@@ -1591,9 +1608,9 @@ export interface Column {
|
|
|
1591
1608
|
*
|
|
1592
1609
|
* **Details**
|
|
1593
1610
|
*
|
|
1594
|
-
* Codecs are resolved once per column. SQL `NULL` becomes `null
|
|
1595
|
-
*
|
|
1596
|
-
* fail with `CodecError`.
|
|
1611
|
+
* Codecs are resolved once per column. SQL `NULL` becomes `null`.
|
|
1612
|
+
* Unregistered OIDs decode as UTF-8 text. Invalid UTF-8 and text-format
|
|
1613
|
+
* columns fail with `CodecError`.
|
|
1597
1614
|
*
|
|
1598
1615
|
* **Example** (Updating the reader after `RowDescription`)
|
|
1599
1616
|
*
|
|
@@ -1624,7 +1641,7 @@ export const makeFieldReader = (
|
|
|
1624
1641
|
return (bytes: Uint8Array, offset: number, size: number, column: number): unknown => {
|
|
1625
1642
|
if (size < 0) return null
|
|
1626
1643
|
const codec = codecs[column]
|
|
1627
|
-
if (codec === undefined) return bytes
|
|
1644
|
+
if (codec === undefined) return decodeUtf8(bytes, offset, size)
|
|
1628
1645
|
const read = codec.read
|
|
1629
1646
|
return read === undefined ? codec.decode(bytes.subarray(offset, offset + size)) : read(bytes, offset, size)
|
|
1630
1647
|
}
|
|
@@ -1653,8 +1670,9 @@ export const encode = (value: unknown, oid: number, registry?: Registry): Result
|
|
|
1653
1670
|
*
|
|
1654
1671
|
* **Details**
|
|
1655
1672
|
*
|
|
1656
|
-
*
|
|
1657
|
-
*
|
|
1673
|
+
* Only binary format (`1`) is supported. Unregistered OIDs decode as UTF-8
|
|
1674
|
+
* text; invalid UTF-8 fails with `CodecError`. See `register` for binary
|
|
1675
|
+
* user-defined types, including arrays.
|
|
1658
1676
|
*
|
|
1659
1677
|
* @category decoding
|
|
1660
1678
|
* @since 4.0.0
|
|
@@ -1670,7 +1688,7 @@ export const decode = (
|
|
|
1670
1688
|
return fail(`Only the binary format is supported, received format ${format}`)
|
|
1671
1689
|
}
|
|
1672
1690
|
const codec = lookupFor(registry)(oid)
|
|
1673
|
-
return codec === undefined ? bytes : codec.decode(bytes)
|
|
1691
|
+
return codec === undefined ? decodeUtf8(bytes, 0, bytes.length) : codec.decode(bytes)
|
|
1674
1692
|
})
|
|
1675
1693
|
|
|
1676
1694
|
// -----------------------------------------------------------------------------
|
|
@@ -1962,21 +1980,21 @@ export const time: (value: bigint | null) => Parameter = parameter(OID.time)
|
|
|
1962
1980
|
export const timetz: (value: string | null) => Parameter = parameter(OID.timetz)
|
|
1963
1981
|
|
|
1964
1982
|
/**
|
|
1965
|
-
* A `timestamp` parameter
|
|
1966
|
-
*
|
|
1983
|
+
* A `timestamp` parameter from a `Date` or epoch milliseconds. UTC fields
|
|
1984
|
+
* become the stored wall-clock fields, regardless of session `TimeZone`.
|
|
1967
1985
|
*
|
|
1968
1986
|
* @category constructors
|
|
1969
1987
|
* @since 4.0.0
|
|
1970
1988
|
*/
|
|
1971
|
-
export const timestamp: (value: number | null) => Parameter = parameter(OID.timestamp)
|
|
1989
|
+
export const timestamp: (value: Date | number | null) => Parameter = parameter(OID.timestamp)
|
|
1972
1990
|
|
|
1973
1991
|
/**
|
|
1974
|
-
* A `timestamptz` parameter, given as Unix epoch milliseconds.
|
|
1992
|
+
* A `timestamptz` parameter, given as a `Date` or Unix epoch milliseconds.
|
|
1975
1993
|
*
|
|
1976
1994
|
* @category constructors
|
|
1977
1995
|
* @since 4.0.0
|
|
1978
1996
|
*/
|
|
1979
|
-
export const timestamptz: (value: number | null) => Parameter = parameter(OID.timestamptz)
|
|
1997
|
+
export const timestamptz: (value: Date | number | null) => Parameter = parameter(OID.timestamptz)
|
|
1980
1998
|
|
|
1981
1999
|
/**
|
|
1982
2000
|
* A one-dimensional array parameter whose elements have the given OID.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
import * as Redacted from "effect/Redacted"
|
|
3
|
+
|
|
4
|
+
export const resolve = (
|
|
5
|
+
password: Redacted.Redacted | Effect.Effect<Redacted.Redacted> | undefined
|
|
6
|
+
): Effect.Effect<string | undefined> =>
|
|
7
|
+
password === undefined
|
|
8
|
+
? Effect.succeed(undefined)
|
|
9
|
+
: Effect.isEffect(password)
|
|
10
|
+
? Effect.map(password, Redacted.value)
|
|
11
|
+
: Effect.succeed(Redacted.value(password))
|