Allow planning lifecycle locks to use non-pooled runtime PostgreSQL connections without a duplicate migration URL. - Select runtime-direct endpoints for direct DATABASE_URL connections and retain migration overrides for pooled endpoints. - Preserve lifecycle transport failures across triage retries and expose lock helpers through the core barrel. - Document the connection behavior and add resolver, advisory-lock, and triage regression coverage. Files changed: .../fn-8911-direct-database-url-planning-lock.md | 7 + docs/architecture.md | 2 +- docs/multi-project.md | 4 +- .../__tests__/postgres/backend-resolver.test.ts | 43 +++++- .../planning-lifecycle-advisory-lock.pg.test.ts | 28 +++- packages/core/src/index.ts | 2 + packages/core/src/postgres/advisory-locks.ts | 6 +- packages/core/src/postgres/backend-resolver.ts | 29 +++- packages/core/src/postgres/index.ts | 5 + ...ge-planning-lifecycle-transport-failure.test.ts | 159 +++++++++++++++++++++ packages/engine/src/triage.ts | 100 +++++++++++-- 11 files changed, 360 insertions(+), 25 deletions(-) Fusion-Task-Id: FN-8911 Fusion-Task-Lineage: ac2ec0d6-f409-40ba-a678-6dd7da7006dd Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
221 lines
9.1 KiB
TypeScript
221 lines
9.1 KiB
TypeScript
/**
|
|
* PostgreSQL backend resolution.
|
|
*
|
|
* FNXC:PostgresConnection 2026-06-24-01:45:
|
|
* The engine supports two modes, resolved at startup by checking DATABASE_URL:
|
|
* 1. DATABASE_URL set → external/remote PostgreSQL (no embedded instance started).
|
|
* 2. DATABASE_URL unset → embedded mode (handled by the embedded-lifecycle feature).
|
|
*
|
|
* The resolver is a pure function over environment variables: it does not open
|
|
* connections or start processes. It returns a descriptor that the connection
|
|
* layer (connection.ts) and the embedded lifecycle manager consume.
|
|
*
|
|
* DATABASE_MIGRATION_URL:
|
|
* When the runtime DATABASE_URL uses a transaction-pooling connection (Supavisor/
|
|
* PgBouncer in transaction mode), prepared statements break because each
|
|
* transaction may land on a different backend connection. The migration URL
|
|
* routes schema/migration work to a direct (non-pooled) connection. If
|
|
* DATABASE_MIGRATION_URL is unset, schema work uses the runtime URL.
|
|
*
|
|
* Pooled-URL warning:
|
|
* When DATABASE_URL looks like a transaction pooler and no DATABASE_MIGRATION_URL
|
|
* is set, a warning is emitted about prepared-statement risk (VAL-CONN-008).
|
|
*/
|
|
|
|
import { redactConnectionString } from "./credential-redact.js";
|
|
|
|
/** The resolved backend mode. */
|
|
export type BackendMode = "embedded" | "external";
|
|
|
|
/**
|
|
* The resolved connection targets for the PostgreSQL backend.
|
|
*
|
|
* - `mode` — whether the backend is embedded (local bundled Postgres) or
|
|
* external (a user-provided DATABASE_URL).
|
|
* - `runtimeUrl` — the connection string used for runtime queries. In embedded
|
|
* mode this is null until the embedded lifecycle provides it.
|
|
* - `migrationUrl` — the connection string used for schema/migration work. Falls
|
|
* back to `runtimeUrl` when DATABASE_MIGRATION_URL is not set.
|
|
* - `migrationUrlOverridden` — true when DATABASE_MIGRATION_URL was explicitly
|
|
* set (used for logging and the pooler-warning gate).
|
|
*/
|
|
export interface ResolvedBackend {
|
|
readonly mode: BackendMode;
|
|
readonly runtimeUrl: string | null;
|
|
readonly migrationUrl: string | null;
|
|
readonly migrationUrlOverridden: boolean;
|
|
/** A proven direct endpoint for session-scoped lifecycle advisory locks. */
|
|
readonly directSessionUrl?: string | null;
|
|
readonly directSessionProvenance?: "embedded-lifecycle" | "migration-override" | "runtime-direct" | null;
|
|
}
|
|
|
|
/**
|
|
* Options for resolving the backend. Defaults read from `process.env` so the
|
|
* resolver remains a pure function over its inputs and is trivially testable.
|
|
*/
|
|
export interface ResolveBackendOptions {
|
|
/** The runtime connection string (DATABASE_URL). */
|
|
readonly databaseUrl?: string | null;
|
|
/** The migration connection string (DATABASE_MIGRATION_URL). */
|
|
readonly databaseMigrationUrl?: string | null;
|
|
}
|
|
|
|
/** Environment variable names used for backend resolution. */
|
|
export const DATABASE_URL_ENV = "DATABASE_URL";
|
|
export const DATABASE_MIGRATION_URL_ENV = "DATABASE_MIGRATION_URL";
|
|
|
|
/**
|
|
* Resolve the PostgreSQL backend from environment variables.
|
|
*
|
|
* Rules:
|
|
* - DATABASE_URL set and non-empty → external mode, runtimeUrl = DATABASE_URL.
|
|
* - DATABASE_URL unset or empty → embedded mode, runtimeUrl = null.
|
|
* - migrationUrl = DATABASE_MIGRATION_URL if set, else runtimeUrl.
|
|
*
|
|
* Whitespace-only values are treated as unset (empty).
|
|
*/
|
|
export function resolveBackend(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): ResolvedBackend {
|
|
return resolveBackendWithOptions({
|
|
databaseUrl: env[DATABASE_URL_ENV] ?? null,
|
|
databaseMigrationUrl: env[DATABASE_MIGRATION_URL_ENV] ?? null,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resolve the backend from explicit option values (testable without env mutation).
|
|
*/
|
|
export function resolveBackendWithOptions(
|
|
opts: ResolveBackendOptions,
|
|
): ResolvedBackend {
|
|
const databaseUrl = (opts.databaseUrl ?? "").trim();
|
|
const databaseMigrationUrl = (opts.databaseMigrationUrl ?? "").trim();
|
|
|
|
const mode: BackendMode = databaseUrl.length > 0 ? "external" : "embedded";
|
|
const runtimeUrl = mode === "external" ? databaseUrl : null;
|
|
|
|
// In embedded mode, DATABASE_MIGRATION_URL is meaningless (the embedded
|
|
// lifecycle provides its own connection URLs). Only honor it in external mode.
|
|
const migrationUrlOverridden = mode === "external" && databaseMigrationUrl.length > 0;
|
|
const migrationUrl = migrationUrlOverridden
|
|
? databaseMigrationUrl
|
|
: runtimeUrl;
|
|
|
|
/*
|
|
FNXC:PostgresConnection 2026-08-09-21:53:
|
|
Planning lifecycle locks require one stable PostgreSQL session. A direct runtime URL is
|
|
itself eligible; only a pooled runtime needs DATABASE_MIGRATION_URL because a transaction
|
|
pooler can swap sessions between pg_advisory_lock and pg_advisory_unlock.
|
|
*/
|
|
const directSessionUrl = migrationUrlOverridden && !looksLikePoolerUrl(databaseMigrationUrl)
|
|
? databaseMigrationUrl
|
|
: runtimeUrl && !looksLikePoolerUrl(runtimeUrl)
|
|
? runtimeUrl
|
|
: null;
|
|
return {
|
|
mode, runtimeUrl, migrationUrl, migrationUrlOverridden,
|
|
directSessionUrl,
|
|
directSessionProvenance: directSessionUrl
|
|
? migrationUrlOverridden && directSessionUrl === databaseMigrationUrl
|
|
? "migration-override"
|
|
: "runtime-direct"
|
|
: null,
|
|
};
|
|
}
|
|
|
|
// ── Pooler detection ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Heuristic detection of transaction-pooling connection strings.
|
|
*
|
|
* FNXC:PostgresConnection 2026-06-24-01:50:
|
|
* Transaction poolers (Supavisor, PgBouncer in transaction mode) break
|
|
* prepared statements because each transaction may use a different backend
|
|
* connection. Drizzle/postgres.js uses prepared statements by default
|
|
* (`prepare: true`), which silently fails under a transaction pooler.
|
|
*
|
|
* Detection is heuristic — there is no reliable way to know the server-side
|
|
* pool mode from a connection string alone. We check for common pooler host
|
|
* patterns and the `?pgbouncer=true` / `?pool_mode=transaction` query params.
|
|
*
|
|
* Known pooler host indicators:
|
|
* - Supavisor: `*.supavisor.*`, `*.pooler.supabase.*`
|
|
* - PgBouncer: hosts containing `pgbouncer` (rare in the URL but possible)
|
|
* - Supabase pooler: `*.pooler.supabase.com`, `*.pooler.supabase.co`
|
|
*/
|
|
export function looksLikePoolerUrl(url: string): boolean {
|
|
const lower = url.toLowerCase();
|
|
|
|
// Query-parameter hints (explicit pooler configuration)
|
|
if (/[?&]pgbouncer=true\b/i.test(lower)) return true;
|
|
if (/[?&]pool_mode=transaction\b/i.test(lower)) return true;
|
|
|
|
// Host-based heuristics for well-known managed poolers
|
|
if (/\.supavisor\./i.test(lower)) return true;
|
|
if (/\.pooler\.supabase\./i.test(lower)) return true;
|
|
if (/\bpgbouncer\b/i.test(lower)) return true;
|
|
|
|
// Supavisor uses port 6543 / 5432 in pooler mode — but port alone is too
|
|
// noisy (many local Postgres instances use 5432). Only flag the host patterns.
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* The warning text emitted when a pooled runtime URL is used without a
|
|
* DATABASE_MIGRATION_URL. Exported for test assertion (VAL-CONN-008).
|
|
*/
|
|
export const POOLER_PREPARED_STATEMENT_WARNING =
|
|
"DATABASE_URL appears to use a transaction pooler (Supavisor/PgBouncer) " +
|
|
"but DATABASE_MIGRATION_URL is not set. Prepared statements may break under " +
|
|
"transaction-mode pooling. Set DATABASE_MIGRATION_URL to a direct connection " +
|
|
"for schema/migration work, or disable prepared statements in the runtime pool.";
|
|
|
|
/**
|
|
* Check whether a pooler warning should be emitted for the resolved backend,
|
|
* and return the warning message if so.
|
|
*
|
|
* A warning is emitted when:
|
|
* - The backend is external (DATABASE_URL is set).
|
|
* - The runtime URL looks like a pooler connection.
|
|
* - DATABASE_MIGRATION_URL was NOT explicitly set.
|
|
*
|
|
* Returns `null` when no warning is needed.
|
|
*/
|
|
export function poolerWarning(
|
|
backend: ResolvedBackend,
|
|
): string | null {
|
|
if (backend.mode !== "external") return null;
|
|
if (backend.migrationUrlOverridden) return null;
|
|
if (!backend.runtimeUrl) return null;
|
|
if (!looksLikePoolerUrl(backend.runtimeUrl)) return null;
|
|
return POOLER_PREPARED_STATEMENT_WARNING;
|
|
}
|
|
|
|
/**
|
|
* Produce a log-safe description of the resolved backend for startup logging.
|
|
* Never includes the password (uses credential redaction).
|
|
*/
|
|
export function describeBackendForLog(backend: ResolvedBackend): string {
|
|
if (backend.mode === "embedded") {
|
|
return "embedded backend resolved (DATABASE_URL unset) — embedded lifecycle will provide the connection";
|
|
}
|
|
const safeRuntime = backend.runtimeUrl
|
|
? redactConnectionString(backend.runtimeUrl)
|
|
: "<unknown>";
|
|
const parts = [`external backend resolved (DATABASE_URL set): ${safeRuntime}`];
|
|
if (backend.migrationUrlOverridden && backend.migrationUrl) {
|
|
parts.push(
|
|
`DATABASE_MIGRATION_URL overrides schema-work target: ${redactConnectionString(backend.migrationUrl)}`,
|
|
);
|
|
}
|
|
const directSession = backend.directSessionProvenance === "runtime-direct"
|
|
? "runtime URL"
|
|
: backend.directSessionProvenance === "migration-override"
|
|
? "migration URL"
|
|
: "none (pooled endpoint — set DATABASE_MIGRATION_URL to a direct connection)";
|
|
parts.push(`planning lifecycle direct session: ${directSession}`);
|
|
return parts.join(" | ");
|
|
}
|