mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 19:49:14 +00:00
* fix(recipes/x-to-brain): use /users/by/username for app-only bearer health check Takeover of #2343. /users/me requires user-context OAuth and always fails under the app-only bearer the recipe collects. Health check + setup curls now use /users/by/username/$X_HANDLE, with X_HANDLE declared in secrets so the installer prompts for it. Recipe version 0.8.1 -> 0.8.2. Co-authored-by: ethanbeard <ethanbeard@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cycle): bound propose_takes with per-call timeout + phase deadline Takeover of #2262. The extractor's gateway.chat call had no abortSignal, so one stalled provider socket could pin the phase for the 300s gateway default per page; the nightly wrapper then SIGTERMed the whole phase mid-run. Each extractor call is now bounded at 90s (per-page failure already logs a warning and continues), and the page loop carries a 30-min wall-clock deadline that breaks cleanly into a partial result with deadline_hit:true + warn status. Unlike the original PR, the default pageLimit stays at 100 — shrinking it to 30 was an unrelated product-knob change that would permanently cut nightly take coverage. Co-authored-by: tschew72 <tschew72@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(capture): make fallback title truncation explicit and astral-safe Takeover of #2310. deriveTitle's silent .slice(0, 80) could split an astral surrogate pair mid-character and gave no signal the title was cut. Truncation is now codepoint-aware and appends an ellipsis (still capped at 80 codepoints). Unlike the original PR, this stays a three-line change: no whitespace normalization of every derived title, no word-boundary heuristics. Co-authored-by: xd-Neji <xd-Neji@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(doctor): clear extract_atoms raw source-holder backlog + normalize pooler direct-URL overrides Takeover of #2242, split to the two concerns that survive review: - extract_atoms: exclude source pages whose frontmatter declares a raw payload pointer from discovery AND the doctor backlog count (shared SQL fragment so they can't drift). Extraction on these yields zero atoms, so no atom row is ever written and they re-enter the backlog every cycle — a permanent no-progress doctor blocker. - connection-manager: a direct-URL override (opts/env) that still points at the Supavisor TRANSACTION pooler (port 6543, usually a copy-paste of the primary URL) is normalized to the real direct host via deriveDirectUrl. Session-mode pooler overrides (port 5432) pass through — they are a legitimate direct-ish target, which the original PR would have nulled out. Dropped from the original PR: orphan-reporting atom exclusions (master already excludes atoms/ and raw/ first segments plus /raw/ segments in src/commands/orphans.ts) and the drain dry-run status tweak. Co-authored-by: benjonp <benjonp@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cycle): record propose_takes deadline break as a halt in the extract rollup A deadline-hit run breaks the page loop mid-list — same posture as budget exhaustion — but the rollup still counted it as a completed round with no halt, hiding chronic never-finishing nightly runs from extract-status/ doctor. Treat deadline_hit like budget_exhausted in the rollup deltas; deadline test now pins halt=1 / completed=0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Garry Tan <garrytan@gmail.com> Co-authored-by: ethanbeard <ethanbeard@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: tschew72 <tschew72@users.noreply.github.com> Co-authored-by: xd-Neji <xd-Neji@users.noreply.github.com> Co-authored-by: benjonp <benjonp@users.noreply.github.com>
367 lines
14 KiB
TypeScript
367 lines
14 KiB
TypeScript
import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
|
|
import {
|
|
isSupabasePoolerUrl,
|
|
deriveDirectUrl,
|
|
normalizeDirectUrl,
|
|
readKillSwitchEnv,
|
|
isNetworkUnreachableError,
|
|
resolveDirectPoolSize,
|
|
ConnectionManager,
|
|
DEFAULT_DIRECT_POOL_SIZE,
|
|
} from '../src/core/connection-manager.ts';
|
|
|
|
describe('isSupabasePoolerUrl', () => {
|
|
test('detects port 6543', () => {
|
|
expect(isSupabasePoolerUrl('postgresql://u:p@host:6543/db')).toBe(true);
|
|
});
|
|
|
|
test('detects pooler.supabase.com hostname', () => {
|
|
expect(
|
|
isSupabasePoolerUrl('postgresql://u:p@aws-0-us-east-1.pooler.supabase.com:5432/db')
|
|
).toBe(true);
|
|
});
|
|
|
|
test('rejects direct supabase host', () => {
|
|
expect(
|
|
isSupabasePoolerUrl('postgresql://u:p@db.abc.supabase.co:5432/postgres')
|
|
).toBe(false);
|
|
});
|
|
|
|
test('rejects self-hosted on standard port', () => {
|
|
expect(isSupabasePoolerUrl('postgresql://u:p@localhost:5432/gbrain_test')).toBe(false);
|
|
});
|
|
|
|
test('handles malformed URL gracefully', () => {
|
|
expect(isSupabasePoolerUrl('not a url')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('deriveDirectUrl', () => {
|
|
test('swaps pooler hostname + port for known shape', () => {
|
|
const direct = deriveDirectUrl(
|
|
'postgresql://postgres.abcxyz:secret@aws-0-us-east-1.pooler.supabase.com:6543/postgres'
|
|
);
|
|
expect(direct).toBeTruthy();
|
|
expect(direct).toContain('db.abcxyz.supabase.co:5432');
|
|
expect(direct).toContain(':secret@'); // creds preserved
|
|
});
|
|
|
|
test('strips .<project-ref> suffix from username when going pooler→direct', () => {
|
|
// Supabase direct connections require bare `postgres`; the `postgres.<ref>`
|
|
// form is pooler-only (Supavisor uses the suffix for tenant routing).
|
|
// Without the strip, direct auth fails with "password authentication
|
|
// failed for user postgres.<ref>" even with the correct password.
|
|
const direct = deriveDirectUrl(
|
|
'postgresql://postgres.abcxyz:secret@aws-0-us-east-1.pooler.supabase.com:6543/postgres'
|
|
);
|
|
expect(direct).toContain('postgres:secret@'); // bare username
|
|
expect(direct).not.toContain('postgres.abcxyz:secret@'); // no pooler suffix
|
|
});
|
|
|
|
test('falls back to port-only swap when project-ref unparseable', () => {
|
|
const direct = deriveDirectUrl(
|
|
'postgresql://customuser:secret@some.pooler.supabase.com:6543/db'
|
|
);
|
|
expect(direct).toBeTruthy();
|
|
expect(direct).toContain(':5432');
|
|
expect(direct).toContain('some.pooler.supabase.com'); // host preserved
|
|
expect(direct).toContain('customuser:secret@'); // non-pooler username preserved
|
|
});
|
|
|
|
test('returns null for non-pooler URL', () => {
|
|
expect(deriveDirectUrl('postgresql://u:p@localhost:5432/db')).toBeNull();
|
|
});
|
|
|
|
test('preserves query string', () => {
|
|
const direct = deriveDirectUrl(
|
|
'postgresql://postgres.ref:p@aws.pooler.supabase.com:6543/db?prepare=false'
|
|
);
|
|
expect(direct).toContain('?prepare=false');
|
|
});
|
|
});
|
|
|
|
describe('normalizeDirectUrl', () => {
|
|
test('normalizes a transaction-pooler (6543) override to the real direct host', () => {
|
|
const direct = normalizeDirectUrl(
|
|
'postgresql://postgres.abcxyz:p@aws-0-us-west-2.pooler.supabase.com:6543/postgres',
|
|
'postgresql://postgres.abcxyz:p@aws-0-us-west-2.pooler.supabase.com:6543/postgres',
|
|
);
|
|
expect(direct).toBe('postgresql://postgres:p@db.abcxyz.supabase.co:5432/postgres');
|
|
});
|
|
|
|
test('keeps a non-pooler direct override', () => {
|
|
const direct = normalizeDirectUrl(
|
|
'postgresql://postgres.abc:p@aws.pooler.supabase.com:6543/db',
|
|
'postgresql://u:p@custom-direct.example.com:5432/db',
|
|
);
|
|
expect(direct).toBe('postgresql://u:p@custom-direct.example.com:5432/db');
|
|
});
|
|
|
|
test('keeps a session-mode pooler override (pooler host, port 5432)', () => {
|
|
const sessionUrl = 'postgresql://postgres.abc:p@aws.pooler.supabase.com:5432/db';
|
|
const direct = normalizeDirectUrl(
|
|
'postgresql://postgres.abc:p@aws.pooler.supabase.com:6543/db',
|
|
sessionUrl,
|
|
);
|
|
expect(direct).toBe(sessionUrl);
|
|
});
|
|
|
|
test('no override: derives from the primary as before', () => {
|
|
const direct = normalizeDirectUrl(
|
|
'postgresql://postgres.abc:p@aws.pooler.supabase.com:6543/db',
|
|
);
|
|
expect(direct).toContain('db.abc.supabase.co:5432');
|
|
});
|
|
|
|
test('no override, non-Supabase primary: null', () => {
|
|
expect(normalizeDirectUrl('postgresql://u:p@localhost:5432/db')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('readKillSwitchEnv', () => {
|
|
let original: string | undefined;
|
|
beforeEach(() => { original = process.env.GBRAIN_DISABLE_DIRECT_POOL; });
|
|
afterEach(() => {
|
|
if (original === undefined) delete process.env.GBRAIN_DISABLE_DIRECT_POOL;
|
|
else process.env.GBRAIN_DISABLE_DIRECT_POOL = original;
|
|
});
|
|
|
|
test('false when unset', () => {
|
|
delete process.env.GBRAIN_DISABLE_DIRECT_POOL;
|
|
expect(readKillSwitchEnv()).toBe(false);
|
|
});
|
|
|
|
test('true when "1"', () => {
|
|
process.env.GBRAIN_DISABLE_DIRECT_POOL = '1';
|
|
expect(readKillSwitchEnv()).toBe(true);
|
|
});
|
|
|
|
test('true when "true"', () => {
|
|
process.env.GBRAIN_DISABLE_DIRECT_POOL = 'true';
|
|
expect(readKillSwitchEnv()).toBe(true);
|
|
});
|
|
|
|
test('false for any other value', () => {
|
|
process.env.GBRAIN_DISABLE_DIRECT_POOL = '0';
|
|
expect(readKillSwitchEnv()).toBe(false);
|
|
process.env.GBRAIN_DISABLE_DIRECT_POOL = 'false';
|
|
expect(readKillSwitchEnv()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('resolveDirectPoolSize', () => {
|
|
let original: string | undefined;
|
|
beforeEach(() => { original = process.env.GBRAIN_DIRECT_POOL_SIZE; });
|
|
afterEach(() => {
|
|
if (original === undefined) delete process.env.GBRAIN_DIRECT_POOL_SIZE;
|
|
else process.env.GBRAIN_DIRECT_POOL_SIZE = original;
|
|
});
|
|
|
|
test('default to 3', () => {
|
|
delete process.env.GBRAIN_DIRECT_POOL_SIZE;
|
|
expect(resolveDirectPoolSize()).toBe(DEFAULT_DIRECT_POOL_SIZE);
|
|
expect(DEFAULT_DIRECT_POOL_SIZE).toBe(3);
|
|
});
|
|
|
|
test('explicit overrides env', () => {
|
|
process.env.GBRAIN_DIRECT_POOL_SIZE = '5';
|
|
expect(resolveDirectPoolSize(7)).toBe(7);
|
|
});
|
|
|
|
test('env overrides default', () => {
|
|
process.env.GBRAIN_DIRECT_POOL_SIZE = '5';
|
|
expect(resolveDirectPoolSize()).toBe(5);
|
|
});
|
|
|
|
test('rejects invalid env values', () => {
|
|
process.env.GBRAIN_DIRECT_POOL_SIZE = 'abc';
|
|
expect(resolveDirectPoolSize()).toBe(DEFAULT_DIRECT_POOL_SIZE);
|
|
process.env.GBRAIN_DIRECT_POOL_SIZE = '0';
|
|
expect(resolveDirectPoolSize()).toBe(DEFAULT_DIRECT_POOL_SIZE);
|
|
process.env.GBRAIN_DIRECT_POOL_SIZE = '999';
|
|
expect(resolveDirectPoolSize()).toBe(DEFAULT_DIRECT_POOL_SIZE);
|
|
});
|
|
});
|
|
|
|
describe('ConnectionManager — describeMode + dual-pool routing', () => {
|
|
let originalKillSwitch: string | undefined;
|
|
let originalDirectUrl: string | undefined;
|
|
beforeEach(() => {
|
|
originalKillSwitch = process.env.GBRAIN_DISABLE_DIRECT_POOL;
|
|
originalDirectUrl = process.env.GBRAIN_DIRECT_DATABASE_URL;
|
|
delete process.env.GBRAIN_DISABLE_DIRECT_POOL;
|
|
delete process.env.GBRAIN_DIRECT_DATABASE_URL;
|
|
});
|
|
afterEach(() => {
|
|
if (originalKillSwitch === undefined) delete process.env.GBRAIN_DISABLE_DIRECT_POOL;
|
|
else process.env.GBRAIN_DISABLE_DIRECT_POOL = originalKillSwitch;
|
|
if (originalDirectUrl === undefined) delete process.env.GBRAIN_DIRECT_DATABASE_URL;
|
|
else process.env.GBRAIN_DIRECT_DATABASE_URL = originalDirectUrl;
|
|
});
|
|
|
|
test('non-Supabase URL → single mode', () => {
|
|
const cm = new ConnectionManager({ url: 'postgresql://u:p@localhost:5432/db' });
|
|
expect(cm.isSupabase()).toBe(false);
|
|
expect(cm.isDualPoolActive()).toBe(false);
|
|
expect(cm.describeMode().mode).toBe('single (non-supabase)');
|
|
});
|
|
|
|
test('Supabase pooler URL → dual mode (without kill-switch)', () => {
|
|
const cm = new ConnectionManager({
|
|
url: 'postgresql://postgres.abc:p@aws.pooler.supabase.com:6543/db',
|
|
});
|
|
expect(cm.isSupabase()).toBe(true);
|
|
expect(cm.isDualPoolActive()).toBe(true);
|
|
expect(cm.describeMode().mode).toBe('split');
|
|
expect(cm.describeMode().direct_host).toContain('db.abc.supabase.co:5432');
|
|
});
|
|
|
|
test('kill-switch active → single mode (kill-switch)', () => {
|
|
process.env.GBRAIN_DISABLE_DIRECT_POOL = '1';
|
|
const cm = new ConnectionManager({
|
|
url: 'postgresql://postgres.abc:p@aws.pooler.supabase.com:6543/db',
|
|
});
|
|
expect(cm.isSupabase()).toBe(true);
|
|
expect(cm.isKillSwitchActive()).toBe(true);
|
|
expect(cm.isDualPoolActive()).toBe(false);
|
|
expect(cm.describeMode().mode).toBe('single (kill-switch)');
|
|
});
|
|
|
|
test('explicit directUrl override wins', () => {
|
|
const cm = new ConnectionManager({
|
|
url: 'postgresql://postgres.abc:p@aws.pooler.supabase.com:6543/db',
|
|
directUrl: 'postgresql://u:p@custom-direct.example.com:5432/db',
|
|
});
|
|
expect(cm.resolveDirectUrl()).toContain('custom-direct.example.com');
|
|
});
|
|
|
|
test('explicit transaction-pooler directUrl override is normalized to direct host', () => {
|
|
const cm = new ConnectionManager({
|
|
url: 'postgresql://postgres.abc:p@aws.pooler.supabase.com:6543/db',
|
|
directUrl: 'postgresql://postgres.abc:p@aws.pooler.supabase.com:6543/db',
|
|
});
|
|
expect(cm.resolveDirectUrl()).toContain('db.abc.supabase.co:5432');
|
|
expect(cm.resolveDirectUrl()).not.toContain(':6543');
|
|
});
|
|
|
|
test('env transaction-pooler directUrl override is normalized to direct host', () => {
|
|
process.env.GBRAIN_DIRECT_DATABASE_URL =
|
|
'postgresql://postgres.abc:p@aws.pooler.supabase.com:6543/db';
|
|
const cm = new ConnectionManager({
|
|
url: 'postgresql://postgres.abc:p@aws.pooler.supabase.com:6543/db',
|
|
});
|
|
expect(cm.resolveDirectUrl()).toContain('db.abc.supabase.co:5432');
|
|
expect(cm.resolveDirectUrl()).not.toContain(':6543');
|
|
});
|
|
|
|
test('host string contains creds neither in describeMode nor resolveDirectUrl logging', () => {
|
|
const cm = new ConnectionManager({
|
|
url: 'postgresql://postgres.abc:secret@aws.pooler.supabase.com:6543/db',
|
|
});
|
|
const desc = cm.describeMode();
|
|
expect(desc.direct_host ?? '').not.toContain('secret');
|
|
});
|
|
});
|
|
|
|
describe('ConnectionManager — parent inheritance (A2)', () => {
|
|
test('child inherits kill-switch from parent', () => {
|
|
const original = process.env.GBRAIN_DISABLE_DIRECT_POOL;
|
|
try {
|
|
process.env.GBRAIN_DISABLE_DIRECT_POOL = '1';
|
|
const parent = new ConnectionManager({
|
|
url: 'postgresql://postgres.abc:p@aws.pooler.supabase.com:6543/db',
|
|
});
|
|
// Child constructed AFTER env reset — parent's snapshot is what matters.
|
|
delete process.env.GBRAIN_DISABLE_DIRECT_POOL;
|
|
const child = new ConnectionManager({
|
|
url: 'postgresql://postgres.abc:p@aws.pooler.supabase.com:6543/db',
|
|
parent,
|
|
});
|
|
expect(child.isKillSwitchActive()).toBe(true);
|
|
expect(child.isDualPoolActive()).toBe(false);
|
|
} finally {
|
|
if (original === undefined) delete process.env.GBRAIN_DISABLE_DIRECT_POOL;
|
|
else process.env.GBRAIN_DISABLE_DIRECT_POOL = original;
|
|
}
|
|
});
|
|
|
|
test('child without parent reads env at construction', () => {
|
|
const original = process.env.GBRAIN_DISABLE_DIRECT_POOL;
|
|
try {
|
|
delete process.env.GBRAIN_DISABLE_DIRECT_POOL;
|
|
const cm = new ConnectionManager({
|
|
url: 'postgresql://postgres.abc:p@aws.pooler.supabase.com:6543/db',
|
|
});
|
|
expect(cm.isKillSwitchActive()).toBe(false);
|
|
// Mutating env after construction does NOT change the manager's state.
|
|
process.env.GBRAIN_DISABLE_DIRECT_POOL = '1';
|
|
expect(cm.isKillSwitchActive()).toBe(false); // snapshot semantics
|
|
} finally {
|
|
if (original === undefined) delete process.env.GBRAIN_DISABLE_DIRECT_POOL;
|
|
else process.env.GBRAIN_DISABLE_DIRECT_POOL = original;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('isNetworkUnreachableError (#1641)', () => {
|
|
test('classifies network codes as unreachable', () => {
|
|
for (const code of ['ENOTFOUND', 'ECONNREFUSED', 'ENETUNREACH', 'EHOSTUNREACH', 'ETIMEDOUT', 'CONNECT_TIMEOUT']) {
|
|
const err = Object.assign(new Error('connect failed'), { code });
|
|
expect(isNetworkUnreachableError(err)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('classifies by message when code absent', () => {
|
|
expect(isNetworkUnreachableError(new Error('getaddrinfo ENOTFOUND db.abc.supabase.co'))).toBe(true);
|
|
});
|
|
|
|
test('auth/SQL errors are NOT unreachable', () => {
|
|
expect(isNetworkUnreachableError(new Error('password authentication failed for user "postgres"'))).toBe(false);
|
|
expect(isNetworkUnreachableError(new Error('syntax error at or near "SELEC"'))).toBe(false);
|
|
expect(isNetworkUnreachableError(null)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('ConnectionManager — direct-pool fallback on unreachable host (#1641)', () => {
|
|
let originalKillSwitch: string | undefined;
|
|
let originalError: typeof console.error;
|
|
let errLines: string[];
|
|
beforeEach(() => {
|
|
originalKillSwitch = process.env.GBRAIN_DISABLE_DIRECT_POOL;
|
|
delete process.env.GBRAIN_DISABLE_DIRECT_POOL;
|
|
originalError = console.error;
|
|
errLines = [];
|
|
console.error = (...args: unknown[]) => { errLines.push(args.join(' ')); };
|
|
});
|
|
afterEach(() => {
|
|
console.error = originalError;
|
|
if (originalKillSwitch === undefined) delete process.env.GBRAIN_DISABLE_DIRECT_POOL;
|
|
else process.env.GBRAIN_DISABLE_DIRECT_POOL = originalKillSwitch;
|
|
});
|
|
|
|
test('ddl() falls back to the read pool when the direct host is unreachable', async () => {
|
|
const cm = new ConnectionManager({
|
|
url: 'postgresql://postgres.abc:p@aws.pooler.supabase.com:6543/db',
|
|
// 127.0.0.1:9 (discard) → instant ECONNREFUSED, the IPv4-only-network shape.
|
|
directUrl: 'postgresql://postgres:p@127.0.0.1:9/db',
|
|
});
|
|
const fakeReadPool = {} as ReturnType<typeof ConnectionManager.prototype.read>;
|
|
cm.setReadPool(fakeReadPool);
|
|
expect(cm.isDualPoolActive()).toBe(true);
|
|
|
|
const pool = await cm.ddl(); // without the fix this throws ECONNREFUSED
|
|
expect(pool).toBe(fakeReadPool);
|
|
// Self-activating kill-switch: subsequent calls skip the direct pool.
|
|
expect(cm.isKillSwitchActive()).toBe(true);
|
|
expect(cm.isDualPoolActive()).toBe(false);
|
|
expect(cm.describeMode().mode).toBe('single (kill-switch)');
|
|
// One stderr line mentioning the power-user override.
|
|
const warning = errLines.filter(l => l.includes('GBRAIN_DIRECT_DATABASE_URL'));
|
|
expect(warning.length).toBe(1);
|
|
|
|
const again = await cm.ddl();
|
|
expect(again).toBe(fakeReadPool);
|
|
expect(errLines.filter(l => l.includes('GBRAIN_DIRECT_DATABASE_URL')).length).toBe(1);
|
|
}, 20000);
|
|
});
|