diff --git a/src/cli.ts b/src/cli.ts index d8906a0da..c8560c2eb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -646,6 +646,25 @@ async function connectEngine(): Promise { process.env.GBRAIN_NO_RETRY_CONNECT === '1'; const { connectWithRetry } = await import('./core/db.ts'); await connectWithRetry(engine, toEngineConfig(config), { noRetry }); + + // 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. + // This is the conditional version of #652 (oyi77's investigation): + // same correctness, no perf regression on the hot path. + try { + const { hasPendingMigrations } = await import('./core/migrate.ts'); + if (await hasPendingMigrations(engine)) { + await engine.initSchema(); + } + } catch (err) { + // Non-fatal: if probe or initSchema fails, surface a hint and continue + // with the connected engine. Subsequent operations will surface the + // real schema error in context. + console.warn(` Schema probe/migrate failed: ${(err as Error).message}`); + console.warn(' Try: gbrain init --migrate-only'); + } + return engine; } diff --git a/src/core/migrate.ts b/src/core/migrate.ts index 10afefd2e..09ce97301 100644 --- a/src/core/migrate.ts +++ b/src/core/migrate.ts @@ -1649,6 +1649,32 @@ async function runMigrationSQL( } } +/** + * Cheap probe: does this engine have schema migrations pending? + * + * Reads the `version` config row in a single round-trip (no schema replay, + * no migration apply). Used by `connectEngine` to gate `initSchema()` so + * short-lived CLI invocations on already-migrated brains don't pay the + * full bootstrap-probe + SCHEMA_SQL replay + ledger-check cost on every + * `gbrain stats` / `gbrain query` / `gbrain doctor`. + * + * Defensive: treats a getConfig failure (config table missing, query error) + * as "yes pending" so the caller falls through to the full initSchema path. + * Worst case on a wedged brain is one extra schema replay — same as before. + * + * Closes #651 in cooperation with the post-upgrade auto-apply hook (X1) + * without the perf cost #652 would have introduced on every CLI call. + */ +export async function hasPendingMigrations(engine: BrainEngine): Promise { + try { + const currentStr = await engine.getConfig('version'); + const current = parseInt(currentStr || '1', 10); + return current < LATEST_VERSION; + } catch { + return true; + } +} + export async function runMigrations(engine: BrainEngine): Promise<{ applied: number; current: number }> { const currentStr = await engine.getConfig('version'); const current = parseInt(currentStr || '1', 10); diff --git a/test/migrate.test.ts b/test/migrate.test.ts index 88dda1b05..55b102d81 100644 --- a/test/migrate.test.ts +++ b/test/migrate.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, beforeAll, afterAll, spyOn } from 'bun:test'; -import { LATEST_VERSION, runMigrations, MIGRATIONS, getIdleBlockers } from '../src/core/migrate.ts'; +import { LATEST_VERSION, runMigrations, MIGRATIONS, getIdleBlockers, hasPendingMigrations } from '../src/core/migrate.ts'; import type { IdleBlocker } from '../src/core/migrate.ts'; import type { BrainEngine } from '../src/core/engine.ts'; import { PGLiteEngine } from '../src/core/pglite-engine.ts'; @@ -20,6 +20,49 @@ describe('migrate', () => { // and are covered in the E2E suite (test/e2e/mechanical.test.ts) }); +// v0.28.5 — A1: cheap probe used by `connectEngine` to gate `initSchema()` +// so already-migrated brains don't pay the schema-replay cost on every +// short-lived CLI invocation. Closes #651 in cooperation with X1's +// post-upgrade auto-apply, without #652's perf regression. +describe('hasPendingMigrations', () => { + test('returns false on a fully-migrated brain (version === LATEST)', async () => { + const engine = new PGLiteEngine(); + await engine.connect({}); + try { + await engine.initSchema(); // applies all migrations through LATEST_VERSION + expect(await hasPendingMigrations(engine)).toBe(false); + } finally { + await engine.disconnect(); + } + }, 30000); + + test('returns true when version config is behind LATEST_VERSION', async () => { + const engine = new PGLiteEngine(); + await engine.connect({}); + try { + await engine.initSchema(); + // Simulate an older brain by rewinding the version row. + await engine.setConfig('version', '1'); + expect(await hasPendingMigrations(engine)).toBe(true); + } finally { + await engine.disconnect(); + } + }, 30000); + + test('returns true when version config is missing entirely (defensive default)', async () => { + const engine = new PGLiteEngine(); + await engine.connect({}); + try { + // Don't call initSchema. Probe against an empty PGlite — getConfig should + // either return null (treated as version=1) or throw on missing config + // table; either way the probe must say "yes pending." + expect(await hasPendingMigrations(engine)).toBe(true); + } finally { + await engine.disconnect(); + } + }, 30000); +}); + // ───────────────────────────────────────────────────────────────── // v0.18.0 — v16 sources_table_additive (Step 1, Lane A) // ─────────────────────────────────────────────────────────────────