diff --git a/src/commands/migrations/v0_28_0.ts b/src/commands/migrations/v0_28_0.ts index 7742ced80..abbb77bd3 100644 --- a/src/commands/migrations/v0_28_0.ts +++ b/src/commands/migrations/v0_28_0.ts @@ -63,11 +63,23 @@ async function phaseASchema( const versionStr = await engine.getConfig('version'); const v = parseInt(versionStr || '0', 10); if (v < 38) { - return { - name: 'schema', - status: 'failed', - detail: `expected schema version >= 38 (takes + access_tokens.permissions); got ${v}. Run \`gbrain apply-migrations --yes\` to apply.`, - }; + // Cold-start path: schema migrations haven't run yet (e.g. direct + // upgrade from v0.22 without going through `gbrain upgrade`). Apply + // them now rather than failing — this is exactly what --force-schema + // does in apply-migrations.ts and what gbrain init --migrate-only + // does, but inline so the user doesn't need a two-step command. + console.log(` Schema version ${v} < 38; applying pending schema migrations...`); + await engine.initSchema(); + // Re-read version after apply to confirm we reached v38. + const newVerStr = await engine.getConfig('version'); + const newV = parseInt(newVerStr || '0', 10); + if (newV < 38) { + return { + name: 'schema', + status: 'failed', + detail: `schema migration applied but version is still ${newV} (expected >= 38). Check for DDL errors above.`, + }; + } } // Quick post-condition: takes + synthesis_evidence tables exist const rows = await engine.executeRaw<{ tablename: string }>( diff --git a/src/commands/migrations/v0_29_1.ts b/src/commands/migrations/v0_29_1.ts index 38b677d6c..2e90fb45d 100644 --- a/src/commands/migrations/v0_29_1.ts +++ b/src/commands/migrations/v0_29_1.ts @@ -48,26 +48,32 @@ async function phaseBBackfill(opts: OrchestratorOpts): Promise { - totalExamined = cumulative; - totalUpdated += rowsTouched; - if (batch % 10 === 0) { - process.stderr.write(` [backfill] batch ${batch} | last_id=${lastId} | examined=${cumulative} | updated_so_far=${totalUpdated}\n`); - } - }, - }); + try { + const result = await backfillEffectiveDate(engine, { + onBatch: ({ batch, lastId, rowsTouched, cumulative }) => { + totalExamined = cumulative; + totalUpdated += rowsTouched; + if (batch % 10 === 0) { + process.stderr.write(` [backfill] batch ${batch} | last_id=${lastId} | examined=${cumulative} | updated_so_far=${totalUpdated}\n`); + } + }, + }); - return { - name: 'backfill_effective_date', - status: 'complete', - detail: `examined=${result.examined} updated=${result.updated} fallback=${result.fallback} dur=${result.durationSec.toFixed(1)}s`, - }; + return { + name: 'backfill_effective_date', + status: 'complete', + detail: `examined=${result.examined} updated=${result.updated} fallback=${result.fallback} dur=${result.durationSec.toFixed(1)}s`, + }; + } finally { + try { await engine.disconnect(); } catch { /* best-effort */ } + } } catch (e) { return { name: 'backfill_effective_date', status: 'failed', detail: e instanceof Error ? e.message : String(e) }; } @@ -82,23 +88,29 @@ async function phaseCVerify(opts: OrchestratorOpts): Promise( - `SELECT COUNT(*)::text AS count FROM pages WHERE effective_date IS NULL`, - ); - const remaining = Number(rows[0]?.count ?? 0); - if (remaining > 0) { - return { - name: 'verify', - status: 'failed', - detail: `${remaining} pages still have NULL effective_date (backfill incomplete)`, - }; + const engineConfig = toEngineConfig(cfg); + const engine = await createEngine(engineConfig); + await engine.connect(engineConfig); + try { + // Count rows where effective_date is still NULL but frontmatter HAS a + // parseable date — those are the rows the backfill should have touched + // but didn't. (Rows that fall through to 'fallback' have non-null + // effective_date already; this catches genuine misses.) + const rows = await engine.executeRaw<{ count: string }>( + `SELECT COUNT(*)::text AS count FROM pages WHERE effective_date IS NULL`, + ); + const remaining = Number(rows[0]?.count ?? 0); + if (remaining > 0) { + return { + name: 'verify', + status: 'failed', + detail: `${remaining} pages still have NULL effective_date (backfill incomplete)`, + }; + } + return { name: 'verify', status: 'complete', detail: '0 pages with NULL effective_date' }; + } finally { + try { await engine.disconnect(); } catch { /* best-effort */ } } - return { name: 'verify', status: 'complete', detail: '0 pages with NULL effective_date' }; } catch (e) { return { name: 'verify', status: 'failed', detail: e instanceof Error ? e.message : String(e) }; } diff --git a/test/migration-orchestrator-v0_29_1.test.ts b/test/migration-orchestrator-v0_29_1.test.ts new file mode 100644 index 000000000..9b1f3fe31 --- /dev/null +++ b/test/migration-orchestrator-v0_29_1.test.ts @@ -0,0 +1,67 @@ +/** + * v0.29.1 orchestrator contract tests. + * + * Validates registration, phase shape, and dry-run behavior without + * actually running `gbrain init --migrate-only` or touching a database. + * + * Added as part of the cold-start robustness fix (2026-05-08): + * Bug 1 — phaseBBackfill / phaseCVerify were missing engine.connect() + * calls, causing "No database connection" on any cold-start + * upgrade from a pre-v0.29.1 schema. + */ + +import { describe, test, expect } from 'bun:test'; + +describe('v0.29.1 orchestrator — backfill effective_date', () => { + test('registered in the TS migration registry', async () => { + const { migrations, getMigration } = await import('../src/commands/migrations/index.ts'); + const versions = migrations.map(m => m.version); + expect(versions).toContain('0.29.1'); + const m = getMigration('0.29.1'); + expect(m).not.toBeNull(); + expect(m!.featurePitch.headline).toContain('salience'); + expect(typeof m!.orchestrator).toBe('function'); + }); + + test('v0.29.1 comes after v0.28.0 in registry order', async () => { + const { migrations } = await import('../src/commands/migrations/index.ts'); + const versions = migrations.map(m => m.version); + const idx28 = versions.indexOf('0.28.0'); + const idx29 = versions.indexOf('0.29.1'); + expect(idx28).toBeGreaterThanOrEqual(0); + expect(idx29).toBeGreaterThan(idx28); + }); + + test('phase functions exported for unit testing', async () => { + const { __testing } = await import('../src/commands/migrations/v0_29_1.ts'); + expect(typeof __testing.phaseASchema).toBe('function'); + expect(typeof __testing.phaseBBackfill).toBe('function'); + expect(typeof __testing.phaseCVerify).toBe('function'); + }); + + test('dry-run skips all side-effect phases', async () => { + const { v0_29_1 } = await import('../src/commands/migrations/v0_29_1.ts'); + const result = await v0_29_1.orchestrator({ + yes: true, + dryRun: true, + noAutopilotInstall: true, + }); + expect(result.version).toBe('0.29.1'); + expect(result.phases.length).toBeGreaterThanOrEqual(3); + const skippedCount = result.phases.filter(p => p.status === 'skipped').length; + expect(skippedCount).toBe(result.phases.length); // all phases skip in dry-run + }); + + test('dry-run phase names match expected shape (schema, backfill_effective_date, verify)', async () => { + const { v0_29_1 } = await import('../src/commands/migrations/v0_29_1.ts'); + const result = await v0_29_1.orchestrator({ + yes: true, + dryRun: true, + noAutopilotInstall: true, + }); + const names = result.phases.map(p => p.name); + expect(names).toContain('schema'); + expect(names).toContain('backfill_effective_date'); + expect(names).toContain('verify'); + }); +});