Files
gbrain/test/migration-in-process.serial.test.ts
T
d7c9625395 v0.42.66.0 test(pglite): add CLI-level regression coverage for pre-v121 schema replay (#2775) (#3438)
#2775 reported that `gbrain init --migrate-only` fails with
`column "event_page_id" does not exist` on PGLite brains predating
migration v121, because PGLiteEngine#initSchema() replayed the embedded
schema blob (which indexes timeline_entries.event_page_id) before
runMigrations() could add the column.

That ordering bug was already fixed on master by #2735 (which resolved
the Postgres-side report of the same bug, #2724) via a forward-reference
bootstrap probe in both pglite-engine.ts and postgres-engine.ts, with
coverage in test/bootstrap.test.ts and
test/schema-bootstrap-coverage.test.ts.

Add a regression test at the actual CLI-facing entry point
(runMigrateOnlyCore, what `gbrain init --migrate-only` calls) against a
downgraded pre-v121 brain, closing the gap between the existing
engine-method-level tests and the command users actually run. Verified
this test fails with the exact reported error when the bootstrap probe
is neutralized, and passes with it in place.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:14:03 -07:00

184 lines
7.9 KiB
TypeScript

// v0.41.37.0 #1605 — migration schema phases run IN-PROCESS (was a
// `gbrain init --migrate-only` subprocess that died with getaddrinfo ENOTFOUND
// on Windows+bun+Supabase). runMigrateOnlyCore is the single in-process path;
// runGbrainSubprocess captures child stderr for the remaining backfill spawns.
import { describe, test, expect } from 'bun:test';
import { tmpdir } from 'os';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from 'fs';
import { join } from 'path';
import { withEnv } from './helpers/with-env.ts';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { LATEST_VERSION } from '../src/core/migrate.ts';
import {
runMigrateOnlyCore,
runGbrainSubprocess,
MigrateOnlyError,
} from '../src/commands/migrations/in-process.ts';
const MIGRATION_DIR = join(import.meta.dir, '..', 'src', 'commands', 'migrations');
const SCHEMA_PHASE_FILES = [
'v0_11_0', 'v0_12_0', 'v0_12_2', 'v0_13_0', 'v0_16_0',
'v0_18_0', 'v0_18_1', 'v0_21_0', 'v0_29_1',
];
describe('#1605 runMigrateOnlyCore (in-process schema)', () => {
test('brings a fresh PGLite brain to head without spawning', async () => {
const home = mkdtempSync(join(tmpdir(), 'mip-'));
const dataDir = join(home, 'data');
mkdirSync(join(home, '.gbrain'), { recursive: true });
writeFileSync(
join(home, '.gbrain', 'config.json'),
JSON.stringify({ engine: 'pglite', database_path: dataDir }),
);
const result = await withEnv(
{ GBRAIN_HOME: home, DATABASE_URL: undefined, GBRAIN_DATABASE_URL: undefined },
() => runMigrateOnlyCore(),
);
expect(result.engine).toBe('pglite');
// Verify schema landed: reconnect a fresh engine to the same data dir and
// confirm a core table exists (proves initSchema ran in-process).
const verify = new PGLiteEngine();
await verify.connect({ database_path: dataDir });
try {
const rows = await verify.executeRaw<{ t: string | null }>(
"SELECT to_regclass('public.pages')::text AS t",
);
expect(rows[0]?.t).toBe('pages');
} finally {
await verify.disconnect();
}
});
// Regression coverage for #2775: `gbrain init --migrate-only` (this
// function, in-process) failed with `column "event_page_id" does not
// exist` on any PGLite brain whose schema predates migration v121, because
// `PGLiteEngine#initSchema` replayed the embedded schema blob — which
// indexes `timeline_entries.event_page_id` — BEFORE `runMigrations()` had
// a chance to add that column. The fix (forward-reference bootstrap probe
// for `timeline_entries.event_page_id`) already shipped in #2735 (closing
// #2724, the Postgres-side report of the same ordering bug) ahead of this
// test. `test/bootstrap.test.ts` and `test/schema-bootstrap-coverage.test.ts`
// already cover the bootstrap contract at the engine-method level; this
// test closes the remaining gap by exercising the exact CLI-facing entry
// point (`runMigrateOnlyCore`, i.e. `gbrain init --migrate-only`) so a
// future regression in the wiring between the CLI and `initSchema()` would
// still be caught even if the lower-level bootstrap contract stayed intact.
test('brings a pre-v121 PGLite brain (missing timeline_entries.event_page_id) to head without spawning (#2775)', async () => {
const home = mkdtempSync(join(tmpdir(), 'mip-pre121-'));
const dataDir = join(home, 'data');
mkdirSync(join(home, '.gbrain'), { recursive: true });
writeFileSync(
join(home, '.gbrain', 'config.json'),
JSON.stringify({ engine: 'pglite', database_path: dataDir }),
);
await withEnv(
{ GBRAIN_HOME: home, DATABASE_URL: undefined, GBRAIN_DATABASE_URL: undefined },
async () => {
// Bring the brain to LATEST first (fresh install), then simulate a
// pre-v121 brain by stripping the forward-referenced column/indexes
// migration v121 added and rolling `config.version` back to a
// pre-v121 value — the same down-mutation pattern used by
// test/bootstrap.test.ts's "pre-v121 timeline shape" case.
await runMigrateOnlyCore();
const rollback = new PGLiteEngine();
await rollback.connect({ database_path: dataDir });
try {
await (rollback as any).db.exec(`
DROP INDEX IF EXISTS idx_timeline_event_page;
DROP INDEX IF EXISTS idx_timeline_event_dedup;
ALTER TABLE timeline_entries DROP CONSTRAINT IF EXISTS timeline_entries_event_page_id_fkey;
ALTER TABLE timeline_entries DROP COLUMN IF EXISTS event_page_id;
`);
await rollback.setConfig('version', '97');
} finally {
await rollback.disconnect();
}
// The literal repro from #2775: re-running the `gbrain init
// --migrate-only` code path against the downgraded brain must NOT
// throw `column "event_page_id" does not exist` — it must bring the
// schema back to LATEST_VERSION.
const result = await runMigrateOnlyCore();
expect(result.engine).toBe('pglite');
},
);
const verify = new PGLiteEngine();
await verify.connect({ database_path: dataDir });
try {
const versionStr = await verify.getConfig('version');
expect(parseInt(versionStr || '0', 10)).toBe(LATEST_VERSION);
const rows = await verify.executeRaw<{ t: string | null }>(
"SELECT to_regclass('public.idx_timeline_event_page')::text AS t",
);
expect(rows[0]?.t).toBe('idx_timeline_event_page');
} finally {
await verify.disconnect();
}
});
test('throws MigrateOnlyError when no brain is configured', async () => {
const home = mkdtempSync(join(tmpdir(), 'mip-noconf-'));
await expect(
withEnv(
{ GBRAIN_HOME: home, DATABASE_URL: undefined, GBRAIN_DATABASE_URL: undefined },
() => runMigrateOnlyCore(),
),
).rejects.toBeInstanceOf(MigrateOnlyError);
});
});
describe('#1605 runGbrainSubprocess (stderr capture)', () => {
test('folds child stderr into the thrown error', () => {
let msg = '';
try {
runGbrainSubprocess("sh -c 'echo BOOM_STDERR 1>&2; exit 1'");
} catch (e) {
msg = e instanceof Error ? e.message : String(e);
}
expect(msg).toContain('BOOM_STDERR');
});
test('returns child stdout on success', () => {
const out = runGbrainSubprocess("sh -c 'echo hello-stdout'");
expect(out).toContain('hello-stdout');
});
});
describe('#1605 structural guard: schema phases are in-process', () => {
test('no schema phase still execSyncs `gbrain init --migrate-only`', () => {
for (const f of SCHEMA_PHASE_FILES) {
const src = readFileSync(join(MIGRATION_DIR, `${f}.ts`), 'utf-8');
expect(src).not.toContain("execSync('gbrain init --migrate-only'");
}
});
test('every schema phase calls runMigrateOnlyCore + is awaited', () => {
for (const f of SCHEMA_PHASE_FILES) {
const src = readFileSync(join(MIGRATION_DIR, `${f}.ts`), 'utf-8');
expect(src).toContain('runMigrateOnlyCore()');
// phaseASchema must be async + awaited at its call site.
expect(src).toContain('async function phaseASchema');
const awaited = src.includes('await phaseASchema(opts)') ||
src.includes('push(await phaseASchema(opts))');
expect(awaited).toBe(true);
}
});
test('NO migration orchestrator anywhere spawns `gbrain init --migrate-only`', () => {
// All-files invariant (not just the 9): the subprocess spawn is the
// Windows-ENOTFOUND bug class. Other files (v0_22_4, v0_28_0, v0_31_0,
// v0_14_0, v0_32_2) define an in-process phaseASchema that never spawned —
// those are fine. We only ban the spawn literal.
const files = readdirSync(MIGRATION_DIR).filter(n => /^v\d/.test(n) && n.endsWith('.ts'));
for (const n of files) {
const src = readFileSync(join(MIGRATION_DIR, n), 'utf-8');
expect(src).not.toContain("execSync('gbrain init --migrate-only'");
}
});
});