import { blindCast } from '@internal/utils/casts'; import { InternalError } from '@internal/utils/internal-error'; import type { Client, Pool } from 'pg'; import { postgresError } from '../errors'; export const isPgPool = (pg: Pool | Client): pg is Pool => 'totalCount' in pg && 'idleCount' in pg && 'waitingCount' in pg; export const isPgClient = (pg: Pool | Client): pg is Client => 'escapeIdentifier' in pg && 'escapeLiteral' in pg; export type PostgresBinding = | { readonly kind: 'url'; readonly url: string } | { readonly kind: 'pgPool'; readonly pool: Pool } | { readonly kind: 'pgClient'; readonly client: Client }; export type PostgresBindingInput = | { readonly binding: PostgresBinding; readonly url?: never; readonly pg?: never; } | { readonly url: string; readonly binding?: never; readonly pg?: never; } | { readonly pg: Pool | Client; readonly binding?: never; readonly url?: never; }; type PostgresBindingFields = { readonly binding?: PostgresBinding; readonly url?: string; readonly pg?: Pool | Client; }; export function validatePostgresUrl(url: string): string { const trimmed = url.trim(); if (trimmed.length === 0) { throw postgresError('RUNTIME.BINDING_INVALID', 'Postgres URL must be a non-empty string', { meta: { extension: 'postgres', reason: 'empty url' }, }); } let parsed: URL; try { parsed = new URL(trimmed); } catch { throw postgresError('RUNTIME.BINDING_INVALID', 'Postgres URL must be a valid URL', { meta: { extension: 'postgres', reason: 'unparseable url' }, }); } if (parsed.protocol !== 'postgres:' && parsed.protocol !== 'postgresql:') { throw postgresError( 'RUNTIME.BINDING_INVALID', 'Postgres URL must use postgres:// or postgresql://', { meta: { extension: 'postgres', reason: 'wrong scheme', received: parsed.protocol } }, ); } return trimmed; } function typeOfInput(value: unknown): string { return value === null ? 'null' : typeof value; } const inputRequirements = { binding: { accepts: (value: unknown) => typeof value === 'object' && value !== null, message: 'The binding option must be an object with a kind', expected: 'an object with a kind', fix: "Pass binding: { kind: 'url', url }, { kind: 'pgPool', pool } or { kind: 'pgClient', client }.", }, url: { accepts: (value: unknown) => typeof value === 'string', message: 'The url option must be a string', expected: 'a connection string', fix: 'Pass a postgres:// or postgresql:// connection string as url, and check that the variable it comes from is set.', }, pg: { accepts: (value: unknown) => typeof value === 'object' && value !== null, message: 'The pg option must be a pg.Pool or a pg.Client', expected: 'a pg.Pool or pg.Client object', fix: 'Pass a pg.Pool or pg.Client as pg, or pass a connection string as url.', }, } as const; const bindingKinds: ReadonlySet = new Set([ 'url', 'pgPool', 'pgClient', ]); function checkBindingKind(binding: object): void { const kind: unknown = 'kind' in binding ? binding.kind : undefined; if (bindingKinds.has(kind)) return; const receivedKind = typeof kind === 'string' ? kind : typeOfInput(kind); throw postgresError( 'RUNTIME.BINDING_INVALID', "The binding option must have kind 'url', 'pgPool' or 'pgClient'", { why: kind === undefined ? 'The binding option has no kind, so it names no database.' : `The binding option has kind ${typeof kind === 'string' ? `'${kind}'` : `of type ${receivedKind}`}, which is not a Postgres binding kind.`, fix: "Pass binding: { kind: 'url', url }, { kind: 'pgPool', pool } or { kind: 'pgClient', client }.", meta: { extension: 'postgres', reason: 'unknown binding kind', receivedKind }, }, ); } function checkInputTypes(options: PostgresBindingFields): void { for (const option of ['binding', 'url', 'pg'] as const) { const value: unknown = options[option]; const requirement = inputRequirements[option]; if (value === undefined || requirement.accepts(value)) continue; const receivedType = typeOfInput(value); throw postgresError('RUNTIME.BINDING_INVALID', requirement.message, { why: `The ${option} option is ${receivedType === 'null' ? 'null' : `a ${receivedType}`}, and it must be ${requirement.expected}.`, fix: requirement.fix, meta: { extension: 'postgres', reason: `invalid ${option} input`, receivedType }, }); } if (options.binding !== undefined) checkBindingKind(options.binding); } export function resolvePostgresBinding(options: PostgresBindingInput): PostgresBinding { checkInputTypes(options); const providedCount = Number(options.binding !== undefined) + Number(options.url !== undefined) + Number(options.pg !== undefined); if (providedCount !== 1) { throw postgresError( 'RUNTIME.BINDING_INVALID', 'Provide one binding input: binding, url, or pg', { fix: 'Pass exactly one of `binding`, `url`, or `pg`.', meta: { extension: 'postgres', reason: 'zero or multiple binding inputs' }, }, ); } if (options.binding !== undefined) { return options.binding; } if (options.url !== undefined) { return { kind: 'url', url: validatePostgresUrl(options.url) }; } const pgBinding = options.pg; if (pgBinding === undefined) { throw new InternalError('Invariant violation: expected pg binding after validation'); } if (isPgPool(pgBinding)) { return { kind: 'pgPool', pool: pgBinding }; } if (isPgClient(pgBinding)) { return { kind: 'pgClient', client: pgBinding }; } throw postgresError( 'RUNTIME.BINDING_INVALID', 'Unable to determine pg binding type from pg input; use binding with explicit kind', { fix: 'Pass `binding: { kind: "pgPool", pool }` or `binding: { kind: "pgClient", client }` instead of `pg`.', meta: { extension: 'postgres', reason: 'unrecognizable pg object' }, }, ); } export function resolveOptionalPostgresBinding( options: PostgresBindingFields, ): PostgresBinding | undefined { const providedCount = Number(options.binding !== undefined) + Number(options.url !== undefined) + Number(options.pg !== undefined); if (providedCount === 0) { return undefined; } return resolvePostgresBinding( blindCast< PostgresBindingInput, 'provided-count validation narrows optional binding fields to one binding input' >(options), ); }