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
parent 1399e519c0
commit dffb607ef7
32 changed files with 3964 additions and 38 deletions

View File

@@ -2,6 +2,73 @@
All notable changes to GBrain will be documented in this file.
## [0.30.1] - 2026-05-08
**Operational hardening: gbrain upgrade just works on Supabase. DDL stops timing out on the pooler. Migrations stop wedging. HNSW rebuilds stop nuking your search. Backfills stop being bespoke scripts.**
Twelve releases in a weekend taught us where the cracks are: every Supabase upgrade required Garry at the keyboard. Statement timeouts on the pooler. Wedged migrations after three partial runs. 3.5-hour HNSW rebuilds. The v0.27 → v0.29.1 walk through 12 versions made the operational story unshippable. v0.30.1 fixes the substrate.
### What you can now do
**`gbrain backfill <kind>`** — first-class bulk operations. Three registered backfills (`effective_date`, `emotional_weight`, `embedding_voyage`) with keyset pagination, automatic checkpoint persistence in the `config` table, adaptive batch halving on statement timeout, connection-drop reconnect, and pinned-backend semantics so `SET LOCAL statement_timeout` actually persists across the BEGIN/UPDATE/COMMIT cycle. Run `gbrain backfill list` to see what's registered.
```bash
gbrain backfill effective_date
gbrain backfill emotional_weight --concurrency 2
gbrain backfill list
```
**`gbrain apply-migrations --force-schema` / `--force-orchestrator` / `--force`** — namespaced wedge recovery. `--force-retry vX.Y.Z` still resets a single orchestrator wedge. New flags reset whole ledgers in one shot. `--force-schema` re-runs `runMigrations()` against the current `config.version`. `--force` does both.
**`gbrain doctor` zombie HNSW sweep** — on every engine connect, drops `indisvalid=false` indexes left over from crashed `CREATE INDEX CONCURRENTLY` calls. Guarded against in-progress builds via `pg_stat_activity` so it can't compete with Supabase auto-maintenance.
### How it works under the hood
**Connection routing**`ConnectionManager` auto-detects Supabase via hostname `pooler.supabase.com` or port 6543. `read()` goes to the pooler (fast, 10 conns); `ddl()` and `bulk()` go to a direct connection (port 5432, 30-min statement_timeout, capped at 3 conns, `maintenance_work_mem='256MB'`). Override the direct URL with `GBRAIN_DIRECT_DATABASE_URL`. Disable the split with `GBRAIN_DISABLE_DIRECT_POOL=1` (kill-switch, falls back to single-pool legacy). Worker engines (cycle, sync) inherit kill-switch state from their parent ConnectionManager.
**Migration retry + verify hooks** — every migration retries 3 times on statement_timeout (5s/15s/45s backoff), with `getIdleBlockers()` logged before each retry. On retry exhaustion, the error envelope names the most recent blocker by PID and prints the `pg_terminate_backend(<pid>)` recovery command. `Migration.verify` lets a migration declare a post-condition probe; if verify returns false on an idempotent migration, the runner re-runs it once. On non-idempotent migrations, `MigrationDriftError` requires `--skip-verify` to force.
**HNSW atomic-swap rebuild**`dropAndRebuild` builds a new index with a temp name, swaps atomically via `DROP INDEX old; ALTER INDEX temp RENAME TO old`. If the rebuild fails, the old index is intact and search keeps serving queries. No more "production-degraded silent failure" mode.
**Upgrade-checkpoint primitive** — every step of `gbrain post-upgrade` (pull → install → schema → features → backfills → verify) writes to `~/.gbrain/upgrade-checkpoint.json` with a brain-identity hash (`sha256(database_url)`). Cross-brain checkpoint application is refused. Foundation for `gbrain upgrade --resume` (the CLI plumbing lands in v0.30.2).
### For contributors
40 substantive scope decisions across 4 review rounds (CEO + plan-eng + 2 codex outside-voice passes) before any code shipped. The plan file at `.context/v0.30.1-plan.md` documents the complete decision history including codex's 16 findings (4 plan-breaking, 8 spec-tightening, 4 minor) — every one adopted as a plan correction before implementation began. See the per-lane commits for the bisect-friendly trail:
- Lane A: connection-manager foundation + X1 initSchema routing (1237 LOC)
- Lane B: migration runner retry + verify hooks + namespaced --force flags (583 LOC)
- Lane C: backfill primitive + registry + X4 + X5 (999 LOC)
- Lane D: HNSW lifecycle manager + A3 atomic-swap (462 LOC)
- Lane E: upgrade pipeline checkpoint + brain_id binding + get_health migrations (405 LOC)
- E2E + serial quarantine: integration smoke + test-isolation hygiene (212 LOC)
Test count grew by 146 (132 unit + 14 integration). Three test files quarantined to `*.serial.test.ts` because they mutate `process.env`.
### Out of scope (deferred to v0.30.2)
- `gbrain upgrade --resume` / `--status` CLI flags (substrate shipped + tested; CLI plumbing follows)
- Three new doctor checks: `connection_routing`, `migration_wedge`, `hnsw_health` (substrate shipped; doctor wiring follows)
- `BrainEngine.getHealth()` populating the new `migrations: {schema, orchestrator}` field (type contract committed; engine impls land alongside multi-column embedding)
- Multi-column embedding schema migration (`embedding_voyage vector(1024)`) — backfill primitive already plumbs `--column` flag
### To take advantage of v0.30.1
`gbrain upgrade` will pull and run schema migrations automatically. v44 (`pages_emotional_weight_recomputed_at` column) lands as a metadata-only ALTER (instant on tables of any size).
If you're on Supabase and want the dual-pool routing immediately:
```bash
# Optional explicit override (otherwise auto-derived from your pooler URL):
export GBRAIN_DIRECT_DATABASE_URL="postgresql://postgres.<ref>:<password>@db.<ref>.supabase.co:5432/postgres"
gbrain doctor # verifies connection-manager mode
```
If `gbrain upgrade` ever wedges:
```bash
gbrain apply-migrations --force # resets every wedged ledger
gbrain doctor # confirms schema_version + zombie sweep
```
## [0.30.0] - 2026-05-07
**Calibration scorecards land. Find out if your bets are actually as good as you think.**

View File

@@ -1 +1 @@
0.30.0
0.30.1

View File

@@ -1,6 +1,6 @@
{
"name": "gbrain",
"version": "0.30.0",
"version": "0.30.1",
"description": "Postgres-native personal knowledge brain with hybrid RAG search",
"type": "module",
"main": "src/core/index.ts",

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# CI grep guard (v0.30.1, finding F3): no source file under src/ may emit
# a postgresql:// URL with userinfo to a logging surface.
#
# Specifically we forbid string literals or template substitutions that
# look like `postgresql://user:pass@host` being passed to:
# - console.log / .warn / .error
# - process.stderr.write / process.stdout.write
# - appendFileSync / writeFileSync (audit JSONL writes)
# - new logging APIs that may show up later (the regex matches the URL,
# not the consumer; any leak will trip)
#
# Wired into bun run check:all and bun run verify.
#
# Exit codes: 0 = clean, 1 = found at least one suspect line.
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/.." && pwd)
# False-positive allow-list: lines we know are safe.
# - The redactor itself: src/core/url-redact.ts
# - Test fixtures that build redacted strings from full URLs
# - Documentation comments referring to the pattern
ALLOW_REGEX='url-redact\.ts|test/url-redact\.test\.ts|/\* allow-pg-url-literal \*/'
# The pattern matches an unredacted Postgres URL appearing in a string
# literal, NOT preceded by `redactPgUrl(` or `***@`. We also match any
# URL containing `[^*]@` (i.e. the `***@` redacted form passes).
PATTERN='postgres(ql)?://[^@*"`]+@'
# Search src/ only — tests are excluded since they intentionally construct
# unredacted URLs as input fixtures.
HITS=$(grep -rEn "$PATTERN" "$ROOT/src" 2>/dev/null || true)
if [ -z "$HITS" ]; then
exit 0
fi
# Filter against the allow-list.
FILTERED=$(echo "$HITS" | grep -vE "$ALLOW_REGEX" || true)
if [ -z "$FILTERED" ]; then
exit 0
fi
echo "ERROR: unredacted postgres:// URL found in source. Use redactPgUrl() before logging."
echo ""
echo "$FILTERED"
echo ""
echo "Allowed exemption: append \"/* allow-pg-url-literal */\" comment on the line"
echo "(only for fixtures and the redactor itself)."
exit 1

View File

@@ -755,10 +755,20 @@ async function handleCliOnly(command: string, args: string[]) {
// src/core/backfill-effective-date.ts (same code path the v0.29.1
// migration orchestrator uses). The orchestrator runs once on
// upgrade; this command is for after-the-fact frontmatter edits.
//
// v0.30.1: still works; canonical entrypoint is now `gbrain backfill
// effective_date`. This command stays as a thin alias for back-compat.
const { reindexFrontmatterCli } = await import('./commands/reindex-frontmatter.ts');
await reindexFrontmatterCli(args);
return; // reindexFrontmatterCli handles its own engine lifecycle
}
case 'backfill': {
// v0.30.1: first-class generic backfill command. Subcommand dispatch
// is inside runBackfillCommand (kind | list | --help).
const { runBackfillCommand } = await import('./commands/backfill.ts');
await runBackfillCommand(args);
return;
}
case 'code-callers': {
// v0.20.0 Cathedral II Layer 10 (C4): "who calls <symbol>?"
const { runCodeCallers } = await import('./commands/code-callers.ts');
@@ -807,7 +817,7 @@ function buildGatewayConfig(c: GBrainConfig): AIGatewayConfig {
};
}
async function connectEngine(): Promise<BrainEngine> {
async function connectEngine(opts?: { probeOnly?: boolean }): Promise<BrainEngine> {
const config = loadConfig();
if (!config) {
console.error('No brain configured. Run: gbrain init');
@@ -826,6 +836,14 @@ async function connectEngine(): Promise<BrainEngine> {
const { connectWithRetry } = await import('./core/db.ts');
await connectWithRetry(engine, toEngineConfig(config), { noRetry });
// v0.30.1 (Codex X1 / C2): probeOnly skips both hasPendingMigrations() probe
// AND initSchema(). Used by `get_health` MCP op + `gbrain upgrade --status`
// + doctor's migration_wedge check — these surfaces report wedge state and
// must NEVER themselves start or block on migrations.
if (opts?.probeOnly === true) {
return engine;
}
// Auto-apply pending schema migrations on connect (#651). Cheap probe
// first so already-migrated brains don't pay the bootstrap-probe +
// SCHEMA_SQL replay + ledger-check cost on every short-lived CLI call.

View File

@@ -31,6 +31,17 @@ interface ApplyMigrationsArgs {
noAutopilotInstall: boolean;
/** Bug 3 — explicit reset for a wedged migration. Writes a 'retry' marker. */
forceRetry?: string;
/**
* v0.30.1 namespaced --force flags (codex T5):
* --force-orchestrator: write 'retry' markers for ALL wedged orchestrator migrations
* --force-schema: reset schema-version drift (re-run runMigrations)
* --force-all: both
*/
forceOrchestrator?: boolean;
forceSchema?: boolean;
forceAll?: boolean;
/** v0.30.1 (D6 / X3): bypass verify-hook drift detection on a single run. */
skipVerify?: boolean;
help: boolean;
}
@@ -55,6 +66,10 @@ function parseArgs(args: string[]): ApplyMigrationsArgs {
hostDir: val('--host-dir'),
noAutopilotInstall: has('--no-autopilot-install'),
forceRetry: val('--force-retry'),
forceOrchestrator: has('--force-orchestrator'),
forceSchema: has('--force-schema'),
forceAll: has('--force-all') || has('--force'),
skipVerify: has('--skip-verify'),
help: has('--help') || has('-h'),
};
}
@@ -73,6 +88,16 @@ Usage:
Clear a wedged migration (3+ consecutive
partials). Writes a 'retry' marker so the
next run treats it as fresh.
gbrain apply-migrations --force-orchestrator
Reset every wedged orchestrator migration
in one shot (writes 'retry' for each).
gbrain apply-migrations --force-schema
Reset schema-version drift; re-runs
runMigrations from current config.version.
gbrain apply-migrations --force (alias --force-all) Apply both
--force-orchestrator and --force-schema.
gbrain apply-migrations --skip-verify Bypass post-condition verify hooks on
non-idempotent migrations (D6 escape hatch).
Flags:
--mode <always|pain_triggered|off> Set minion_mode without prompting.
@@ -278,6 +303,57 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
return;
}
// v0.30.1 (codex T5): --force-orchestrator OR --force-all writes a 'retry'
// marker for EVERY wedged orchestrator migration in one shot. User re-runs
// `gbrain apply-migrations --yes` to actually re-attempt.
if (cli.forceOrchestrator || cli.forceAll) {
const completed = loadCompletedMigrations();
const idx = indexCompleted(completed);
let resetCount = 0;
for (const m of migrations) {
const status = statusForVersion(m.version, idx);
if (status === 'wedged') {
appendCompletedMigration({ version: m.version, status: 'retry' });
console.log(`Wrote 'retry' marker for v${m.version} (${m.featurePitch.headline.slice(0, 60)})`);
resetCount++;
}
}
if (resetCount === 0) {
console.log('No wedged orchestrator migrations found.');
} else {
console.log(`\nReset ${resetCount} wedged orchestrator migration(s). Run \`gbrain apply-migrations --yes\` to re-attempt.`);
}
if (!cli.forceAll) return; // --force-schema continues below if --force-all is set
}
// v0.30.1 (codex T5): --force-schema OR --force-all resets schema-version
// drift by re-running runMigrations(). When the actual DDL state diverges
// from config.version (the brain_config incident), this is the manual
// recovery path.
if (cli.forceSchema || cli.forceAll) {
try {
const { runMigrations } = await import('../core/migrate.ts');
const { loadConfig: lc, toEngineConfig } = await import('../core/config.ts');
const { createEngine } = await import('../core/engine-factory.ts');
const cfg = lc();
if (!cfg) {
console.error('No brain configured for --force-schema.');
process.exit(2);
}
const eng = await createEngine(toEngineConfig(cfg));
await eng.connect(toEngineConfig(cfg));
console.log('Running schema migrations from current config.version...');
const result = await runMigrations(eng);
console.log(`Applied ${result.applied} schema migration(s); now at v${result.current}.`);
await eng.disconnect();
} catch (err) {
console.error(`--force-schema failed: ${(err as Error).message}`);
process.exit(1);
}
if (cli.forceSchema && !cli.forceAll) return;
if (cli.forceAll) return; // both surfaces flushed
}
// Pre-flight: warn if schema migrations (migrate.ts) are behind.
// apply-migrations runs orchestrator migrations only; schema migrations
// run via connectEngine() / initSchema(). Users often expect this CLI

199
src/commands/backfill.ts Normal file
View File

@@ -0,0 +1,199 @@
/**
* gbrain backfill — first-class bulk operations (v0.30.1 Fix 3).
*
* Generalizes the keyset+checkpoint pattern from backfill-effective-date.ts
* so future backfills (embedding_voyage in v0.30.2, etc.) reuse one tested
* runner instead of cloning the SQL. T3 fixes the SET LOCAL evaporation
* bug by routing writes through withReservedConnection. P2/X4 corrects
* the emotional_weight predicate via a new recomputed_at column.
*
* Usage:
* gbrain backfill <kind> [--batch-size N] [--concurrency N] [--resume]
* [--dry-run] [--keep-index] [--max-errors N]
* gbrain backfill list
*
* X5: --concurrency clamps to GBRAIN_DIRECT_POOL_SIZE - 1 with a warning,
* always reserving 1 connection for HNSW + heartbeat + doctor probes.
*/
import { resolveDirectPoolSize } from '../core/connection-manager.ts';
import { listBackfills, getBackfill } from '../core/backfill-registry.ts';
import { runBackfill, clearBackfillCheckpoint } from '../core/backfill-base.ts';
import { loadConfig, toEngineConfig } from '../core/config.ts';
interface BackfillArgs {
kind?: string;
list?: boolean;
batchSize?: number;
concurrency?: number;
resume?: boolean;
dryRun?: boolean;
keepIndex?: boolean;
maxErrors?: number;
fresh?: boolean;
help?: boolean;
}
function parseArgs(args: string[]): BackfillArgs {
const has = (flag: string) => args.includes(flag);
const val = (flag: string): string | undefined => {
const i = args.indexOf(flag);
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
};
const num = (flag: string): number | undefined => {
const v = val(flag);
if (!v) return undefined;
const n = parseInt(v, 10);
return Number.isFinite(n) && n > 0 ? n : undefined;
};
// First non-flag positional becomes the kind / list.
const positional: string[] = [];
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a.startsWith('--')) {
// Skip the value when the flag takes one.
if (['--batch-size', '--concurrency', '--max-errors'].includes(a)) i++;
continue;
}
positional.push(a);
}
const kind = positional[0];
return {
kind: kind === 'list' ? undefined : kind,
list: kind === 'list' || has('--list'),
batchSize: num('--batch-size'),
concurrency: num('--concurrency'),
resume: has('--resume'),
dryRun: has('--dry-run'),
keepIndex: has('--keep-index'),
maxErrors: num('--max-errors'),
fresh: has('--fresh'),
help: has('--help') || has('-h'),
};
}
function printHelp(): void {
console.log(`gbrain backfill — first-class bulk operations.
Usage:
gbrain backfill <kind> [flags] Run a registered backfill.
gbrain backfill list Show registered backfills + checkpoints.
Backfills (v0.30.1):
effective_date Compute effective_date for pages imported pre-v0.29.1.
emotional_weight Recompute emotional_weight for pages with stale stamp.
embedding_voyage Declared-only in v0.30.1 (multi-column embedding lands
in v0.30.2 alongside the schema migration).
Flags:
--batch-size N Initial batch size before adaptive halving (default 1000).
--concurrency N Parallel batches; clamped to GBRAIN_DIRECT_POOL_SIZE - 1
(default 3 - 1 = 2). Always reserves 1 conn for HNSW +
heartbeat + doctor probes.
--resume Pick up from last checkpoint (auto-detected; default on).
--fresh Restart from id=0, ignoring checkpoint.
--dry-run Report what WOULD happen; no writes.
--keep-index Skip HNSW drop-rebuild for embedding backfills.
--max-errors N Bail after N total errors (default 200).
`);
}
function clampConcurrency(requested: number | undefined): { effective: number; warning?: string } {
const poolSize = resolveDirectPoolSize();
// Always reserve 1 conn for HNSW + heartbeat + doctor.
const ceiling = Math.max(1, poolSize - 1);
if (requested === undefined) {
return { effective: Math.min(ceiling, 3) };
}
if (requested > ceiling) {
return {
effective: ceiling,
warning: `[backfill] --concurrency ${requested} clamped to ${ceiling} (pool size ${poolSize}, reserved 1 for HNSW/heartbeat). Bump GBRAIN_DIRECT_POOL_SIZE if you need more concurrency.`,
};
}
return { effective: requested };
}
export async function runBackfillCommand(args: string[]): Promise<void> {
const cli = parseArgs(args);
if (cli.help) { printHelp(); return; }
if (cli.list) {
const entries = listBackfills();
console.log(`Registered backfills (v0.30.1):\n`);
for (const e of entries) {
const status = e.v030_1_status === 'implemented' ? '✓' : '⊘';
console.log(` ${status} ${e.spec.name.padEnd(20)} ${e.description}`);
}
console.log('');
return;
}
if (!cli.kind) {
console.error('Usage: gbrain backfill <kind> [flags] | gbrain backfill list');
process.exit(2);
}
const reg = getBackfill(cli.kind);
if (!reg) {
console.error(`No backfill registered with name "${cli.kind}". Run \`gbrain backfill list\`.`);
process.exit(2);
}
if (reg.v030_1_status === 'declared-only') {
console.error(`Backfill "${cli.kind}" is declared-only in v0.30.1 — the schema migration ships in v0.30.2.`);
process.exit(2);
}
const config = loadConfig();
if (!config) {
console.error('No brain configured. Run: gbrain init');
process.exit(2);
}
// X5 admission control — clamp concurrency to direct-pool capacity.
const { effective: concurrency, warning } = clampConcurrency(cli.concurrency);
if (warning) console.warn(warning);
const { createEngine } = await import('../core/engine-factory.ts');
const engine = await createEngine(toEngineConfig(config));
await engine.connect(toEngineConfig(config));
if (cli.fresh) {
await clearBackfillCheckpoint(engine, reg.spec.name);
console.log(`Cleared checkpoint for backfill.${reg.spec.name}`);
}
console.log(`Running backfill: ${reg.spec.name}${cli.dryRun ? ' (dry-run)' : ''}`);
console.log(` batch_size=${cli.batchSize ?? 1000} concurrency=${concurrency} max_errors=${cli.maxErrors ?? 200}`);
let lastReport = Date.now();
const result = await runBackfill(engine, reg.spec, {
maxRows: undefined,
batchSize: cli.batchSize,
fresh: cli.fresh === true,
dryRun: cli.dryRun === true,
maxErrors: cli.maxErrors,
onBatch: (info) => {
const now = Date.now();
if (now - lastReport > 2000) {
console.log(` batch ${info.batch}: cumulative=${info.cumulative} lastId=${info.lastId} errors=${info.errorsSeen} effectiveBatchSize=${info.effectiveBatchSize}`);
lastReport = now;
}
},
});
console.log('');
console.log(`Backfill ${reg.spec.name} complete.`);
console.log(` examined: ${result.examined}`);
console.log(` updated: ${result.updated}`);
console.log(` errors: ${result.errors}`);
console.log(` lastId: ${result.lastId}`);
console.log(` duration: ${result.durationSec.toFixed(2)}s`);
if (result.cappedByMaxRows) console.log(` ⚠️ Capped by --max-rows; more remain.`);
if (result.cappedByErrors) console.log(` ⚠️ Capped by --max-errors at ${result.errors}.`);
await engine.disconnect();
if (result.cappedByErrors) process.exit(1);
}
export const _internal = { clampConcurrency, parseArgs };

View File

@@ -612,7 +612,7 @@ async function supabaseWizard(): Promise<string> {
}
console.log('\nEnter your Supabase/Postgres connection URL:');
console.log(' Format: postgresql://postgres.[ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres');
console.log(' Format: postgresql://postgres.[ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres'); /* allow-pg-url-literal */
console.log(' Find it: Supabase Dashboard > Connect (top bar) > Connection String > Session Pooler\n');
const url = await readLine('Connection URL: ');

332
src/core/backfill-base.ts Normal file
View File

@@ -0,0 +1,332 @@
/**
* Generic backfill runner — v0.30.1 (Fix 3).
*
* Generalizes the keyset+checkpoint+adaptive-batch pattern from
* src/core/backfill-effective-date.ts so future backfills (embedding_voyage,
* emotional_weight, etc.) reuse the proven pieces instead of cloning them.
*
* Codex T3 correction: writes go through engine.withReservedConnection so
* BEGIN / SET LOCAL / UPDATE / COMMIT execute on the SAME backend. With
* pooled engine.executeRaw, SET LOCAL evaporates between calls because
* the next call can land on a different connection. Pinned backend +
* SET LOCAL inside the same txn gives durable per-batch timeout semantics.
*
* Codex P2 / X4: backfills declare an optional `requiredIndex` (partial
* index on the predicate column). On first run, the runner verifies the
* index exists and creates it CONCURRENTLY if missing.
*/
import type { BrainEngine } from './engine.ts';
import { isStatementTimeoutError, isRetryableConnError } from './retry-matcher.ts';
export interface BackfillSpec<TRow = Record<string, unknown>> {
/** Stable identifier — used in checkpoint key + CLI dispatch. */
name: string;
/** Postgres table name (used in keyset query). */
table: string;
/**
* Primary-key column name. Keyset pagination uses `WHERE id > $lastId
* ORDER BY id LIMIT $batchSize` — column name controls both ORDER BY
* and the comparison. Defaults to 'id'.
*/
idColumn?: string;
/**
* Columns to select for `compute()`. The id column is always included.
*/
selectColumns: string[];
/**
* SQL fragment for `WHERE` (without `WHERE`). Names the un-backfilled rows.
* E.g. "effective_date IS NULL" or "embedding_voyage IS NULL".
*/
needsBackfill: string;
/**
* Compute updates for a batch of rows. Returns one entry per row that
* needs updating; rows not present in the result are unchanged.
*/
compute: (rows: TRow[], engine: BrainEngine) => Promise<Array<{ id: number; updates: Record<string, unknown> }>>;
/**
* Optional partial-index requirement (P2 / X4). Runner verifies/creates
* the index CONCURRENTLY on first run. Skipped on PGLite (no CONCURRENTLY).
*/
requiredIndex?: { name: string; sql: string };
/** Estimate of rows-per-second for ETA reporting. Pure-display. */
estimateRowsPerSecond?: number;
}
export interface BackfillRunOpts {
/** Hard cap on total rows touched (testing). Undefined = no cap. */
maxRows?: number;
/** Initial batch size before adaptive halving. Default 1000. */
batchSize?: number;
/** Skip checkpoint, restart from id=0. Default false. */
fresh?: boolean;
/** Don't write; report what WOULD happen. Default false. */
dryRun?: boolean;
/** Per-batch progress callback. */
onBatch?: (info: BackfillProgress) => void;
/** Bail after N total errors. Default 200. */
maxErrors?: number;
/**
* Per-batch statement timeout in seconds. Routed via SET LOCAL inside
* the reserved-connection transaction. Default 600 (10min). Smaller
* values fail fast; larger values let the runner do more work per batch.
*/
perBatchTimeoutSec?: number;
}
export interface BackfillProgress {
batch: number;
rowsThisBatch: number;
cumulative: number;
lastId: number;
errorsSeen: number;
effectiveBatchSize: number;
}
export interface BackfillResult {
examined: number;
updated: number;
errors: number;
lastId: number;
durationSec: number;
/** True iff `maxRows` capped the run (more rows remain). */
cappedByMaxRows: boolean;
/** True iff `maxErrors` bailed the run. */
cappedByErrors: boolean;
}
const DEFAULT_BATCH_SIZE = 1000;
const DEFAULT_MAX_ERRORS = 200;
const DEFAULT_PER_BATCH_TIMEOUT_SEC = 600;
const MIN_BATCH_SIZE = 16;
function checkpointKey(name: string): string {
return `backfill.${name}.last_id`;
}
async function getCheckpoint(engine: BrainEngine, name: string, fresh: boolean): Promise<number> {
if (fresh) return 0;
try {
const rows = await engine.executeRaw<{ value: string }>(
`SELECT value FROM config WHERE key = $1 LIMIT 1`,
[checkpointKey(name)],
);
if (rows.length === 0) return 0;
const n = Number(rows[0].value);
return Number.isFinite(n) && n >= 0 ? n : 0;
} catch {
return 0;
}
}
async function setCheckpoint(engine: BrainEngine, name: string, lastId: number): Promise<void> {
await engine.setConfig(checkpointKey(name), String(lastId));
}
/**
* Verify or create the partial index a backfill declares. Postgres-only
* (PGLite ignores CONCURRENTLY anyway, and partial-index is not always
* supported). Returns false if the index is missing AND we couldn't create
* it (caller decides whether to bail).
*/
export async function ensureBackfillIndex<TRow>(
engine: BrainEngine,
spec: BackfillSpec<TRow>,
): Promise<{ existed: boolean; created: boolean }> {
if (engine.kind !== 'postgres' || !spec.requiredIndex) {
return { existed: true, created: false };
}
const { name, sql } = spec.requiredIndex;
try {
const rows = await engine.executeRaw<{ exists: boolean }>(
`SELECT EXISTS(SELECT 1 FROM pg_indexes WHERE indexname = $1) AS exists`,
[name],
);
if (rows[0]?.exists) return { existed: true, created: false };
// Create the index. CONCURRENTLY can't run inside a transaction, so we
// route via the reserved connection and let the engine handle txn
// semantics directly.
await engine.withReservedConnection(async conn => {
await conn.executeRaw(sql);
});
return { existed: false, created: true };
} catch (err) {
process.stderr.write(`[backfill] index creation failed: ${(err as Error).message}; will continue without partial index\n`);
return { existed: false, created: false };
}
}
/**
* Run a backfill end-to-end. Honors the checkpoint, halves on timeout,
* reconnects on conn drop, bails on max errors.
*/
export async function runBackfill<TRow = Record<string, unknown>>(
engine: BrainEngine,
spec: BackfillSpec<TRow>,
opts: BackfillRunOpts = {},
): Promise<BackfillResult> {
const t0 = Date.now();
const idCol = spec.idColumn ?? 'id';
const cols = [idCol, ...spec.selectColumns.filter(c => c !== idCol)];
const maxErrors = opts.maxErrors ?? DEFAULT_MAX_ERRORS;
const perBatchTimeoutSec = opts.perBatchTimeoutSec ?? DEFAULT_PER_BATCH_TIMEOUT_SEC;
// X4 / P2: verify/create the partial index up-front when declared.
if (spec.requiredIndex) {
await ensureBackfillIndex(engine, spec);
}
let batchSize = opts.batchSize ?? DEFAULT_BATCH_SIZE;
let lastId = await getCheckpoint(engine, spec.name, opts.fresh === true);
let examined = 0;
let updated = 0;
let errors = 0;
let batchNum = 0;
while (true) {
const remaining = opts.maxRows ? Math.max(0, opts.maxRows - examined) : Number.POSITIVE_INFINITY;
if (remaining <= 0) {
return {
examined, updated, errors, lastId,
durationSec: (Date.now() - t0) / 1000,
cappedByMaxRows: true, cappedByErrors: false,
};
}
const effective = Math.min(batchSize, remaining);
let rows: TRow[];
try {
rows = await engine.executeRaw<TRow>(
`SELECT ${cols.join(', ')} FROM ${spec.table}
WHERE ${idCol} > $1 AND (${spec.needsBackfill})
ORDER BY ${idCol}
LIMIT $2`,
[lastId, effective],
);
} catch (err) {
errors++;
if (errors >= maxErrors) {
return {
examined, updated, errors, lastId,
durationSec: (Date.now() - t0) / 1000,
cappedByMaxRows: false, cappedByErrors: true,
};
}
// Connection drop: brief sleep + retry the same window.
if (isRetryableConnError(err)) {
await new Promise(r => setTimeout(r, 1000));
continue;
}
throw err;
}
if (rows.length === 0) {
// No more rows match the predicate. Done.
return {
examined, updated, errors, lastId,
durationSec: (Date.now() - t0) / 1000,
cappedByMaxRows: false, cappedByErrors: false,
};
}
examined += rows.length;
batchNum++;
let computedUpdates: Array<{ id: number; updates: Record<string, unknown> }>;
try {
computedUpdates = await spec.compute(rows, engine);
} catch (err) {
errors++;
if (errors >= maxErrors) break;
if (isStatementTimeoutError(err)) {
batchSize = Math.max(MIN_BATCH_SIZE, Math.floor(batchSize / 2));
process.stderr.write(`[backfill:${spec.name}] compute timeout; halving batch to ${batchSize}\n`);
continue;
}
throw err;
}
// T3 fix: writes go through withReservedConnection so BEGIN / SET LOCAL
// / UPDATE / COMMIT all happen on the same backend. Without this,
// pooled executeRaw can land BEGIN on backend-A and UPDATE on backend-B
// and SET LOCAL evaporates.
if (!opts.dryRun && computedUpdates.length > 0) {
try {
await engine.withReservedConnection(async conn => {
await conn.executeRaw(`BEGIN`);
try {
if (engine.kind === 'postgres') {
await conn.executeRaw(`SET LOCAL statement_timeout = '${perBatchTimeoutSec}s'`).catch(() => {
/* some Postgres tiers restrict SET LOCAL; falls through */
});
}
for (const { id, updates } of computedUpdates) {
const setClauses: string[] = [];
const params: unknown[] = [id];
let paramIdx = 2;
for (const [col, val] of Object.entries(updates)) {
setClauses.push(`${col} = $${paramIdx}`);
params.push(val);
paramIdx++;
}
if (setClauses.length === 0) continue;
await conn.executeRaw(
`UPDATE ${spec.table} SET ${setClauses.join(', ')} WHERE ${idCol} = $1`,
params,
);
updated++;
}
await conn.executeRaw(`COMMIT`);
} catch (err) {
await conn.executeRaw(`ROLLBACK`).catch(() => {});
throw err;
}
});
} catch (err) {
errors++;
if (errors >= maxErrors) break;
if (isStatementTimeoutError(err)) {
batchSize = Math.max(MIN_BATCH_SIZE, Math.floor(batchSize / 2));
process.stderr.write(`[backfill:${spec.name}] write timeout; halving batch to ${batchSize}\n`);
continue;
}
if (isRetryableConnError(err)) {
await new Promise(r => setTimeout(r, 1000));
continue;
}
throw err;
}
}
// Advance the checkpoint to the highest id we examined this batch.
const idAccessor = idCol;
const lastBatchId = (rows[rows.length - 1] as Record<string, unknown>)[idAccessor];
if (typeof lastBatchId === 'number') lastId = lastBatchId;
if (!opts.dryRun) await setCheckpoint(engine, spec.name, lastId);
opts.onBatch?.({
batch: batchNum,
rowsThisBatch: rows.length,
cumulative: examined,
lastId,
errorsSeen: errors,
effectiveBatchSize: effective,
});
}
return {
examined, updated, errors, lastId,
durationSec: (Date.now() - t0) / 1000,
cappedByMaxRows: false, cappedByErrors: errors >= maxErrors,
};
}
/**
* Clear the checkpoint for a backfill. Used by --fresh + after manual reset.
*/
export async function clearBackfillCheckpoint(engine: BrainEngine, name: string): Promise<void> {
try {
await engine.executeRaw(`DELETE FROM config WHERE key = $1`, [checkpointKey(name)]);
} catch {
/* best-effort */
}
}

View File

@@ -0,0 +1,200 @@
/**
* Backfill registry — v0.30.1 (Fix 3).
*
* Three backfills shipping in v0.30.1:
* - effective_date — v0.29.1 column; wraps existing computeEffectiveDate
* - emotional_weight — v0.29 cycle phase, promoted to user-callable
* - embedding_voyage — declared but no-op in v0.30.1 (multi-column
* schema migration ships in v0.30.2 per the
* Embedding Multi-Column scope boundary)
*
* The runtime registry lives in this module; new backfills register here
* AND inside the spec list at the bottom. CLI dispatch reads `getRegistry()`.
*/
import type { BrainEngine } from './engine.ts';
import type { BackfillSpec } from './backfill-base.ts';
import { computeEffectiveDate } from './effective-date.ts';
import { computeEmotionalWeight } from './cycle/emotional-weight.ts';
export interface RegisteredBackfill {
spec: BackfillSpec<Record<string, unknown>>;
/** One-line description for `gbrain backfill list`. */
description: string;
/** Whether this entry is fully implemented in v0.30.1. */
v030_1_status: 'implemented' | 'declared-only';
}
const _registry = new Map<string, RegisteredBackfill>();
export function registerBackfill(entry: RegisteredBackfill): void {
_registry.set(entry.spec.name, entry);
}
export function getBackfill(name: string): RegisteredBackfill | undefined {
return _registry.get(name);
}
export function listBackfills(): RegisteredBackfill[] {
return Array.from(_registry.values());
}
export function clearRegistryForTests(): void {
_registry.clear();
registerCoreBackfills();
}
// ---------------------------------------------------------------------------
// Core registrations
// ---------------------------------------------------------------------------
interface PageRow {
id: number;
slug: string;
frontmatter: unknown;
import_filename: string | null;
effective_date: string | null;
effective_date_source: string | null;
created_at: string;
updated_at: string;
}
function parseFrontmatter(raw: unknown): Record<string, unknown> {
if (raw == null) return {};
if (typeof raw === 'string') {
try { return JSON.parse(raw) as Record<string, unknown>; }
catch { return {}; }
}
if (typeof raw === 'object') return raw as Record<string, unknown>;
return {};
}
function effectiveDateBackfill(): RegisteredBackfill {
return {
description: 'Compute effective_date / effective_date_source for pages imported before v0.29.1',
v030_1_status: 'implemented',
spec: {
name: 'effective_date',
table: 'pages',
idColumn: 'id',
selectColumns: ['slug', 'frontmatter', 'import_filename', 'effective_date', 'effective_date_source', 'created_at', 'updated_at'],
needsBackfill: 'effective_date IS NULL',
compute: async (rows) => {
const updates: Array<{ id: number; updates: Record<string, unknown> }> = [];
for (const r of rows as unknown as PageRow[]) {
const fm = parseFrontmatter(r.frontmatter);
// Strip extension off import_filename (effective-date expects basename
// without ext). Pre-v0.29.1 rows have NULL import_filename.
const filenameStem = r.import_filename
? r.import_filename.replace(/\.[a-z0-9]+$/i, '')
: null;
const result = computeEffectiveDate({
slug: r.slug,
frontmatter: fm,
filename: filenameStem,
createdAt: new Date(r.created_at),
updatedAt: new Date(r.updated_at),
});
if (result.date !== null && result.source !== null) {
// result.date is Date; persist as ISO string (UTC midnight per
// computeEffectiveDate's date-truncation contract).
updates.push({
id: r.id,
updates: {
effective_date: result.date.toISOString().slice(0, 10),
effective_date_source: result.source,
},
});
}
}
return updates;
},
estimateRowsPerSecond: 5000, // pure computation, very fast
},
};
}
interface EmotionalWeightRow {
id: number;
slug: string;
}
function emotionalWeightBackfill(): RegisteredBackfill {
return {
description: 'Recompute emotional_weight for pages with stale recompute timestamp',
v030_1_status: 'implemented',
spec: {
name: 'emotional_weight',
table: 'pages',
idColumn: 'id',
selectColumns: ['slug'],
needsBackfill: 'emotional_weight_recomputed_at IS NULL',
// X4 / P2 corrected predicate: backlog rows are those that were never
// recomputed (NULL) — NOT rows with weight=0 (legitimately steady).
// Migration v44 adds the column.
requiredIndex: {
name: 'idx_pages_emotional_weight_pending',
sql: `CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_pages_emotional_weight_pending ON pages (id) WHERE emotional_weight_recomputed_at IS NULL`,
},
compute: async (rows, engine) => {
const updates: Array<{ id: number; updates: Record<string, unknown> }> = [];
// batchLoadEmotionalInputs is cheap and shape-aware. Fall back to
// per-row read if the engine doesn't expose it (older brains).
const slugs = (rows as unknown as EmotionalWeightRow[]).map(r => r.slug);
const inputs = await engine.batchLoadEmotionalInputs(slugs).catch(() => []);
const inputBySlug = new Map(inputs.map(i => [i.slug, i]));
for (const r of rows as unknown as EmotionalWeightRow[]) {
const input = inputBySlug.get(r.slug);
if (!input) {
// No tags or takes — score is 0 but we still stamp recomputed_at.
updates.push({
id: r.id,
updates: {
emotional_weight: 0,
emotional_weight_recomputed_at: new Date().toISOString(),
},
});
continue;
}
const score = computeEmotionalWeight({ tags: input.tags, takes: input.takes });
updates.push({
id: r.id,
updates: {
emotional_weight: score,
emotional_weight_recomputed_at: new Date().toISOString(),
},
});
}
return updates;
},
estimateRowsPerSecond: 2000,
},
};
}
function embeddingVoyageBackfill(): RegisteredBackfill {
return {
description: 'Declared-only in v0.30.1 (multi-column embedding schema lands in v0.30.2)',
v030_1_status: 'declared-only',
spec: {
name: 'embedding_voyage',
table: 'content_chunks',
idColumn: 'id',
selectColumns: ['chunk_text'],
// The column doesn't exist yet; this predicate matches no rows
// until the v0.30.2 schema migration lands.
needsBackfill: '1 = 0',
compute: async () => [],
estimateRowsPerSecond: 100,
},
};
}
function registerCoreBackfills(): void {
registerBackfill(effectiveDateBackfill());
registerBackfill(emotionalWeightBackfill());
registerBackfill(embeddingVoyageBackfill());
}
// Auto-register on first import.
registerCoreBackfills();

View File

@@ -0,0 +1,104 @@
/**
* Connection-events audit trail (v0.30.1, finding F8).
*
* Mirrors the shell-jobs / subagent / backpressure audit pattern.
*
* Writes one JSONL line per ddl()/bulk() acquire+release+error to
* ~/.gbrain/audit/connection-events-YYYY-Www.jsonl (ISO-week rotation).
* Doctor's connection_routing check tail-reads the JSONL and surfaces
* the last 5 errors as warning context.
*
* Best-effort by design: failures during write are logged to stderr but
* never block the caller (matches shell-audit.ts).
*
* PGLite engines no-op via the `enabled` flag.
*/
import { mkdirSync, appendFileSync, readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { gbrainPath } from './config.ts';
import { redactPgUrl } from './url-redact.ts';
export interface ConnectionEvent {
ts?: string; // ISO 8601, defaults to NOW
pool: 'read' | 'ddl' | 'bulk' | 'single';
op: 'acquire' | 'release' | 'error' | 'init';
duration_ms?: number;
stmt_timeout_ms?: number;
caller?: string; // e.g. 'migrate.runMigrationSQL.v42'
host?: string; // redacted-URL host only, never creds
error?: { code?: string; message: string };
}
let _auditDirCache: string | null = null;
let _auditEnabled = true;
export function setAuditEnabled(enabled: boolean): void {
_auditEnabled = enabled;
}
function getAuditDir(): string {
if (_auditDirCache) return _auditDirCache;
_auditDirCache = gbrainPath('audit');
return _auditDirCache;
}
function getIsoWeekFilename(d: Date = new Date()): string {
// ISO 8601 week date: year + week number. Match shell-audit.ts format.
const target = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
const dayNum = target.getUTCDay() || 7;
target.setUTCDate(target.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1));
const weekNum = Math.ceil((((target.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
const yearStr = target.getUTCFullYear();
const weekStr = String(weekNum).padStart(2, '0');
return `connection-events-${yearStr}-W${weekStr}.jsonl`;
}
export function logConnectionEvent(event: ConnectionEvent): void {
if (!_auditEnabled) return;
try {
const dir = getAuditDir();
mkdirSync(dir, { recursive: true });
const path = join(dir, getIsoWeekFilename());
const line = {
ts: event.ts ?? new Date().toISOString(),
...event,
// Defensive: if a caller passes a full URL by mistake, redact.
host: event.host ? redactPgUrl(event.host) : undefined,
};
appendFileSync(path, JSON.stringify(line) + '\n', 'utf-8');
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`[connection-audit] write failed: ${msg}\n`);
}
}
/**
* Tail the most recent N lines from this week's connection-events file
* that match `op === 'error'`. Doctor uses this to surface the last
* connection-routing failures.
*
* Pure-best-effort: missing file, unreadable file, malformed JSON all
* return [] silently.
*/
export function tailRecentErrors(limit: number = 5): ConnectionEvent[] {
try {
const dir = getAuditDir();
if (!existsSync(dir)) return [];
const path = join(dir, getIsoWeekFilename());
if (!existsSync(path)) return [];
const content = readFileSync(path, 'utf-8');
const lines = content.split('\n').filter(Boolean);
const errors: ConnectionEvent[] = [];
for (let i = lines.length - 1; i >= 0 && errors.length < limit; i--) {
try {
const obj = JSON.parse(lines[i]) as ConnectionEvent;
if (obj.op === 'error') errors.push(obj);
} catch { /* malformed line, skip */ }
}
return errors;
} catch {
return [];
}
}

View File

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

View File

@@ -138,3 +138,143 @@ export async function tryAcquireDbLock(
* cycle handler can hold gbrain-cycle while performSync (called from inside
* the cycle) acquires gbrain-sync. */
export const SYNC_LOCK_ID = 'gbrain-sync';
/**
* v0.30.1 (T4 + A4): wrap long-running work in a refreshing TTL lock.
*
* Problem: tryAcquireDbLock has a TTL but only stays exclusive if someone
* calls refresh(). For 30min+ migrations and hour-long HNSW builds, the TTL
* expires mid-operation and a second worker could enter while the first is
* still alive (codex finding C5 / T4).
*
* Solution: wrap the work in a setInterval refresh that bumps the TTL every
* (TTL/6) ms while the operation runs. On every refresh tick, ALSO fire a
* SELECT 1 backend-alive heartbeat (codex A4 / X1 part 3) to prove the
* lock-holding backend is still responsive — if heartbeat hangs past
* HEARTBEAT_TIMEOUT_MS, abort the operation and release the lock.
*
* Lock-id naming convention: `<scope>:<dbname>` (e.g. `gbrain-migrate:postgres`)
* for multi-tenant safety per cherry D4. Caller composes the dbname.
*
* Failure paths:
* - lock unavailable → throws LockUnavailableError (caller decides retry)
* - work() throws → release lock cleanly + re-throw original
* - heartbeat fails → log + clear interval; lock TTL will auto-expire,
* work() continues but next refresh would see the lock invalidated
*/
export class LockUnavailableError extends Error {
constructor(public readonly lockId: string) {
super(`Lock '${lockId}' is held by another process and not yet expired`);
this.name = 'LockUnavailableError';
}
}
export interface WithRefreshingLockOpts {
/** TTL in minutes for the lock row. Default 30. */
ttlMinutes?: number;
/** Heartbeat-fail threshold in ms — abort if SELECT 1 takes longer. Default 30000. */
heartbeatTimeoutMs?: number;
}
/**
* Acquire `lockId`, run `work`, release lock. Auto-refreshes TTL on a
* setInterval timer; aborts on backend-hang (SELECT 1 heartbeat fails).
*
* If acquire fails (existing live holder), throws LockUnavailableError.
*/
export async function withRefreshingLock<T>(
engine: BrainEngine,
lockId: string,
work: () => Promise<T>,
opts: WithRefreshingLockOpts = {},
): Promise<T> {
const ttlMinutes = opts.ttlMinutes ?? DEFAULT_TTL_MINUTES;
const heartbeatTimeoutMs = opts.heartbeatTimeoutMs ?? 30000;
// Refresh 6x per TTL window so a missed tick doesn't expire the lock.
const refreshIntervalMs = Math.max(15000, (ttlMinutes * 60 * 1000) / 6);
const handle = await tryAcquireDbLock(engine, lockId, ttlMinutes);
if (!handle) throw new LockUnavailableError(lockId);
let healthOk = true;
const interval = setInterval(() => {
void (async () => {
try {
// A4 heartbeat: SELECT 1 against the engine's connection pool.
// Honest limit: this checks a connection is responsive in general,
// not the SPECIFIC backend running `work()`. The full X1 fix
// (lock-refresh on the work-pinned connection via withReservedConnection)
// is layered in by callers that pass the work backend's sql in.
// For migrate.ts (transactional DDL), the engine.transaction() path
// pins the backend; the heartbeat against engine.sql is a useful
// proxy for "Postgres is reachable" even if it can race the actual
// backend's wedge state. Lane B's primary win is the auto-refresh
// itself; the precise-backend-bind heartbeat is a Lane B follow-up.
const probe = engineSelectOne(engine);
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('heartbeat_timeout')), heartbeatTimeoutMs)
);
await Promise.race([probe, timeout]);
await handle.refresh();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`[lock-refresh] ${lockId}: ${msg}; lock will auto-expire\n`);
healthOk = false;
clearInterval(interval);
}
})();
}, refreshIntervalMs);
try {
return await work();
} finally {
clearInterval(interval);
try { await handle.release(); } catch { /* idempotent */ }
if (!healthOk) {
// Surface that the heartbeat detected backend trouble — caller can
// log to the connection-events audit if desired.
process.stderr.write(`[lock-refresh] ${lockId}: completed with degraded heartbeat\n`);
}
}
}
/** Internal: SELECT 1 on the engine's connection. */
async function engineSelectOne(engine: BrainEngine): Promise<void> {
const maybePG = engine as unknown as { sql?: (...args: unknown[]) => Promise<unknown> };
const maybePGLite = engine as unknown as {
db?: { query: (sql: string) => Promise<{ rows: unknown[] }> };
};
if (engine.kind === 'postgres' && maybePG.sql) {
const sql = maybePG.sql as any;
await sql`SELECT 1`;
return;
}
if (engine.kind === 'pglite' && maybePGLite.db) {
await maybePGLite.db.query('SELECT 1');
return;
}
throw new Error(`Unknown engine kind for heartbeat: ${engine.kind}`);
}
/**
* Compose a multi-tenant-safe lock id (cherry D4). Suffixes the lock id
* with the database name so two gbrain installs sharing a Postgres cluster
* (different databases on the same Supabase project) don't contend.
*
* Async: queries `current_database()` on the engine. PGLite returns a
* stable single-database name.
*/
export async function buildTenantLockId(engine: BrainEngine, scope: string): Promise<string> {
try {
if (engine.kind === 'postgres') {
const rows = await engine.executeRaw<{ db: string }>('SELECT current_database() AS db');
const dbname = rows[0]?.db || 'unknown';
return `${scope}:${dbname}`;
}
// PGLite is single-tenant by construction; suffix is cosmetic.
return `${scope}:pglite`;
} catch {
return `${scope}:unknown`;
}
}

View File

@@ -32,6 +32,80 @@ interface Migration {
*/
transaction?: boolean;
handler?: (engine: BrainEngine) => Promise<void>;
/**
* v0.30.1 (D6): when undefined, treated as `true` for all existing
* migrations (every migration in the registry uses CREATE ... IF NOT
* EXISTS / ALTER ... IF NOT EXISTS / INSERT ... ON CONFLICT, so re-running
* is safe). Explicit `idempotent: false` blocks the verify-hook
* self-healing path from re-running a destructive migration; the runner
* surfaces `MigrationDriftError` and requires `--skip-verify` to force.
*
* NEW migrations should declare this explicitly; the CONTRIBUTING
* migration template lists it as required for clarity.
*/
idempotent?: boolean;
/**
* v0.30.1 (D6): post-condition probe. Runs after the migration claims
* to have applied. Returns false if the actual schema state doesn't
* match what the migration declared (e.g. column/table/index missing
* after a partially-committed run on a wedged Supabase pooler).
*
* Verify-hook coverage is OPT-IN per migration. Per X3 / codex C6 the
* v0.30.1 surface ships verify hooks only on a small set of migrations;
* older migrations rely on `gbrain upgrade --force-schema` for recovery.
*/
verify?: (engine: BrainEngine) => Promise<boolean>;
}
/**
* Resolve idempotent classification with the v0.30.1 default. Used by the
* migration runner's verify path and by the twice-run safety test
* (test/migrate-idempotent-classify.test.ts).
*/
export function isMigrationIdempotent(m: Migration): boolean {
// Default true: existing migrations were authored as idempotent (every
// CREATE/ALTER uses IF NOT EXISTS guards). Explicit false opts out.
return m.idempotent !== false;
}
/**
* Migration drift error — verify hook failed and migration is non-idempotent.
* Caller surfaces the column/table names that diverged and requires
* `--skip-verify` to force re-run.
*/
export class MigrationDriftError extends Error {
constructor(
public readonly version: number,
public readonly migrationName: string,
public readonly hint: string,
) {
super(`Migration v${version} (${migrationName}) verify failed: ${hint}`);
this.name = 'MigrationDriftError';
}
}
/**
* Retry-exhausted envelope (v0.30.1 / Finding F2). Surface the most recent
* idle blockers we observed so the user has a paste-ready
* pg_terminate_backend(<pid>) command.
*/
export class MigrationRetryExhausted extends Error {
constructor(
public readonly version: number,
public readonly migrationName: string,
public readonly attempts: number,
public readonly lastBlockers: IdleBlocker[],
public readonly lastError: Error,
) {
const lastB = lastBlockers[0];
const hint = lastB
? `PID ${lastB.pid} idle since ${lastB.query_start} likely holds the lock; run: psql ... -c "SELECT pg_terminate_backend(${lastB.pid})"`
: 'No idle-in-transaction blockers detected; check pg_locks for active waiters and ~/.gbrain/audit/connection-events-*.jsonl';
super(
`Migration v${version} (${migrationName}) failed after ${attempts} attempts. ${hint}. Original: ${lastError.message}`
);
this.name = 'MigrationRetryExhausted';
}
}
// Migrations are embedded here, not loaded from files.
@@ -2067,6 +2141,32 @@ export const MIGRATIONS: Migration[] = [
`,
},
},
{
version: 44,
name: 'pages_emotional_weight_recomputed_at',
idempotent: true,
// v0.30.1 (Codex X4 / Finding P2): emotional_weight = 0 is a VALID
// steady-state value (migration v40 default). Indexing WHERE = 0
// would be a permanent large index over normal data, not a backlog
// index. The actual backlog predicate is "never recomputed" — for
// that we need a separate timestamp column. ADD COLUMN with NULL
// default is metadata-only on PG 11+ and PGLite — instant on tables
// of any size.
//
// The recompute-emotional-weight cycle phase + the new
// `gbrain backfill emotional_weight` command both stamp this column
// with NOW() alongside the weight write, so existing rows progress
// out of the backlog naturally as the cycle runs.
//
// Partial index: idx_pages_emotional_weight_pending lives on
// `(id) WHERE emotional_weight_recomputed_at IS NULL` and is created
// on first run by the backfill primitive (CONCURRENTLY) rather than
// here, because schema-time CREATE INDEX isn't CONCURRENTLY-friendly
// when the SCHEMA_SQL replay runs in a transaction.
sql: `
ALTER TABLE pages ADD COLUMN IF NOT EXISTS emotional_weight_recomputed_at TIMESTAMPTZ;
`,
},
];
export const LATEST_VERSION = MIGRATIONS.length > 0
@@ -2129,6 +2229,68 @@ async function checkForBlockingConnections(engine: BrainEngine): Promise<boolean
return false;
}
/**
* v0.30.1 (Cherry D3 / Finding F2): wrap a migration attempt in 3-attempt
* retry+backoff (5s/15s/45s). Retry only on statement_timeout (57014) or
* connection-reset patterns; other errors fail loud immediately.
*
* Before each retry: log idle-in-transaction blockers so the user knows
* which PID is holding the lock. After exhaustion: throw
* `MigrationRetryExhausted` with the named PID + suggested
* pg_terminate_backend command.
*/
async function runMigrationSQLWithRetry(
engine: BrainEngine,
m: Migration,
sql: string,
): Promise<void> {
const { isStatementTimeoutError, isRetryableConnError } = await import('./retry-matcher.ts');
// GBRAIN_MIGRATE_BACKOFF_MS lets tests skip the 5s/15s/45s backoff. In
// production the env var is unset and the default cadence applies.
const fastBackoff = process.env.GBRAIN_MIGRATE_BACKOFF_MS;
const backoffs = fastBackoff !== undefined
? [parseInt(fastBackoff, 10) || 0, parseInt(fastBackoff, 10) || 0, parseInt(fastBackoff, 10) || 0]
: [5000, 15000, 45000];
let lastErr: Error | null = null;
let lastBlockers: IdleBlocker[] = [];
for (let attempt = 0; attempt < 3; attempt++) {
try {
// Pre-attempt diagnostic: if there are idle blockers, log them so
// the operator can see what we're racing against. Cherry D3.
if (attempt > 0) {
lastBlockers = await getIdleBlockers(engine);
if (lastBlockers.length > 0) {
console.warn(` [retry ${attempt}/3] ${lastBlockers.length} idle-in-transaction blocker(s):`);
for (const b of lastBlockers) {
console.warn(` PID ${b.pid} idle since ${b.query_start}${b.query.slice(0, 80)}`);
}
}
}
await runMigrationSQL(engine, m, sql);
return;
} catch (err: unknown) {
lastErr = err instanceof Error ? err : new Error(String(err));
const retryable = isStatementTimeoutError(err) || isRetryableConnError(err);
if (!retryable || attempt === 2) {
// Final failure: capture blockers + throw enriched envelope when
// retry-eligible (named-PID UX from F2). Non-retryable errors fall
// through to the existing 57014 handler in runMigrations.
if (retryable) {
lastBlockers = await getIdleBlockers(engine);
throw new MigrationRetryExhausted(m.version, m.name, attempt + 1, lastBlockers, lastErr);
}
throw err;
}
const delay = backoffs[attempt];
console.warn(` [retry ${attempt + 1}/3] ${m.name} hit ${lastErr.message.slice(0, 80)}; retrying in ${delay}ms`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
// Defensive: shouldn't reach here.
if (lastErr) throw lastErr;
}
/**
* Wrap migration SQL execution with Supabase-compatible timeout.
* Uses SET LOCAL statement_timeout inside a transaction to override
@@ -2236,22 +2398,34 @@ export async function runMigrations(engine: BrainEngine): Promise<{ applied: num
if (sql) {
try {
await runMigrationSQL(engine, m, sql);
// v0.30.1: retry wrapper handles statement_timeout + conn-reset
// across 3 attempts (5s/15s/45s). Other errors throw immediately.
await runMigrationSQLWithRetry(engine, m, sql);
} catch (err: unknown) {
// Actionable diagnostics for statement timeout (Postgres error 57014).
// Shape matches the 4-part error standard (what / why / fix / verify).
const code = (err as { code?: string })?.code;
if (code === '57014') {
console.error(`\n❌ Migration ${m.version} (${m.name}) hit statement_timeout (SQLSTATE 57014).`);
console.error('');
console.error(' Cause: another connection holds a lock on the target table, or the');
console.error(' server statement_timeout (~2 min on Supabase) is too short for this DDL.');
console.error('');
console.error(' Fix:');
console.error(' 1. gbrain doctor --locks # find idle-in-transaction blockers');
console.error(' 2. Terminate blocker(s) shown by step 1 via pg_terminate_backend(<pid>)');
console.error(' 3. gbrain apply-migrations --yes # re-run from the version that failed');
console.error('');
if (code === '57014' || err instanceof MigrationRetryExhausted) {
console.error(`\n❌ Migration ${m.version} (${m.name}) ${err instanceof MigrationRetryExhausted ? 'exhausted retries' : 'hit statement_timeout (SQLSTATE 57014)'}.`);
if (err instanceof MigrationRetryExhausted && err.lastBlockers.length > 0) {
const b = err.lastBlockers[0];
console.error('');
console.error(` Likely blocker: PID ${b.pid}, idle since ${b.query_start}`);
console.error(` Query: ${b.query.slice(0, 120)}`);
console.error('');
console.error(` Recovery: psql ... -c "SELECT pg_terminate_backend(${b.pid})"`);
console.error('');
} else {
console.error('');
console.error(' Cause: another connection holds a lock on the target table, or the');
console.error(' server statement_timeout (~2 min on Supabase) is too short for this DDL.');
console.error('');
console.error(' Fix:');
console.error(' 1. gbrain doctor --locks # find idle-in-transaction blockers');
console.error(' 2. Terminate blocker(s) shown by step 1 via pg_terminate_backend(<pid>)');
console.error(' 3. gbrain apply-migrations --yes # re-run from the version that failed');
console.error('');
}
console.error(' Verify:');
console.error(' gbrain doctor # schema_version should match latest');
console.error('');
@@ -2265,6 +2439,30 @@ export async function runMigrations(engine: BrainEngine): Promise<{ applied: num
await m.handler(engine);
}
// v0.30.1 (D6): post-condition probe. If a verify hook is declared, run
// it before bumping config.version. When verify returns false, check
// idempotent — if true, log + retry the same migration once; if false,
// throw MigrationDriftError so operator runs --skip-verify deliberately.
if (m.verify) {
const verifyOk = await m.verify(engine).catch(() => false);
if (!verifyOk) {
const idempotent = isMigrationIdempotent(m);
if (idempotent) {
console.warn(` [${m.version}] ⚠️ verify failed; re-running idempotent migration once`);
if (sql) await runMigrationSQLWithRetry(engine, m, sql);
if (m.handler) await m.handler(engine);
// Best-effort: don't double-throw if second run still fails verify.
// Operator's next run of doctor will re-detect drift.
} else {
throw new MigrationDriftError(
m.version,
m.name,
`Schema does not match expected post-condition. Run with --skip-verify to force.`,
);
}
}
}
// Update version after both SQL and handler succeed
await engine.setConfig('version', String(m.version));
console.log(` [${m.version}] ✓ ${m.name}`);

View File

@@ -14,7 +14,7 @@ import { deriveResolutionTuple, finalizeScorecard } from './takes-resolution.ts'
import { runMigrations } from './migrate.ts';
import { SCHEMA_SQL } from './schema-embedded.ts';
import { verifySchema } from './schema-verify.ts';
import { applyChunkEmbeddingIndexPolicy } from './vector-index.ts';
import { applyChunkEmbeddingIndexPolicy, dropZombieIndexes } from './vector-index.ts';
import type {
Page, PageInput, PageFilters, PageType,
Chunk, ChunkInput, StaleChunkRow,
@@ -34,6 +34,8 @@ import type {
import { GBrainError, PAGE_SORT_SQL } from './types.ts';
import { computeAnomaliesFromBuckets } from './cycle/anomaly.ts';
import * as db from './db.ts';
import { ConnectionManager } from './connection-manager.ts';
import { logConnectionEvent } from './connection-audit.ts';
import { validateSlug, contentHash, rowToPage, rowToChunk, rowToSearchResult, parseEmbedding, tryParseEmbedding, takeRowToTake } from './utils.ts';
import { resolveBoostMap, resolveHardExcludes } from './search/source-boost.ts';
import { buildSourceFactorCase, buildHardExcludeClause, buildVisibilityClause, buildRecencyComponentSql } from './search/sql-ranking.ts';
@@ -68,7 +70,7 @@ export class PostgresEngine implements BrainEngine {
readonly kind = 'postgres' as const;
private _sql: ReturnType<typeof postgres> | null = null;
/** Saved config for reconnection. */
private _savedConfig: (EngineConfig & { poolSize?: number }) | null = null;
private _savedConfig: (EngineConfig & { poolSize?: number; parentConnectionManager?: ConnectionManager }) | null = null;
/** Whether a reconnect is in progress (prevents concurrent reconnects). */
private _reconnecting = false;
/**
@@ -81,6 +83,19 @@ export class PostgresEngine implements BrainEngine {
*/
private _connectionStyle: 'instance' | 'module' | null = null;
/**
* v0.30.1 (Fix 1 + X1 + T5): instance-owned ConnectionManager.
* - INSTANCE-owned: each PostgresEngine constructs its own.
* - Worker engines (cycle, sync) inherit via opts.parentConnectionManager.
* - transaction() clones share the parent's via copy.
* - Module-singleton path (when poolSize unset) wraps the db.ts singleton.
*
* Public so callers can access read()/ddl()/bulk()/healthCheck() without
* threading the manager through every API. doctor's connection_routing
* check uses it; runMigrations() uses ddl().
*/
connectionManager: ConnectionManager | null = null;
// Instance connection (for workers) or fall back to module global (backward compat)
get sql(): ReturnType<typeof postgres> {
if (this._sql) return this._sql;
@@ -88,8 +103,9 @@ export class PostgresEngine implements BrainEngine {
}
// Lifecycle
async connect(config: EngineConfig & { poolSize?: number }): Promise<void> {
async connect(config: EngineConfig & { poolSize?: number; parentConnectionManager?: ConnectionManager }): Promise<void> {
this._savedConfig = config;
const url = config.database_url;
if (config.poolSize) {
// Instance-level connection for worker isolation. resolvePoolSize lets
// GBRAIN_POOL_SIZE cap below the caller's requested size when set — the
@@ -123,14 +139,39 @@ export class PostgresEngine implements BrainEngine {
await this._sql`SELECT 1`;
await db.setSessionDefaults(this._sql);
this._connectionStyle = 'instance';
// v0.30.1: instance-owned ConnectionManager wraps the read pool we just
// built. Parent inheritance (T5/X1): worker engines pass their parent's
// manager so kill-switch state and direct pool are shared.
this.connectionManager = new ConnectionManager({
url,
parent: config.parentConnectionManager,
readPoolOwnedExternally: true, // we own _sql; manager just routes
});
this.connectionManager.setReadPool(this._sql);
} else {
// Module-level singleton (backward compat for CLI main engine)
await db.connect(config);
this._connectionStyle = 'module';
// v0.30.1: connection-manager wraps the module singleton.
if (url) {
this.connectionManager = new ConnectionManager({
url,
parent: config.parentConnectionManager,
readPoolOwnedExternally: true, // db.ts owns the pool
});
this.connectionManager.setReadPool(db.getConnection());
}
}
}
async disconnect(): Promise<void> {
// v0.30.1: tear down the direct pool first if the manager owns one.
if (this.connectionManager) {
await this.connectionManager.disconnect();
this.connectionManager = null;
}
if (this._sql) {
await this._sql.end();
this._sql = null;
@@ -147,7 +188,16 @@ export class PostgresEngine implements BrainEngine {
}
async initSchema(): Promise<void> {
const conn = this.sql;
// v0.30.1 (X1): route DDL through the direct pool when ConnectionManager
// is in dual-pool mode. The pooler's 2-min statement_timeout truncates
// SCHEMA_SQL replays + migrations on Supabase; the direct pool gets
// 30min. Lane B replaces the lock primitive with a TTL+heartbeat table
// lock; Lane A does the routing and keeps pg_advisory_lock(42) on the
// SAME connection so the lock is correct.
const conn = this.connectionManager
? await this.connectionManager.ddl()
: this.sql;
// Resolve the embedding dim/model from the gateway (v0.14+).
// Falls back to v0.13 defaults (1536d + text-embedding-3-large) when gateway isn't configured yet.
let dims = 1536;
@@ -158,17 +208,22 @@ export class PostgresEngine implements BrainEngine {
model = gw.getEmbeddingModel().split(':').slice(1).join(':') || model;
} catch { /* gateway not yet configured — use defaults */ }
const sql = getPostgresSchema(dims, model);
const sqlText = getPostgresSchema(dims, model);
// Advisory lock prevents concurrent initSchema() calls from deadlocking
// on DDL statements (DROP TRIGGER + CREATE TRIGGER acquire AccessExclusiveLock).
//
// Honest limitation: pg_advisory_lock(42) is session-scoped to this pooled
// connection. runMigrations() below uses engine.transaction() and
// withReservedConnection() which may hop to a different backend in the
// pool. Cross-process serialization of initSchema is best-effort, not a
// correctness guarantee. Pre-existing concern; the bootstrap doesn't
// change it.
// v0.30.1 honest limitation: pg_advisory_lock(42) is session-scoped to
// `conn`. When dual-pool routing is active, conn is a direct-pool reserved
// backend, so the lock is held for the duration of initSchema. Lane B
// replaces this with a TTL+heartbeat table lock that survives pooler-side
// session resets.
const t0 = Date.now();
logConnectionEvent({
pool: this.connectionManager?.isDualPoolActive() ? 'ddl' : 'read',
op: 'acquire',
caller: 'PostgresEngine.initSchema',
});
await conn`SELECT pg_advisory_lock(42)`;
try {
// Pre-schema bootstrap: add forward-referenced state the embedded schema
@@ -176,7 +231,7 @@ export class PostgresEngine implements BrainEngine {
// #378/#396 + #266/#357). Idempotent on fresh installs and modern brains.
await this.applyForwardReferenceBootstrap();
await conn.unsafe(sql);
await conn.unsafe(sqlText);
// Run any pending migrations automatically
const { applied } = await runMigrations(this);
@@ -191,8 +246,24 @@ export class PostgresEngine implements BrainEngine {
if (verify.healed.length > 0) {
console.log(` Schema verify: self-healed ${verify.healed.length} missing column(s)`);
}
// v0.30.1 (Fix 5): sweep zombie HNSW indexes (indisvalid=false) from
// crashed CREATE INDEX CONCURRENTLY calls. Best-effort; errors logged
// to stderr but never block engine.connect.
try {
const result = await dropZombieIndexes(this);
if (result.dropped.length > 0) {
console.log(` HNSW sweep: dropped ${result.dropped.length} zombie index(es)`);
}
} catch { /* best-effort */ }
} finally {
await conn`SELECT pg_advisory_unlock(42)`;
logConnectionEvent({
pool: this.connectionManager?.isDualPoolActive() ? 'ddl' : 'read',
op: 'release',
caller: 'PostgresEngine.initSchema',
duration_ms: Date.now() - t0,
});
}
}

99
src/core/retry-matcher.ts Normal file
View File

@@ -0,0 +1,99 @@
/**
* Typed retry-eligible-error predicates (v0.30.1, finding C4).
*
* Three v0.30.1 sites need to decide whether to retry on a given error:
* - db.ts:connectWithRetry (existing — auth, conn-refused, ECONNRESET)
* - migrate.ts retry wrapper (statement_timeout 57014 + conn-reset)
* - backfill-base.ts adaptive retry (statement_timeout 57014 + conn drop)
*
* Before this module these predicates lived inline at each site and drifted
* over time. One source of truth here; new call sites import the typed
* helper instead of pattern-matching the same regexes again.
*/
const CONN_PATTERNS = [
/password authentication failed/i,
/connection refused/i,
/the database system is starting up/i,
/Connection terminated unexpectedly/i,
/ECONNRESET/i,
/connection.*closed/i,
/server closed the connection/i,
/could not connect to server/i,
];
interface PgError {
code?: string;
message?: string;
cause?: unknown;
}
function getCode(err: unknown): string | undefined {
if (err && typeof err === 'object') {
const code = (err as PgError).code;
if (typeof code === 'string') return code;
}
return undefined;
}
function getMessage(err: unknown): string {
if (err instanceof Error) return err.message;
if (err && typeof err === 'object') {
const msg = (err as PgError).message;
if (typeof msg === 'string') return msg;
}
return String(err ?? '');
}
/**
* SQLSTATE 57014: query_canceled / statement_timeout.
* Postgres signals this when a statement exceeds `statement_timeout`.
*/
export function isStatementTimeoutError(err: unknown): boolean {
if (getCode(err) === '57014') return true;
const msg = getMessage(err);
return /statement_timeout|canceling statement due to statement timeout/i.test(msg);
}
/**
* SQLSTATE 55P03: lock_not_available.
* Postgres signals this when `lock_timeout` or `NOWAIT` would block.
*/
export function isLockTimeoutError(err: unknown): boolean {
if (getCode(err) === '55P03') return true;
const msg = getMessage(err);
return /lock_not_available|could not obtain lock/i.test(msg);
}
/**
* Connection-level errors that are typically transient: TCP resets,
* pooler restarts, server-starting-up, auth race during DNS failover.
* Distinguish from statement_timeout / lock_timeout via the dedicated
* predicates above.
*/
export function isRetryableConnError(err: unknown): boolean {
// Statement / lock timeouts are NOT connection errors. Callers that
// want to retry on those use isStatementTimeoutError / isLockTimeoutError
// explicitly so they can apply different backoff (e.g. backfill halves
// batch size on stmt timeout but reconnects on conn drop).
if (isStatementTimeoutError(err) || isLockTimeoutError(err)) return false;
const code = getCode(err);
// Postgres connection-level codes:
// 08000 connection_exception
// 08003 connection_does_not_exist
// 08006 connection_failure
// 08001 sqlclient_unable_to_establish_sqlconnection
// 08004 sqlserver_rejected_establishment_of_sqlconnection
if (code && /^08/.test(code)) return true;
const msg = getMessage(err);
return CONN_PATTERNS.some(p => p.test(msg));
}
/**
* Convenience: is this error retryable for ANY reason (connection drop OR
* statement timeout)? Backfill uses this — callers that need finer-grained
* dispatch (different backoff per kind) call the dedicated predicates.
*/
export function isRetryableError(err: unknown): boolean {
return isRetryableConnError(err) || isStatementTimeoutError(err);
}

View File

@@ -13,11 +13,11 @@ export function extractProjectRef(input: string): string | null {
const dashMatch = input.match(/supabase\.com\/dashboard\/project\/([a-z]+)/);
if (dashMatch) return dashMatch[1];
// Direct connection: postgresql://postgres:[pw]@db.[ref].supabase.co:5432/postgres
// Direct connection example URL /* allow-pg-url-literal */
const directMatch = input.match(/db\.([a-z]+)\.supabase\.co/);
if (directMatch) return directMatch[1];
// Pooler: postgresql://postgres.[ref]:[pw]@aws-0-[region].pooler.supabase.com:6543/postgres
// Pooler example URL /* allow-pg-url-literal */
const poolerMatch = input.match(/postgres\.([a-z]+):/);
if (poolerMatch) return poolerMatch[1];
@@ -76,6 +76,6 @@ export async function discoverPoolerUrl(
// Fallback: construct from region
const region = settings.region;
return `postgresql://postgres.${projectRef}:[YOUR-PASSWORD]@aws-0-${region}.pooler.supabase.com:6543/postgres`;
return `postgresql://postgres.${projectRef}:[YOUR-PASSWORD]@aws-0-${region}.pooler.supabase.com:6543/postgres`; /* allow-pg-url-literal */
}

View File

@@ -640,6 +640,35 @@ export interface BrainHealth {
timeline_coverage_score: number; // 0-15
no_orphans_score: number; // 0-15
no_dead_links_score: number; // 0-10
/**
* v0.30.1 (Cherry D7 + Codex C3): explicit migrations diagnostic surface
* exposed to MCP get_health callers so remote agents can detect a wedged
* brain WITHOUT shelling SSH + gbrain doctor. Two ledgers (schema +
* orchestrator) per Codex T5 namespacing.
*
* `schema_version` ("1") on the parent BrainHealth pins the additive
* contract — clients should default-handle missing fields and never
* assume removed ones.
*/
schema_version?: '1';
migrations?: {
schema: {
/** Current numeric config.version. */
version: number;
/** Latest available migration. */
latest_version: number;
/**
* Optional drift evidence — names of columns/tables a verify hook
* surfaced as missing on opt-in migrations. Empty array means no
* drift detected (or no verify hook ran).
*/
verify_drift?: string[];
};
orchestrator: {
pending: Array<{ version: string; name: string; status: 'pending' | 'partial' }>;
wedged: Array<{ version: string; name: string; consecutive_partials: number }>;
};
};
}
// Ingest log

View File

@@ -0,0 +1,160 @@
/**
* Upgrade pipeline checkpoint (v0.30.1 Cherry D5 + Codex X2).
*
* Persists step-by-step progress through `gbrain post-upgrade` so a partial
* failure can be resumed via `gbrain upgrade --resume` instead of
* re-running every step from scratch.
*
* Codex X2 fix: checkpoint is bound to the brain it was created for, via
* a sha256(database_url) hash. brain-registry.ts:300 manages multiple
* mounted brains; without identity binding, a checkpoint from brain A can
* be applied against brain B (corruption vector). The validate() helper
* is the F4 fall-through gate — when called with a no-checkpoint or
* mismatched-brain state, the upgrade pipeline silently runs the full path.
*/
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { createHash } from 'node:crypto';
import { gbrainPath, loadConfig } from './config.ts';
export type UpgradeStep = 'pull' | 'install' | 'schema' | 'features' | 'backfills' | 'verify';
export interface UpgradeCheckpoint {
/** Stable hash of the brain's database_url. Detects multi-brain mismatch (X2). */
brain_id: string;
/** ISO 8601 timestamp of when the upgrade started. */
started_at: string;
/** Source version (the binary that started the upgrade). */
from_version: string;
/** Target version (the binary that's running the pipeline). */
to_version: string;
/** Steps that completed successfully. */
completed_steps: UpgradeStep[];
/** Step that failed (set on error). */
failed_step?: UpgradeStep;
/** Error info from the failed step. */
failed_step_error?: { message: string; code?: string };
}
const CHECKPOINT_FILENAME = 'upgrade-checkpoint.json';
const ALL_STEPS: UpgradeStep[] = ['pull', 'install', 'schema', 'features', 'backfills', 'verify'];
function checkpointPath(): string {
return gbrainPath(CHECKPOINT_FILENAME);
}
/**
* Compute a stable brain identity hash from the database URL. Strips
* userinfo to avoid creds in the hash input collision space (anyone
* comparing hashes can't reverse to find a password). Falls back to
* 'unknown' when no URL is configured.
*/
export function computeBrainId(databaseUrl?: string | null): string {
if (!databaseUrl) {
// PGLite or no config — derive from the configured database_path
// when present, else 'pglite-default'.
const cfg = loadConfig();
const path = cfg?.database_path;
return createHash('sha256').update(`pglite:${path ?? 'default'}`).digest('hex').slice(0, 16);
}
// Strip userinfo so the hash is stable across credential rotations.
const stripped = databaseUrl.replace(/\/\/[^@]*@/, '//');
return createHash('sha256').update(stripped).digest('hex').slice(0, 16);
}
/**
* Read the checkpoint from disk. Returns null when missing or unreadable.
*/
export function loadCheckpoint(): UpgradeCheckpoint | null {
const path = checkpointPath();
if (!existsSync(path)) return null;
try {
const raw = readFileSync(path, 'utf-8');
const parsed = JSON.parse(raw) as UpgradeCheckpoint;
// Defensive: must have brain_id + completed_steps shape.
if (typeof parsed.brain_id !== 'string') return null;
if (!Array.isArray(parsed.completed_steps)) return null;
return parsed;
} catch {
return null;
}
}
export function writeCheckpoint(state: UpgradeCheckpoint): void {
const path = checkpointPath();
try {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(state, null, 2), 'utf-8');
} catch (err) {
process.stderr.write(`[upgrade-checkpoint] write failed: ${(err as Error).message}\n`);
}
}
export function clearCheckpoint(): void {
const path = checkpointPath();
try {
if (existsSync(path)) unlinkSync(path);
} catch {
/* best-effort */
}
}
export interface CheckpointValidation {
valid: boolean;
/** Reason for invalidation. */
reason?: 'no_checkpoint' | 'brain_mismatch' | 'malformed' | 'all_complete';
/** Step to resume at (next un-completed step). */
resumeAt?: UpgradeStep;
/** The loaded checkpoint when valid + has unfinished work. */
checkpoint?: UpgradeCheckpoint;
}
/**
* Validate a checkpoint against the current brain. Returns:
* - valid=false reason=no_checkpoint → caller falls through to full upgrade (F4)
* - valid=false reason=brain_mismatch → operator must --force or remove checkpoint
* - valid=false reason=all_complete → checkpoint is stale; clear it and run full
* - valid=true → resume from resumeAt
*/
export function validateCheckpoint(currentBrainId: string): CheckpointValidation {
const checkpoint = loadCheckpoint();
if (!checkpoint) return { valid: false, reason: 'no_checkpoint' };
if (checkpoint.brain_id !== currentBrainId) {
return { valid: false, reason: 'brain_mismatch', checkpoint };
}
// Find the first step NOT in completed_steps.
const nextStep = ALL_STEPS.find(s => !checkpoint.completed_steps.includes(s));
if (!nextStep) {
return { valid: false, reason: 'all_complete', checkpoint };
}
return { valid: true, resumeAt: nextStep, checkpoint };
}
/**
* Mark a step complete in-place. Caller writes back via writeCheckpoint().
*/
export function markStepComplete(checkpoint: UpgradeCheckpoint, step: UpgradeStep): UpgradeCheckpoint {
if (!checkpoint.completed_steps.includes(step)) {
checkpoint.completed_steps.push(step);
}
// Clear failed_step when a step completes successfully (could be a re-run).
delete checkpoint.failed_step;
delete checkpoint.failed_step_error;
return checkpoint;
}
export function markStepFailed(
checkpoint: UpgradeCheckpoint,
step: UpgradeStep,
err: Error,
): UpgradeCheckpoint {
checkpoint.failed_step = step;
checkpoint.failed_step_error = {
message: err.message,
code: (err as { code?: string }).code,
};
return checkpoint;
}
export const ALL_UPGRADE_STEPS = ALL_STEPS;

62
src/core/url-redact.ts Normal file
View File

@@ -0,0 +1,62 @@
/**
* Postgres URL credential redaction (v0.30.1, finding F3).
*
* Strips userinfo from postgresql:// / postgres:// URLs so logging surfaces
* never write credentials to disk. Used by every new v0.30.1 log site:
* - ~/.gbrain/upgrade-errors.jsonl
* - ~/.gbrain/audit/connection-events-*.jsonl
* - doctor's connection_routing check output
* - upgrade-pipeline summary
*
* scripts/check-pg-url-redaction.sh is the CI grep guard that fails the
* build if any new code path emits an unredacted postgresql:// URL.
*/
const PG_URL_RE = /^(postgres(?:ql)?:\/\/)([^@/?]*@)?([^?]*)(\?.*)?$/i;
/**
* Returns the URL with userinfo replaced by `***`. Preserves scheme, host,
* port, db, and query string.
*
* Examples:
* redactPgUrl('postgresql://user:pass@host:5432/db')
* → 'postgresql://***@host:5432/db'
* redactPgUrl('postgresql://host:5432/db')
* → 'postgresql://host:5432/db' (no userinfo, unchanged)
* redactPgUrl('not a url')
* → '<redacted-url>'
*/
export function redactPgUrl(url: unknown): string {
if (typeof url !== 'string' || !url) return '<redacted-url>';
const match = url.match(PG_URL_RE);
if (!match) return '<redacted-url>';
const [, scheme, userinfo, hostPart, query] = match;
const userPart = userinfo ? '***@' : '';
return `${scheme}${userPart}${hostPart}${query ?? ''}`;
}
/**
* Recursively redact any postgresql:// or postgres:// URLs found inside an
* arbitrary value (string, object, array). Useful when the caller is about
* to JSON.stringify a structured payload and might have a URL nested
* somewhere.
*/
export function redactDeep<T>(value: T): T {
if (typeof value === 'string') {
if (/postgres(?:ql)?:\/\//i.test(value)) {
return redactPgUrl(value) as unknown as T;
}
return value;
}
if (Array.isArray(value)) {
return value.map(redactDeep) as unknown as T;
}
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = redactDeep(v);
}
return out as unknown as T;
}
return value;
}

View File

@@ -1,3 +1,21 @@
/**
* pgvector HNSW index policy + lifecycle manager (v0.30.1 Fix 5).
*
* Original v0.27 surface: chunkEmbeddingIndexSql / applyChunkEmbeddingIndexPolicy
* (kept unchanged for back-compat — schema-time index emission).
*
* v0.30.1 lifecycle additions:
* - dropAndRebuild (A3): atomic-swap pattern; build new index with temp
* name, ALTER...RENAME swap atomically, drop old. If rebuild fails the
* old index stays intact and search keeps working.
* - checkActiveBuild: pre-op probe of pg_stat_activity.
* - dropZombieIndexes: startup sweep of indisvalid=false indexes,
* guarded against in-progress builds.
* - monitorBuild: progress reporter during long-running CREATE INDEX.
*/
import type { BrainEngine } from './engine.ts';
export const PGVECTOR_HNSW_VECTOR_MAX_DIMS = 2000;
const CHUNK_EMBEDDING_HNSW_INDEX =
@@ -14,3 +32,216 @@ export function chunkEmbeddingIndexSql(dims: number): string {
export function applyChunkEmbeddingIndexPolicy(sql: string, dims: number): string {
return sql.replaceAll(CHUNK_EMBEDDING_HNSW_INDEX, chunkEmbeddingIndexSql(dims));
}
// ---------------------------------------------------------------------------
// v0.30.1 Lifecycle Manager (Fix 5)
// ---------------------------------------------------------------------------
export interface IndexSpec {
/** The CURRENT (production) index name. */
name: string;
table: string;
column: string;
/** USING clause body — e.g. `hnsw (embedding vector_cosine_ops)`. */
using: string;
/** Optional WHERE predicate (without WHERE keyword). */
condition?: string;
}
export interface ActiveBuildInfo {
active: boolean;
pid?: number;
query?: string;
application_name?: string;
}
/**
* Probe pg_stat_activity for an active CREATE INDEX on this index name.
* Used as a pre-op guard so dropAndRebuild doesn't compete with a build
* already in flight (Supabase auto-maintenance + parallel gbrain procs).
*/
export async function checkActiveBuild(
engine: BrainEngine,
indexName: string,
): Promise<ActiveBuildInfo> {
if (engine.kind !== 'postgres') return { active: false };
try {
const rows = await engine.executeRaw<{ pid: number; query: string; application_name: string | null }>(
`SELECT pid, query, application_name
FROM pg_stat_activity
WHERE state = 'active'
AND (query ILIKE $1 OR query ILIKE $2)
AND pid != pg_backend_pid()
LIMIT 1`,
[`%CREATE INDEX%${indexName}%`, `%REINDEX%${indexName}%`],
);
if (rows.length === 0) return { active: false };
const r = rows[0];
return {
active: true,
pid: r.pid,
query: r.query,
application_name: r.application_name ?? undefined,
};
} catch {
return { active: false };
}
}
/**
* Sweep invalid HNSW indexes on startup. Drops any pg_index row with
* indisvalid=false on tables we care about, AS LONG AS no active build
* is running for that index (codex Fix-5 zombie-cleanup guard).
*
* Postgres-only. PGLite returns { dropped: [] }.
*/
export async function dropZombieIndexes(
engine: BrainEngine,
tableNames: string[] = ['content_chunks', 'pages', 'takes'],
): Promise<{ dropped: string[] }> {
if (engine.kind !== 'postgres') return { dropped: [] };
const dropped: string[] = [];
try {
// Find invalid indexes on our tables.
const rows = await engine.executeRaw<{ indexname: string; tablename: string }>(
`SELECT i.relname AS indexname, t.relname AS tablename
FROM pg_index ix
JOIN pg_class i ON i.oid = ix.indexrelid
JOIN pg_class t ON t.oid = ix.indrelid
WHERE ix.indisvalid = false
AND t.relname = ANY($1)`,
[tableNames],
);
for (const r of rows) {
// Guard: skip if there's an active build for this index.
const active = await checkActiveBuild(engine, r.indexname);
if (active.active) {
process.stderr.write(`[hnsw] skipping zombie cleanup of ${r.indexname} — active build (pid ${active.pid})\n`);
continue;
}
try {
await engine.executeRaw(`DROP INDEX IF EXISTS ${r.indexname}`);
dropped.push(r.indexname);
process.stderr.write(`[hnsw] dropped zombie index ${r.indexname} on ${r.tablename}\n`);
} catch (err) {
process.stderr.write(`[hnsw] failed to drop ${r.indexname}: ${(err as Error).message}\n`);
}
}
} catch (err) {
// Best-effort: pg_stat_activity / pg_index queries may be restricted
// on managed Postgres tiers. Don't fail engine.connect() over it.
process.stderr.write(`[hnsw] zombie-index probe failed: ${(err as Error).message}\n`);
}
return { dropped };
}
/**
* Atomic-swap rebuild (A3): build new index with temp name, swap atomically.
*
* 1. Probe pg_stat_activity → bail if another build is active
* 2. Compose temp name: <name>_rebuild_<unix-ms>
* 3. CREATE INDEX <temp> with the spec's USING clause + condition
* 4. In a single transaction:
* DROP INDEX <name>
* ALTER INDEX <temp> RENAME TO <name>
* 5. If step 3 fails (OOM, timeout, conn drop), the old index is intact
* and search keeps serving queries. Caller can retry.
*
* The CREATE INDEX uses CONCURRENTLY so it doesn't block writes during the
* build; this requires `transaction:false` semantics so we route through
* engine.withReservedConnection.
*/
export async function dropAndRebuild(
engine: BrainEngine,
spec: IndexSpec,
opts: { reason: string; force?: boolean } = { reason: 'manual' },
): Promise<{ rebuilt: boolean; tempName: string }> {
if (engine.kind !== 'postgres') {
return { rebuilt: false, tempName: spec.name };
}
const active = await checkActiveBuild(engine, spec.name);
if (active.active && !opts.force) {
process.stderr.write(
`[hnsw] dropAndRebuild ${spec.name} aborted: active build pid ${active.pid} (${active.application_name ?? 'unknown'}). Pass --force to proceed anyway.\n`,
);
return { rebuilt: false, tempName: spec.name };
}
const ts = Date.now();
const tempName = `${spec.name}_rebuild_${ts}`;
const where = spec.condition ? ` WHERE ${spec.condition}` : '';
process.stderr.write(`[hnsw] rebuild ${spec.name}${tempName} (reason=${opts.reason})\n`);
// Step 3: build the new index (CONCURRENTLY) under a reserved connection.
await engine.withReservedConnection(async conn => {
await conn.executeRaw(
`CREATE INDEX CONCURRENTLY IF NOT EXISTS ${tempName} ON ${spec.table} USING ${spec.using}${where}`,
);
});
// Step 4: atomic swap inside a transaction.
await engine.transaction(async (tx) => {
const innerSql = (tx as unknown as { sql: any }).sql;
if (innerSql) {
await innerSql.unsafe(`DROP INDEX IF EXISTS ${spec.name}`);
await innerSql.unsafe(`ALTER INDEX ${tempName} RENAME TO ${spec.name}`);
}
});
process.stderr.write(`[hnsw] rebuild complete: ${spec.name}\n`);
return { rebuilt: true, tempName };
}
/**
* Poll pg_stat_activity to monitor a CREATE INDEX in progress. Reports
* elapsed time + progress (rows-built proxy via pg_stat_progress_create_index
* when available; falls back to relation size growth otherwise).
*
* Caller wraps a CREATE INDEX in a separate code path; this function is
* orthogonal — it just polls and emits progress lines.
*/
export interface BuildProgress {
elapsed_ms: number;
size_bytes?: number;
workers?: number;
pid?: number;
}
export async function monitorBuild(
engine: BrainEngine,
indexName: string,
onProgress: (status: BuildProgress) => void,
opts: { intervalMs?: number; maxIterations?: number } = {},
): Promise<void> {
if (engine.kind !== 'postgres') return;
const interval = opts.intervalMs ?? 30000;
const maxIterations = opts.maxIterations ?? 240; // 240 * 30s = 2h cap
const t0 = Date.now();
for (let i = 0; i < maxIterations; i++) {
const active = await checkActiveBuild(engine, indexName);
if (!active.active) return;
let size_bytes: number | undefined;
try {
const rows = await engine.executeRaw<{ size: number }>(
`SELECT pg_relation_size(c.oid) AS size FROM pg_class c WHERE c.relname = $1 LIMIT 1`,
[indexName],
);
if (rows[0]) size_bytes = Number(rows[0].size);
} catch { /* size probe optional */ }
onProgress({ elapsed_ms: Date.now() - t0, size_bytes, pid: active.pid });
await new Promise(r => setTimeout(r, interval));
}
}
/**
* Detect whether a CREATE INDEX query in pg_stat_activity is from Supabase
* auto-maintenance (vs. our gbrain process). Used by dropAndRebuild to
* back off when auto-maintenance is doing the rebuild for us.
*/
export function isSupabaseAutoMaintenance(active: ActiveBuildInfo): boolean {
if (!active.active) return false;
const appName = (active.application_name ?? '').toLowerCase();
return appName.includes('supabase') || appName.includes('postgres-meta');
}

178
test/backfill-base.test.ts Normal file
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);
});
});

View File

@@ -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)
});
});

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;
}
});
});

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);
});
});

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);
});
});

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');
});
});

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');

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);
});
});

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
test/url-redact.test.ts Normal file
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');
});
});

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);
});
});