Files
gbrain/src/core/connection-manager.ts
T
e084457c2d v0.41.5.0 fix-wave: warm-narwhal — 6 community PRs + E2E reliability (#1374)
* fix(recipes/openai): add max_batch_tokens to embedding touchpoint

OpenAI is the only recipe in the codebase without a max_batch_tokens cap.
Every other provider declares one (voyage=120K, azure-openai=8K, dashscope=8K,
zhipu=8K, minimax=4K). Without it, gbrain's recursive-halving safety net never
engages — batches dispatched purely on the char/4 estimator window will trip
OpenAI's 1M-token TPM ceiling on token-dense pages (Discord exports, JSON
dumps, code-heavy markdown), then retry storm and block the queue head.

Setting cap to 100_000:
- gbrain's batcher estimates tokens as chars/4
- Token-dense markdown+JSON tokenizes at ~chars/2.7
- 100K estimated = ~150K real worst-case, safely under OpenAI's 300K
  per-request hard cap and the 1M/min TPM ceiling
- Leaves headroom for recursive-halving on outlier chunks

(cherry picked from commit 40536aace5)

* fix(ai/embed): recognize OpenAI 'maximum request size' error in isTokenLimitError

OpenAI's /v1/embeddings endpoint hard-caps a single request at 300k tokens
total across all input items. When the cap is exceeded it returns:

    Invalid 'input': maximum request size is 300000 tokens per request.

None of the three existing regexes in isTokenLimitError matched this
phrasing, so the recursive-halving safety net in embedSubBatch never
engaged for OpenAI. The same fat page (a token-dense markdown export,
e.g. a Discord transcript) would re-fail every pass, blocking forward
progress on the whole batch indefinitely.

Locally reproduced on a 31,129-chunk Postgres brain: 2,125 chunks
stuck at 'remaining' across 30+ embed --stale passes with retry
loops + sleep delays. Adding the two new patterns lets halving fire;
the same backlog cleared in one pass after the regex change (the
companion max_batch_tokens recipe fix from PR #924 caps fresh batches,
but existing oversize pages still need halving to recover).

Adds:
  - /maximum request size.*tokens/i  — OpenAI verbatim
  - /max.*tokens.*per.*request/i    — defensive against minor rewording

Tests:
  - Regression test for the exact OpenAI error string
  - Coverage for the generic 'max tokens per request' variant
  - All 25 tests in adaptive-embed-batch.test.ts pass

No behavior change for providers whose errors already matched.

(cherry picked from commit b834e84c56)

* fix(connection-manager): strip .<project-ref> suffix from username when deriving direct URL

`deriveDirectUrl()` correctly rewrites the host (`aws-0-us-east-1.pooler.supabase.com`
→ `db.abcxyz.supabase.co`) but preserves the full pooler-form username
(`postgres.abcxyz`). Supabase direct connections expect a bare `postgres`
username — Supavisor uses the `.<ref>` suffix for tenant routing, but it's
not a real database user. The auto-derived URL therefore fails to authenticate
even with the correct password:

    password authentication failed for user "postgres.abcxyz"

Strip the suffix to `postgres` whenever the project-ref was successfully
extracted (same condition that triggers the host rewrite). The non-pooler
username branch is unaffected — preserved as-is to keep the port-only
fallback case working.

Hit while exercising v0.30.1's dual-pool routing on a real Supabase brain;
the kill switch (`GBRAIN_DISABLE_DIRECT_POOL=1`) papered over it locally
but every Supabase user with a stock pooler URL would silently fall through
to single-pool until the user-supplied a `GBRAIN_DIRECT_DATABASE_URL`
override. With this fix, dual-pool works out of the box for the canonical
Supabase shape.

Test additions:
  - 1 case asserting bare `postgres:secret@` in the derived URL when
    project-ref is parseable from the pooler URL (the new behavior)
  - extends the existing "falls back to port-only" case with an
    assertion that non-pooler usernames are preserved (unchanged behavior)

`bun run typecheck` clean. `deriveDirectUrl` test block passes 5/5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit ddf2c6a9a0)

* fix(init): --help should not mutate config or scan filesystem

`gbrain init --help` (and `-h`) currently fall through to the smart-detection
branch in runInit(), which scans cwd for .md files and on a directory with
1000+ files prints "Found ~1500 .md files. For a brain this size, Supabase
gives faster search..." then defaults to PGLite — calling saveConfig() and
overwriting any existing Postgres config with `engine: 'pglite' +
database_path: ~/.gbrain/brain.pglite`.

Confirmed in the wild: ran `gbrain init --help` from $HOME on a machine where
~/.gbrain/config.json pointed at a Supabase Postgres brain with 10K+ pages.
The config was silently flipped to PGLite. The Supabase data was intact, but
gbrain stopped pointing at it until the config was manually restored.

Root cause: cli.ts:62-69 only routes --help → printOpHelp() for shared-op
commands; CLI_ONLY commands (init, embed, etc.) fall through to their handler
with --help still in argv. None of them check for it.

Fix: add a --help/-h guard at the top of runInit() that prints help text and
returns. Help should never mutate state — Postel's robustness principle for
CLI tools.

Help text covers all flags (engine selection, AI provider options, thin-client
mode) so users running `--help` get the canonical list rather than having to
read the source.

A wider architectural fix — adding --help routing for all CLI_ONLY commands in
cli.ts — is plausible follow-up, but each CLI_ONLY command would still need
its own help text. This per-command pattern matches how shared ops handle it
via printOpHelp(). Init is the highest-stakes case because it's the only
CLI_ONLY command that calls saveConfig().

Smoke test: from a directory with 1500 .md files, with GBRAIN_HOME pointed at
a fresh tempdir:
  - Before fix: ~/.gbrain/config.json materialized with engine: 'pglite'
  - After fix: help text printed, no config dir created

`bun run typecheck` clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit ed11fdd58c)

* test(frontmatter-install-hook): isolate hooksPath assertion from developer global config

The "installHook writes ... and sets core.hooksPath" test asserted
`git config --get core.hooksPath` returns `.githooks`, which falls
back to the global scope when local is unset. Developers who set
`core.hooksPath` globally (common with dotfiles managers pointing at
~/.config/git/hooks) saw a deterministic FAIL because installHook
intentionally respects an existing global value and skips writing
the local one — exactly the documented contract.

Fix: read via `git config --local --get core.hooksPath` (scope-locked)
and branch the assertion on whether a global is already set. Both
clean-CI (local should be '.githooks') and developer-with-global
(local should be empty; installHook correctly didn't clobber) now
pass deterministically.

No API change. installHook behavior is unchanged.

Verified locally with the affected test passing under
`GIT_CONFIG_GLOBAL=~/.gitconfig` carrying `core.hooksPath=...`.

(cherry picked from commit 0e4da2cb38)

* fix: guard against missing 'intent' field in routing-eval fixtures

Two defensive fixes:

1. normalizeText(): return empty string on null/undefined input instead
   of crashing with 'undefined is not an object (evaluating s.toLowerCase)'

2. loadRoutingFixtures(): validate that parsed fixture has 'intent' as a
   string before adding to fixtures array. Fixtures with wrong field
   names (e.g. 'input' instead of 'intent') are now reported as
   malformed with a helpful error message listing the actual keys found.

Root cause: a skill's routing-eval.jsonl used {"input": ...} instead
of {"intent": ...}. The JSON parsed fine but the cast to
RoutingFixture was unchecked, so fixture.intent was undefined.
normalizeText(undefined) then crashed. This made 'gbrain doctor'
completely unusable.

(cherry picked from commit b142bbdb0d)

* fix(test): isolate HOME in run-e2e.sh to stop config corruption

Replaces #517 (re-ported fresh against current scripts/run-e2e.sh after
v0.23.1 rewrote the script — original cherry-pick would not apply).

E2E tests call setupDB which writes $HOME/.gbrain/config.json pointing at
the docker test container. When the container tears down, the user's real
autopilot daemon wedges trying to connect to a vanished postgres. Three
operators hit this within 16 days before the original PR filed.

Fix: wrapper exports HOME + GBRAIN_HOME to a mktemp tmpdir BEFORE bun
starts so config writes land in the tmpdir, with a post-run breach
detector that compares md5 of the user's real config against pre-run.
Both env vars required: loadConfig/saveConfig resolve via HOME while
configPath honors GBRAIN_HOME. HOME set before bun starts because
os.homedir() caches at first call.

Test seam: test/gbrain-home-isolation.test.ts updated to assert against
homedir() === configDir() when GBRAIN_HOME unset (correct under the
safety wrapper itself) instead of the prior "not /tmp/" sentinel.

Revert path: git revert <this-sha> if test:e2e regresses on master.

Co-Authored-By: orendi84 <orendi84@users.noreply.github.com>

* test(dream-cycle): add schema-suggest to EXPECTED_PHASES

v0.40.7.0 Schema Cathedral v3 added the 'schema-suggest' phase between
'orphans' and 'purge' in ALL_PHASES, but the E2E phase-order test was
not updated to match. ALL_PHASES vs EXPECTED_PHASES diverged and the
shape-pin test failed every run on master.

Surfaced during fix-wave: warm-narwhal E2E gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(autopilot-fanout): use relative timestamp inside freshness window

The 'end-to-end: updateSourceConfig persists timestamp visible to next
listAllSources' test pinned last_full_cycle_at to a hardcoded
'2026-05-22T15:00:00.000Z'. The 60-minute freshness window passed
within ~1 hour of write — every run after the deadline classified the
source as stale and dispatched it, breaking the test's
.skippedFresh expectation.

Switch to Date.now() - 30min relative timestamp (mirrors the prior
'source with last_full_cycle_at < 60min ago is skipped by gate' test).

Surfaced during fix-wave: warm-narwhal E2E gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(fresh-install-pglite): unset other provider keys in beforeEach

init.ts:455 fails loud when multiple embedding providers are env-ready
in non-TTY mode. The test sets ZEROENTROPY_API_KEY then runs init,
but developer machines commonly have OPENAI_API_KEY + VOYAGE_API_KEY +
ZEROENTROPY_API_KEY all set, so init sees 3 providers and exits 1.

Save+unset OPENAI_API_KEY + VOYAGE_API_KEY in beforeEach, restore in
afterEach. Now only ZE is env-ready, init picks it, schema sized to
zembed-1's 1280d as the test expects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(voyage-multimodal): switch fixture from AVIF to PNG

Voyage's /multimodalembeddings endpoint rejects AVIF as of 2026-05
with 'Please provide a valid base64-encoded image'. The prior comment
('AVIF is fine for an embed call') held at v0.27.x and regressed
silently on the provider side.

Add test/fixtures/images/tiny.png (16x16 RGB PNG, 1307 bytes generated
via sips from the macOS default wallpaper). PNG is universally
accepted by Voyage and other multimodal providers.

Surfaced during fix-wave: warm-narwhal E2E gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cycle/synthesize): prefix bare anthropic model ids before queue.add

queue.add's subagent capability validator (classifyCapabilities →
resolveRecipe) requires provider:model format and rejects bare ids
with 'unknown provider'. resolveModel returns the bare id from
TIER_DEFAULTS / DEFAULT_ALIASES (e.g. 'claude-sonnet-4-6'), which the
validator then rejects, dropping the synthesize phase to status:fail
with SYNTH_PHASE_FAIL.

Narrow fix at the call site: if config.model has no colon AND starts
with 'claude-', prefix 'anthropic:'. Other providers must already
declare a colon. Avoids changing TIER_DEFAULTS / DEFAULT_ALIASES
constant shapes, which would ripple across every resolveModel caller.

Surfaced by dream-synthesize-chunking E2E during fix-wave: warm-narwhal.
Affected tests: 'single-chunk transcript uses legacy idempotency key'
and 'multi-chunk transcript spawns N children with chunk-suffixed
idempotency keys' — both relied on result.details.children_submitted
which only the ok() path sets; the failed() path returns details: {}.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(mechanical): pin doctor init embedding model + clean non-default sources

Two fixes in the E2E Doctor Command describe block, both surfaced by
cross-file state pollution under the full sequential E2E run:

1. Pass --embedding-model openai:text-embedding-3-large to the init
   subprocess. Without the explicit flag, doctor inherits whatever the
   resolver picks from env keys (ZE if ZEROENTROPY_API_KEY is set,
   defaulting to zembed-1 at 1280d). The test's setupDB initialized
   schema at 1536d, so the dim mismatch fires
   embedding_width_consistency WARN, exiting doctor 1.

2. DELETE FROM sources WHERE id != 'default' in beforeAll. Prior E2E
   files leave non-default source rows (e.g. 'delta' from autopilot /
   sources tests). sync_freshness + cycle_freshness then FAIL on those
   orphans because they were never synced/cycled, exiting doctor 1.
   setupDB TRUNCATEs sources but schema.sql re-seeds 'default' via
   initSchema; this leaves only the canonical single-source brain
   the test expects.

Surfaced during fix-wave: warm-narwhal E2E gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(run-e2e): per-file connection flush + 180s outer timeout

Two cross-file isolation hardenings for the sequential E2E runner:

1. Terminate stale Postgres connections before each file. Without this,
   idle connections from the prior bun process's pool race with the
   next file's setupDB() TRUNCATE CASCADE, producing 'fixture pages
   disappear mid-test' failures. The terminate call is idempotent +
   ~50ms; first iteration is a no-op.

2. Hard outer timeout (180s per file) via gtimeout / timeout. bun's
   --timeout=60000 is per-test; if a PGLite WASM call hangs in
   beforeAll/afterAll (e.g. ingestion-roundtrip.test.ts wedging
   30+ minutes on macOS), --timeout never fires and the entire suite
   wedges. Outer SIGKILL lets the suite advance and the file is
   recorded as failed for triage. Falls through to bare bun if neither
   gtimeout nor timeout is on PATH.

Surfaced during fix-wave: warm-narwhal — 3 of 5 cross-file flakes
caught by the connection flush; ingestion-roundtrip 30-min wedge
caught by the outer timeout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.41.3.0)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: annotate synthesize.ts narrow prefix fix (v0.41.3.0)

CLAUDE.md gains the v0.41.3.0 note on src/core/cycle/synthesize.ts (narrow
anthropic: prefix at the queue.add boundary so resolveModel's bare ids
satisfy the subagent validator). llms-full.txt regenerated to match.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: rebump v0.41.3.0 → v0.41.5.0 (queue drift; PR #1377 claimed .4.0)

Sibling fix-wave PR #1377 (garrytan/community-pr-wave) claimed v0.41.4.0
between my queue check (.3.0 was available) and PR creation. Re-bump to
the next available slot per workspace-aware allocator.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(cycle/synthesize): refuse empty brainDir + resolve relative paths

Pre-fix, runPhaseSynthesize accepted any brainDir string and passed it
to writeReversePages which does join(brainDir, '<slug>.md'). When
brainDir is '' or relative ('.' / './brain' / etc), join() produces a
relative path that writeFileSync resolves against cwd. Result: every
synthesize reverse-write spills into <cwd>/companies/<slug>.md,
<cwd>/people/<slug>.md, etc. instead of the intended brainDir tempdir.

Surfaced by the warm-narwhal wave when E2E test cleanup found orphan
synthesize pages (companies/novamind.md, people/sarah-chen.md,
meetings/2025-04-01-novamind-board-update.md) at the gbrain repo root
from a runCycle({brainDir: '.'}) chain that ran during morning E2E
execution.

Fix at the function entry, single location, all callers protected:
  1. Empty/whitespace brainDir → return failed(BRAINDIR_EMPTY) loud
     instead of silently resolving against cwd
  2. Relative brainDir → resolve(opts.brainDir) before any read/write
     can use it. opts.brainDir mutated so writeReversePages,
     writeSummaryPage, and every join() downstream see the absolute path

Regression test pins all 4 contracts:
  - empty string → fail(BRAINDIR_EMPTY)
  - whitespace-only → fail(BRAINDIR_EMPTY)
  - '.' → mutated to absolute on entry
  - already-absolute → unchanged

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(dream): resolve brainDir to absolute at CLI surface

Defense-in-depth for the synthesize-braindir spillage bug class. The
core fix lives in runPhaseSynthesize (commit 98222a08); this resolves
brainDir one layer earlier so the entire 9-phase runCycle gets the
absolute path, not just synthesize.

Two paths in resolveBrainDir get path.resolve():
  - explicit --dir argument (e.g., `gbrain dream --dir .`)
  - sync.repo_path config (in case it was ever stored relative)

resolveBrainDir already checked existsSync; resolve() just canonicalizes
before return. No behavior change for paths already absolute.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Matt Gunnin <mgunnin@esports.one>
Co-authored-by: Brandon Lipman <brandon@offdeck.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Jeremy Knows <jeremy@veefriends.com>
Co-authored-by: root <root@localhost>
Co-authored-by: orendi84 <orendigergo@gmail.com>
Co-authored-by: orendi84 <orendi84@users.noreply.github.com>
Co-authored-by: Garry Tan <garry@ycombinator.com>
2026-05-25 12:48:55 -07:00

447 lines
17 KiB
TypeScript

/**
* Connection Manager — route Postgres queries by query type (v0.30.1, Fix 1).
*
* Three pools, one decision: read() goes to the pooler (port 6543, fast,
* many connections); ddl() and bulk() go to a direct connection (port 5432,
* 30min statement_timeout, capped at 3 conns) so DDL doesn't time out on
* the Supabase pooler's 2-min statement_timeout.
*
* The connection-manager is the URL-routing layer. It layers on top of
* postgres.js's existing pool primitives + PostgresEngine.withReservedConnection.
*
* ┌─────────────────────────────┐
* │ GBRAIN_DATABASE_URL │ GBRAIN_DIRECT_DATABASE_URL (override)
* │ (pooler, port 6543) │
* └────────┬────────────────────┘
* │
* ▼ auto-detect Supabase
* ┌──────────────┐ ┌──────────────┐
* │ read pool │ │ direct pool │
* │ size 10 │ │ size 3 │
* │ prepare:no │ │ stmt 30min │
* │ stmt 5min │ │ idle 5min │
* └──────────────┘ │ mwm 256MB │
* └──────────────┘
*
* Architectural notes:
* - INSTANCE-owned (T5 / X1 amendment): each PostgresEngine constructs its
* own ConnectionManager. Worker engines (cycle, sync) inherit the parent's
* via constructor option `parent`. transaction() clones share the parent's.
* - Lazy direct pool init via cached Promise<Sql> (A1): concurrent first
* callers await the same Promise, so no double-init.
* - Kill-switch (F1): GBRAIN_DISABLE_DIRECT_POOL=1 falls back to single-pool
* legacy path. With parent set, inherit parent's kill-switch state (A2).
* - Audit (F8): every acquire/release/error logs to connection-events.jsonl.
* - Non-Supabase passthrough: if URL isn't a Supabase pooler and no
* GBRAIN_DIRECT_DATABASE_URL override, ddl()/bulk() share the read pool.
*/
import postgres from 'postgres';
import { resolvePrepare, resolveSessionTimeouts, resolvePoolSize } from './db.ts';
import { redactPgUrl } from './url-redact.ts';
import { logConnectionEvent } from './connection-audit.ts';
export type Sql = ReturnType<typeof postgres>;
export interface ConnectionManagerOpts {
/** Primary URL — usually the pooler (port 6543) on Supabase. */
url: string;
/**
* Override for the direct URL. When set, takes precedence over auto-derivation.
* Sourced from GBRAIN_DIRECT_DATABASE_URL or explicit caller config.
*/
directUrl?: string | null;
/**
* Inherit pools + kill-switch state from a parent manager (worker engines,
* transaction clones). When set, this manager is a thin reference holder
* and does NOT open its own pools.
*/
parent?: ConnectionManager;
/**
* Read pool size override (defaults to resolvePoolSize() — 10 normally).
*/
readPoolSize?: number;
/**
* Direct pool size override (defaults to GBRAIN_DIRECT_POOL_SIZE env or 3).
*/
directPoolSize?: number;
/**
* When true, the read pool is owned by some other code (e.g. db.ts:connect's
* module singleton). The connection manager will USE it via getReadPool but
* not call .end() on disconnect(). Default false (we own both pools).
*/
readPoolOwnedExternally?: boolean;
}
/** Default direct-pool size (P1 raised from 2 to 3). Override via env. */
export const DEFAULT_DIRECT_POOL_SIZE = 3;
/** Search statement timeout (F5 consolidation) — was 8s scattered. */
export const SEARCH_STMT_TIMEOUT_MS = 8000;
/** DDL pool default statement_timeout (Fix 1). */
const DDL_STMT_TIMEOUT_MS = 30 * 60 * 1000; // 30min
/** DDL pool default idle-in-transaction timeout (Fix 1). */
const DDL_IDLE_TX_TIMEOUT_MS = 5 * 60 * 1000; // 5min
/** Bulk pool default maintenance_work_mem (P1). 256MB safe on Supabase. */
const BULK_MAINTENANCE_WORK_MEM = '256MB';
/**
* Hostname patterns that indicate a Supabase pooler. Used for auto-detection
* of the dual-pool topology. Adding more patterns is safe; mis-detection
* just means we open a "direct" pool against the same URL — wasteful but
* not broken (the kill-switch is the operator's escape hatch).
*/
const SUPABASE_POOLER_HOSTNAME_PATTERNS = [
/\.pooler\.supabase\.com$/i,
/^pooler\.supabase\.com$/i,
];
const SUPABASE_POOLER_PORTS = new Set(['6543']);
/**
* True if the URL looks like a Supabase pooler endpoint. Used for kill-switch
* activation and dual-pool routing.
*/
export function isSupabasePoolerUrl(url: string): boolean {
try {
const parsed = new URL(url.replace(/^postgres(ql)?:\/\//, 'http://'));
if (SUPABASE_POOLER_PORTS.has(parsed.port)) return true;
if (SUPABASE_POOLER_HOSTNAME_PATTERNS.some(re => re.test(parsed.hostname))) return true;
return false;
} catch {
return false;
}
}
/**
* Derive a direct (non-pooler) URL from a Supabase pooler URL. Two known shapes:
*
* Pooler hostname: aws-N-region.pooler.supabase.com on port 6543
* → swap to db.<project-ref>.supabase.co on port 5432
* (project-ref encoded in the user component as postgres.<ref>)
* Direct hostname: db.<ref>.supabase.co already on port 5432 → returned as-is
*
* For the modern shape, we try to extract project-ref from the user component.
* If we cannot, we fall back to swapping port-only and the caller may warn.
*
* Returns null when the URL isn't a recognized Supabase pooler.
*/
export function deriveDirectUrl(url: string): string | null {
try {
const parsed = new URL(url.replace(/^postgres(ql)?:\/\//, 'http://'));
const port = parsed.port;
const hostname = parsed.hostname;
const isPoolerHost = SUPABASE_POOLER_HOSTNAME_PATTERNS.some(re => re.test(hostname));
if (port !== '6543' && !isPoolerHost) return null;
// User part on Supabase pooler is typically `postgres.<project-ref>`.
// Extract <project-ref> for the direct hostname.
const user = parsed.username || '';
const decodedUser = decodeURIComponent(user);
const refMatch = decodedUser.match(/^postgres\.([a-z0-9]+)$/i);
let directHost = hostname;
let directUser = parsed.username;
if (refMatch && refMatch[1] && isPoolerHost) {
directHost = `db.${refMatch[1]}.supabase.co`;
// Supabase direct connections use bare `postgres`; the `postgres.<ref>`
// form is pooler-only (Supavisor uses the suffix for tenant routing).
// Without this strip, direct auth fails with `password authentication
// failed for user "postgres.<ref>"` even though the password is correct.
directUser = 'postgres';
}
// Compose direct URL by swapping host + port. Preserve auth, db, query.
parsed.hostname = directHost;
parsed.port = '5432';
// Reconstruct with the original scheme.
const scheme = url.match(/^postgres(?:ql)?:\/\//i)?.[0] ?? 'postgres://';
const auth = directUser
? `${directUser}${parsed.password ? `:${parsed.password}` : ''}@`
: '';
const search = parsed.search ?? '';
const path = parsed.pathname ?? '';
return `${scheme}${auth}${directHost}:5432${path}${search}`;
} catch {
return null;
}
}
/**
* Read kill-switch state from env. Subordinate to parent manager's state
* when present (A2 inheritance).
*/
export function readKillSwitchEnv(): boolean {
return process.env.GBRAIN_DISABLE_DIRECT_POOL === '1' ||
process.env.GBRAIN_DISABLE_DIRECT_POOL === 'true';
}
/**
* Resolve direct pool size: explicit > env > default.
*/
export function resolveDirectPoolSize(explicit?: number): number {
if (typeof explicit === 'number' && explicit > 0) return explicit;
const raw = process.env.GBRAIN_DIRECT_POOL_SIZE;
if (raw) {
const parsed = parseInt(raw, 10);
if (Number.isFinite(parsed) && parsed > 0 && parsed <= 20) return parsed;
}
return DEFAULT_DIRECT_POOL_SIZE;
}
export class ConnectionManager {
private readonly opts: ConnectionManagerOpts;
private _readPool: Sql | null = null;
private _readPoolOwnedExternally: boolean;
private _directInit: Promise<Sql | null> | null = null;
private _directPool: Sql | null = null;
private _killSwitch: boolean;
private _directUrl: string | null;
private _isSupabase: boolean;
constructor(opts: ConnectionManagerOpts) {
this.opts = opts;
this._readPoolOwnedExternally = opts.readPoolOwnedExternally === true;
// A2: kill-switch resolution. Parent overrides env when present.
if (opts.parent) {
this._killSwitch = opts.parent.isKillSwitchActive();
this._isSupabase = opts.parent.isSupabase();
this._directUrl = opts.parent.resolveDirectUrl();
this._readPool = opts.parent.peekReadPool();
this._readPoolOwnedExternally = true; // never end the parent's pool
} else {
this._killSwitch = readKillSwitchEnv();
this._isSupabase = isSupabasePoolerUrl(opts.url);
// Direct URL: explicit override > env > derive > null
const envOverride = process.env.GBRAIN_DIRECT_DATABASE_URL;
this._directUrl = opts.directUrl ?? envOverride ?? deriveDirectUrl(opts.url);
}
}
/** Whether dual-pool routing is active (false on non-Supabase or kill-switch). */
isDualPoolActive(): boolean {
return this._isSupabase && !this._killSwitch && !!this._directUrl;
}
isSupabase(): boolean { return this._isSupabase; }
isKillSwitchActive(): boolean { return this._killSwitch; }
resolveDirectUrl(): string | null { return this._directUrl; }
/**
* Internal: peek at the read pool without forcing init. Used by parent
* inheritance to share the same instance.
*/
peekReadPool(): Sql | null { return this._readPool; }
/**
* Set the read pool. Used by db.ts:connect or PostgresEngine.connect when
* they own the pool externally (and connection-manager is just routing).
*/
setReadPool(sql: Sql): void {
this._readPool = sql;
this._readPoolOwnedExternally = true;
}
/**
* Get or lazily create the read pool. Honors `readPoolOwnedExternally` so
* we don't double-create when db.ts:connect already owns the singleton.
*/
async getReadPool(): Promise<Sql> {
if (this._readPool) return this._readPool;
if (this._readPoolOwnedExternally) {
throw new Error('connection-manager: read pool marked as externally-owned but not provided');
}
const opts: Record<string, unknown> = {
max: resolvePoolSize(this.opts.readPoolSize),
idle_timeout: 20,
connect_timeout: 10,
types: { bigint: postgres.BigInt },
};
const timeouts = resolveSessionTimeouts();
if (Object.keys(timeouts).length > 0) opts.connection = timeouts;
const prepare = resolvePrepare(this.opts.url);
if (typeof prepare === 'boolean') opts.prepare = prepare;
this._readPool = postgres(this.opts.url, opts);
logConnectionEvent({ pool: 'read', op: 'init' });
return this._readPool;
}
/**
* Acquire the read connection. Synchronous accessor — assumes read pool
* is already initialized (matches existing engine.sql semantics).
* Throws if pool not ready.
*/
read(): Sql {
if (!this._readPool) {
throw new Error('connection-manager: read pool not initialized; call getReadPool() first or set externally');
}
return this._readPool;
}
/**
* Acquire (and lazy-init) the direct DDL pool. When kill-switch is active
* or non-Supabase, returns the read pool (single-pool fallback).
*
* A1: lazy init wraps in a cached Promise<Sql> so concurrent first-callers
* await the same init instead of racing two pool constructions.
*/
async ddl(): Promise<Sql> {
if (!this.isDualPoolActive()) {
return this.getReadPool();
}
return this.getDirectPool();
}
/**
* Acquire the direct pool for a long-running BULK operation. Caller can
* override the per-op timeout via SET LOCAL inside a sql.begin block.
* Same pool as ddl(); the distinction is callsite intent (used by audit
* + caller-side timeout SET LOCAL).
*/
async bulk(_timeoutSeconds?: number): Promise<Sql> {
if (!this.isDualPoolActive()) {
return this.getReadPool();
}
return this.getDirectPool();
}
private async getDirectPool(): Promise<Sql> {
if (this._directPool) return this._directPool;
// A1: cache the Promise so concurrent first callers await the same init.
if (!this._directInit) {
this._directInit = this.initDirectPool().then(pool => {
this._directPool = pool;
return pool;
}).catch(err => {
// Reset cache on failure so next caller can retry.
this._directInit = null;
throw err;
});
}
const pool = await this._directInit;
if (!pool) {
// Defensive — initDirectPool should have thrown.
throw new Error('connection-manager: direct pool init returned null');
}
return pool;
}
private async initDirectPool(): Promise<Sql> {
if (!this._directUrl) {
throw new Error('connection-manager: cannot init direct pool — no direct URL');
}
const size = resolveDirectPoolSize(this.opts.directPoolSize);
const opts: Record<string, unknown> = {
max: size,
idle_timeout: 20,
connect_timeout: 10,
types: { bigint: postgres.BigInt },
// Always use prepared statements on the direct pool — no PgBouncer
// here, so the prepare-cache invalidation issue doesn't apply.
prepare: true,
// Apply DDL session GUCs as connection startup parameters (durable
// through any intermediary pooling layer, same trick as
// resolveSessionTimeouts).
connection: {
statement_timeout: String(DDL_STMT_TIMEOUT_MS),
idle_in_transaction_session_timeout: String(DDL_IDLE_TX_TIMEOUT_MS),
maintenance_work_mem: BULK_MAINTENANCE_WORK_MEM,
},
};
const t0 = Date.now();
try {
const pool = postgres(this._directUrl, opts);
// Probe to validate connectivity early.
await pool`SELECT 1`;
logConnectionEvent({
pool: 'ddl',
op: 'init',
duration_ms: Date.now() - t0,
host: this._directUrl ? this.hostOnly(this._directUrl) : undefined,
});
return pool;
} catch (err) {
logConnectionEvent({
pool: 'ddl',
op: 'error',
duration_ms: Date.now() - t0,
error: { message: err instanceof Error ? err.message : String(err) },
});
throw err;
}
}
/**
* SELECT 1 latency probe on each pool. Used by doctor's connection_routing
* check + healthCheck() in the gateway.
*/
async healthCheck(): Promise<{ read: number | null; direct: number | null }> {
const result: { read: number | null; direct: number | null } = { read: null, direct: null };
try {
const t0 = Date.now();
const pool = await this.getReadPool();
await pool`SELECT 1`;
result.read = Date.now() - t0;
} catch { /* leave null */ }
if (this.isDualPoolActive()) {
try {
const t0 = Date.now();
const pool = await this.getDirectPool();
await pool`SELECT 1`;
result.direct = Date.now() - t0;
} catch { /* leave null */ }
}
return result;
}
/**
* Disconnect pools we own. Read pool stays alive if marked externally owned
* (db.ts singleton path). Direct pool is always ours.
*/
async disconnect(): Promise<void> {
if (this._directPool) {
try { await this._directPool.end(); } catch { /* idempotent */ }
this._directPool = null;
this._directInit = null;
}
if (this._readPool && !this._readPoolOwnedExternally) {
try { await this._readPool.end(); } catch { /* idempotent */ }
this._readPool = null;
}
}
/**
* Diagnostic snapshot for doctor / get_health surfaces.
*/
describeMode(): {
mode: 'split' | 'single (kill-switch)' | 'single (non-supabase)' | 'single (no-direct-url)';
direct_host?: string;
kill_switch_active: boolean;
direct_pool_size: number;
} {
let mode: 'split' | 'single (kill-switch)' | 'single (non-supabase)' | 'single (no-direct-url)';
if (!this._isSupabase) mode = 'single (non-supabase)';
else if (this._killSwitch) mode = 'single (kill-switch)';
else if (!this._directUrl) mode = 'single (no-direct-url)';
else mode = 'split';
return {
mode,
direct_host: this._directUrl ? this.hostOnly(this._directUrl) : undefined,
kill_switch_active: this._killSwitch,
direct_pool_size: resolveDirectPoolSize(this.opts.directPoolSize),
};
}
private hostOnly(url: string): string {
// Redact creds first, then strip everything except host:port for doctor display.
const redacted = redactPgUrl(url);
try {
const parsed = new URL(redacted.replace(/^postgres(ql)?:\/\//, 'http://'));
return `${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}`;
} catch {
return redacted;
}
}
}