Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions src/commands/migrations/v0_28_0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>(
Expand Down
74 changes: 43 additions & 31 deletions src/commands/migrations/v0_29_1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,26 +48,32 @@ async function phaseBBackfill(opts: OrchestratorOpts): Promise<OrchestratorPhase
const { backfillEffectiveDate } = await import('../../core/backfill-effective-date.ts');
const cfg = loadConfig();
if (!cfg) throw new Error('No gbrain config; run `gbrain init` first.');
const engine = await createEngine(toEngineConfig(cfg));
const engineConfig = toEngineConfig(cfg);
const engine = await createEngine(engineConfig);
await engine.connect(engineConfig);

let totalExamined = 0;
let totalUpdated = 0;

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`);
}
},
});
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) };
}
Expand All @@ -82,23 +88,29 @@ async function phaseCVerify(opts: OrchestratorOpts): Promise<OrchestratorPhaseRe
const { loadConfig, toEngineConfig } = await import('../../core/config.ts');
const cfg = loadConfig();
if (!cfg) throw new Error('No gbrain config; run `gbrain init` first.');
const engine = await createEngine(toEngineConfig(cfg));
// 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)`,
};
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) };
}
Expand Down
67 changes: 67 additions & 0 deletions test/migration-orchestrator-v0_29_1.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading