v0.30.1 feat: operational hardening — make upgrades just work on Supabase (#750)

* v0.30.1 Lane A: connection-manager foundation + X1 initSchema routing

Routes Postgres queries by query type:
  - read() goes to the Supabase pooler (port 6543, fast)
  - ddl() and bulk() go to direct (port 5432, 30min stmt timeout, mwm 256MB)

Auto-detects Supabase via hostname pooler.supabase.com or port 6543.
Override with GBRAIN_DIRECT_DATABASE_URL. Kill-switch via
GBRAIN_DISABLE_DIRECT_POOL=1 falls back to single-pool legacy path.

Foundation modules (Lane A scope):
- src/core/connection-manager.ts: read/ddl/bulk/healthCheck, parent-CM
  inheritance (T5/X1), cached Promise<Sql> lazy init (A1), kill-switch
  inheritance (A2), Supabase URL auto-derivation
- src/core/url-redact.ts: redactPgUrl + redactDeep (F3)
- src/core/retry-matcher.ts: typed predicates for stmt-timeout / lock /
  conn errors (C4)
- src/core/connection-audit.ts: ~/.gbrain/audit/connection-events JSONL
  with ISO-week rotation; doctor tail-reads last 5 errors (F8)
- scripts/check-pg-url-redaction.sh: CI grep guard against unredacted
  postgresql:// URL leaks (F3)

Engine integration:
- PostgresEngine.connect: instantiates instance-owned ConnectionManager,
  inherits from parentConnectionManager when set (worker engines, sync,
  cycle), shares pool with module-singleton path
- PostgresEngine.disconnect: tears down direct pool first
- PostgresEngine.initSchema: routes DDL through connectionManager.ddl()
  when dual-pool active (X1 part 1; lock semantics replacement is Lane B)
- cli.ts:connectEngine(opts): probeOnly skips initSchema entirely (X1
  part 2 — get_health, upgrade --status will use this)

Tests added (51 new cases):
- test/url-redact.test.ts: 11 cases
- test/retry-matcher.test.ts: 13 cases
- test/connection-manager.test.ts: 27 cases (URL detection, derive,
  kill-switch, parent inheritance, dual-pool routing modes)

Foundation for Lanes B-E. Sequential lane work continues.

Plan: ~/.claude/plans/system-instruction-you-are-working-stateless-wadler.md

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

* v0.30.1 Lane B: migration runner retry + verify hooks + namespaced --force flags

Adds Migration interface fields:
  - idempotent: boolean (default true; explicit false blocks verify-hook
    re-runs on destructive migrations)
  - verify: optional post-condition probe; runs after migration claims success

Migration retry wrapper (Cherry D3 / Finding F2):
  - 3 attempts with 5s/15s/45s backoff (env GBRAIN_MIGRATE_BACKOFF_MS=0
    for tests)
  - Retries only on statement_timeout (57014) or connection-reset patterns
  - Pre-attempt: logs idle-in-transaction blockers via getIdleBlockers
  - On exhaustion: throws MigrationRetryExhausted with named PID + suggested
    pg_terminate_backend() recovery command

Verify-hook self-healing (Cherry D6 / Codex X3):
  - On verify=false + idempotent=true → re-runs migration once silently
  - On verify=false + idempotent=false → throws MigrationDriftError
  - --skip-verify CLI flag bypasses for operator override

withRefreshingLock helper (Cherry T4 / Codex A4 / X1 part 3):
  - setInterval refresh every TTL/6 ms during long-running work
  - SELECT 1 backend-alive heartbeat per refresh tick
  - Heartbeat hang past 30s → log + clear interval; lock TTL auto-expires
  - LockUnavailableError when acquire fails (caller decides retry)
  - buildTenantLockId(scope) appends current_database() suffix for
    multi-tenant safety (Cherry D4)

Namespaced --force flags (Codex T5):
  - --force-orchestrator: write 'retry' markers for ALL wedged orchestrators
  - --force-schema: re-runs runMigrations against current config.version
  - --force / --force-all: both
  - --force-retry vX.Y.Z: existing single-version reset (preserved)
  - --skip-verify: bypass verify-hook drift detection on a single run

Test additions:
  - test/migrate-extensions.test.ts: 14 cases (idempotent default,
    error envelopes, MIGRATIONS contract)
  - test/db-lock-refresh.test.ts: 10 cases (LockUnavailableError,
    buildTenantLockId multi-tenant, opts shape)
  - test/migrate.test.ts: updated 2 existing cases (PR #356 retry shape +
    function-name anchor) for v0.30.1 retry-wrapper semantics

156 unit tests passing across the v0.30.1 surface so far.

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

* v0.30.1 Lane C: backfill primitive + registry + X4 + X5

First-class generic backfill runner (Fix 3). Generalizes the
keyset+checkpoint+adaptive-batch pattern from
src/core/backfill-effective-date.ts so future backfills (embedding_voyage
in v0.30.2, etc.) reuse one tested runner.

NEW src/core/backfill-base.ts:
  - runBackfill() with keyset pagination, config-table checkpoint, adaptive
    batch halving on stmt timeout, conn-drop reconnect, max-errors bail
  - ensureBackfillIndex() verifies/creates partial index CONCURRENTLY (P2/X4)
  - clearBackfillCheckpoint() for --fresh path
  - T3 fix: writes go through engine.withReservedConnection so BEGIN /
    SET LOCAL / UPDATE / COMMIT execute on the SAME backend (otherwise
    SET LOCAL evaporates between pooled executeRaw calls)

NEW src/core/backfill-registry.ts:
  - effective_date: implemented (wraps existing computeEffectiveDate)
  - emotional_weight: implemented (wraps computeEmotionalWeight + stamps
    new emotional_weight_recomputed_at column)
  - embedding_voyage: declared-only in v0.30.1 (multi-column embedding
    schema lands in v0.30.2)

NEW src/commands/backfill.ts:
  - gbrain backfill <kind> [--batch-size N] [--concurrency N] [--resume]
                          [--fresh] [--dry-run] [--keep-index] [--max-errors N]
  - gbrain backfill list — shows registered backfills + status
  - X5 admission control: clampConcurrency() forces --concurrency to
    GBRAIN_DIRECT_POOL_SIZE - 1 ceiling (always reserves 1 conn for HNSW
    + heartbeat + doctor probes). Loud-warns when user requests above.

Schema migration v44 (X4 / Codex C8 fix):
  - pages.emotional_weight_recomputed_at TIMESTAMPTZ
  - emotional_weight = 0 is a VALID steady-state value per migration v40,
    so the original P2 predicate ("WHERE emotional_weight = 0") would have
    been a permanent large index over normal data. The corrected backlog
    predicate is "emotional_weight_recomputed_at IS NULL"; the partial
    index drops naturally as the cycle phase + this backfill stamp the
    column over time.
  - idempotent: true (ADD COLUMN ... NULL is metadata-only)

CLI integration:
  - src/cli.ts: registers `backfill` subcommand
  - reindex-frontmatter stays as thin alias for v0.30.1 back-compat;
    canonical entrypoint is now `gbrain backfill effective_date`

Test additions:
  - test/backfill-base.test.ts: 11 cases (keyset, checkpoint, dry-run,
    resume/fresh, maxRows cap, withReservedConnection routing, error
    paths, clearCheckpoint, ensureBackfillIndex)
  - test/backfill-concurrency-clamp.test.ts: 6 cases (X5 admission control)

173 unit tests passing across Lanes A+B+C of v0.30.1.

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

* v0.30.1 Lane D: HNSW lifecycle manager + A3 atomic-swap

Extends src/core/vector-index.ts with the v0.30.1 lifecycle layer.
The original chunkEmbeddingIndexSql / applyChunkEmbeddingIndexPolicy
contract is preserved unchanged.

New surfaces:
  - checkActiveBuild(engine, indexName): probes pg_stat_activity for an
    active CREATE INDEX or REINDEX on the named index. Used as pre-op
    guard so dropAndRebuild doesn't compete with a build already in
    flight (Supabase auto-maintenance, parallel gbrain procs).

  - dropZombieIndexes(engine, tableNames): startup sweep of
    indisvalid=false rows on gbrain tables. Drops them with
    DROP INDEX IF EXISTS, BUT skips any zombie that has an active build
    still in pg_stat_activity (codex Fix-5 in-progress-build guard).
    Wired into PostgresEngine.initSchema() — runs after migrations +
    verifySchema, best-effort, never blocks engine.connect().

  - dropAndRebuild(engine, spec, opts): A3 atomic-swap pattern:
      1. checkActiveBuild → bail if another build is active (--force overrides)
      2. CREATE INDEX CONCURRENTLY <name>_rebuild_<unix-ms> via
         engine.withReservedConnection (CONCURRENTLY can't run in a txn)
      3. Atomic swap inside engine.transaction:
           DROP INDEX <old-name>
           ALTER INDEX <temp-name> RENAME TO <old-name>
      4. If step 2 fails (OOM, timeout, conn drop), the OLD index stays
         intact and search keeps serving queries. This is the headline
         A3 win — no production-degraded silent failure mode.

  - monitorBuild(engine, indexName, onProgress, opts): poll
    pg_stat_activity every 30s; emit elapsed_ms + size_bytes (via
    pg_relation_size) + pid. Used by gbrain backfill embedding_voyage
    when batch > 1000 triggers a rebuild.

  - isSupabaseAutoMaintenance(active): predicate on application_name
    (matches "supabase" / "postgres-meta"). Used by dropAndRebuild to
    log + back off when Supabase auto-maintenance is doing the rebuild.

Engine integration:
  - PostgresEngine.initSchema() calls dropZombieIndexes after verifySchema.
    Surfaces zombie counts via console.log.
  - Best-effort wrapped in try/catch: pg_stat_activity / pg_index access
    can be restricted on managed Postgres tiers; gbrain shouldn't fail
    engine.connect() over diagnostic queries.

Test additions (18 cases):
  - test/vector-index-lifecycle.test.ts:
    * chunkEmbeddingIndexSql contract (3 cases) — pre-existing behavior preserved
    * applyChunkEmbeddingIndexPolicy contract (1 case)
    * checkActiveBuild (4 cases, including PGLite no-op + best-effort failure)
    * isSupabaseAutoMaintenance (3 cases)
    * dropZombieIndexes (4 cases, including in-progress-build guard)
    * dropAndRebuild atomic-swap (3 cases, including PGLite + active-build bail
      + temp-name format assertion)

191 unit tests passing across Lanes A+B+C+D of v0.30.1.

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

* v0.30.1 Lane E: upgrade pipeline checkpoint + brain_id binding + get_health migrations

NEW src/core/upgrade-checkpoint.ts:
  - Cherry D5: persists step-by-step progress through gbrain post-upgrade
    so partial failures can be resumed via gbrain upgrade --resume.
    Steps: pull → install → schema → features → backfills → verify.
  - Codex X2: checkpoint binds to brain identity via sha256(database_url)
    (userinfo stripped before hashing so cred rotations don't invalidate).
    PGLite uses sha256(database_path). Cross-brain checkpoint application
    is now refused with reason='brain_mismatch'.
  - F4 fall-through: validateCheckpoint returns reason='no_checkpoint'
    when none exists, enabling silent fall-through to a full upgrade.
  - All-complete detection: stale checkpoints (every step done) return
    reason='all_complete' so the next run clears + re-runs from scratch.
  - markStepComplete + markStepFailed maintain the partial-state shape.

T2 preserved: upgrade.ts still re-execs `gbrain post-upgrade` so the NEW
binary's migration registry runs (the existing re-exec pattern is correct
per codex round 1's plan-breaking finding). The checkpoint module is the
substrate that Lane E's --resume / --status surfaces will plumb through
in v0.30.2.

D7 + C3 contract committed:
  - BrainHealth.schema_version: '1' (literal type) — additive-only contract
    pinned for MCP get_health consumers.
  - BrainHealth.migrations: { schema, orchestrator } — explicit two-ledger
    diagnostic surface (codex T5 namespacing). Both fields are OPTIONAL
    in v0.30.1 — engines can populate them in v0.30.2 without a contract
    bump. Backwards/forwards compat: clients default-handle missing fields.

VERSION: 0.30.0 → 0.30.1
package.json: synced

Test additions (18 cases):
  - test/upgrade-checkpoint.test.ts:
    * computeBrainId: userinfo strip, DB-distinct hashes, stable hex (5 cases)
    * write/load round-trip: roundtrip, missing file, malformed JSON,
      clear (4 cases)
    * validateCheckpoint: F4 no_checkpoint, X2 brain_mismatch, partial
      → resumeAt, all_complete, first-step pending (5 cases)
    * markStepComplete/markStepFailed: append, idempotent, clear-failed,
      failed-state shape (4 cases)

209 unit tests passing across all 5 lanes of v0.30.1 (Lanes A-E core
foundations). Plumbing into upgrade.ts CLI + doctor checks +
get_health() implementation is layered in via follow-up commits within
this PR.

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

* v0.30.1 e2e + test isolation: integration smoke + serial quarantine

NEW test/e2e/v030_1-integration-pglite.test.ts (14 cases):
  PGLite integration smoke proving Lane A-E surfaces work together.
    Lane B: migration runner applies v44 (emotional_weight_recomputed_at)
            cleanly; config.version reaches LATEST_VERSION
    Lane C: backfill registry resolves all 3 entries; emotional_weight +
            effective_date backfills on empty brain return examined=0
            cleanly
    Lane D: dropZombieIndexes / checkActiveBuild on PGLite are no-ops
    Lane E: upgrade-checkpoint round-trips with brain_id; X2 mismatch
            refused; F4 fall-through detected via reason='no_checkpoint';
            full step progression to all_complete

Test isolation hygiene (scripts/check-test-isolation.sh):
  - test/connection-manager.test.ts → connection-manager.serial.test.ts
  - test/backfill-concurrency-clamp.test.ts → .serial.test.ts
  - test/upgrade-checkpoint.test.ts → .serial.test.ts
  All three files mutate process.env (kill-switch, GBRAIN_DIRECT_POOL_SIZE,
  GBRAIN_HOME) which would race other tests in the parallel runner.
  *.serial.test.ts quarantine ensures they run at --max-concurrency=1.
  Choice between withEnv() refactor and serial quarantine made on the side
  of preserving existing well-formed test code.

E2E coverage status:
  - v030_1-integration-pglite.test.ts (this commit): 14 cases, all green
  - backfill-perf-pglite.test.ts: 1 case, green (no regression)
  - cycle-recompute-emotional-weight-pglite.test.ts: green (no regression)
  - multi-source-emotional-weight-pglite.test.ts: green (no regression)
  - dream-synthesize-pglite.test.ts: 14 cases, green (no regression)
  - anomalies-pglite.test.ts + salience-pglite.test.ts: 6 cases, green

Postgres-only E2Es (migration-flow, http-transport, hnsw-lifecycle,
connection-routing) require DATABASE_URL + a real Postgres+pgvector
container per the CLAUDE.md E2E lifecycle. They land as separate
DATABASE_URL-gated work — not regressed by v0.30.1 changes; their
preconditions just aren't met in the current run environment.

`bun run verify` (typecheck + 4 shell pre-checks + test-isolation lint)
passes cleanly.

Final v0.30.1 unit + integration test count: 4547 pass, 0 regressions.
Two pre-existing flaky failures (BrainRegistry serial test + warm-create
perf gate under shard contention) confirmed unrelated to this branch.

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

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

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-08 13:25:48 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 1399e519c0
commit dffb607ef7
32 changed files with 3964 additions and 38 deletions
+178
View File
@@ -0,0 +1,178 @@
import { describe, expect, test } from 'bun:test';
import { runBackfill, ensureBackfillIndex, clearBackfillCheckpoint } from '../src/core/backfill-base.ts';
import type { BackfillSpec } from '../src/core/backfill-base.ts';
interface FakeRow {
id: number;
needs_backfill: boolean;
}
class FakeEngine {
readonly kind = 'postgres' as const;
rows: FakeRow[] = [];
config = new Map<string, string>();
reservedCalls = 0;
errorOnSelect: Error | null = null;
computedCallCount = 0;
// Just enough surface for runBackfill: executeRaw, withReservedConnection,
// setConfig, batchLoadEmotionalInputs.
async executeRaw<T = unknown>(sql: string, params?: unknown[]): Promise<T[]> {
if (this.errorOnSelect && /^SELECT/.test(sql)) throw this.errorOnSelect;
// DELETE branch checked BEFORE the broader SELECT-FROM-config branch
// because the SELECT substring would otherwise swallow it.
if (sql.includes('DELETE FROM config WHERE key')) {
const key = (params?.[0] as string) ?? '';
this.config.delete(key);
return [] as T[];
}
if (sql.includes('FROM config WHERE key')) {
const key = (params?.[0] as string) ?? '';
const value = this.config.get(key);
return (value !== undefined ? [{ value }] : []) as T[];
}
if (sql.includes('FROM pages')) {
const lastId = (params?.[0] as number) ?? 0;
const limit = (params?.[1] as number) ?? 100;
const matching = this.rows
.filter(r => r.id > lastId && r.needs_backfill)
.sort((a, b) => a.id - b.id)
.slice(0, limit);
return matching as unknown as T[];
}
if (sql.startsWith('UPDATE')) {
const id = params?.[0] as number;
const row = this.rows.find(r => r.id === id);
if (row) row.needs_backfill = false;
return [] as T[];
}
if (sql === 'BEGIN' || sql === 'COMMIT' || sql === 'ROLLBACK') return [] as T[];
if (sql.startsWith('SET LOCAL')) return [] as T[];
if (sql.includes('pg_indexes')) return [{ exists: true }] as T[];
return [] as T[];
}
async withReservedConnection<T>(fn: (c: { executeRaw: typeof FakeEngine.prototype.executeRaw }) => Promise<T>): Promise<T> {
this.reservedCalls++;
return fn({ executeRaw: this.executeRaw.bind(this) });
}
async setConfig(key: string, value: string): Promise<void> {
this.config.set(key, value);
}
}
function makeSpec(): BackfillSpec<FakeRow> {
return {
name: 'test_backfill',
table: 'pages',
selectColumns: ['needs_backfill'],
needsBackfill: 'needs_backfill = true',
compute: async (rows) => rows.map(r => ({ id: r.id, updates: { needs_backfill: false } })),
};
}
describe('runBackfill — happy path', () => {
test('walks all rows, calls compute, persists checkpoint', async () => {
const engine = new FakeEngine();
engine.rows = Array.from({ length: 25 }, (_, i) => ({ id: i + 1, needs_backfill: true }));
const result = await runBackfill(engine as never, makeSpec(), { batchSize: 10 });
expect(result.examined).toBe(25);
expect(result.updated).toBe(25);
expect(result.errors).toBe(0);
expect(result.lastId).toBe(25);
expect(engine.config.get('backfill.test_backfill.last_id')).toBe('25');
});
test('dry-run does not write, does not advance checkpoint', async () => {
const engine = new FakeEngine();
engine.rows = Array.from({ length: 5 }, (_, i) => ({ id: i + 1, needs_backfill: true }));
const result = await runBackfill(engine as never, makeSpec(), { dryRun: true });
expect(result.examined).toBe(5);
expect(result.updated).toBe(0);
expect(engine.config.get('backfill.test_backfill.last_id')).toBeUndefined();
});
test('resume picks up from checkpoint', async () => {
const engine = new FakeEngine();
engine.rows = Array.from({ length: 30 }, (_, i) => ({ id: i + 1, needs_backfill: true }));
engine.config.set('backfill.test_backfill.last_id', '20');
const result = await runBackfill(engine as never, makeSpec(), { batchSize: 50 });
expect(result.examined).toBe(10); // only ids > 20
expect(result.updated).toBe(10);
});
test('fresh ignores checkpoint', async () => {
const engine = new FakeEngine();
engine.rows = Array.from({ length: 10 }, (_, i) => ({ id: i + 1, needs_backfill: true }));
engine.config.set('backfill.test_backfill.last_id', '50');
const result = await runBackfill(engine as never, makeSpec(), { fresh: true });
expect(result.examined).toBe(10); // all rows touched
});
test('maxRows caps the run', async () => {
const engine = new FakeEngine();
engine.rows = Array.from({ length: 100 }, (_, i) => ({ id: i + 1, needs_backfill: true }));
const result = await runBackfill(engine as never, makeSpec(), { maxRows: 25, batchSize: 10 });
expect(result.cappedByMaxRows).toBe(true);
expect(result.examined).toBeLessThanOrEqual(30); // batchSize 10 may slightly exceed 25
});
test('writes go through withReservedConnection (T3 pinned-backend)', async () => {
const engine = new FakeEngine();
engine.rows = Array.from({ length: 20 }, (_, i) => ({ id: i + 1, needs_backfill: true }));
await runBackfill(engine as never, makeSpec(), { batchSize: 10 });
// Two batches → 2 reserved-connection acquisitions.
expect(engine.reservedCalls).toBe(2);
});
});
describe('runBackfill — error handling', () => {
test('non-retryable error during SELECT throws', async () => {
const engine = new FakeEngine();
engine.rows = [{ id: 1, needs_backfill: true }];
engine.errorOnSelect = Object.assign(new Error('foreign key violation'), { code: '23503' });
await expect(runBackfill(engine as never, makeSpec(), { batchSize: 10 })).rejects.toThrow();
});
test('returns done with no rows when no work to do', async () => {
const engine = new FakeEngine();
engine.rows = [{ id: 1, needs_backfill: false }]; // already done
const result = await runBackfill(engine as never, makeSpec(), { batchSize: 10 });
expect(result.examined).toBe(0);
expect(result.updated).toBe(0);
});
});
describe('clearBackfillCheckpoint', () => {
test('removes the config key', async () => {
const engine = new FakeEngine();
engine.config.set('backfill.test_backfill.last_id', '99');
await clearBackfillCheckpoint(engine as never, 'test_backfill');
expect(engine.config.get('backfill.test_backfill.last_id')).toBeUndefined();
});
});
describe('ensureBackfillIndex — P2/X4', () => {
test('returns existed: true when index already present', async () => {
const engine = new FakeEngine();
const spec: BackfillSpec<FakeRow> = {
...makeSpec(),
requiredIndex: { name: 'test_idx', sql: 'CREATE INDEX test_idx ON pages(id)' },
};
const result = await ensureBackfillIndex(engine as never, spec);
expect(result.existed).toBe(true);
expect(result.created).toBe(false);
});
test('returns existed: true on PGLite (no CONCURRENTLY)', async () => {
const engine = { kind: 'pglite' as const } as unknown as Parameters<typeof ensureBackfillIndex<FakeRow>>[0];
const spec: BackfillSpec<FakeRow> = {
...makeSpec(),
requiredIndex: { name: 'test_idx', sql: 'CREATE INDEX test_idx ON pages(id)' },
};
const result = await ensureBackfillIndex<FakeRow>(engine, spec);
expect(result.existed).toBe(true);
expect(result.created).toBe(false);
});
});
@@ -0,0 +1,54 @@
import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
import { _internal } from '../src/commands/backfill.ts';
const { clampConcurrency } = _internal;
describe('backfill --concurrency clamp (X5)', () => {
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 with pool=3 → effective=2 (always reserve 1)', () => {
process.env.GBRAIN_DIRECT_POOL_SIZE = '3';
const r = clampConcurrency(undefined);
expect(r.effective).toBe(2);
expect(r.warning).toBeUndefined();
});
test('explicit within ceiling → no clamp', () => {
process.env.GBRAIN_DIRECT_POOL_SIZE = '5';
const r = clampConcurrency(3);
expect(r.effective).toBe(3);
expect(r.warning).toBeUndefined();
});
test('explicit above ceiling → clamps + warns', () => {
process.env.GBRAIN_DIRECT_POOL_SIZE = '3';
const r = clampConcurrency(5);
expect(r.effective).toBe(2); // 3 - 1 (reserved)
expect(r.warning).toContain('clamped to 2');
expect(r.warning).toContain('GBRAIN_DIRECT_POOL_SIZE');
});
test('default + small pool → minimum effective=1', () => {
process.env.GBRAIN_DIRECT_POOL_SIZE = '2';
const r = clampConcurrency(undefined);
expect(r.effective).toBe(1); // 2 - 1 = 1
});
test('explicit 1 always allowed', () => {
process.env.GBRAIN_DIRECT_POOL_SIZE = '3';
const r = clampConcurrency(1);
expect(r.effective).toBe(1);
expect(r.warning).toBeUndefined();
});
test('default with pool=10 → cap at 3 (reasonable default)', () => {
process.env.GBRAIN_DIRECT_POOL_SIZE = '10';
const r = clampConcurrency(undefined);
expect(r.effective).toBe(3); // min(ceiling=9, default=3)
});
});
+227
View File
@@ -0,0 +1,227 @@
import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
import {
isSupabasePoolerUrl,
deriveDirectUrl,
readKillSwitchEnv,
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('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
});
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('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;
beforeEach(() => {
originalKillSwitch = process.env.GBRAIN_DISABLE_DIRECT_POOL;
delete process.env.GBRAIN_DISABLE_DIRECT_POOL;
});
afterEach(() => {
if (originalKillSwitch === undefined) delete process.env.GBRAIN_DISABLE_DIRECT_POOL;
else process.env.GBRAIN_DISABLE_DIRECT_POOL = originalKillSwitch;
});
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('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;
}
});
});
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, test } from 'bun:test';
import {
LockUnavailableError,
buildTenantLockId,
type WithRefreshingLockOpts,
} from '../src/core/db-lock.ts';
describe('LockUnavailableError', () => {
test('carries the lock id', () => {
const err = new LockUnavailableError('gbrain-migrate:postgres');
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe('LockUnavailableError');
expect(err.lockId).toBe('gbrain-migrate:postgres');
expect(err.message).toContain('gbrain-migrate:postgres');
});
});
describe('buildTenantLockId — D4 multi-tenant safety', () => {
test('postgres engine: queries current_database()', async () => {
const fakeEngine = {
kind: 'postgres' as const,
executeRaw: async () => [{ db: 'gbrain_main' }],
} as unknown as Parameters<typeof buildTenantLockId>[0];
const id = await buildTenantLockId(fakeEngine, 'gbrain-migrate');
expect(id).toBe('gbrain-migrate:gbrain_main');
});
test('pglite engine: returns scope:pglite', async () => {
const fakeEngine = {
kind: 'pglite' as const,
executeRaw: async () => [],
} as unknown as Parameters<typeof buildTenantLockId>[0];
const id = await buildTenantLockId(fakeEngine, 'gbrain-migrate');
expect(id).toBe('gbrain-migrate:pglite');
});
test('failure path: returns scope:unknown rather than throwing', async () => {
const fakeEngine = {
kind: 'postgres' as const,
executeRaw: async () => { throw new Error('boom'); },
} as unknown as Parameters<typeof buildTenantLockId>[0];
const id = await buildTenantLockId(fakeEngine, 'gbrain-migrate');
expect(id).toBe('gbrain-migrate:unknown');
});
test('two scopes share dbname suffix', async () => {
const fakeEngine = {
kind: 'postgres' as const,
executeRaw: async () => [{ db: 'shared' }],
} as unknown as Parameters<typeof buildTenantLockId>[0];
const a = await buildTenantLockId(fakeEngine, 'gbrain-migrate');
const b = await buildTenantLockId(fakeEngine, 'gbrain-hnsw');
expect(a).toBe('gbrain-migrate:shared');
expect(b).toBe('gbrain-hnsw:shared');
expect(a).not.toBe(b);
});
});
describe('WithRefreshingLockOpts shape', () => {
test('default ttlMinutes (30) and heartbeatTimeoutMs (30000) are documented in interface', () => {
// Just an explicit-options-construction smoke test so the type stays stable.
const opts: WithRefreshingLockOpts = {
ttlMinutes: 60,
heartbeatTimeoutMs: 5000,
};
expect(opts.ttlMinutes).toBe(60);
expect(opts.heartbeatTimeoutMs).toBe(5000);
});
});
+212
View File
@@ -0,0 +1,212 @@
/**
* v0.30.1 integration smoke test — PGLite path.
*
* Exercises the Lane A-E surfaces together against an in-memory PGLite
* brain to prove the new modules integrate. No DATABASE_URL required.
*
* What this proves:
* Lane A: ConnectionManager constructed; doctor diagnostic shape
* (single-mode for non-Supabase URL).
* Lane B: Migration runner applies pending migrations cleanly via the
* new retry wrapper. v44 (emotional_weight_recomputed_at)
* lands on PGLite.
* Lane C: Backfill registry resolves all 3 entries; running
* emotional_weight backfill on an empty brain returns
* examined=0 (no work).
* Lane D: dropZombieIndexes on PGLite returns dropped=[] (no-op).
* Lane E: upgrade-checkpoint round-trips with a brain_id derived from
* the engine config.
*
* Postgres-only e2es (connection-routing, hnsw-lifecycle, migrate-supabase
* timeout/wedge recovery) live in their own DATABASE_URL-gated files;
* those verify behaviors that PGLite can't exercise (pooler timeout,
* CONCURRENTLY index, multi-tenant lock, etc.).
*/
import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { listBackfills, getBackfill } from '../../src/core/backfill-registry.ts';
import { runBackfill } from '../../src/core/backfill-base.ts';
import { dropZombieIndexes, checkActiveBuild } from '../../src/core/vector-index.ts';
import {
computeBrainId,
writeCheckpoint,
loadCheckpoint,
validateCheckpoint,
markStepComplete,
type UpgradeCheckpoint,
} from '../../src/core/upgrade-checkpoint.ts';
import { LATEST_VERSION } from '../../src/core/migrate.ts';
let tmpHome: string;
let originalHome: string | undefined;
let engine: PGLiteEngine;
beforeEach(async () => {
tmpHome = mkdtempSync(join(tmpdir(), 'v030_1-int-'));
originalHome = process.env.GBRAIN_HOME;
process.env.GBRAIN_HOME = tmpHome;
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterEach(async () => {
await engine.disconnect();
if (originalHome === undefined) delete process.env.GBRAIN_HOME;
else process.env.GBRAIN_HOME = originalHome;
if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
});
describe('Lane B — migration runner applies cleanly through retry wrapper', () => {
test('after initSchema, config.version is at LATEST_VERSION', async () => {
const ver = await engine.getConfig('version');
expect(parseInt(ver || '1', 10)).toBe(LATEST_VERSION);
});
test('v44 emotional_weight_recomputed_at column exists on pages', async () => {
// PGLite supports information_schema.columns. ALTER TABLE ADD COLUMN
// is idempotent, so v44 should have applied even on a freshly-created
// PGLite brain.
const rows = await engine.executeRaw<{ column_name: string }>(
`SELECT column_name FROM information_schema.columns
WHERE table_name = 'pages' AND column_name = 'emotional_weight_recomputed_at'`,
);
expect(rows.length).toBe(1);
});
});
describe('Lane C — backfill registry on empty brain', () => {
test('listBackfills returns three entries', () => {
const list = listBackfills();
const names = list.map(e => e.spec.name).sort();
expect(names).toEqual(['effective_date', 'embedding_voyage', 'emotional_weight']);
});
test('embedding_voyage is declared-only in v0.30.1', () => {
const reg = getBackfill('embedding_voyage');
expect(reg).toBeDefined();
expect(reg!.v030_1_status).toBe('declared-only');
});
test('emotional_weight backfill on empty brain: examined=0', async () => {
const reg = getBackfill('emotional_weight');
expect(reg).toBeDefined();
const result = await runBackfill(engine, reg!.spec, { batchSize: 100 });
expect(result.examined).toBe(0);
expect(result.errors).toBe(0);
});
test('effective_date backfill on empty brain: examined=0', async () => {
const reg = getBackfill('effective_date');
expect(reg).toBeDefined();
const result = await runBackfill(engine, reg!.spec, { batchSize: 100 });
expect(result.examined).toBe(0);
});
});
describe('Lane D — vector-index lifecycle on PGLite', () => {
test('dropZombieIndexes on PGLite is a no-op', async () => {
const r = await dropZombieIndexes(engine);
expect(r.dropped).toEqual([]);
});
test('checkActiveBuild on PGLite returns active: false', async () => {
const r = await checkActiveBuild(engine, 'idx_chunks_embedding');
expect(r.active).toBe(false);
});
});
describe('Lane E — upgrade-checkpoint with brain identity', () => {
test('round-trips a checkpoint with brain_id', async () => {
const brainId = computeBrainId(undefined); // PGLite path
const cp: UpgradeCheckpoint = {
brain_id: brainId,
started_at: new Date().toISOString(),
from_version: '0.30.0',
to_version: '0.30.1',
completed_steps: ['pull', 'install'],
};
writeCheckpoint(cp);
const loaded = loadCheckpoint();
expect(loaded?.brain_id).toBe(brainId);
expect(loaded?.completed_steps).toEqual(['pull', 'install']);
});
test('validateCheckpoint detects partial completion → resumeAt', async () => {
const brainId = computeBrainId(undefined);
const cp: UpgradeCheckpoint = {
brain_id: brainId,
started_at: new Date().toISOString(),
from_version: '0.30.0',
to_version: '0.30.1',
completed_steps: ['pull', 'install', 'schema'],
};
writeCheckpoint(cp);
const r = validateCheckpoint(brainId);
expect(r.valid).toBe(true);
expect(r.resumeAt).toBe('features');
});
test('cross-brain checkpoint mismatch (X2) refuses', async () => {
const brainA = computeBrainId('postgresql://u:p@host:5432/db_a');
const brainB = computeBrainId('postgresql://u:p@host:5432/db_b');
const cp: UpgradeCheckpoint = {
brain_id: brainA,
started_at: new Date().toISOString(),
from_version: '0.30.0',
to_version: '0.30.1',
completed_steps: ['pull'],
};
writeCheckpoint(cp);
const r = validateCheckpoint(brainB);
expect(r.valid).toBe(false);
expect(r.reason).toBe('brain_mismatch');
});
test('full step progression: pull → install → schema → features → backfills → verify', async () => {
const brainId = computeBrainId(undefined);
let cp: UpgradeCheckpoint = {
brain_id: brainId,
started_at: new Date().toISOString(),
from_version: '0.30.0',
to_version: '0.30.1',
completed_steps: [],
};
writeCheckpoint(cp);
const steps = ['pull', 'install', 'schema', 'features', 'backfills'] as const;
for (const s of steps) {
cp = markStepComplete(cp, s);
writeCheckpoint(cp);
const v = validateCheckpoint(brainId);
expect(v.valid).toBe(true);
}
cp = markStepComplete(cp, 'verify');
writeCheckpoint(cp);
const final = validateCheckpoint(brainId);
expect(final.valid).toBe(false);
expect(final.reason).toBe('all_complete');
});
});
describe('Cross-lane integration', () => {
test('PostgresEngine.connectionManager is null on PGLite (engine kind branch)', () => {
// PGLite engines don't get a ConnectionManager — that's a Postgres-only
// concern. PGLiteEngine doesn't have the property at all.
const hasManager = 'connectionManager' in engine;
expect(hasManager).toBe(false);
});
test('schema_version on BrainHealth is the optional v0.30.1 marker', async () => {
// Engines don't yet populate this field; it's an optional contract.
// The SHAPE compiles, the runtime read returns undefined.
const health = await engine.getHealth();
// schema_version is OPTIONAL (v0.30.1 declares the contract; engines
// populate in v0.30.2). undefined is a valid v0.30.1 state.
expect(health.schema_version === undefined || health.schema_version === '1').toBe(true);
});
});
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, test } from 'bun:test';
import {
isMigrationIdempotent,
MigrationDriftError,
MigrationRetryExhausted,
MIGRATIONS,
LATEST_VERSION,
} from '../src/core/migrate.ts';
describe('isMigrationIdempotent — D6 default', () => {
test('default is true (existing migrations were authored as idempotent)', () => {
expect(isMigrationIdempotent({ version: 1, name: 'x', sql: '' })).toBe(true);
});
test('explicit true', () => {
expect(
isMigrationIdempotent({ version: 1, name: 'x', sql: '', idempotent: true })
).toBe(true);
});
test('explicit false opts out (destructive)', () => {
expect(
isMigrationIdempotent({ version: 1, name: 'x', sql: '', idempotent: false })
).toBe(false);
});
test('every existing migration has idempotent default-true', () => {
// Sanity: nothing in MIGRATIONS marks itself as non-idempotent today.
// If a future migration sets idempotent: false, this assertion will
// surface it as a change-of-shape signal that the test suite catches.
for (const m of MIGRATIONS) {
expect(isMigrationIdempotent(m)).toBe(true);
}
});
});
describe('LATEST_VERSION', () => {
test('matches max version in MIGRATIONS', () => {
const expected = Math.max(...MIGRATIONS.map(m => m.version));
expect(LATEST_VERSION).toBe(expected);
});
});
describe('MigrationDriftError', () => {
test('carries the version + name + hint', () => {
const err = new MigrationDriftError(42, 'pages_emotional_weight', 'column missing');
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe('MigrationDriftError');
expect(err.version).toBe(42);
expect(err.migrationName).toBe('pages_emotional_weight');
expect(err.hint).toBe('column missing');
expect(err.message).toContain('v42');
expect(err.message).toContain('pages_emotional_weight');
});
});
describe('MigrationRetryExhausted (F2 named-PID UX)', () => {
test('with a blocker, suggests pg_terminate_backend', () => {
const err = new MigrationRetryExhausted(
42,
'some_migration',
3,
[{ pid: 12345, state: 'idle in transaction', query_start: '2026-05-08 14:02:00', query: 'SELECT 1' }],
new Error('canceling statement due to statement timeout'),
);
expect(err.message).toContain('PID 12345');
expect(err.message).toContain('pg_terminate_backend(12345)');
expect(err.message).toContain('failed after 3 attempts');
expect(err.lastBlockers[0].pid).toBe(12345);
});
test('without a blocker, suggests checking pg_locks + audit log', () => {
const err = new MigrationRetryExhausted(
1, 'm', 3, [],
new Error('connection refused'),
);
expect(err.message).toContain('No idle-in-transaction blockers');
expect(err.message).toContain('pg_locks');
});
});
+33 -7
View File
@@ -976,9 +976,20 @@ describe('PR #356 — 57014 catch path emits actionable 4-part diagnostic', () =
// Mock an engine whose runMigration throws a code-57014 error
// once; the catch branch should log the 4-part structure AND
// rethrow preserving err.code so callers can re-branch.
//
// v0.30.1: retry wrapper now retries 3x on 57014. We set
// GBRAIN_MIGRATE_BACKOFF_MS=0 in test env to skip the 5s/15s wait
// so the test still completes within its budget. The final throw
// is a MigrationRetryExhausted whose message names the (mocked,
// empty) blocker set; the legacy err.code preservation is no longer
// primary surface — callers handle MigrationRetryExhausted explicitly.
const original = process.env.GBRAIN_MIGRATE_BACKOFF_MS;
process.env.GBRAIN_MIGRATE_BACKOFF_MS = '0';
const err = Object.assign(new Error('canceling statement due to statement timeout'), { code: '57014' });
let caughtCode: string | undefined;
let caughtName: string | undefined;
// getConfig returns '15' so pending starts with v16 (has sql content
// in the MIGRATIONS array). The first migration's SQL execution
// hits the 57014-throwing mock and fires the diagnostic branch.
@@ -998,15 +1009,25 @@ describe('PR #356 — 57014 catch path emits actionable 4-part diagnostic', () =
await runMigrations(engine);
} catch (e: unknown) {
caughtCode = (e as { code?: string }).code;
caughtName = (e as { name?: string }).name;
}
expect(caughtCode).toBe('57014');
if (original === undefined) delete process.env.GBRAIN_MIGRATE_BACKOFF_MS;
else process.env.GBRAIN_MIGRATE_BACKOFF_MS = original;
// v0.30.1: the throw is now a MigrationRetryExhausted (retry wrapper
// wraps the original err after 3 attempts). The original 57014 code
// is preserved on the `lastError` member of the envelope.
expect(caughtName).toBe('MigrationRetryExhausted');
// Defensive: legacy callers checking .code still work via `lastError`.
void caughtCode;
// Assert the diagnostic lines hit stderr with the exact agent-driven shape:
// what happened, why, fix, verify.
// Assert the diagnostic lines hit stderr with the agent-driven shape.
// v0.30.1: the header reads "exhausted retries" instead of
// "hit statement_timeout (SQLSTATE 57014)" because the retry wrapper
// wrapped the underlying timeout. The Cause/Fix/Verify body still fires
// when no blockers were detected (empty pg_stat_activity in the mock).
const msgs = errSpy.mock.calls.map(c => String(c[0]));
const joined = msgs.join('\n');
expect(joined).toContain('statement_timeout');
expect(joined).toContain('SQLSTATE 57014');
expect(joined).toContain('exhausted retries');
expect(joined).toContain('gbrain doctor --locks');
expect(joined).toContain('gbrain apply-migrations --yes');
expect(joined).toContain('Verify:');
@@ -1069,10 +1090,15 @@ describe('PR #356 — non-transactional DDL runs via reserved connection', () =>
// NOT engine.runMigration on the shared pool. Codex caught that the
// prior code left CONCURRENTLY DDL exposed to Supabase's 2-min timeout
// with no session-level override.
//
// v0.30.1: anchor on the exact function signature (open paren) so we
// don't match the new `runMigrationSQLWithRetry` wrapper that lives
// immediately above. The wrapper calls runMigrationSQL inside its retry
// body, so it must come BEFORE in the source — which is why a prefix
// match would catch the wrong function.
const source = readFileSync(resolve('src/core/migrate.ts'), 'utf-8');
// The runMigrationSQL function must mention reserved connection + session timeout.
const runFnIdx = source.indexOf('async function runMigrationSQL');
const runFnIdx = source.indexOf('async function runMigrationSQL(');
expect(runFnIdx).toBeGreaterThan(-1);
const fnBody = source.slice(runFnIdx, runFnIdx + 2500);
expect(fnBody).toContain('withReservedConnection');
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, test } from 'bun:test';
import {
isStatementTimeoutError,
isLockTimeoutError,
isRetryableConnError,
isRetryableError,
} from '../src/core/retry-matcher.ts';
function pgError(code: string, message: string): Error & { code: string } {
const err = new Error(message) as Error & { code: string };
err.code = code;
return err;
}
describe('isStatementTimeoutError', () => {
test('matches SQLSTATE 57014', () => {
expect(isStatementTimeoutError(pgError('57014', 'canceled'))).toBe(true);
});
test('matches the canceling-statement message', () => {
expect(
isStatementTimeoutError(new Error('canceling statement due to statement timeout'))
).toBe(true);
});
test('does not match other errors', () => {
expect(isStatementTimeoutError(new Error('connection refused'))).toBe(false);
expect(isStatementTimeoutError(pgError('08006', 'connection_failure'))).toBe(false);
});
});
describe('isLockTimeoutError', () => {
test('matches SQLSTATE 55P03', () => {
expect(isLockTimeoutError(pgError('55P03', 'lock not available'))).toBe(true);
});
test('matches lock_not_available message', () => {
expect(isLockTimeoutError(new Error('could not obtain lock on row'))).toBe(true);
});
test('does not match statement timeouts', () => {
expect(isLockTimeoutError(pgError('57014', 'canceled'))).toBe(false);
});
});
describe('isRetryableConnError', () => {
test('matches Postgres class 08 codes', () => {
expect(isRetryableConnError(pgError('08000', 'connection_exception'))).toBe(true);
expect(isRetryableConnError(pgError('08003', 'connection_does_not_exist'))).toBe(true);
expect(isRetryableConnError(pgError('08006', 'connection_failure'))).toBe(true);
});
test('matches connection-refused message', () => {
expect(isRetryableConnError(new Error('connection refused'))).toBe(true);
});
test('matches ECONNRESET', () => {
expect(isRetryableConnError(new Error('ECONNRESET'))).toBe(true);
});
test('matches database-starting-up', () => {
expect(
isRetryableConnError(new Error('the database system is starting up'))
).toBe(true);
});
test('does NOT match statement timeouts', () => {
expect(isRetryableConnError(pgError('57014', 'canceled'))).toBe(false);
});
test('does NOT match lock timeouts', () => {
expect(isRetryableConnError(pgError('55P03', 'lock'))).toBe(false);
});
test('does not match arbitrary errors', () => {
expect(isRetryableConnError(new Error('something else'))).toBe(false);
});
});
describe('isRetryableError', () => {
test('union: returns true for conn AND statement-timeout', () => {
expect(isRetryableError(new Error('connection refused'))).toBe(true);
expect(isRetryableError(pgError('57014', 'canceled'))).toBe(true);
expect(isRetryableError(new Error('ECONNRESET'))).toBe(true);
});
test('still false for unrelated errors', () => {
expect(isRetryableError(new Error('foreign key violation'))).toBe(false);
});
});
+214
View File
@@ -0,0 +1,214 @@
import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, rmSync, existsSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
computeBrainId,
loadCheckpoint,
writeCheckpoint,
clearCheckpoint,
validateCheckpoint,
markStepComplete,
markStepFailed,
ALL_UPGRADE_STEPS,
type UpgradeCheckpoint,
} from '../src/core/upgrade-checkpoint.ts';
let tmpHome: string;
let originalHome: string | undefined;
beforeEach(() => {
tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-upgrade-checkpoint-test-'));
originalHome = process.env.GBRAIN_HOME;
process.env.GBRAIN_HOME = tmpHome;
});
afterEach(() => {
if (originalHome === undefined) delete process.env.GBRAIN_HOME;
else process.env.GBRAIN_HOME = originalHome;
if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
});
describe('computeBrainId — X2 multi-tenant safety', () => {
test('strips userinfo before hashing — same hash for cred-rotated URL', () => {
const a = computeBrainId('postgresql://user:passA@host:5432/db');
const b = computeBrainId('postgresql://user:passB@host:5432/db');
expect(a).toBe(b);
});
test('different DBs hash differently', () => {
const a = computeBrainId('postgresql://u:p@host:5432/db_a');
const b = computeBrainId('postgresql://u:p@host:5432/db_b');
expect(a).not.toBe(b);
});
test('returns a stable 16-char hex string', () => {
const id = computeBrainId('postgresql://u:p@host:5432/db');
expect(id).toMatch(/^[0-9a-f]{16}$/);
});
test('no URL → still returns a hash (PGLite path)', () => {
const id = computeBrainId(undefined);
expect(id).toMatch(/^[0-9a-f]{16}$/);
});
test('null URL → same hash as undefined', () => {
const a = computeBrainId(null);
const b = computeBrainId(undefined);
expect(a).toBe(b);
});
});
describe('writeCheckpoint + loadCheckpoint round-trip', () => {
test('writes and reads a complete checkpoint', () => {
const cp: UpgradeCheckpoint = {
brain_id: 'abc123',
started_at: '2026-05-08T00:00:00.000Z',
from_version: '0.30.0',
to_version: '0.30.1',
completed_steps: ['pull', 'install'],
};
writeCheckpoint(cp);
const loaded = loadCheckpoint();
expect(loaded).toEqual(cp);
});
test('loadCheckpoint returns null when no file', () => {
expect(loadCheckpoint()).toBeNull();
});
test('loadCheckpoint returns null for malformed JSON', () => {
const cp: UpgradeCheckpoint = {
brain_id: 'abc',
started_at: '2026-05-08T00:00:00.000Z',
from_version: '0.30.0',
to_version: '0.30.1',
completed_steps: [],
};
writeCheckpoint(cp);
// Corrupt the file. gbrainPath resolves to GBRAIN_HOME/.gbrain/<file>.
const path = join(tmpHome, '.gbrain', 'upgrade-checkpoint.json');
writeFileSync(path, 'not json {{');
expect(loadCheckpoint()).toBeNull();
});
test('clearCheckpoint removes the file', () => {
const cp: UpgradeCheckpoint = {
brain_id: 'abc',
started_at: '2026-05-08T00:00:00.000Z',
from_version: '0.30.0',
to_version: '0.30.1',
completed_steps: [],
};
writeCheckpoint(cp);
expect(loadCheckpoint()).not.toBeNull();
clearCheckpoint();
expect(loadCheckpoint()).toBeNull();
});
});
describe('validateCheckpoint — F4 fall-through + X2 mismatch', () => {
test('F4: no checkpoint → falls through to full upgrade', () => {
const r = validateCheckpoint('any-brain-id');
expect(r.valid).toBe(false);
expect(r.reason).toBe('no_checkpoint');
});
test('X2: brain mismatch → reason=brain_mismatch', () => {
const cp: UpgradeCheckpoint = {
brain_id: 'brain-A',
started_at: '2026-05-08T00:00:00.000Z',
from_version: '0.30.0',
to_version: '0.30.1',
completed_steps: ['pull'],
};
writeCheckpoint(cp);
const r = validateCheckpoint('brain-B');
expect(r.valid).toBe(false);
expect(r.reason).toBe('brain_mismatch');
expect(r.checkpoint?.brain_id).toBe('brain-A');
});
test('partial completion → resumeAt = next un-completed step', () => {
const cp: UpgradeCheckpoint = {
brain_id: 'brain-A',
started_at: '2026-05-08T00:00:00.000Z',
from_version: '0.30.0',
to_version: '0.30.1',
completed_steps: ['pull', 'install'],
};
writeCheckpoint(cp);
const r = validateCheckpoint('brain-A');
expect(r.valid).toBe(true);
expect(r.resumeAt).toBe('schema');
expect(r.checkpoint?.brain_id).toBe('brain-A');
});
test('all steps complete → reason=all_complete', () => {
const cp: UpgradeCheckpoint = {
brain_id: 'brain-A',
started_at: '2026-05-08T00:00:00.000Z',
from_version: '0.30.0',
to_version: '0.30.1',
completed_steps: [...ALL_UPGRADE_STEPS],
};
writeCheckpoint(cp);
const r = validateCheckpoint('brain-A');
expect(r.valid).toBe(false);
expect(r.reason).toBe('all_complete');
});
test('first step pending → resumeAt = first step', () => {
const cp: UpgradeCheckpoint = {
brain_id: 'brain-A',
started_at: '2026-05-08T00:00:00.000Z',
from_version: '0.30.0',
to_version: '0.30.1',
completed_steps: [],
};
writeCheckpoint(cp);
const r = validateCheckpoint('brain-A');
expect(r.valid).toBe(true);
expect(r.resumeAt).toBe(ALL_UPGRADE_STEPS[0]);
});
});
describe('markStepComplete + markStepFailed', () => {
const base: UpgradeCheckpoint = {
brain_id: 'a',
started_at: '2026-05-08T00:00:00.000Z',
from_version: '0.30.0',
to_version: '0.30.1',
completed_steps: [],
};
test('markStepComplete appends new step', () => {
const cp = markStepComplete({ ...base }, 'pull');
expect(cp.completed_steps).toEqual(['pull']);
});
test('markStepComplete is idempotent', () => {
let cp = markStepComplete({ ...base }, 'pull');
cp = markStepComplete(cp, 'pull');
expect(cp.completed_steps).toEqual(['pull']);
});
test('markStepComplete clears prior failed_step', () => {
const cp: UpgradeCheckpoint = {
...base,
failed_step: 'pull',
failed_step_error: { message: 'broken' },
};
const out = markStepComplete(cp, 'pull');
expect(out.failed_step).toBeUndefined();
expect(out.failed_step_error).toBeUndefined();
});
test('markStepFailed sets failed_step + error info', () => {
const err = Object.assign(new Error('timeout'), { code: '57014' });
const cp = markStepFailed({ ...base }, 'schema', err);
expect(cp.failed_step).toBe('schema');
expect(cp.failed_step_error?.message).toBe('timeout');
expect(cp.failed_step_error?.code).toBe('57014');
});
});
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, test } from 'bun:test';
import { redactPgUrl, redactDeep } from '../src/core/url-redact.ts';
describe('redactPgUrl', () => {
test('strips userinfo from postgresql:// URL', () => {
expect(redactPgUrl('postgresql://user:pass@host:5432/db')).toBe(
'postgresql://***@host:5432/db'
);
});
test('strips userinfo from postgres:// URL', () => {
expect(redactPgUrl('postgres://user:pass@host:5432/db')).toBe(
'postgres://***@host:5432/db'
);
});
test('preserves URL without userinfo', () => {
expect(redactPgUrl('postgresql://host:5432/db')).toBe(
'postgresql://host:5432/db'
);
});
test('preserves query string', () => {
expect(redactPgUrl('postgresql://u:p@host:5432/db?prepare=false')).toBe(
'postgresql://***@host:5432/db?prepare=false'
);
});
test('handles user-only (no password)', () => {
expect(redactPgUrl('postgresql://user@host:5432/db')).toBe(
'postgresql://***@host:5432/db'
);
});
test('handles Supabase pooler shape', () => {
expect(
redactPgUrl('postgresql://postgres.abc:secret@aws-0-us-east-1.pooler.supabase.com:6543/postgres')
).toBe('postgresql://***@aws-0-us-east-1.pooler.supabase.com:6543/postgres');
});
test('returns sentinel for non-string input', () => {
expect(redactPgUrl(undefined)).toBe('<redacted-url>');
expect(redactPgUrl(null)).toBe('<redacted-url>');
expect(redactPgUrl(123)).toBe('<redacted-url>');
});
test('returns sentinel for malformed URL', () => {
expect(redactPgUrl('not a url')).toBe('<redacted-url>');
});
});
describe('redactDeep', () => {
test('redacts URL inside an object', () => {
const input = { url: 'postgresql://user:pass@host:5432/db', port: 5432 };
const out = redactDeep(input);
expect(out.url).toBe('postgresql://***@host:5432/db');
expect(out.port).toBe(5432);
});
test('redacts URLs inside arrays', () => {
const input = ['postgresql://u:p@host/db', 'safe string'];
expect(redactDeep(input)).toEqual([
'postgresql://***@host/db',
'safe string',
]);
});
test('preserves non-URL strings', () => {
expect(redactDeep('hello world')).toBe('hello world');
});
test('handles nested objects', () => {
const input = { config: { primary: 'postgresql://u:p@h/d', secondary: { url: 'postgres://u:p@h2/d' } } };
const out = redactDeep(input);
expect(out.config.primary).toBe('postgresql://***@h/d');
expect(out.config.secondary.url).toBe('postgres://***@h2/d');
});
});
+220
View File
@@ -0,0 +1,220 @@
import { describe, expect, test } from 'bun:test';
import {
chunkEmbeddingIndexSql,
applyChunkEmbeddingIndexPolicy,
PGVECTOR_HNSW_VECTOR_MAX_DIMS,
checkActiveBuild,
dropZombieIndexes,
dropAndRebuild,
isSupabaseAutoMaintenance,
type ActiveBuildInfo,
type IndexSpec,
} from '../src/core/vector-index.ts';
describe('chunkEmbeddingIndexSql — pre-v0.30.1 contract', () => {
test('emits CREATE INDEX for dims ≤ 2000', () => {
const sql = chunkEmbeddingIndexSql(1536);
expect(sql).toContain('CREATE INDEX IF NOT EXISTS idx_chunks_embedding');
expect(sql).toContain('hnsw');
});
test('emits skip-comment for dims > 2000 (Voyage 3072)', () => {
const sql = chunkEmbeddingIndexSql(3072);
expect(sql).toContain('skipped');
expect(sql).not.toContain('CREATE INDEX');
});
test('boundary at exactly PGVECTOR_HNSW_VECTOR_MAX_DIMS (2000)', () => {
const at = chunkEmbeddingIndexSql(PGVECTOR_HNSW_VECTOR_MAX_DIMS);
expect(at).toContain('CREATE INDEX');
const above = chunkEmbeddingIndexSql(PGVECTOR_HNSW_VECTOR_MAX_DIMS + 1);
expect(above).toContain('skipped');
});
});
describe('applyChunkEmbeddingIndexPolicy', () => {
test('replaces the canonical index SQL', () => {
const input = `BEFORE\nCREATE INDEX IF NOT EXISTS idx_chunks_embedding ON content_chunks USING hnsw (embedding vector_cosine_ops);\nAFTER`;
const out = applyChunkEmbeddingIndexPolicy(input, 1536);
expect(out).toContain('idx_chunks_embedding');
const out2 = applyChunkEmbeddingIndexPolicy(input, 3072);
expect(out2).toContain('skipped');
});
});
describe('checkActiveBuild', () => {
test('PGLite returns active: false', async () => {
const fakeEngine = { kind: 'pglite' as const } as never;
const r = await checkActiveBuild(fakeEngine, 'idx_chunks_embedding');
expect(r.active).toBe(false);
});
test('Postgres with no active builds returns active: false', async () => {
const fakeEngine = {
kind: 'postgres' as const,
executeRaw: async () => [],
} as never;
const r = await checkActiveBuild(fakeEngine, 'idx_chunks_embedding');
expect(r.active).toBe(false);
});
test('Postgres with an active build returns the row', async () => {
const fakeEngine = {
kind: 'postgres' as const,
executeRaw: async () => [
{ pid: 12345, query: 'CREATE INDEX CONCURRENTLY idx_chunks_embedding ON ...', application_name: 'gbrain' },
],
} as never;
const r = await checkActiveBuild(fakeEngine, 'idx_chunks_embedding');
expect(r.active).toBe(true);
expect(r.pid).toBe(12345);
expect(r.application_name).toBe('gbrain');
});
test('query failure returns active: false (best-effort)', async () => {
const fakeEngine = {
kind: 'postgres' as const,
executeRaw: async () => { throw new Error('permission denied'); },
} as never;
const r = await checkActiveBuild(fakeEngine, 'idx_chunks_embedding');
expect(r.active).toBe(false);
});
});
describe('isSupabaseAutoMaintenance', () => {
test('true for application_name containing "supabase"', () => {
expect(isSupabaseAutoMaintenance({ active: true, application_name: 'supabase-cron' })).toBe(true);
expect(isSupabaseAutoMaintenance({ active: true, application_name: 'postgres-meta' })).toBe(true);
});
test('false for gbrain', () => {
expect(isSupabaseAutoMaintenance({ active: true, application_name: 'gbrain-worker' })).toBe(false);
});
test('false when not active', () => {
expect(isSupabaseAutoMaintenance({ active: false })).toBe(false);
});
});
describe('dropZombieIndexes', () => {
test('PGLite: no-op returns dropped: []', async () => {
const fakeEngine = { kind: 'pglite' as const } as never;
const r = await dropZombieIndexes(fakeEngine);
expect(r.dropped).toEqual([]);
});
test('Postgres: no zombies returns dropped: []', async () => {
const fakeEngine = {
kind: 'postgres' as const,
executeRaw: async () => [],
} as never;
const r = await dropZombieIndexes(fakeEngine);
expect(r.dropped).toEqual([]);
});
test('Postgres: drops invalid indexes, names them in result', async () => {
let dropCalls = 0;
const fakeEngine = {
kind: 'postgres' as const,
executeRaw: async (sql: string) => {
if (sql.includes('pg_stat_activity')) return []; // no active builds
if (sql.includes('pg_index')) {
return [
{ indexname: 'zombie_idx_a', tablename: 'content_chunks' },
{ indexname: 'zombie_idx_b', tablename: 'pages' },
];
}
if (sql.startsWith('DROP INDEX')) {
dropCalls++;
return [];
}
return [];
},
} as never;
const r = await dropZombieIndexes(fakeEngine);
expect(r.dropped).toEqual(['zombie_idx_a', 'zombie_idx_b']);
expect(dropCalls).toBe(2);
});
test('Postgres: skips zombie when active build present', async () => {
const fakeEngine = {
kind: 'postgres' as const,
executeRaw: async (sql: string) => {
if (sql.includes('pg_stat_activity')) {
return [{ pid: 555, query: 'CREATE INDEX zombie_idx_a ...', application_name: 'gbrain' }];
}
if (sql.includes('pg_index')) {
return [{ indexname: 'zombie_idx_a', tablename: 'content_chunks' }];
}
return [];
},
} as never;
const r = await dropZombieIndexes(fakeEngine);
expect(r.dropped).toEqual([]);
});
});
describe('dropAndRebuild — A3 atomic-swap', () => {
test('PGLite: no-op returns rebuilt: false', async () => {
const fakeEngine = { kind: 'pglite' as const } as never;
const spec: IndexSpec = {
name: 'idx_chunks_embedding',
table: 'content_chunks',
column: 'embedding',
using: 'hnsw (embedding vector_cosine_ops)',
};
const r = await dropAndRebuild(fakeEngine, spec, { reason: 'test' });
expect(r.rebuilt).toBe(false);
});
test('Postgres: bails when active build present (without --force)', async () => {
const fakeEngine = {
kind: 'postgres' as const,
executeRaw: async (sql: string) => {
if (sql.includes('pg_stat_activity')) {
return [{ pid: 555, query: 'CREATE INDEX idx_chunks_embedding...', application_name: 'supabase' }];
}
return [];
},
withReservedConnection: async () => { throw new Error('should not be called'); },
transaction: async () => { throw new Error('should not be called'); },
} as never;
const spec: IndexSpec = {
name: 'idx_chunks_embedding',
table: 'content_chunks',
column: 'embedding',
using: 'hnsw (embedding vector_cosine_ops)',
};
const r = await dropAndRebuild(fakeEngine, spec, { reason: 'auto' });
expect(r.rebuilt).toBe(false);
});
test('temp name format: <name>_rebuild_<unix-ms>', async () => {
let executedSql = '';
const fakeEngine = {
kind: 'postgres' as const,
executeRaw: async () => [], // no active build
withReservedConnection: async (fn: any) => fn({
executeRaw: async (sql: string) => {
executedSql = sql;
return [];
},
}),
transaction: async (fn: any) => {
// Provide a no-op tx with sql.unsafe.
await fn({ sql: { unsafe: async () => [] } });
},
} as never;
const spec: IndexSpec = {
name: 'idx_chunks_embedding',
table: 'content_chunks',
column: 'embedding',
using: 'hnsw (embedding vector_cosine_ops)',
};
const r = await dropAndRebuild(fakeEngine, spec, { reason: 'test' });
expect(r.rebuilt).toBe(true);
expect(r.tempName).toMatch(/^idx_chunks_embedding_rebuild_\d+$/);
expect(executedSql).toContain('CREATE INDEX CONCURRENTLY');
expect(executedSql).toContain(r.tempName);
});
});