Files
fusion/packages/core/src/postgres/connection.ts
gsxdsm cb57093d03 refactor: domain folder layout (types, API, core, engine) (#2398)
## Summary

Wave 17 organizes Fusion into **domain folders** (stacks on #2397).

### Layout
- **core/types/** — board, task, agents, settings, merge, workflow,
mesh, …
- **core/src/** — agents, ai, async-stores, workflows, tasks, config,
db, …
- **dashboard/app/api/** — client, tasks, agents, git, missions,
planning, …
- **engine/src/** — agents, auth, execution, merge, missions, overseer,
worktree, …

Root keepers retained for large entrypoints (`store.ts`, `executor.ts`,
`merger.ts`, …).

Public barrels (`@fusion/core`, `@fusion/engine`, `app/api.ts` → legacy)
stay stable.

## Test plan
- [x] `@fusion/core` typecheck
- [x] `@fusion/engine` typecheck (pre-existing playwright-core noise
only)
- [ ] CI merge gate

**Stack:** #2394 → #2397 → **this PR**
2026-08-03 00:20:53 -07:00

288 lines
11 KiB
TypeScript

/**
* PostgreSQL connection management.
*
* FNXC:PostgresConnection 2026-06-24-01:55:
* Manages the Drizzle connection pool backed by postgres.js, with the
* DATABASE_MIGRATION_URL split for pooled runtime connections.
*
* Two connections may exist:
* 1. Runtime pool — serves all normal queries. Uses DATABASE_URL (or the
* embedded lifecycle's resolved URL). May be a pooled/pooler connection.
* 2. Migration connection — a direct (non-pooled) connection for schema work
* (DDL, migrations). Uses DATABASE_MIGRATION_URL when set, else the runtime
* URL. Always `prepare: false` so it works under transaction poolers.
*
* When the runtime URL is a transaction pooler and no migration URL is set,
* runtime prepared statements are disabled automatically and a warning is
* emitted (VAL-CONN-008). When a migration URL is set, runtime keeps prepared
* statements enabled (the migration URL handles the DDL).
*
* The runtime pool disables prepared statements if the URL looks like a pooler,
* because a pooler in transaction mode cannot safely cache prepared statements
* across connections.
*/
import postgres from "postgres";
import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
import { createLogger } from "../process/logger.js";
import {
resolveBackend,
type ResolvedBackend,
looksLikePoolerUrl,
poolerWarning,
describeBackendForLog,
} from "./backend-resolver.js";
import { redactCredentialsFromMessage, redactConnectionString } from "./credential-redact.js";
const log = createLogger("postgres-connection");
/**
* FNXC:PostgresConnection 2026-06-24-01:55:
* Connection pool sizing. A small default pool is used for the runtime
* connection since Fusion's workload is primarily short transactional queries.
* The embedded mode may use an even smaller pool. These can be tuned via
* environment variables if needed.
*/
// External databases frequently impose a low connection quota. Each Fusion
// store owns a runtime pool plus a migration session, so keep the default
// deliberately small; callers with a known capacity can still override it.
const DEFAULT_POOL_MAX = 3;
const DEFAULT_CONNECT_TIMEOUT_SECONDS = 10;
const DEFAULT_IDLE_TIMEOUT_SECONDS = 20;
/** Schema type placeholder until the Drizzle schema (U3) is defined. */
type AnySchema = Record<string, never>;
/**
* Options for creating the connection manager. Allows tests to override env
* without mutating process.env.
*/
export interface CreateConnectionOptions {
readonly backend?: ResolvedBackend;
readonly poolMax?: number;
readonly connectTimeoutSeconds?: number;
readonly idleTimeoutSeconds?: number;
readonly onWarning?: (message: string) => void;
/** FNXC:ProjectDataIsolation 2026-07-14-14:25: Bind runtime queries to one project partition. */
readonly projectId?: string;
/** FNXC:ProjectDataIsolation 2026-07-14-14:25: Permit intentional schema/migration/cross-project administration. */
readonly bypassProjectIsolation?: boolean;
/** FNXC:ProjectDataIsolation 2026-07-14-14:25: Use the non-superuser role only after startup proves it exists and is grantable. */
readonly useRuntimeRole?: boolean;
}
/** A live PostgreSQL connection set with runtime + migration Drizzle instances. */
export interface PostgresConnections {
/** Drizzle instance for runtime queries (may use a pooled connection). */
readonly runtime: PostgresJsDatabase<AnySchema>;
/**
* Drizzle instance for schema/migration work (direct connection, no pooler).
* May be the same underlying connection as runtime when no migration URL split
* is configured.
*/
readonly migration: PostgresJsDatabase<AnySchema>;
/** The resolved backend descriptor. */
readonly backend: ResolvedBackend;
/** Close all underlying connections. */
close(): Promise<void>;
/** Run a health-check query against the runtime connection. */
ping(): Promise<void>;
}
/**
* Error thrown when the database connection fails at startup.
*
* FNXC:PostgresConnection 2026-06-24-02:00:
* Unreachable DATABASE_URL must produce a clear, actionable error and non-zero
* exit (VAL-CONN-004). The error message redacts credentials so the password
* is never exposed even in crash logs.
*/
export class DatabaseConnectionError extends Error {
readonly cause: unknown;
readonly safeUrl: string;
constructor(url: string, cause: unknown) {
const reason = cause instanceof Error ? cause.message : String(cause);
super(
`Cannot connect to PostgreSQL at ${redactConnectionString(url)}: ` +
`${redactCredentialsFromMessage(reason)}`,
);
this.name = "DatabaseConnectionError";
this.cause = cause;
this.safeUrl = redactConnectionString(url);
}
}
/**
* Create the PostgreSQL connection set from environment variables.
*
* Resolves the backend, creates the runtime and migration postgres.js
* connections, wraps them in Drizzle, and verifies connectivity.
*
* Throws `DatabaseConnectionError` (with redacted credentials) if the initial
* connection probe fails — the caller should exit non-zero.
*
* In embedded mode (DATABASE_URL unset), this throws because the connection
* URL is not yet known — the embedded lifecycle feature must start Postgres
* first and then call `createConnectionSetFromUrl` with the resolved URL.
*/
export async function createConnectionSet(
env: NodeJS.ProcessEnv = process.env,
options: CreateConnectionOptions = {},
): Promise<PostgresConnections> {
const backend = options.backend ?? resolveBackend(env);
if (backend.mode === "embedded") {
// Embedded mode: the URL is provided by the embedded lifecycle (U2).
// This connection layer does not start the embedded instance itself.
throw new Error(
"Cannot create a connection set in embedded mode without a resolved URL. " +
"The embedded lifecycle (DATABASE_URL unset) must start Postgres first " +
"and provide the connection URL via createConnectionSetFromUrl().",
);
}
// External mode requires a runtime URL.
if (!backend.runtimeUrl) {
throw new Error(
"External backend resolved but runtimeUrl is null. This is an internal error.",
);
}
return createConnectionSetFromUrl(backend, options);
}
/**
* Create the connection set from an already-resolved backend (used by the
* embedded lifecycle after it starts the bundled Postgres).
*/
export async function createConnectionSetFromUrl(
backend: ResolvedBackend,
options: CreateConnectionOptions = {},
): Promise<PostgresConnections> {
const poolMax = options.poolMax ?? DEFAULT_POOL_MAX;
const connectTimeout = options.connectTimeoutSeconds ?? DEFAULT_CONNECT_TIMEOUT_SECONDS;
const idleTimeout = options.idleTimeoutSeconds ?? DEFAULT_IDLE_TIMEOUT_SECONDS;
const onWarning = options.onWarning ?? ((msg: string) => log.warn(msg));
// FNXC:PostgresTuiLogging 2026-07-15-14:52: Embedded PostgreSQL is the
// default zero-config backend, so its already-resolved connection details
// are routine startup noise in the TUI. Keep external target diagnostics.
if (backend.mode === "external") {
log.log(describeBackendForLog(backend));
}
// Emit pooler warning if applicable (VAL-CONN-008).
const warning = poolerWarning(backend);
if (warning) {
onWarning(warning);
}
// FNXC:ProjectDataIsolation 2026-07-14-12:10:
// External and embedded databases may safely host multiple projects because
// forced RLS partitions every project-owned table at the session boundary.
const runtimeUrl = backend.runtimeUrl;
if (!runtimeUrl) {
throw new Error(
"Cannot create connection set: backend.runtimeUrl is null. " +
"Ensure the embedded lifecycle has provided a URL or DATABASE_URL is set.",
);
}
// Determine whether to use prepared statements in the runtime pool.
// Disable when the URL is a pooler and no migration URL split is configured.
const runtimeIsPooler = looksLikePoolerUrl(runtimeUrl);
const runtimePrepare = backend.migrationUrlOverridden ? true : !runtimeIsPooler;
/*
FNXC:ProjectDataIsolation 2026-07-14-12:10:
PostgreSQL row-level security reads these startup parameters on every pooled session. Production project runtimes must provide projectId; unbound maintenance and existing test/admin callers default to the explicit bypass while the migration connection always bypasses isolation.
*/
const runtimeConnectionParameters = options.projectId
? {
...(options.useRuntimeRole ? { role: "fusion_runtime" } : {}),
"fusion.project_id": options.projectId,
}
: options.bypassProjectIsolation === false
? {}
: { "fusion.project_bypass": "on" };
const runtimeSql = postgres(runtimeUrl, {
max: poolMax,
connect_timeout: connectTimeout,
idle_timeout: idleTimeout,
prepare: runtimePrepare,
connection: runtimeConnectionParameters,
// Suppress the default onnotice (which logs to console.log) to avoid
// leaking connection-parameter notices that might contain sensitive info.
onnotice: () => {},
});
const runtimeDb = drizzle(runtimeSql);
// Migration connection: use DATABASE_MIGRATION_URL if set, else runtime URL.
// Always prepare: false for migration work (DDL under a pooler must not use
// prepared statements).
const migrationUrl = backend.migrationUrl ?? runtimeUrl;
/*
FNXC:PostgresMigrationSession 2026-07-14-00:05:
Migration work always owns a dedicated single-connection pool, even when runtime and migration URLs match. Session advisory locks and session_replication_role must cover the same backend session for the entire copy and must never leak trigger-disabled state into runtime traffic.
*/
const migrationSql = postgres(migrationUrl, {
max: 1,
connect_timeout: connectTimeout,
idle_timeout: idleTimeout,
prepare: false,
connection: { "fusion.project_bypass": "on" },
onnotice: () => {},
});
const migrationDb: PostgresJsDatabase<AnySchema> = drizzle(migrationSql);
const connections: PostgresConnections = {
runtime: runtimeDb,
migration: migrationDb,
backend,
async close() {
const closePromises: Promise<unknown>[] = [
migrationSql.end({ timeout: 5 }),
runtimeSql.end({ timeout: 5 }),
];
await Promise.allSettled(closePromises);
},
async ping() {
// Simple connectivity probe.
await runtimeSql`SELECT 1`;
},
};
return connections;
}
/**
* Verify that a connection URL is reachable. Used as a startup precondition
* (VAL-CONN-004: unreachable DATABASE_URL fails loudly).
*
* Throws `DatabaseConnectionError` with redacted credentials on failure.
*/
export async function verifyConnection(
url: string,
timeoutSeconds = DEFAULT_CONNECT_TIMEOUT_SECONDS,
): Promise<void> {
const sql = postgres(url, {
max: 1,
connect_timeout: timeoutSeconds,
idle_timeout: 1,
prepare: false,
onnotice: () => {},
});
try {
await sql`SELECT 1`;
} catch (error) {
throw new DatabaseConnectionError(url, error);
} finally {
await sql.end({ timeout: 5 }).catch(() => {});
}
}
export { redactConnectionString };