@effect/sql-pg 4.0.0-rc.114 → 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.
@@ -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
- readonly password?: Redacted.Redacted | undefined
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
- * The transport, optional `SSLRequest`, startup, and authentication steps run
227
- * under `connectTimeout` (5 seconds by default). The effect resolves once the
228
- * backend sends `ReadyForQuery`. When the scope closes, the
229
- * session sends `Terminate` and ends the socket.
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
- * When `ssl` is enabled, a server that rejects `SSLRequest` fails the
232
- * connection. Unix sockets and custom streams should set `ssl.servername`
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.map(
242
- connect(config),
243
- (session) => new PgConnectionImpl(options, config, session, options.types)
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, Cause.interrupt())
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.processId, this.session.secretKey)
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> => listenChannel(this, this.pin, channel)
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> => listenChannel(this.base, this.pin, channel)
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, pid: number, secret: number): Effect.Effect<void> =>
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
- // Never send the cancel secret over a connection the server refused
1834
- // to upgrade.
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 (config.password === undefined) {
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 config.password
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", startup)
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 database: string | undefined
2151
+ readonly sslOptional: boolean
2121
2152
  readonly username: string
2122
- readonly password: string | undefined
2153
+ readonly password: Redacted.Redacted | Effect.Effect<Redacted.Redacted> | undefined
2123
2154
  readonly connectTimeout: Duration.Duration
2124
- readonly applicationName: string
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), options.ssl !== undefined)
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
- database: options.database ?? url.database,
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.value(options.password) : url.password,
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
- applicationName: options.applicationName ?? url.applicationName ?? "@effect/sql-pg",
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, hasExplicitSsl: boolean): EffectResult.Result<UrlConfig, SqlError> => {
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
- if (hasExplicitSsl) break
2276
- return EffectResult.fail(
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: sql.config.password
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) => new Migrator.MigrationError({ kind: "Failed", message: error.message }))
70
+ Effect.mapError((error) =>
71
+ new Migrator.MigrationError({ kind: "Failed", message: error.message, cause: error })
72
+ )
73
73
  )
74
74
 
75
- const pgDumpSchema = pgDump(["--schema-only"])
76
-
77
- const pgDumpMigrations = pgDump([
78
- "--column-inserts",
79
- "--data-only",
80
- `--table=${table}`
81
- ])
82
-
83
- const pgDumpAll = Effect.map(
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) => new Migrator.MigrationError({ kind: "Failed", message: error.message }))
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` has no time zone on the wire and is treated as UTC in both
14
- * directions. Decoding drops sub-millisecond precision by truncating toward
15
- * zero, including for timestamps before the PostgreSQL epoch.
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
- * Converts epoch milliseconds to the halves of the wire int64. Both encoding
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
- const ms = requireNumber(value, "timestamp")
1006
- if (Number.isNaN(ms)) {
1007
- fail("timestamp cannot be NaN")
1008
- } else if (ms === Number.POSITIVE_INFINITY) {
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
- // Inside these bounds the whole conversion is float arithmetic, so it
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.POSITIVE_INFINITY
1049
- if (micros === INT64_MIN) return Number.NEGATIVE_INFINITY
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`, and columns
1595
- * without a registered codec return a copy of their bytes. Text-format columns
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.slice(offset, offset + size)
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
- * `format` must be `1`; the text format is not implemented. An OID that is
1657
- * neither built in nor registered decodes to the raw bytes.
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, given as Unix epoch milliseconds and interpreted
1966
- * as UTC.
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))