diff --git a/bin/gstack-gbrain-detect b/bin/gstack-gbrain-detect index 66503905e4..897bec2431 100755 --- a/bin/gstack-gbrain-detect +++ b/bin/gstack-gbrain-detect @@ -18,7 +18,8 @@ * "gstack_brain_sync_mode": "off"|"artifacts-only"|"full", * "gstack_brain_git": true|false, * "gstack_artifacts_remote": "https://..." | "", - * "gbrain_local_status": "ok"|"no-cli"|"missing-config"|"broken-config"|"broken-db" + * "gbrain_local_status": "ok"|"no-cli"|"missing-config"|"broken-config"|"broken-db", + * "gbrain_pooler_mode": "transaction"|"session"|null * } * * Backward compatibility (per plan codex #5): the 9 pre-existing fields stay @@ -42,6 +43,7 @@ import { resolveGbrainBin, readGbrainVersion, } from "../lib/gbrain-local-status"; +import { isTransactionModePooler } from "../lib/gbrain-exec"; const STATE_DIR = process.env.GSTACK_HOME || join(userHome(), ".gstack"); const SCRIPT_DIR = __dirname; @@ -98,6 +100,17 @@ function detectConfig(): { exists: boolean; engine: "pglite" | "postgres" | null return { exists: true, engine: null }; } +// --- pooler mode detection (#1435) --- +// +// Reads DATABASE_URL from ~/.gbrain/config.json and checks whether it targets +// a PgBouncer transaction-mode pooler (port 6543). Surfaced so /sync-gbrain +// and /setup-gbrain can advise users when search may require GBRAIN_PREPARE. +function detectPoolerMode(): "transaction" | "session" | "unknown" | null { + const parsed = tryReadJSON(GBRAIN_CONFIG) as { database_url?: string } | null; + if (!parsed?.database_url) return null; + return isTransactionModePooler(parsed.database_url) ? "transaction" : "session"; +} + // --- gbrain doctor health (any nonzero exit or non-"ok"/"warnings" status → false) --- // // Uses --fast to avoid hanging on a dead DB. Per the local-status classifier @@ -215,6 +228,7 @@ function main(): void { gstack_brain_git: detectBrainGit(), gstack_artifacts_remote: detectArtifactsRemote(), gbrain_local_status: localEngineStatus({ noCache }), + gbrain_pooler_mode: detectPoolerMode(), }; process.stdout.write(JSON.stringify(out, null, 2) + "\n"); diff --git a/lib/gbrain-exec.ts b/lib/gbrain-exec.ts index 5b768749f8..4568ef41ae 100644 --- a/lib/gbrain-exec.ts +++ b/lib/gbrain-exec.ts @@ -54,6 +54,26 @@ export interface BuildGbrainEnvOptions { announce?: boolean; } +/** + * Detect whether a DATABASE_URL targets a PgBouncer transaction-mode pooler. + * + * Supabase transaction-mode poolers conventionally run on port 6543 at + * `*.pooler.supabase.com`. When gbrain connects through one of these, it + * auto-disables prepared statements — but search requires them (#1435). + * Returns `true` when the URL looks like a transaction-mode pooler so the + * caller can set `GBRAIN_PREPARE=true` to re-enable prepared statements. + */ +export function isTransactionModePooler(url: string): boolean { + try { + // DATABASE_URLs use postgresql:// scheme which URL() doesn't natively + // parse host/port from, so swap to http:// for reliable parsing. + const parsed = new URL(url.replace(/^postgres(ql)?:\/\//, "http://")); + return parsed.port === "6543"; + } catch { + return false; + } +} + /** * Build an env dict with DATABASE_URL seeded from * `${GBRAIN_HOME:-$HOME/.gbrain}/config.json`. Returns the base env @@ -63,6 +83,11 @@ export interface BuildGbrainEnvOptions { * - the config has no `database_url`, * - the caller already set DATABASE_URL to the same value. * + * When the effective DATABASE_URL targets a PgBouncer transaction-mode + * pooler (port 6543), sets `GBRAIN_PREPARE=true` so gbrain re-enables + * prepared statements needed for search (#1435). Caller can override + * with `GBRAIN_PREPARE=false` in the base env. + * * Always returns a fresh object — mutating the returned env never * affects the caller's env. Tests assert on effective values, not * object identity. @@ -84,14 +109,31 @@ export function buildGbrainEnv(opts: BuildGbrainEnvOptions = {}): NodeJS.Process return out; } if (!cfg.database_url) return out; - if (baseEnv.DATABASE_URL === cfg.database_url) return out; const hadCaller = baseEnv.DATABASE_URL !== undefined; - out.DATABASE_URL = cfg.database_url; - if (opts.announce) { - const note = hadCaller ? " (overrode value from caller env / .env.local)" : ""; - process.stderr.write(`[gbrain-exec] seeded DATABASE_URL from ${configPath}${note}\n`); + const alreadyMatch = baseEnv.DATABASE_URL === cfg.database_url; + if (!alreadyMatch) { + out.DATABASE_URL = cfg.database_url; + if (opts.announce) { + const note = hadCaller ? " (overrode value from caller env / .env.local)" : ""; + process.stderr.write(`[gbrain-exec] seeded DATABASE_URL from ${configPath}${note}\n`); + } } + + // PgBouncer transaction-mode pooler detection (#1435): when the effective + // DATABASE_URL targets port 6543 (Supabase transaction-mode convention), + // gbrain auto-disables prepared statements — but search needs them. + // Set GBRAIN_PREPARE=true unless the caller explicitly opted out. + const effectiveUrl = out.DATABASE_URL || cfg.database_url; + if (effectiveUrl && !out.GBRAIN_PREPARE && isTransactionModePooler(effectiveUrl)) { + out.GBRAIN_PREPARE = "true"; + if (opts.announce) { + process.stderr.write( + `[gbrain-exec] set GBRAIN_PREPARE=true (port 6543 transaction-mode pooler detected)\n`, + ); + } + } + return out; } diff --git a/sync-gbrain/SKILL.md b/sync-gbrain/SKILL.md index f7b9b52305..8525b55f54 100644 --- a/sync-gbrain/SKILL.md +++ b/sync-gbrain/SKILL.md @@ -905,13 +905,25 @@ Capability check (per /plan-eng-review §6): ```bash SLUG="_capability_check_$$" +CAPABILITY_OK=0 if [ -f ~/.gbrain/config.json ] && \ - gbrain --version 2>/dev/null | grep -q '^gbrain ' && \ - echo "ping" | gbrain put "$SLUG" >/dev/null 2>&1 && \ - gbrain search "ping" 2>/dev/null | grep -q "$SLUG"; then - CAPABILITY_OK=1 -else - CAPABILITY_OK=0 + gbrain --version 2>/dev/null | grep -q '^gbrain '; then + # GBRAIN_PREPARE=true ensures prepared statements stay enabled when + # connecting through a PgBouncer transaction-mode pooler (port 6543). + # Without it, search silently returns no results (#1435). + export GBRAIN_PREPARE=true + if echo "ping" | gbrain put "$SLUG" >/dev/null 2>&1; then + # Retry search up to 3 times with 1s delay — under transaction-mode + # pooling the search index may not be visible on the next connection + # immediately after the put. + for _attempt in 1 2 3; do + if gbrain search "ping" 2>/dev/null | grep -q "$SLUG"; then + CAPABILITY_OK=1 + break + fi + sleep 1 + done + fi fi gbrain delete "$SLUG" 2>/dev/null || true ``` diff --git a/sync-gbrain/SKILL.md.tmpl b/sync-gbrain/SKILL.md.tmpl index b05c390664..73809d6dd2 100644 --- a/sync-gbrain/SKILL.md.tmpl +++ b/sync-gbrain/SKILL.md.tmpl @@ -185,13 +185,25 @@ Capability check (per /plan-eng-review §6): ```bash SLUG="_capability_check_$$" +CAPABILITY_OK=0 if [ -f ~/.gbrain/config.json ] && \ - gbrain --version 2>/dev/null | grep -q '^gbrain ' && \ - echo "ping" | gbrain put "$SLUG" >/dev/null 2>&1 && \ - gbrain search "ping" 2>/dev/null | grep -q "$SLUG"; then - CAPABILITY_OK=1 -else - CAPABILITY_OK=0 + gbrain --version 2>/dev/null | grep -q '^gbrain '; then + # GBRAIN_PREPARE=true ensures prepared statements stay enabled when + # connecting through a PgBouncer transaction-mode pooler (port 6543). + # Without it, search silently returns no results (#1435). + export GBRAIN_PREPARE=true + if echo "ping" | gbrain put "$SLUG" >/dev/null 2>&1; then + # Retry search up to 3 times with 1s delay — under transaction-mode + # pooling the search index may not be visible on the next connection + # immediately after the put. + for _attempt in 1 2 3; do + if gbrain search "ping" 2>/dev/null | grep -q "$SLUG"; then + CAPABILITY_OK=1 + break + fi + sleep 1 + done + fi fi gbrain delete "$SLUG" 2>/dev/null || true ``` diff --git a/test/build-gbrain-env.test.ts b/test/build-gbrain-env.test.ts index 4066126d04..be7b8f54d8 100644 --- a/test/build-gbrain-env.test.ts +++ b/test/build-gbrain-env.test.ts @@ -15,7 +15,7 @@ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; -import { buildGbrainEnv } from "../lib/gbrain-exec"; +import { buildGbrainEnv, isTransactionModePooler } from "../lib/gbrain-exec"; describe("buildGbrainEnv", () => { let home: string; @@ -117,4 +117,74 @@ describe("buildGbrainEnv", () => { const result = buildGbrainEnv({ baseEnv }); expect(result.DATABASE_URL).toBe("postgresql://gbrain/db"); }); + + // --- GBRAIN_PREPARE auto-detection (#1435) --- + + it("sets GBRAIN_PREPARE=true when DATABASE_URL targets port 6543 (transaction-mode pooler)", () => { + const poolerUrl = "postgresql://postgres.abc:pw@aws-0-us-east-1.pooler.supabase.com:6543/postgres"; + writeFileSync(join(gbrainHome, "config.json"), JSON.stringify({ database_url: poolerUrl })); + const baseEnv = { HOME: home }; + const result = buildGbrainEnv({ baseEnv }); + expect(result.DATABASE_URL).toBe(poolerUrl); + expect(result.GBRAIN_PREPARE).toBe("true"); + }); + + it("does not set GBRAIN_PREPARE when DATABASE_URL targets port 5432 (session-mode pooler)", () => { + const sessionUrl = "postgresql://postgres.abc:pw@aws-0-us-east-1.pooler.supabase.com:5432/postgres"; + writeFileSync(join(gbrainHome, "config.json"), JSON.stringify({ database_url: sessionUrl })); + const baseEnv = { HOME: home }; + const result = buildGbrainEnv({ baseEnv }); + expect(result.GBRAIN_PREPARE).toBeUndefined(); + }); + + it("does not set GBRAIN_PREPARE for pglite (no port in URL)", () => { + writeFileSync(join(gbrainHome, "config.json"), JSON.stringify({ database_url: "postgresql://gbrain/db" })); + const baseEnv = { HOME: home }; + const result = buildGbrainEnv({ baseEnv }); + expect(result.GBRAIN_PREPARE).toBeUndefined(); + }); + + it("respects caller's explicit GBRAIN_PREPARE=false (opt-out)", () => { + const poolerUrl = "postgresql://postgres.abc:pw@aws-0-us-east-1.pooler.supabase.com:6543/postgres"; + writeFileSync(join(gbrainHome, "config.json"), JSON.stringify({ database_url: poolerUrl })); + const baseEnv = { HOME: home, GBRAIN_PREPARE: "false" }; + const result = buildGbrainEnv({ baseEnv }); + expect(result.GBRAIN_PREPARE).toBe("false"); + }); + + it("sets GBRAIN_PREPARE even when caller DATABASE_URL already matches config on port 6543", () => { + const poolerUrl = "postgresql://postgres.abc:pw@aws-0-us-east-1.pooler.supabase.com:6543/postgres"; + writeFileSync(join(gbrainHome, "config.json"), JSON.stringify({ database_url: poolerUrl })); + const baseEnv = { HOME: home, DATABASE_URL: poolerUrl }; + const result = buildGbrainEnv({ baseEnv }); + expect(result.GBRAIN_PREPARE).toBe("true"); + }); +}); + +describe("isTransactionModePooler", () => { + it("returns true for Supabase transaction-mode pooler URL (port 6543)", () => { + expect(isTransactionModePooler( + "postgresql://postgres.abc:pw@aws-0-us-east-1.pooler.supabase.com:6543/postgres" + )).toBe(true); + }); + + it("returns false for session-mode pooler URL (port 5432)", () => { + expect(isTransactionModePooler( + "postgresql://postgres.abc:pw@aws-0-us-east-1.pooler.supabase.com:5432/postgres" + )).toBe(false); + }); + + it("returns false for pglite-style URL (no port)", () => { + expect(isTransactionModePooler("postgresql://gbrain/db")).toBe(false); + }); + + it("returns false for unparseable URL", () => { + expect(isTransactionModePooler("not-a-url")).toBe(false); + }); + + it("handles postgres:// scheme (without 'ql')", () => { + expect(isTransactionModePooler( + "postgres://postgres.abc:pw@host:6543/postgres" + )).toBe(true); + }); });