Merge commit '36c66dd290d3ce6eb1ccd310d0c658d4a32bb8eb' as 'repos/effect'
This commit is contained in:
3548
repos/effect/packages/sql/pg/CHANGELOG.md
Normal file
3548
repos/effect/packages/sql/pg/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
21
repos/effect/packages/sql/pg/LICENSE
Normal file
21
repos/effect/packages/sql/pg/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023-present The Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
7
repos/effect/packages/sql/pg/README.md
Normal file
7
repos/effect/packages/sql/pg/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# `@effect/sql-pg`
|
||||
|
||||
An Effect SQL implementation using the `pg` library.
|
||||
|
||||
## Documentation
|
||||
|
||||
- **API Reference**: [View the full documentation](https://effect-ts.github.io/effect/docs/sql-pg).
|
||||
23
repos/effect/packages/sql/pg/docgen.json
Normal file
23
repos/effect/packages/sql/pg/docgen.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "../../node_modules/@effect/docgen/schema.json",
|
||||
"srcLink": "https://github.com/Effect-TS/effect/tree/main/packages/sql/pg/src/",
|
||||
"exclude": ["src/internal/**/*.ts"],
|
||||
"examplesCompilerOptions": {
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"module": "ES2022",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"paths": {
|
||||
"effect": ["../../../effect/src/index.js"],
|
||||
"effect/*": ["../../../effect/src/*.js"]
|
||||
},
|
||||
"plugins": [
|
||||
{ "name": "@effect/language-service", "includeSuggestionsInTsc": false }
|
||||
]
|
||||
}
|
||||
}
|
||||
76
repos/effect/packages/sql/pg/package.json
Normal file
76
repos/effect/packages/sql/pg/package.json
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"name": "@effect/sql-pg",
|
||||
"version": "4.0.0-beta.99",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "A PostgreSQL toolkit for Effect",
|
||||
"homepage": "https://effect.website",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Effect-TS/effect.git",
|
||||
"directory": "packages/sql/pg"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/Effect-TS/effect/issues"
|
||||
},
|
||||
"tags": [
|
||||
"typescript",
|
||||
"sql",
|
||||
"database"
|
||||
],
|
||||
"keywords": [
|
||||
"typescript",
|
||||
"sql",
|
||||
"database"
|
||||
],
|
||||
"sideEffects": [],
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": "./src/index.ts",
|
||||
"./*": "./src/*.ts",
|
||||
"./internal/*": null,
|
||||
"./*/index": null
|
||||
},
|
||||
"files": [
|
||||
"src/**/*.ts",
|
||||
"dist/**/*.js",
|
||||
"dist/**/*.js.map",
|
||||
"dist/**/*.d.ts",
|
||||
"dist/**/*.d.ts.map"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true,
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": "./dist/index.js",
|
||||
"./*": "./dist/*.js",
|
||||
"./internal/*": null,
|
||||
"./*/index": null
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"codegen": "effect-utils codegen",
|
||||
"build": "tsc -b tsconfig.json && pnpm babel",
|
||||
"babel": "babel dist --plugins annotate-pure-calls --out-dir dist --source-maps",
|
||||
"check": "tsc -b tsconfig.json",
|
||||
"test": "vitest",
|
||||
"coverage": "vitest --coverage"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testcontainers/postgresql": "^11.14.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/pg-cursor": "^2.7.2",
|
||||
"effect": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"pg": "^8.22.0",
|
||||
"pg-connection-string": "2.14.0",
|
||||
"pg-cursor": "^2.21.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-types": "^4.1.0"
|
||||
}
|
||||
}
|
||||
949
repos/effect/packages/sql/pg/src/PgClient.ts
Normal file
949
repos/effect/packages/sql/pg/src/PgClient.ts
Normal file
@@ -0,0 +1,949 @@
|
||||
/**
|
||||
* Connects Effect SQL to PostgreSQL using the `pg` package.
|
||||
*
|
||||
* This module provides constructors and layers for building a PostgreSQL
|
||||
* client from pool settings, a managed `pg.Client`, an existing `pg.Pool`, or
|
||||
* custom connection code. The client runs Effect SQL queries against
|
||||
* PostgreSQL, including transactions and streamed results, and adds helpers for
|
||||
* JSON values and LISTEN/NOTIFY messages. It also maps common PostgreSQL
|
||||
* failures, such as connection, authentication, constraint, timeout, and
|
||||
* deadlock errors, into Effect SQL errors.
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
import * as Arr from "effect/Array"
|
||||
import * as Cause from "effect/Cause"
|
||||
import * as Channel from "effect/Channel"
|
||||
import * as Config from "effect/Config"
|
||||
import * as Context from "effect/Context"
|
||||
import * as Duration from "effect/Duration"
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as Fiber from "effect/Fiber"
|
||||
import * as Layer from "effect/Layer"
|
||||
import * as Number from "effect/Number"
|
||||
import * as Option from "effect/Option"
|
||||
import * as Queue from "effect/Queue"
|
||||
import * as RcRef from "effect/RcRef"
|
||||
import * as Redacted from "effect/Redacted"
|
||||
import * as Scope from "effect/Scope"
|
||||
import * as Semaphore from "effect/Semaphore"
|
||||
import * as Stream from "effect/Stream"
|
||||
import * as Reactivity from "effect/unstable/reactivity/Reactivity"
|
||||
import * as Client from "effect/unstable/sql/SqlClient"
|
||||
import type { Connection } from "effect/unstable/sql/SqlConnection"
|
||||
import type * as SqlConnection from "effect/unstable/sql/SqlConnection"
|
||||
import {
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
ConnectionError,
|
||||
ConstraintError,
|
||||
DeadlockError,
|
||||
LockTimeoutError,
|
||||
SerializationError,
|
||||
SqlError,
|
||||
SqlSyntaxError,
|
||||
StatementTimeoutError,
|
||||
UniqueViolation,
|
||||
UnknownError
|
||||
} from "effect/unstable/sql/SqlError"
|
||||
import type { Custom, Fragment } from "effect/unstable/sql/Statement"
|
||||
import * as Statement from "effect/unstable/sql/Statement"
|
||||
import type { Duplex } from "node:stream"
|
||||
import type { ConnectionOptions } from "node:tls"
|
||||
import * as Pg from "pg"
|
||||
import * as PgConnString from "pg-connection-string"
|
||||
import Cursor from "pg-cursor"
|
||||
|
||||
/**
|
||||
* Runtime type identifier used to mark `PgClient` values.
|
||||
*
|
||||
* @category type IDs
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export const TypeId: TypeId = "~@effect/sql-pg/PgClient"
|
||||
|
||||
/**
|
||||
* Type-level identifier used to mark `PgClient` values.
|
||||
*
|
||||
* @category type IDs
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export type TypeId = "~@effect/sql-pg/PgClient"
|
||||
|
||||
/**
|
||||
* PostgreSQL client service, extending `SqlClient` with JSON parameter fragments and LISTEN/NOTIFY helpers.
|
||||
*
|
||||
* @category models
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export interface PgClient extends Client.SqlClient {
|
||||
readonly [TypeId]: TypeId
|
||||
readonly config: PgClientConfig
|
||||
readonly json: (_: unknown) => Fragment
|
||||
readonly listen: (channel: string) => Stream.Stream<string, SqlError>
|
||||
readonly notify: (channel: string, payload: string) => Effect.Effect<void, SqlError>
|
||||
}
|
||||
|
||||
/**
|
||||
* Service tag for the PostgreSQL client service.
|
||||
*
|
||||
* **When to use**
|
||||
*
|
||||
* Use to access or provide a PostgreSQL client through the Effect context.
|
||||
*
|
||||
* @category services
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export const PgClient = Context.Service<PgClient>("@effect/sql-pg/PgClient")
|
||||
|
||||
/**
|
||||
* Configuration for a PostgreSQL client, including connection, TLS, custom stream, application name, type parser, JSON transform, and query/result name transform options.
|
||||
*
|
||||
* @category constructors
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export interface PgClientConfig {
|
||||
readonly url?: Redacted.Redacted | undefined
|
||||
|
||||
readonly host?: string | undefined
|
||||
readonly port?: number | undefined
|
||||
readonly path?: string | undefined
|
||||
readonly ssl?: boolean | ConnectionOptions | undefined
|
||||
readonly database?: string | undefined
|
||||
readonly username?: string | undefined
|
||||
readonly password?: Redacted.Redacted | undefined
|
||||
|
||||
readonly connectTimeout?: Duration.Input | undefined
|
||||
|
||||
readonly stream?: (() => Duplex) | undefined
|
||||
|
||||
readonly applicationName?: string | undefined
|
||||
readonly spanAttributes?: Record<string, unknown> | undefined
|
||||
|
||||
readonly transformResultNames?: ((str: string) => string) | undefined
|
||||
readonly transformQueryNames?: ((str: string) => string) | undefined
|
||||
readonly transformJson?: boolean | undefined
|
||||
readonly types?: Pg.CustomTypesConfig | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* PostgreSQL pool configuration, extending `PgClientConfig` with idle timeout, pool size, and connection lifetime settings.
|
||||
*
|
||||
* @category constructors
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export interface PgPoolConfig extends PgClientConfig {
|
||||
readonly idleTimeout?: Duration.Input | undefined
|
||||
|
||||
readonly maxConnections?: number | undefined
|
||||
readonly minConnections?: number | undefined
|
||||
readonly connectionTTL?: Duration.Input | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a scoped PostgreSQL client backed by a managed `pg` connection pool.
|
||||
*
|
||||
* @category constructors
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export const make = (options: PgPoolConfig): Effect.Effect<PgClient, SqlError, Scope.Scope | Reactivity.Reactivity> =>
|
||||
fromPool({
|
||||
...options,
|
||||
acquire: Effect.gen(function*() {
|
||||
const pool = new Pg.Pool({
|
||||
connectionString: options.url ? Redacted.value(options.url) : undefined,
|
||||
user: options.username,
|
||||
host: options.host,
|
||||
database: options.database,
|
||||
password: options.password ? Redacted.value(options.password) : undefined,
|
||||
ssl: options.ssl,
|
||||
port: options.port,
|
||||
...(options.stream ? { stream: options.stream } : {}),
|
||||
connectionTimeoutMillis: options.connectTimeout
|
||||
? Duration.toMillis(Duration.fromInputUnsafe(options.connectTimeout))
|
||||
: undefined,
|
||||
idleTimeoutMillis: options.idleTimeout
|
||||
? Duration.toMillis(Duration.fromInputUnsafe(options.idleTimeout))
|
||||
: undefined,
|
||||
max: options.maxConnections,
|
||||
min: options.minConnections,
|
||||
maxLifetimeSeconds: options.connectionTTL
|
||||
? Duration.toSeconds(Duration.fromInputUnsafe(options.connectionTTL))
|
||||
: undefined,
|
||||
application_name: options.applicationName ?? "@effect/sql-pg",
|
||||
types: options.types
|
||||
})
|
||||
|
||||
pool.on("error", (_err) => {})
|
||||
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.tryPromise({
|
||||
try: () => pool.query("SELECT 1"),
|
||||
catch: (cause) => new SqlError({ reason: classifyError(cause, "PgClient: Failed to connect", "connect") })
|
||||
}),
|
||||
() =>
|
||||
Effect.promise(() => pool.end()).pipe(
|
||||
Effect.timeoutOption(1000)
|
||||
),
|
||||
{ interruptible: true }
|
||||
).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: options.connectTimeout ?? Duration.seconds(5),
|
||||
orElse: () =>
|
||||
Effect.fail(
|
||||
new SqlError({
|
||||
reason: new ConnectionError({
|
||||
cause: new Error("Connection timed out"),
|
||||
message: "PgClient: Connection timed out",
|
||||
operation: "connect"
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
return pool
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Creates a scoped PostgreSQL client backed by a managed single `pg` client, optionally acquiring a separate client for streaming and LISTEN operations.
|
||||
*
|
||||
* @category constructors
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export const makeClient = (
|
||||
options: PgClientConfig & {
|
||||
/**
|
||||
* Whether to acquire a separate client for each sql.stream / sql.listen
|
||||
*/
|
||||
readonly acquireForStream?: boolean | undefined
|
||||
}
|
||||
): Effect.Effect<PgClient, SqlError, Scope.Scope | Reactivity.Reactivity> =>
|
||||
fromClient({
|
||||
...options,
|
||||
acquire: Effect.acquireRelease(
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
const client = new Pg.Client({
|
||||
connectionString: options.url ? Redacted.value(options.url) : undefined,
|
||||
user: options.username,
|
||||
host: options.host,
|
||||
database: options.database,
|
||||
password: options.password ? Redacted.value(options.password) : undefined,
|
||||
ssl: options.ssl,
|
||||
port: options.port,
|
||||
...(options.stream ? { stream: options.stream } : {}),
|
||||
application_name: options.applicationName ?? "@effect/sql-pg",
|
||||
types: options.types
|
||||
})
|
||||
await client.connect()
|
||||
return client
|
||||
},
|
||||
catch: (cause) => new SqlError({ reason: classifyError(cause, "PgClient: Failed to connect", "connect") })
|
||||
}),
|
||||
(client) =>
|
||||
Effect.promise(() => client.end()).pipe(
|
||||
Effect.timeoutOption(1000)
|
||||
),
|
||||
{ interruptible: true }
|
||||
).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: options.connectTimeout ?? Duration.seconds(5),
|
||||
orElse: () =>
|
||||
Effect.fail(
|
||||
new SqlError({
|
||||
reason: new ConnectionError({
|
||||
cause: new Error("Connection timed out"),
|
||||
message: "PgClient: Connection timed out",
|
||||
operation: "connect"
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
),
|
||||
acquireForStream: options.acquireForStream ?? false
|
||||
})
|
||||
|
||||
/**
|
||||
* Builds a PostgreSQL client from a scoped `pg` pool acquisition effect, deriving transaction, streaming, and LISTEN/NOTIFY support from that pool.
|
||||
*
|
||||
* @category constructors
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export const fromPool = Effect.fnUntraced(function*(
|
||||
options: {
|
||||
readonly acquire: Effect.Effect<Pg.Pool, SqlError, Scope.Scope>
|
||||
|
||||
readonly applicationName?: string | undefined
|
||||
readonly spanAttributes?: Record<string, unknown> | undefined
|
||||
|
||||
readonly transformResultNames?: ((str: string) => string) | undefined
|
||||
readonly transformQueryNames?: ((str: string) => string) | undefined
|
||||
readonly transformJson?: boolean | undefined
|
||||
readonly types?: Pg.CustomTypesConfig | undefined
|
||||
}
|
||||
): Effect.fn.Return<PgClient, SqlError, Scope.Scope | Reactivity.Reactivity> {
|
||||
const pool = yield* options.acquire
|
||||
|
||||
const makeConection = (client?: Pg.PoolClient) =>
|
||||
new ConnectionImpl(
|
||||
function runWithClient<A>(f: (client: Pg.ClientBase, resume: (_: Effect.Effect<A, SqlError>) => void) => void) {
|
||||
if (client !== undefined) {
|
||||
return Effect.callback<A, SqlError>((resume) => {
|
||||
f(client!, resume)
|
||||
return makeCancel(pool, client!)
|
||||
})
|
||||
}
|
||||
return Effect.callback<A, SqlError>((resume) => {
|
||||
let done = false
|
||||
let cancel: Effect.Effect<void> | undefined = undefined
|
||||
let client: Pg.PoolClient | undefined = undefined
|
||||
function onError(cause: Error) {
|
||||
cleanup(cause)
|
||||
resume(Effect.fail(new SqlError({ reason: classifyError(cause, "Connection error", "acquireConnection") })))
|
||||
}
|
||||
function cleanup(cause?: Error) {
|
||||
if (!done) client?.release(cause)
|
||||
done = true
|
||||
client?.off("error", onError)
|
||||
}
|
||||
pool.connect((cause, client_) => {
|
||||
if (cause) {
|
||||
return resume(
|
||||
Effect.fail(
|
||||
new SqlError({
|
||||
reason: classifyError(cause, "Failed to acquire connection", "acquireConnection")
|
||||
})
|
||||
)
|
||||
)
|
||||
} else if (!client_) {
|
||||
return resume(
|
||||
Effect.fail(
|
||||
new SqlError({
|
||||
reason: new ConnectionError({
|
||||
message: "Failed to acquire connection",
|
||||
cause: new Error("No client returned"),
|
||||
operation: "acquireConnection"
|
||||
})
|
||||
})
|
||||
)
|
||||
)
|
||||
} else if (done) {
|
||||
client_.release()
|
||||
return
|
||||
}
|
||||
client = client_
|
||||
client.once("error", onError)
|
||||
cancel = makeCancel(pool, client)
|
||||
f(client, (eff) => {
|
||||
cleanup()
|
||||
resume(eff)
|
||||
})
|
||||
})
|
||||
return Effect.suspend(() => {
|
||||
if (!cancel) {
|
||||
cleanup()
|
||||
return Effect.void
|
||||
}
|
||||
return Effect.ensuring(cancel, Effect.sync(cleanup))
|
||||
})
|
||||
})
|
||||
},
|
||||
client ? Effect.succeed(client) : reserveRaw
|
||||
)
|
||||
|
||||
const reserveRaw = Effect.callback<Pg.PoolClient, SqlError, Scope.Scope>((resume) => {
|
||||
const fiber = Fiber.getCurrent()!
|
||||
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
|
||||
let cause: Error | undefined = undefined
|
||||
function onError(cause_: Error) {
|
||||
cause = cause_
|
||||
}
|
||||
pool.connect((err, client, release) => {
|
||||
if (err) {
|
||||
return resume(
|
||||
Effect.fail(
|
||||
new SqlError({
|
||||
reason: classifyError(
|
||||
err,
|
||||
"Failed to acquire connection for transaction",
|
||||
"acquireConnection"
|
||||
)
|
||||
})
|
||||
)
|
||||
)
|
||||
} else if (!client) {
|
||||
return resume(
|
||||
Effect.fail(
|
||||
new SqlError({
|
||||
reason: new ConnectionError({
|
||||
message: "Failed to acquire connection for transaction",
|
||||
cause: new Error("No client returned"),
|
||||
operation: "acquireConnection"
|
||||
})
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
client.on("error", onError)
|
||||
resume(Effect.as(
|
||||
Scope.addFinalizer(
|
||||
scope,
|
||||
Effect.sync(() => {
|
||||
client.off("error", onError)
|
||||
release(cause)
|
||||
})
|
||||
),
|
||||
client
|
||||
))
|
||||
})
|
||||
})
|
||||
const reserve = Effect.map(reserveRaw, makeConection)
|
||||
|
||||
const onListenClientError = (_: Error) => {
|
||||
}
|
||||
|
||||
const listenAcquirer = yield* RcRef.make({
|
||||
acquire: Effect.acquireRelease(
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
const client = new Pg.Client(pool.options)
|
||||
await client.connect()
|
||||
client.on("error", onListenClientError)
|
||||
return client
|
||||
},
|
||||
catch: (cause) =>
|
||||
new SqlError({
|
||||
reason: classifyError(cause, "Failed to acquire connection for listen", "acquireConnection")
|
||||
})
|
||||
}),
|
||||
(client) =>
|
||||
Effect.promise(() => {
|
||||
client.off("error", onListenClientError)
|
||||
return client.end()
|
||||
}).pipe(
|
||||
Effect.timeoutOption(1000)
|
||||
),
|
||||
{ interruptible: true }
|
||||
)
|
||||
})
|
||||
|
||||
let config: PgClientConfig = {
|
||||
url: pool.options.connectionString ? Redacted.make(pool.options.connectionString) : undefined,
|
||||
host: pool.options.host,
|
||||
port: pool.options.port,
|
||||
database: pool.options.database,
|
||||
username: pool.options.user,
|
||||
password: typeof pool.options.password === "string" ? Redacted.make(pool.options.password) : undefined,
|
||||
ssl: pool.options.ssl,
|
||||
applicationName: pool.options.application_name,
|
||||
types: pool.options.types
|
||||
}
|
||||
if (pool.options.connectionString) {
|
||||
// @effect-diagnostics-next-line tryCatchInEffectGen:off
|
||||
try {
|
||||
const parsed = PgConnString.parse(pool.options.connectionString)
|
||||
config = {
|
||||
...config,
|
||||
host: config.host ?? parsed.host ?? undefined,
|
||||
port: config.port ?? (parsed.port ? Option.getOrUndefined(Number.parse(parsed.port)) : undefined),
|
||||
username: config.username ?? parsed.user ?? undefined,
|
||||
password: config.password ?? (parsed.password ? Redacted.make(parsed.password) : undefined),
|
||||
database: config.database ?? parsed.database ?? undefined
|
||||
}
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
return yield* makeWith({
|
||||
acquirer: Effect.succeed(makeConection()),
|
||||
transactionAcquirer: reserve,
|
||||
listenAcquirer: RcRef.get(listenAcquirer),
|
||||
config,
|
||||
spanAttributes: options.spanAttributes,
|
||||
transformResultNames: options.transformResultNames,
|
||||
transformQueryNames: options.transformQueryNames,
|
||||
transformJson: options.transformJson
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Builds a PostgreSQL client from a scoped `pg` client acquisition effect, serializing access when sharing the client and optionally using separate clients for streams and LISTEN.
|
||||
*
|
||||
* @category constructors
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export const fromClient = Effect.fnUntraced(function*(
|
||||
options: {
|
||||
readonly acquire: Effect.Effect<Pg.Client, SqlError, Scope.Scope>
|
||||
|
||||
/**
|
||||
* Whether to acquire a separate client for each sql.stream / sql.listen.
|
||||
*/
|
||||
readonly acquireForStream: boolean
|
||||
|
||||
readonly applicationName?: string | undefined
|
||||
readonly spanAttributes?: Record<string, unknown> | undefined
|
||||
|
||||
readonly transformResultNames?: ((str: string) => string) | undefined
|
||||
readonly transformQueryNames?: ((str: string) => string) | undefined
|
||||
readonly transformJson?: boolean | undefined
|
||||
readonly types?: Pg.CustomTypesConfig | undefined
|
||||
}
|
||||
): Effect.fn.Return<PgClient, SqlError, Scope.Scope | Reactivity.Reactivity> {
|
||||
function onError() {}
|
||||
const acquireWithErrorHandler = options.acquire.pipe(
|
||||
Effect.tap((client) => {
|
||||
client.on("error", onError)
|
||||
return Effect.addFinalizer(() => {
|
||||
client.off("error", onError)
|
||||
return Effect.void
|
||||
})
|
||||
})
|
||||
)
|
||||
const client = yield* acquireWithErrorHandler
|
||||
|
||||
const semaphore = Semaphore.makeUnsafe(1)
|
||||
let streamClient = options.acquireForStream ? acquireWithErrorHandler : Effect.acquireRelease(
|
||||
Effect.as(semaphore.take(1), client),
|
||||
() => semaphore.release(1)
|
||||
)
|
||||
|
||||
const makeConection = (client: Pg.Client) =>
|
||||
new ConnectionImpl(
|
||||
function runWithClient<A>(f: (client: Pg.ClientBase, resume: (_: Effect.Effect<A, SqlError>) => void) => void) {
|
||||
return Effect.callback<A, SqlError>((resume) => {
|
||||
f(client, resume)
|
||||
})
|
||||
},
|
||||
streamClient
|
||||
)
|
||||
const connection = makeConection(client)
|
||||
const acquirer = semaphore.withPermit(Effect.succeed(connection))
|
||||
|
||||
const config: PgClientConfig = {
|
||||
...options,
|
||||
host: client.host,
|
||||
port: client.port,
|
||||
database: client.database,
|
||||
username: client.user,
|
||||
password: typeof client.password === "string" ? Redacted.make(client.password) : undefined,
|
||||
ssl: client.ssl
|
||||
}
|
||||
|
||||
return yield* makeWith({
|
||||
acquirer,
|
||||
transactionAcquirer: acquirer,
|
||||
listenAcquirer: streamClient,
|
||||
config,
|
||||
spanAttributes: options.spanAttributes,
|
||||
transformResultNames: options.transformResultNames,
|
||||
transformQueryNames: options.transformQueryNames,
|
||||
transformJson: options.transformJson
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Creates a `PgClient` from SQL connection acquirers, a LISTEN acquirer, client configuration, and transformation options.
|
||||
*
|
||||
* **When to use**
|
||||
*
|
||||
* Use to build a PostgreSQL client from custom connection acquisition logic
|
||||
* instead of the built-in pool or single-client constructors.
|
||||
*
|
||||
* @category constructors
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export const makeWith = Effect.fnUntraced(function*(
|
||||
options: {
|
||||
readonly acquirer: SqlConnection.Acquirer
|
||||
readonly transactionAcquirer: SqlConnection.Acquirer
|
||||
readonly listenAcquirer: Effect.Effect<Pg.ClientBase, SqlError, Scope.Scope>
|
||||
|
||||
readonly config: PgClientConfig
|
||||
readonly spanAttributes?: Record<string, unknown> | undefined
|
||||
|
||||
readonly transformResultNames?: ((str: string) => string) | undefined
|
||||
readonly transformQueryNames?: ((str: string) => string) | undefined
|
||||
readonly transformJson?: boolean | undefined
|
||||
}
|
||||
): Effect.fn.Return<PgClient, SqlError, Scope.Scope | Reactivity.Reactivity> {
|
||||
const compiler = makeCompiler(
|
||||
options.transformQueryNames,
|
||||
options.transformJson
|
||||
)
|
||||
const transformRows = options.transformResultNames ?
|
||||
Statement.defaultTransforms(
|
||||
options.transformResultNames,
|
||||
options.transformJson
|
||||
).array :
|
||||
undefined
|
||||
|
||||
const config = options.config
|
||||
|
||||
return Object.assign(
|
||||
yield* Client.make({
|
||||
acquirer: options.acquirer,
|
||||
transactionAcquirer: options.transactionAcquirer,
|
||||
compiler,
|
||||
spanAttributes: [
|
||||
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
|
||||
[ATTR_DB_SYSTEM_NAME, "postgresql"],
|
||||
[ATTR_DB_NAMESPACE, config.database ?? config.username ?? "postgres"],
|
||||
[ATTR_SERVER_ADDRESS, config.host ?? "localhost"],
|
||||
[ATTR_SERVER_PORT, config.port ?? 5432]
|
||||
],
|
||||
transformRows
|
||||
}),
|
||||
{
|
||||
[TypeId]: TypeId as TypeId,
|
||||
config: options.config,
|
||||
json: (_: unknown) => Statement.fragment([PgJson(_)]),
|
||||
listen: (channel: string) =>
|
||||
Stream.callback<string, SqlError>(Effect.fnUntraced(function*(queue) {
|
||||
const client = yield* options.listenAcquirer
|
||||
function onNotification(msg: Pg.Notification) {
|
||||
if (msg.channel === channel && msg.payload) {
|
||||
Queue.offerUnsafe(queue, msg.payload)
|
||||
}
|
||||
}
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => {
|
||||
client.off("notification", onNotification)
|
||||
return client.query(`UNLISTEN ${Pg.escapeIdentifier(channel)}`)
|
||||
})
|
||||
)
|
||||
yield* Effect.tryPromise({
|
||||
try: () => client.query(`LISTEN ${Pg.escapeIdentifier(channel)}`),
|
||||
catch: (cause) => new SqlError({ reason: classifyError(cause, "Failed to listen", "listen") })
|
||||
})
|
||||
client.on("notification", onNotification)
|
||||
})),
|
||||
notify: (channel: string, payload: string) =>
|
||||
Effect.asVoid(Effect.scoped(Effect.flatMap(
|
||||
options.acquirer,
|
||||
(conn) => conn.executeRaw(`SELECT pg_notify($1, $2)`, [channel, payload])
|
||||
)))
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
class ConnectionImpl implements Connection {
|
||||
constructor(
|
||||
runWithClient: <A>(
|
||||
f: (client: Pg.ClientBase, resume: (_: Effect.Effect<A, SqlError>) => void) => void
|
||||
) => Effect.Effect<A, SqlError>,
|
||||
reserve: Effect.Effect<Pg.ClientBase, SqlError, Scope.Scope>
|
||||
) {
|
||||
this.runWithClient = runWithClient
|
||||
this.reserve = reserve
|
||||
}
|
||||
|
||||
private readonly runWithClient: <A>(
|
||||
f: (client: Pg.ClientBase, resume: (_: Effect.Effect<A, SqlError>) => void) => void
|
||||
) => Effect.Effect<A, SqlError>
|
||||
private readonly reserve: Effect.Effect<Pg.ClientBase, SqlError, Scope.Scope>
|
||||
|
||||
private run(query: string, params: ReadonlyArray<unknown>) {
|
||||
return this.runWithClient<ReadonlyArray<any>>((client, resume) => {
|
||||
client.query(query, params as any, (err, result) => {
|
||||
if (err) {
|
||||
resume(
|
||||
Effect.fail(new SqlError({ reason: classifyError(err, "Failed to execute statement", "execute") }))
|
||||
)
|
||||
} else {
|
||||
// Multi-statement queries return an array of results
|
||||
resume(Effect.succeed(
|
||||
Array.isArray(result)
|
||||
? result.map((r) => r.rows ?? [])
|
||||
: result.rows ?? []
|
||||
))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
execute(
|
||||
sql: string,
|
||||
params: ReadonlyArray<unknown>,
|
||||
transformRows: (<A extends object>(row: ReadonlyArray<A>) => ReadonlyArray<A>) | undefined
|
||||
) {
|
||||
return transformRows
|
||||
? Effect.map(this.run(sql, params), transformRows)
|
||||
: this.run(sql, params)
|
||||
}
|
||||
executeRaw(sql: string, params: ReadonlyArray<unknown>) {
|
||||
return this.runWithClient<Pg.Result>((client, resume) => {
|
||||
client.query(sql, params as any, (err, result) => {
|
||||
if (err) {
|
||||
resume(
|
||||
Effect.fail(new SqlError({ reason: classifyError(err, "Failed to execute statement", "execute") }))
|
||||
)
|
||||
} else {
|
||||
resume(Effect.succeed(result))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
executeWithoutTransform(sql: string, params: ReadonlyArray<unknown>) {
|
||||
return this.run(sql, params)
|
||||
}
|
||||
executeValues(sql: string, params: ReadonlyArray<unknown>) {
|
||||
return this.runWithClient<ReadonlyArray<any>>((client, resume) => {
|
||||
client.query(
|
||||
{
|
||||
text: sql,
|
||||
rowMode: "array",
|
||||
values: params as Array<string>
|
||||
},
|
||||
(err, result) => {
|
||||
if (err) {
|
||||
resume(
|
||||
Effect.fail(new SqlError({ reason: classifyError(err, "Failed to execute statement", "execute") }))
|
||||
)
|
||||
} else {
|
||||
resume(Effect.succeed(result.rows))
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
executeValuesUnprepared(sql: string, params: ReadonlyArray<unknown>) {
|
||||
return this.executeValues(sql, params)
|
||||
}
|
||||
executeUnprepared(
|
||||
sql: string,
|
||||
params: ReadonlyArray<unknown>,
|
||||
transformRows: (<A extends object>(row: ReadonlyArray<A>) => ReadonlyArray<A>) | undefined
|
||||
) {
|
||||
return this.execute(sql, params, transformRows)
|
||||
}
|
||||
executeStream(
|
||||
sql: string,
|
||||
params: ReadonlyArray<unknown>,
|
||||
transformRows: (<A extends object>(row: ReadonlyArray<A>) => ReadonlyArray<A>) | undefined
|
||||
) {
|
||||
// oxlint-disable-next-line @typescript-eslint/no-this-alias
|
||||
const self = this
|
||||
return Stream.fromChannel(Channel.fromTransform(Effect.fnUntraced(function*(_, scope) {
|
||||
const client = yield* Scope.provide(self.reserve, scope)
|
||||
yield* Scope.addFinalizer(scope, Effect.promise(() => cursor.close()))
|
||||
const cursor = client.query(new Cursor(sql, params as any))
|
||||
// @effect-diagnostics-next-line returnEffectInGen:off
|
||||
return Effect.callback<Arr.NonEmptyReadonlyArray<any>, SqlError | Cause.Done>((resume) => {
|
||||
cursor.read(128, (err, rows) => {
|
||||
if (err) {
|
||||
resume(Effect.fail(new SqlError({ reason: classifyError(err, "Failed to execute statement", "stream") })))
|
||||
} else if (Arr.isArrayNonEmpty(rows)) {
|
||||
resume(Effect.succeed(transformRows ? transformRows(rows) as any : rows))
|
||||
} else {
|
||||
resume(Cause.done())
|
||||
}
|
||||
})
|
||||
})
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
const cancelEffects = new WeakMap<Pg.PoolClient, Effect.Effect<void> | undefined>()
|
||||
const makeCancel = (pool: Pg.Pool, client: Pg.PoolClient) => {
|
||||
if (cancelEffects.has(client)) {
|
||||
return cancelEffects.get(client)!
|
||||
}
|
||||
const processId = (client as any).processID
|
||||
const eff = processId !== undefined
|
||||
// query cancelation is best-effort, so we don't fail if it doesn't work
|
||||
? Effect.callback<void>((resume) => {
|
||||
if (pool.ending) return resume(Effect.void)
|
||||
pool.query(`SELECT pg_cancel_backend(${processId})`, () => {
|
||||
resume(Effect.void)
|
||||
})
|
||||
}).pipe(
|
||||
Effect.interruptible,
|
||||
Effect.timeoutOption(5000)
|
||||
)
|
||||
: undefined
|
||||
cancelEffects.set(client, eff)
|
||||
return eff
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a layer from an effect that acquires a `PgClient`, providing both `PgClient` and `SqlClient`.
|
||||
*
|
||||
* @category layers
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export const layerFrom = <E, R>(
|
||||
acquire: Effect.Effect<PgClient, E, R>
|
||||
): Layer.Layer<PgClient | Client.SqlClient, E, Exclude<R, Scope.Scope | Reactivity.Reactivity>> =>
|
||||
Layer.effectContext(
|
||||
Effect.map(acquire, (client) =>
|
||||
Context.make(PgClient, client).pipe(
|
||||
Context.add(Client.SqlClient, client)
|
||||
))
|
||||
).pipe(Layer.provide(Reactivity.layer)) as any
|
||||
|
||||
/**
|
||||
* Creates a layer from a `Config`-wrapped PostgreSQL pool configuration, providing both `PgClient` and `SqlClient`.
|
||||
*
|
||||
* @category layers
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export const layerConfig: (
|
||||
config: Config.Wrap<PgPoolConfig>
|
||||
) => Layer.Layer<PgClient | Client.SqlClient, Config.ConfigError | SqlError> = (
|
||||
config: Config.Wrap<PgPoolConfig>
|
||||
): Layer.Layer<PgClient | Client.SqlClient, Config.ConfigError | SqlError> =>
|
||||
layerFrom(Effect.flatMap(
|
||||
Config.unwrap(config),
|
||||
make
|
||||
))
|
||||
|
||||
/**
|
||||
* Creates a layer from a concrete PostgreSQL pool configuration, providing both `PgClient` and `SqlClient`.
|
||||
*
|
||||
* @category layers
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export const layer = (
|
||||
config: PgPoolConfig
|
||||
): Layer.Layer<PgClient | Client.SqlClient, SqlError> => layerFrom(make(config))
|
||||
|
||||
/**
|
||||
* Creates the PostgreSQL statement compiler, using `$1` placeholders, double-quoted identifiers, PostgreSQL returning clauses, and optional JSON value transformation.
|
||||
*
|
||||
* @category constructors
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export const makeCompiler = (
|
||||
transform?: (_: string) => string,
|
||||
transformJson = true
|
||||
): Statement.Compiler => {
|
||||
const transformValue = transformJson && transform
|
||||
? Statement.defaultTransforms(transform).value
|
||||
: undefined
|
||||
|
||||
return Statement.makeCompiler<PgCustom>({
|
||||
dialect: "pg",
|
||||
placeholder(_) {
|
||||
return `$${_}`
|
||||
},
|
||||
onIdentifier: transform ?
|
||||
function(value, withoutTransform) {
|
||||
return withoutTransform ? escape(value) : escape(transform(value))
|
||||
} :
|
||||
escape,
|
||||
onRecordUpdate(placeholders, valueAlias, valueColumns, values, returning) {
|
||||
return [
|
||||
`(values ${placeholders}) AS ${valueAlias}${valueColumns}${returning ? ` RETURNING ${returning[0]}` : ""}`,
|
||||
returning ?
|
||||
values.flat().concat(returning[1]) :
|
||||
values.flat()
|
||||
]
|
||||
},
|
||||
onCustom(type, placeholder, withoutTransform) {
|
||||
switch (type.kind) {
|
||||
case "PgJson": {
|
||||
return [
|
||||
placeholder(undefined),
|
||||
[
|
||||
withoutTransform || transformValue === undefined
|
||||
? type.paramA
|
||||
: transformValue(type.paramA)
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const escape = Statement.defaultEscape("\"")
|
||||
|
||||
/**
|
||||
* PostgreSQL-specific custom statement fragments supported by the compiler, currently JSON parameter fragments.
|
||||
*
|
||||
* @category custom types
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export type PgCustom = PgJson
|
||||
|
||||
/**
|
||||
* @category custom types
|
||||
* @since 4.0.0
|
||||
*/
|
||||
interface PgJson extends Custom<"PgJson", unknown> {}
|
||||
/**
|
||||
* @category custom types
|
||||
* @since 4.0.0
|
||||
*/
|
||||
const PgJson = Statement.custom<PgJson>("PgJson")
|
||||
|
||||
const ATTR_DB_SYSTEM_NAME = "db.system.name"
|
||||
const ATTR_DB_NAMESPACE = "db.namespace"
|
||||
const ATTR_SERVER_ADDRESS = "server.address"
|
||||
const ATTR_SERVER_PORT = "server.port"
|
||||
|
||||
const pgCodeFromCause = (cause: unknown): string | undefined => {
|
||||
if (typeof cause !== "object" || cause === null || !("code" in cause)) {
|
||||
return undefined
|
||||
}
|
||||
const code = cause.code
|
||||
return typeof code === "string" ? code : undefined
|
||||
}
|
||||
|
||||
const pgConstraintFromCause = (cause: unknown): string => {
|
||||
if (typeof cause !== "object" || cause === null || !("constraint" in cause)) {
|
||||
return "unknown"
|
||||
}
|
||||
const constraint = cause.constraint
|
||||
if (typeof constraint !== "string") {
|
||||
return "unknown"
|
||||
}
|
||||
const normalized = constraint.trim()
|
||||
return normalized.length === 0 ? "unknown" : normalized
|
||||
}
|
||||
|
||||
const classifyError = (
|
||||
cause: unknown,
|
||||
message: string,
|
||||
operation: string
|
||||
) => {
|
||||
const props = { cause, message, operation }
|
||||
const code = pgCodeFromCause(cause)
|
||||
if (code !== undefined) {
|
||||
if (code.startsWith("08")) {
|
||||
return new ConnectionError(props)
|
||||
}
|
||||
if (code.startsWith("28")) {
|
||||
return new AuthenticationError(props)
|
||||
}
|
||||
if (code === "42501") {
|
||||
return new AuthorizationError(props)
|
||||
}
|
||||
if (code.startsWith("42")) {
|
||||
return new SqlSyntaxError(props)
|
||||
}
|
||||
if (code === "23505") {
|
||||
return new UniqueViolation({ ...props, constraint: pgConstraintFromCause(cause) })
|
||||
}
|
||||
if (code.startsWith("23")) {
|
||||
return new ConstraintError(props)
|
||||
}
|
||||
if (code === "40P01") {
|
||||
return new DeadlockError(props)
|
||||
}
|
||||
if (code === "40001") {
|
||||
return new SerializationError(props)
|
||||
}
|
||||
if (code === "55P03") {
|
||||
return new LockTimeoutError(props)
|
||||
}
|
||||
if (code === "57014") {
|
||||
return new StatementTimeoutError(props)
|
||||
}
|
||||
}
|
||||
return new UnknownError(props)
|
||||
}
|
||||
120
repos/effect/packages/sql/pg/src/PgMigrator.ts
Normal file
120
repos/effect/packages/sql/pg/src/PgMigrator.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Runs database migrations for PostgreSQL projects that use Effect SQL.
|
||||
*
|
||||
* This module reuses the shared SQL migrator and connects it to PostgreSQL. It
|
||||
* exposes the common migration helpers and adds `run` and `layer` functions
|
||||
* that apply pending migration files with the current SQL client. When schema
|
||||
* dumps are requested, it uses `pg_dump` and the usual process and filesystem
|
||||
* services.
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as FileSystem from "effect/FileSystem"
|
||||
import * as Layer from "effect/Layer"
|
||||
import * as Path from "effect/Path"
|
||||
import * as Redacted from "effect/Redacted"
|
||||
import * as ChildProcess from "effect/unstable/process/ChildProcess"
|
||||
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"
|
||||
import * as Migrator from "effect/unstable/sql/Migrator"
|
||||
import type { SqlClient } from "effect/unstable/sql/SqlClient"
|
||||
import type { SqlError } from "effect/unstable/sql/SqlError"
|
||||
import { PgClient } from "./PgClient.ts"
|
||||
|
||||
/**
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export * from "effect/unstable/sql/Migrator"
|
||||
|
||||
/**
|
||||
* Runs PostgreSQL SQL migrations using the configured clients. Schema dumps use `pg_dump` and require child process, filesystem, and path services.
|
||||
*
|
||||
* @category constructors
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export const run: <R2 = never>(
|
||||
options: Migrator.MigratorOptions<R2>
|
||||
) => Effect.Effect<
|
||||
ReadonlyArray<readonly [id: number, name: string]>,
|
||||
Migrator.MigrationError | SqlError,
|
||||
| SqlClient
|
||||
| PgClient
|
||||
| ChildProcessSpawner.ChildProcessSpawner
|
||||
| FileSystem.FileSystem
|
||||
| Path.Path
|
||||
| R2
|
||||
> = Migrator.make({
|
||||
dumpSchema(path, table) {
|
||||
const pgDump = (args: Array<string>) =>
|
||||
Effect.gen(function*() {
|
||||
const sql = yield* PgClient
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const dump = yield* ChildProcess.make("pg_dump", [...args, "--no-owner", "--no-privileges"], {
|
||||
env: {
|
||||
PATH: (globalThis as any).process?.env.PATH,
|
||||
PGHOST: sql.config.host,
|
||||
PGPORT: sql.config.port?.toString(),
|
||||
PGUSER: sql.config.username,
|
||||
PGPASSWORD: sql.config.password
|
||||
? Redacted.value(sql.config.password)
|
||||
: undefined,
|
||||
PGDATABASE: sql.config.database,
|
||||
PGSSLMODE: sql.config.ssl ? "require" : "prefer"
|
||||
}
|
||||
}).pipe(spawner.string)
|
||||
|
||||
return dump.replace(/^--.*$/gm, "")
|
||||
.replace(/^SET .*$/gm, "")
|
||||
.replace(/^SELECT pg_catalog\..*$/gm, "")
|
||||
.replace(/\n{2,}/gm, "\n\n")
|
||||
.trim()
|
||||
}).pipe(
|
||||
Effect.mapError((error) => new Migrator.MigrationError({ kind: "Failed", message: error.message }))
|
||||
)
|
||||
|
||||
const pgDumpSchema = pgDump(["--schema-only"])
|
||||
|
||||
const pgDumpMigrations = pgDump([
|
||||
"--column-inserts",
|
||||
"--data-only",
|
||||
`--table=${table}`
|
||||
])
|
||||
|
||||
const pgDumpAll = Effect.map(
|
||||
Effect.all([pgDumpSchema, pgDumpMigrations], { concurrency: 2 }),
|
||||
([schema, migrations]) => schema + "\n\n" + migrations
|
||||
)
|
||||
|
||||
const pgDumpFile = (path: string) =>
|
||||
Effect.gen(function*() {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path_ = yield* Path.Path
|
||||
const dump = yield* pgDumpAll
|
||||
yield* fs.makeDirectory(path_.dirname(path), { recursive: true })
|
||||
yield* fs.writeFileString(path, dump)
|
||||
}).pipe(
|
||||
Effect.mapError((error) => new Migrator.MigrationError({ kind: "Failed", message: error.message }))
|
||||
)
|
||||
|
||||
return pgDumpFile(path)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Creates a layer that runs PostgreSQL migrations during layer construction, including `pg_dump`-based schema dump support when requested.
|
||||
*
|
||||
* @category layers
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export const layer = <R>(
|
||||
options: Migrator.MigratorOptions<R>
|
||||
): Layer.Layer<
|
||||
never,
|
||||
Migrator.MigrationError | SqlError,
|
||||
| SqlClient
|
||||
| PgClient
|
||||
| ChildProcessSpawner.ChildProcessSpawner
|
||||
| FileSystem.FileSystem
|
||||
| Path.Path
|
||||
| R
|
||||
> => Layer.effectDiscard(run(options))
|
||||
15
repos/effect/packages/sql/pg/src/index.ts
Normal file
15
repos/effect/packages/sql/pg/src/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @since 4.0.0
|
||||
*/
|
||||
|
||||
// @barrel: Auto-generated exports. Do not edit manually.
|
||||
|
||||
/**
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export * as PgClient from "./PgClient.ts"
|
||||
|
||||
/**
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export * as PgMigrator from "./PgMigrator.ts"
|
||||
382
repos/effect/packages/sql/pg/test/Client.test.ts
Normal file
382
repos/effect/packages/sql/pg/test/Client.test.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
import { PgClient } from "@effect/sql-pg"
|
||||
import { assert, expect, it } from "@effect/vitest"
|
||||
import { Effect, Fiber, Redacted, Stream, String } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { SqlClient } from "effect/unstable/sql"
|
||||
import * as Statement from "effect/unstable/sql/Statement"
|
||||
import { parse as parsePgConnectionString } from "pg-connection-string"
|
||||
import { PgContainer } from "./utils.ts"
|
||||
|
||||
const compilerTransform = PgClient.makeCompiler(String.camelToSnake)
|
||||
const transformsNested = Statement.defaultTransforms(String.snakeToCamel)
|
||||
const transforms = Statement.defaultTransforms(String.snakeToCamel, false)
|
||||
|
||||
it.layer(PgContainer.layerClient, { timeout: "30 seconds" })("PgClient", (it) => {
|
||||
it.effect("insert helper", () =>
|
||||
Effect.gen(function*() {
|
||||
const sql = yield* PgClient.PgClient
|
||||
const [query, params] = sql`INSERT INTO people ${sql.insert({ name: "Tim", age: 10 })}`.compile()
|
||||
expect(query).toEqual(`INSERT INTO people ("name","age") VALUES ($1,$2)`)
|
||||
expect(params).toEqual(["Tim", 10])
|
||||
}))
|
||||
|
||||
it.effect("updateValues helper", () =>
|
||||
Effect.gen(function*() {
|
||||
const sql = yield* PgClient.PgClient
|
||||
const [query, params] = sql`UPDATE people SET name = data.name FROM ${
|
||||
sql.updateValues(
|
||||
[{ name: "Tim" }, { name: "John" }],
|
||||
"data"
|
||||
)
|
||||
}`.compile()
|
||||
expect(query).toEqual(
|
||||
`UPDATE people SET name = data.name FROM (values ($1),($2)) AS data("name")`
|
||||
)
|
||||
expect(params).toEqual(["Tim", "John"])
|
||||
}))
|
||||
|
||||
it.effect("updateValues helper returning", () =>
|
||||
Effect.gen(function*() {
|
||||
const sql = yield* PgClient.PgClient
|
||||
const [query, params] = sql`UPDATE people SET name = data.name FROM ${
|
||||
sql.updateValues(
|
||||
[{ name: "Tim" }, { name: "John" }],
|
||||
"data"
|
||||
).returning("*")
|
||||
}`.compile()
|
||||
expect(query).toEqual(
|
||||
`UPDATE people SET name = data.name FROM (values ($1),($2)) AS data("name") RETURNING *`
|
||||
)
|
||||
expect(params).toEqual(["Tim", "John"])
|
||||
}))
|
||||
|
||||
it.effect("update helper", () =>
|
||||
Effect.gen(function*() {
|
||||
const sql = yield* PgClient.PgClient
|
||||
let result = sql`UPDATE people SET ${sql.update({ name: "Tim" })}`.compile()
|
||||
expect(result[0]).toEqual(`UPDATE people SET "name" = $1`)
|
||||
expect(result[1]).toEqual(["Tim"])
|
||||
|
||||
result = sql`UPDATE people SET ${sql.update({ name: "Tim", age: 10 }, ["age"])}`.compile()
|
||||
expect(result[0]).toEqual(`UPDATE people SET "name" = $1`)
|
||||
expect(result[1]).toEqual(["Tim"])
|
||||
}))
|
||||
|
||||
it.effect("update helper returning", () =>
|
||||
Effect.gen(function*() {
|
||||
const sql = yield* PgClient.PgClient
|
||||
const result = sql`UPDATE people SET ${sql.update({ name: "Tim" }).returning("*")}`.compile()
|
||||
expect(result[0]).toEqual(`UPDATE people SET "name" = $1 RETURNING *`)
|
||||
expect(result[1]).toEqual(["Tim"])
|
||||
}))
|
||||
|
||||
it.effect("array helper", () =>
|
||||
Effect.gen(function*() {
|
||||
const sql = yield* PgClient.PgClient
|
||||
const [query, params] = sql`SELECT * FROM ${sql("people")} WHERE id IN ${sql.in([1, 2, "string"])}`.compile()
|
||||
expect(query).toEqual(`SELECT * FROM "people" WHERE id IN ($1,$2,$3)`)
|
||||
expect(params).toEqual([1, 2, "string"])
|
||||
}))
|
||||
|
||||
it.effect("array helper with column", () =>
|
||||
Effect.gen(function*() {
|
||||
const sql = yield* PgClient.PgClient
|
||||
let result = sql`SELECT * FROM ${sql("people")} WHERE ${sql.in("id", [1, 2, "string"])}`.compile()
|
||||
expect(result[0]).toEqual(`SELECT * FROM "people" WHERE "id" IN ($1,$2,$3)`)
|
||||
expect(result[1]).toEqual([1, 2, "string"])
|
||||
|
||||
result = sql`SELECT * FROM ${sql("people")} WHERE ${sql.in("id", [])}`.compile()
|
||||
expect(result[0]).toEqual(`SELECT * FROM "people" WHERE 1=0`)
|
||||
expect(result[1]).toEqual([])
|
||||
}))
|
||||
|
||||
it.effect("and", () =>
|
||||
Effect.gen(function*() {
|
||||
const sql = yield* PgClient.PgClient
|
||||
const now = new Date()
|
||||
const result = sql`SELECT * FROM ${sql("people")} WHERE ${
|
||||
sql.and([
|
||||
sql.in("name", ["Tim", "John"]),
|
||||
sql`created_at < ${now}`
|
||||
])
|
||||
}`.compile()
|
||||
expect(result[0]).toEqual(`SELECT * FROM "people" WHERE ("name" IN ($1,$2) AND created_at < $3)`)
|
||||
expect(result[1]).toEqual(["Tim", "John", now])
|
||||
}))
|
||||
|
||||
it("transform nested", () => {
|
||||
assert.deepEqual(
|
||||
transformsNested.array([
|
||||
{
|
||||
a_key: 1,
|
||||
nested: [{ b_key: 2 }],
|
||||
arr_primitive: [1, "2", true]
|
||||
}
|
||||
]) as any,
|
||||
[
|
||||
{
|
||||
aKey: 1,
|
||||
nested: [{ bKey: 2 }],
|
||||
arrPrimitive: [1, "2", true]
|
||||
}
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
it("transform non nested", () => {
|
||||
assert.deepEqual(
|
||||
transforms.array([
|
||||
{
|
||||
a_key: 1,
|
||||
nested: [{ b_key: 2 }],
|
||||
arr_primitive: [1, "2", true]
|
||||
}
|
||||
]) as any,
|
||||
[
|
||||
{
|
||||
aKey: 1,
|
||||
nested: [{ b_key: 2 }],
|
||||
arrPrimitive: [1, "2", true]
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
transforms.array([
|
||||
{
|
||||
json_field: {
|
||||
test_value: [1, true, null, "text"],
|
||||
test_nested: {
|
||||
test_value: [1, true, null, "text"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]) as any,
|
||||
[
|
||||
{
|
||||
jsonField: {
|
||||
test_value: [1, true, null, "text"],
|
||||
test_nested: {
|
||||
test_value: [1, true, null, "text"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("insert fragments", () =>
|
||||
Effect.gen(function*() {
|
||||
const sql = yield* PgClient.PgClient
|
||||
const [query, params] = sql`INSERT INTO people ${
|
||||
sql.insert({
|
||||
name: "Tim",
|
||||
age: 10,
|
||||
json: sql.json({ a: 1 })
|
||||
})
|
||||
}`.compile()
|
||||
assert.strictEqual(
|
||||
query,
|
||||
"INSERT INTO people (\"name\",\"age\",\"json\") VALUES ($1,$2,$3)"
|
||||
)
|
||||
assert.lengthOf(params, 3)
|
||||
}))
|
||||
|
||||
it.effect("update fragments", () =>
|
||||
Effect.gen(function*() {
|
||||
const sql = yield* PgClient.PgClient
|
||||
const now = new Date()
|
||||
const [query, params] = sql`UPDATE people SET json = data.json FROM ${
|
||||
sql.updateValues(
|
||||
[{ json: sql.json({ a: 1 }) }, { json: sql.json({ b: 1 }) }],
|
||||
"data"
|
||||
)
|
||||
} WHERE created_at > ${now}`.compile()
|
||||
assert.strictEqual(
|
||||
query,
|
||||
`UPDATE people SET json = data.json FROM (values ($1),($2)) AS data("json") WHERE created_at > $3`
|
||||
)
|
||||
assert.lengthOf(params, 3)
|
||||
}))
|
||||
|
||||
it.effect("onDialect", () =>
|
||||
Effect.gen(function*() {
|
||||
const sql = yield* PgClient.PgClient
|
||||
assert.strictEqual(
|
||||
sql.onDialect({
|
||||
sqlite: () => "A",
|
||||
pg: () => "B",
|
||||
mysql: () => "C",
|
||||
mssql: () => "D",
|
||||
clickhouse: () => "E"
|
||||
}),
|
||||
"B"
|
||||
)
|
||||
assert.strictEqual(
|
||||
sql.onDialectOrElse({
|
||||
orElse: () => "A",
|
||||
pg: () => "B"
|
||||
}),
|
||||
"B"
|
||||
)
|
||||
}))
|
||||
|
||||
it.effect("identifier transform", () =>
|
||||
Effect.gen(function*() {
|
||||
const sql = yield* PgClient.PgClient
|
||||
const [query] = compilerTransform.compile(
|
||||
sql`SELECT * from ${sql("peopleTest")}`,
|
||||
false
|
||||
)
|
||||
expect(query).toEqual(`SELECT * from "people_test"`)
|
||||
}))
|
||||
|
||||
it.effect("jsonb", () =>
|
||||
Effect.gen(function*() {
|
||||
const sql = yield* PgClient.PgClient
|
||||
const rows = yield* sql<{ json: unknown }>`select ${{ testValue: 123 }}::jsonb as json`
|
||||
expect(rows[0].json).toEqual({ testValue: 123 })
|
||||
}))
|
||||
|
||||
it.effect("stream", () =>
|
||||
Effect.gen(function*() {
|
||||
const sql = yield* SqlClient.SqlClient
|
||||
const rows = yield* sql`SELECT generate_series(1, 3)`.stream.pipe(
|
||||
Stream.runCollect
|
||||
)
|
||||
expect(rows).toEqual([
|
||||
{ "generate_series": 1 },
|
||||
{ "generate_series": 2 },
|
||||
{ "generate_series": 3 }
|
||||
])
|
||||
}))
|
||||
})
|
||||
|
||||
it.layer(PgContainer.layerMakeClient, { timeout: "30 seconds" })("PgClient.makeClient", (it) => {
|
||||
it.effect("connects before executing queries", () =>
|
||||
Effect.gen(function*() {
|
||||
const sql = yield* PgClient.PgClient
|
||||
const rows = yield* sql<{ value: number }>`SELECT 1 AS value`
|
||||
assert.deepStrictEqual(rows, [{ value: 1 }])
|
||||
}))
|
||||
})
|
||||
|
||||
it.layer(PgContainer.layerClientWithTransforms, { timeout: "30 seconds" })("PgClient transforms", (it) => {
|
||||
it.effect("insert helper", () =>
|
||||
Effect.gen(function*() {
|
||||
const sql = yield* PgClient.PgClient
|
||||
const [query, params] = sql`INSERT INTO people ${sql.insert({ firstName: "Tim", age: 10 })}`.compile()
|
||||
expect(query).toEqual(`INSERT INTO people ("first_name","age") VALUES ($1,$2)`)
|
||||
expect(params).toEqual(["Tim", 10])
|
||||
}))
|
||||
|
||||
it.effect("insert helper withoutTransforms", () =>
|
||||
Effect.gen(function*() {
|
||||
const sql = (yield* PgClient.PgClient).withoutTransforms()
|
||||
const [query, params] = sql`INSERT INTO people ${sql.insert({ first_name: "Tim", age: 10 })}`.compile()
|
||||
expect(query).toEqual(`INSERT INTO people ("first_name","age") VALUES ($1,$2)`)
|
||||
expect(params).toEqual(["Tim", 10])
|
||||
}))
|
||||
|
||||
it.effect("multi-statement queries", () =>
|
||||
Effect.gen(function*() {
|
||||
const sql = yield* SqlClient.SqlClient
|
||||
|
||||
const result = yield* sql<{ id: string; name: string }>`
|
||||
CREATE TABLE test_multi (id TEXT PRIMARY KEY, name TEXT);
|
||||
INSERT INTO test_multi (id, name) VALUES ('id1', 'test1') RETURNING *;
|
||||
INSERT INTO test_multi (id, name) VALUES ('id2', 'test2') RETURNING *;
|
||||
`
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0]).toEqual([])
|
||||
expect(result[1]).toEqual([{ id: "id1", name: "test1" }])
|
||||
expect(result[2]).toEqual([{ id: "id2", name: "test2" }])
|
||||
}))
|
||||
|
||||
it.effect("interruption", () =>
|
||||
Effect.gen(function*() {
|
||||
const sql = yield* SqlClient.SqlClient
|
||||
const conn = yield* sql.reserve
|
||||
yield* conn.executeRaw("select pg_sleep(1000)", []).pipe(
|
||||
Effect.timeoutOption("50 millis"),
|
||||
TestClock.withLive
|
||||
)
|
||||
const value = yield* conn.executeValues("select 1", [])
|
||||
expect(value).toEqual([[1]])
|
||||
}))
|
||||
|
||||
it.effect("Should populate config", () =>
|
||||
Effect.gen(function*() {
|
||||
const sql = yield* PgClient.PgClient
|
||||
|
||||
assert.isDefined(sql.config.url)
|
||||
|
||||
const parsedConfig = parsePgConnectionString(Redacted.value(sql.config.url))
|
||||
|
||||
expect(sql.config.host).toEqual(parsedConfig.host)
|
||||
assert.isNotNull(parsedConfig.port)
|
||||
assert.isDefined(parsedConfig.port)
|
||||
expect(sql.config.port).toEqual(parseInt(parsedConfig.port))
|
||||
expect(sql.config.username).toEqual(parsedConfig.user)
|
||||
assert.isDefined(sql.config.password)
|
||||
expect(Redacted.value(sql.config.password)).toEqual(parsedConfig.password)
|
||||
expect(sql.config.database).toEqual(parsedConfig.database)
|
||||
}))
|
||||
})
|
||||
|
||||
it.layer(PgContainer.layerClientSingleConnection, { timeout: "30 seconds" })("PgClient listen", (it) => {
|
||||
it.effect("listen does not reserve a pool connection", () =>
|
||||
Effect.gen(function*() {
|
||||
const sql = yield* PgClient.PgClient
|
||||
const channel = "pool_connection_listen"
|
||||
|
||||
const listenFiber = yield* sql.listen(channel).pipe(
|
||||
Stream.take(1),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped
|
||||
)
|
||||
|
||||
yield* Effect.sleep("250 millis")
|
||||
|
||||
const rows = yield* sql<{ value: number }>`SELECT 1 as value`.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "3 seconds",
|
||||
orElse: () => Effect.fail(new Error("query timed out while listener was active"))
|
||||
})
|
||||
)
|
||||
expect(rows).toEqual([{ value: 1 }])
|
||||
|
||||
yield* sql.notify(channel, "payload")
|
||||
const payloads = yield* Fiber.join(listenFiber).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "3 seconds",
|
||||
orElse: () => Effect.fail(new Error("listener did not receive notification in time"))
|
||||
})
|
||||
)
|
||||
expect(Array.from(payloads)).toEqual(["payload"])
|
||||
}).pipe(TestClock.withLive), 20_000)
|
||||
|
||||
it.effect("notify sends payload", () =>
|
||||
Effect.gen(function*() {
|
||||
const sql = yield* PgClient.PgClient
|
||||
const channel = "pool_connection_notify"
|
||||
|
||||
const listenFiber = yield* sql.listen(channel).pipe(
|
||||
Stream.take(1),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped
|
||||
)
|
||||
|
||||
yield* Effect.sleep("250 millis")
|
||||
yield* sql.notify(channel, "payload")
|
||||
|
||||
const payloads = yield* Fiber.join(listenFiber).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "3 seconds",
|
||||
orElse: () => Effect.fail(new Error("listener did not receive notification in time"))
|
||||
})
|
||||
)
|
||||
expect(Array.from(payloads)).toEqual(["payload"])
|
||||
}).pipe(TestClock.withLive), 20_000)
|
||||
})
|
||||
16
repos/effect/packages/sql/pg/test/KeyValueStore.test.ts
Normal file
16
repos/effect/packages/sql/pg/test/KeyValueStore.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Layer } from "effect"
|
||||
import * as KeyValueStoreTest from "effect-test/unstable/persistence/KeyValueStoreTest"
|
||||
import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"
|
||||
import { PgContainer } from "./utils.ts"
|
||||
|
||||
KeyValueStoreTest.suite(
|
||||
"sql-pg",
|
||||
KeyValueStore.layerSql().pipe(Layer.provide(PgContainer.layerClient))
|
||||
)
|
||||
|
||||
KeyValueStoreTest.suite(
|
||||
"sql-pg-custom-table",
|
||||
KeyValueStore.layerSql({ table: "effect_key_value_store_custom" }).pipe(
|
||||
Layer.provide(PgContainer.layerClient)
|
||||
)
|
||||
)
|
||||
106
repos/effect/packages/sql/pg/test/Persistence.test.ts
Normal file
106
repos/effect/packages/sql/pg/test/Persistence.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { assert, it } from "@effect/vitest"
|
||||
import { Effect, Exit, Fiber, Latch, Layer, Schema } from "effect"
|
||||
import * as PersistedCacheTest from "effect-test/unstable/persistence/PersistedCacheTest"
|
||||
import * as PersistedQueueTest from "effect-test/unstable/persistence/PersistedQueueTest"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { PersistedQueue, Persistence } from "effect/unstable/persistence"
|
||||
import { SqlClient } from "effect/unstable/sql"
|
||||
import { PgContainer } from "./utils.ts"
|
||||
|
||||
PersistedCacheTest.suite(
|
||||
"sql-pg-multi",
|
||||
Persistence.layerSqlMultiTable.pipe(Layer.provide(PgContainer.layerClient))
|
||||
)
|
||||
|
||||
PersistedCacheTest.suite(
|
||||
"sql-pg-single",
|
||||
Persistence.layerSql.pipe(Layer.provide(PgContainer.layerClient))
|
||||
)
|
||||
|
||||
PersistedQueueTest.suite(
|
||||
"sql-pg",
|
||||
PersistedQueue.layerStoreSql().pipe(Layer.provide(PgContainer.layerClient))
|
||||
)
|
||||
|
||||
it.layer(PgContainer.layerClient, { timeout: "30 seconds" })("PersistedQueue SQL locks", (it) => {
|
||||
it.effect("refreshes locks for acquired elements", () =>
|
||||
Effect.gen(function*() {
|
||||
const options = {
|
||||
tableName: "effect_queue_lock_refresh",
|
||||
pollInterval: "10 millis",
|
||||
lockRefreshInterval: "100 millis",
|
||||
lockExpiration: "1 second"
|
||||
} as const
|
||||
const store1 = yield* PersistedQueue.makeStoreSql(options)
|
||||
const store2 = yield* PersistedQueue.makeStoreSql(options)
|
||||
const element = { message: "hello" }
|
||||
|
||||
yield* store1.offer({
|
||||
name: "lock-refresh",
|
||||
id: crypto.randomUUID(),
|
||||
element,
|
||||
isCustomId: false
|
||||
})
|
||||
|
||||
const acquired = Latch.makeUnsafe()
|
||||
const first = yield* Effect.scoped(Effect.gen(function*() {
|
||||
yield* store1.take({ name: "lock-refresh", maxAttempts: 10 })
|
||||
yield* acquired.open
|
||||
return yield* Effect.never
|
||||
})).pipe(Effect.forkScoped)
|
||||
|
||||
yield* acquired.await
|
||||
|
||||
const second = yield* Effect.scoped(
|
||||
store2.take({ name: "lock-refresh", maxAttempts: 10 })
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.sleep("1500 millis")
|
||||
assert.isUndefined(second.pollUnsafe())
|
||||
|
||||
yield* Fiber.interrupt(first)
|
||||
const received = yield* Fiber.join(second)
|
||||
assert.deepStrictEqual(received.element, element)
|
||||
}).pipe(TestClock.withLive))
|
||||
|
||||
it.effect("counts malformed JSON as an attempt and continues", () =>
|
||||
Effect.gen(function*() {
|
||||
const tableName = "effect_queue_invalid_json"
|
||||
const store = yield* PersistedQueue.makeStoreSql({
|
||||
tableName,
|
||||
pollInterval: "10 millis"
|
||||
})
|
||||
const factory = yield* PersistedQueue.makeFactory.pipe(
|
||||
Effect.provideService(PersistedQueue.PersistedQueueStore, store)
|
||||
)
|
||||
const queue = yield* factory.make({
|
||||
name: "invalid-json",
|
||||
schema: Schema.String
|
||||
})
|
||||
const sql = (yield* SqlClient.SqlClient).withoutTransforms()
|
||||
const table = sql(tableName)
|
||||
const poisonId = crypto.randomUUID()
|
||||
|
||||
yield* store.offer({
|
||||
name: "invalid-json",
|
||||
id: poisonId,
|
||||
element: "poison",
|
||||
isCustomId: false
|
||||
})
|
||||
yield* sql`UPDATE ${table} SET element = ${"{"} WHERE id = ${poisonId}`
|
||||
yield* queue.offer("valid")
|
||||
|
||||
const malformed = yield* Effect.exit(queue.take(Effect.succeed, { maxAttempts: 1 }))
|
||||
assert.isTrue(Exit.isFailure(malformed))
|
||||
|
||||
const rows = yield* sql<{
|
||||
readonly attempts: number
|
||||
readonly last_failure: string | null
|
||||
}>`SELECT attempts, last_failure FROM ${table} WHERE id = ${poisonId}`
|
||||
assert.strictEqual(rows[0].attempts, 1)
|
||||
assert.isNotNull(rows[0].last_failure)
|
||||
|
||||
const value = yield* queue.take(Effect.succeed, { maxAttempts: 1 })
|
||||
assert.strictEqual(value, "valid")
|
||||
}).pipe(TestClock.withLive))
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { PgClient } from "@effect/sql-pg"
|
||||
import { assert, describe, it } from "@effect/vitest"
|
||||
import { Effect } from "effect"
|
||||
import * as Reactivity from "effect/unstable/reactivity/Reactivity"
|
||||
import type * as SqlError from "effect/unstable/sql/SqlError"
|
||||
|
||||
const queryFailureReason = (cause: unknown) =>
|
||||
Effect.gen(function*() {
|
||||
const client = yield* PgClient.fromPool({
|
||||
acquire: Effect.succeed(makeFailingPool(cause) as any)
|
||||
})
|
||||
const error = yield* Effect.flip(client`SELECT 1`)
|
||||
return error.reason
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(Reactivity.layer)
|
||||
)
|
||||
|
||||
const queryFailureReasonTag = (cause: unknown) => Effect.map(queryFailureReason(cause), (reason) => reason._tag)
|
||||
|
||||
const assertUniqueViolation = (reason: SqlError.SqlErrorReason, constraint: string) => {
|
||||
assert.strictEqual(reason._tag, "UniqueViolation")
|
||||
if (reason._tag === "UniqueViolation") {
|
||||
assert.strictEqual(reason.constraint, constraint)
|
||||
}
|
||||
}
|
||||
|
||||
const makeFailingPool = (cause: unknown) => ({
|
||||
options: {},
|
||||
ending: false,
|
||||
connect: (cb: (cause: unknown, client: any) => void) => cb(null, makeFailingClient(cause)),
|
||||
query: () => undefined
|
||||
})
|
||||
|
||||
const makeFailingClient = (cause: unknown) => ({
|
||||
once: () => undefined,
|
||||
off: () => undefined,
|
||||
release: () => undefined,
|
||||
query: (_sql: string, _params: ReadonlyArray<unknown>, cb: (cause: unknown) => void) => cb(cause)
|
||||
})
|
||||
|
||||
describe("PgClient SqlError classification", () => {
|
||||
it.effect("checks 42501 before generic 42*", () =>
|
||||
Effect.gen(function*() {
|
||||
const authorizationTag = yield* queryFailureReasonTag({ code: "42501" })
|
||||
assert.strictEqual(authorizationTag, "AuthorizationError")
|
||||
|
||||
const syntaxTag = yield* queryFailureReasonTag({ code: "42P01" })
|
||||
assert.strictEqual(syntaxTag, "SqlSyntaxError")
|
||||
}))
|
||||
|
||||
it.effect("falls back to UnknownError for unmapped SQLSTATE", () =>
|
||||
Effect.gen(function*() {
|
||||
const tag = yield* queryFailureReasonTag({ code: "ZZZZZ" })
|
||||
assert.strictEqual(tag, "UnknownError")
|
||||
}))
|
||||
|
||||
it.effect("classifies 23505 as UniqueViolation and trims the constraint name", () =>
|
||||
Effect.gen(function*() {
|
||||
const reason = yield* queryFailureReason({ code: "23505", constraint: " users_email_key " })
|
||||
assertUniqueViolation(reason, "users_email_key")
|
||||
}))
|
||||
|
||||
it.effect("uses unknown for missing, non-string, or blank unique violation constraints", () =>
|
||||
Effect.gen(function*() {
|
||||
const missing = yield* queryFailureReason({ code: "23505" })
|
||||
assertUniqueViolation(missing, "unknown")
|
||||
|
||||
const nonString = yield* queryFailureReason({ code: "23505", constraint: 123 })
|
||||
assertUniqueViolation(nonString, "unknown")
|
||||
|
||||
const blank = yield* queryFailureReason({ code: "23505", constraint: " " })
|
||||
assertUniqueViolation(blank, "unknown")
|
||||
}))
|
||||
|
||||
it.effect("keeps non-unique integrity constraints classified as ConstraintError", () =>
|
||||
Effect.gen(function*() {
|
||||
const tag = yield* queryFailureReasonTag({ code: "23503", constraint: "orders_user_id_fkey" })
|
||||
assert.strictEqual(tag, "ConstraintError")
|
||||
}))
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import * as SqlEventLogServerUnencryptedStorageTest from "effect-test/unstable/eventlog/SqlEventLogServerUnencryptedStorageTest"
|
||||
import { PgContainer } from "./utils.ts"
|
||||
|
||||
SqlEventLogServerUnencryptedStorageTest.suite(
|
||||
"sql-pg",
|
||||
PgContainer.layerClient
|
||||
)
|
||||
52
repos/effect/packages/sql/pg/test/TransactionAcquire.test.ts
Normal file
52
repos/effect/packages/sql/pg/test/TransactionAcquire.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { PgClient } from "@effect/sql-pg"
|
||||
import { assert, it } from "@effect/vitest"
|
||||
import { Effect } from "effect"
|
||||
import * as Reactivity from "effect/unstable/reactivity/Reactivity"
|
||||
|
||||
type ConnectCb = (cause: unknown, client?: unknown, release?: (cause?: Error) => void) => void
|
||||
|
||||
const makeFailingPool = (cause: unknown) => ({
|
||||
options: {},
|
||||
ending: false,
|
||||
connect: (cb: ConnectCb) => cb(cause, undefined, () => undefined),
|
||||
query: () => undefined
|
||||
})
|
||||
|
||||
const makeMissingClientPool = () => ({
|
||||
options: {},
|
||||
ending: false,
|
||||
connect: (cb: ConnectCb) => cb(null, undefined, () => undefined),
|
||||
query: () => undefined
|
||||
})
|
||||
|
||||
it.effect("withTransaction surfaces acquire failures instead of defecting", () =>
|
||||
Effect.gen(function*() {
|
||||
const sql = yield* PgClient.fromPool({
|
||||
acquire: Effect.succeed(makeFailingPool({ code: "08006" }) as any)
|
||||
})
|
||||
|
||||
const error = yield* Effect.flip(sql.withTransaction(sql`SELECT 1`))
|
||||
|
||||
assert.strictEqual(error.reason._tag, "ConnectionError")
|
||||
assert.strictEqual(error.reason.message, "Failed to acquire connection for transaction")
|
||||
assert.strictEqual(error.reason.operation, "acquireConnection")
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(Reactivity.layer)
|
||||
))
|
||||
|
||||
it.effect("withTransaction surfaces missing-client acquire as ConnectionError", () =>
|
||||
Effect.gen(function*() {
|
||||
const sql = yield* PgClient.fromPool({
|
||||
acquire: Effect.succeed(makeMissingClientPool() as any)
|
||||
})
|
||||
|
||||
const error = yield* Effect.flip(sql.withTransaction(sql`SELECT 1`))
|
||||
|
||||
assert.strictEqual(error.reason._tag, "ConnectionError")
|
||||
assert.strictEqual(error.reason.message, "Failed to acquire connection for transaction")
|
||||
assert.strictEqual(error.reason.operation, "acquireConnection")
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(Reactivity.layer)
|
||||
))
|
||||
58
repos/effect/packages/sql/pg/test/utils.ts
Normal file
58
repos/effect/packages/sql/pg/test/utils.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { PgClient } from "@effect/sql-pg"
|
||||
import { PostgreSqlContainer } from "@testcontainers/postgresql"
|
||||
import { Context, Data, Effect, Layer, Redacted, String } from "effect"
|
||||
|
||||
export class ContainerError extends Data.TaggedError("ContainerError")<{
|
||||
cause: unknown
|
||||
}> {}
|
||||
|
||||
export class PgContainer extends Context.Service<PgContainer>()("test/PgContainer", {
|
||||
make: Effect.acquireRelease(
|
||||
Effect.tryPromise({
|
||||
try: () => new PostgreSqlContainer("postgres:alpine").start(),
|
||||
catch: (cause) => new ContainerError({ cause })
|
||||
}),
|
||||
(container) => Effect.promise(() => container.stop())
|
||||
)
|
||||
}) {
|
||||
static readonly layer = Layer.effect(this)(this.make)
|
||||
|
||||
static layerClient = Layer.unwrap(
|
||||
Effect.gen(function*() {
|
||||
const container = yield* PgContainer
|
||||
return PgClient.layer({
|
||||
url: Redacted.make(container.getConnectionUri())
|
||||
})
|
||||
})
|
||||
).pipe(Layer.provide(this.layer))
|
||||
|
||||
static layerMakeClient = Layer.unwrap(
|
||||
Effect.gen(function*() {
|
||||
const container = yield* PgContainer
|
||||
return PgClient.layerFrom(PgClient.makeClient({
|
||||
url: Redacted.make(container.getConnectionUri())
|
||||
}))
|
||||
})
|
||||
).pipe(Layer.provide(this.layer))
|
||||
|
||||
static layerClientWithTransforms = Layer.unwrap(
|
||||
Effect.gen(function*() {
|
||||
const container = yield* PgContainer
|
||||
return PgClient.layer({
|
||||
url: Redacted.make(container.getConnectionUri()),
|
||||
transformResultNames: String.snakeToCamel,
|
||||
transformQueryNames: String.camelToSnake
|
||||
})
|
||||
})
|
||||
).pipe(Layer.provide(this.layer))
|
||||
|
||||
static layerClientSingleConnection = Layer.unwrap(
|
||||
Effect.gen(function*() {
|
||||
const container = yield* PgContainer
|
||||
return PgClient.layer({
|
||||
url: Redacted.make(container.getConnectionUri()),
|
||||
maxConnections: 1
|
||||
})
|
||||
})
|
||||
).pipe(Layer.provide(this.layer))
|
||||
}
|
||||
9
repos/effect/packages/sql/pg/tsconfig.json
Normal file
9
repos/effect/packages/sql/pg/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../effect" },
|
||||
{ "path": "../../platform-node-shared" }
|
||||
]
|
||||
}
|
||||
6
repos/effect/packages/sql/pg/vitest.config.ts
Normal file
6
repos/effect/packages/sql/pg/vitest.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { mergeConfig, type ViteUserConfig } from "vitest/config"
|
||||
import shared from "../../../vitest.shared.ts"
|
||||
|
||||
const config: ViteUserConfig = {}
|
||||
|
||||
export default mergeConfig(shared, config)
|
||||
Reference in New Issue
Block a user