Merge commit '36c66dd290d3ce6eb1ccd310d0c658d4a32bb8eb' as 'repos/effect'

This commit is contained in:
-Puter
2026-07-19 03:25:10 +05:30
parent dd1071cc10
commit 2daf979036
2214 changed files with 673090 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View 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.

View 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).

View 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 }
]
}
}

View 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"
}
}

View 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)
}

View 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))

View 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"

View 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)
})

View 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)
)
)

View 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))
})

View File

@@ -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")
}))
})

View File

@@ -0,0 +1,7 @@
import * as SqlEventLogServerUnencryptedStorageTest from "effect-test/unstable/eventlog/SqlEventLogServerUnencryptedStorageTest"
import { PgContainer } from "./utils.ts"
SqlEventLogServerUnencryptedStorageTest.suite(
"sql-pg",
PgContainer.layerClient
)

View 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)
))

View 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))
}

View File

@@ -0,0 +1,9 @@
{
"$schema": "http://json.schemastore.org/tsconfig",
"extends": "../../../tsconfig.base.json",
"include": ["src"],
"references": [
{ "path": "../../effect" },
{ "path": "../../platform-node-shared" }
]
}

View 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)