Compare commits

..
Author SHA1 Message Date
Garry TanandClaude Fable 5 04577dd7b1 fix(apply-migrations): keep --list read-only — never auto-apply schema migrations when --list is combined with --yes/--non-interactive
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 12:15:28 -07:00
SinabinaandClaude Fable 5 2bfbb4104d fix(apply-migrations): stop reporting 'All migrations up to date' while schema is behind (#1530)
The pre-flight detected schema-version drift but only warned; the
orchestrator path then printed 'All migrations up to date.' and exited 0.
Now --yes/--non-interactive runs the schema migrations during the
pre-flight (engine already connected), and interactive runs exit 1 with a
pointer to --yes / --force-schema instead of the false all-clear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:21:57 -07:00
4 changed files with 120 additions and 103 deletions
+60 -12
View File
@@ -108,7 +108,7 @@ Flags:
Exit codes:
0 Success (including "nothing to do").
1 An orchestrator failed.
1 An orchestrator failed, or schema migrations are pending (re-run with --yes).
2 Invalid arguments.
`);
}
@@ -259,6 +259,41 @@ function printDryRun(plan: Plan, installed: string): void {
}
}
/**
* #1530: schema-drift pre-flight resolution. When the schema version is
* behind, `--yes`/`--non-interactive` runs the schema migrations right there
* (the engine is already connected); interactive runs warn and return true so
* the caller exits non-zero instead of claiming "All migrations up to date".
* All output goes to stderr (migrations never print to stdout).
*
* Returns true when the schema is STILL behind after this call.
*/
async function resolveSchemaBehind(opts: {
schemaVer: number;
latest: number;
autoApply: boolean;
run: () => Promise<{ applied: number; current: number }>;
}): Promise<boolean> {
const { schemaVer, latest, autoApply, run } = opts;
if (schemaVer >= latest) return false;
if (autoApply) {
console.error(`Schema version ${schemaVer} is behind latest ${latest}; running schema migrations...`);
try {
const result = await run();
console.error(`Applied ${result.applied} schema migration(s); now at v${result.current}.`);
return false;
} catch (err) {
console.error(`Schema migration failed: ${err instanceof Error ? err.message : String(err)}`);
return true;
}
}
console.warn(
`\n⚠️ Schema version ${schemaVer} is behind latest ${latest}.\n` +
` Run \`gbrain apply-migrations --yes\` to apply now, or \`gbrain init --migrate-only\`.\n`,
);
return true;
}
function orchestratorOptsFrom(cli: ApplyMigrationsArgs): OrchestratorOpts {
return {
yes: cli.yes || cli.nonInteractive,
@@ -354,10 +389,13 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
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
// to handle everything (Issue 1 from v0.18.0 field report).
// Pre-flight: detect schema migrations (migrate.ts) being behind.
// apply-migrations historically ran orchestrator migrations only; schema
// migrations run via connectEngine() / initSchema(). Users expect this CLI
// to handle everything (Issue 1 from v0.18.0 field report; #1530). With
// --yes/--non-interactive we apply them here; otherwise we warn and make
// sure the run does NOT report "All migrations up to date" with exit 0.
let schemaBehind = false;
try {
const { LATEST_VERSION } = await import('../core/migrate.ts');
const { loadConfig: lc, toEngineConfig } = await import('../core/config.ts');
@@ -377,14 +415,16 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
await eng.connect(toEngineConfig(cfg));
const verStr = await eng.getConfig('version');
const schemaVer = parseInt(verStr || '1', 10);
const { runMigrations } = await import('../core/migrate.ts');
schemaBehind = await resolveSchemaBehind({
schemaVer,
latest: LATEST_VERSION,
// --list and --dry-run are read-only surfaces: never mutate schema
// even when combined with --yes/--non-interactive.
autoApply: (cli.yes || cli.nonInteractive) && !cli.dryRun && !cli.list,
run: () => runMigrations(eng),
});
await eng.disconnect();
if (schemaVer < LATEST_VERSION) {
console.warn(
`\n⚠️ Schema version ${schemaVer} is behind latest ${LATEST_VERSION}.\n` +
` Schema migrations run automatically on next connectEngine() / initSchema().\n` +
` To run them now: gbrain init --migrate-only\n`,
);
}
}
}
} catch {
@@ -419,6 +459,13 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
const toRun: Migration[] = [...plan.partial, ...plan.pending];
if (toRun.length === 0) {
if (schemaBehind) {
console.error(
'Orchestrator migrations are up to date, but schema migrations are behind. ' +
'Run `gbrain apply-migrations --yes` (or `--force-schema`) to apply them.',
);
process.exit(1);
}
console.log('All migrations up to date.');
process.exit(0);
}
@@ -503,4 +550,5 @@ export const __testing = {
buildPlan,
indexCompleted,
statusForVersion,
resolveSchemaBehind,
};
+2 -50
View File
@@ -3,11 +3,7 @@
*
* Usage:
* gbrain migrate --to supabase [--url <connection_string>]
* (--url is persisted to config.json, mode 0600, so the migrated brain
* works without env — #1271)
* gbrain migrate --to pglite [--path <db_path>]
* (an explicit --path destination is bootstrapped with its own
* <path>/.gbrain/config.json so GBRAIN_HOME=<path> just works — #1271)
* gbrain migrate --to <engine> --force (overwrite non-empty target)
*/
@@ -15,9 +11,9 @@ import { createEngine } from '../core/engine-factory.ts';
import { loadConfig, saveConfig, toEngineConfig, gbrainPath, effectiveEnvDatabaseUrl, type GBrainConfig } from '../core/config.ts';
import type { BrainEngine } from '../core/engine.ts';
import type { EngineConfig } from '../core/types.ts';
import { writeFileSync, readFileSync, existsSync, unlinkSync, mkdirSync, chmodSync } from 'fs';
import { writeFileSync, readFileSync, existsSync, unlinkSync } from 'fs';
import { createHash } from 'crypto';
import { resolve, join } from 'path';
import { resolve } from 'path';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
@@ -63,31 +59,6 @@ export interface MigrateManifest {
started_at: string;
}
/**
* #1271 Finding 1: make an explicit `--to pglite --path P` destination usable
* as a standalone brain. Writes `P/.gbrain/config.json` (mode 0600, plus a
* `*` .gitignore) so `GBRAIN_HOME=P` resolves without a manual `gbrain init`.
* Never clobbers an existing config at the destination. Returns the written
* config path, or null when skipped.
*/
export function bootstrapDestinationConfig(dbPath: string): string | null {
const abs = resolve(dbPath);
const dir = join(abs, '.gbrain');
const file = join(dir, 'config.json');
if (existsSync(file)) return null;
mkdirSync(dir, { recursive: true });
const cfg: GBrainConfig = { engine: 'pglite', database_path: abs };
writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
try { chmodSync(file, 0o600); } catch { /* platform-specific */ }
// Same worktree-safety pattern as saveConfig()'s ensureGitignore, scoped
// to the destination home. Don't clobber a user-customized .gitignore.
const gitignore = join(dir, '.gitignore');
if (!existsSync(gitignore)) {
writeFileSync(gitignore, '*\n', { mode: 0o600 });
}
return file;
}
export function migrationTargetId(config: EngineConfig): string {
const locator = config.engine === 'postgres'
? config.database_url ?? ''
@@ -381,25 +352,6 @@ export async function runMigrateEngine(sourceEngine: BrainEngine, args: string[]
};
saveConfig(newConfig);
// #1271 Finding 2 (by design, but say it out loud): the connection string
// is persisted so the migrated brain works without env. Mode 0600.
if (opts.targetEngine === 'postgres' && opts.targetUrl) {
console.error('Note: the --url connection string (including credentials) is persisted to config.json (mode 0600).');
}
// #1271 Finding 1: an explicit --path destination doubles as a standalone
// GBRAIN_HOME. Best-effort — never fail a completed migration over it.
if (opts.targetEngine === 'pglite' && opts.targetPath) {
try {
const written = bootstrapDestinationConfig(opts.targetPath);
if (written) {
console.log(`Destination bootstrapped: ${written} (usable via GBRAIN_HOME=${resolve(opts.targetPath)})`);
}
} catch (e) {
console.warn(` WARN could not bootstrap destination config: ${e instanceof Error ? e.message : String(e)}`);
}
}
// Clean up
clearManifest();
+58 -1
View File
@@ -10,7 +10,7 @@ import { describe, test, expect } from 'bun:test';
import { __testing } from '../src/commands/apply-migrations.ts';
import type { CompletedMigrationEntry } from '../src/core/preferences.ts';
const { parseArgs, indexCompleted, buildPlan, statusForVersion } = __testing;
const { parseArgs, indexCompleted, buildPlan, statusForVersion, resolveSchemaBehind } = __testing;
describe('parseArgs', () => {
test('default flags', () => {
@@ -180,3 +180,60 @@ describe('runApplyMigrations exit codes (v0.36.1.x #1062)', () => {
expect(src).toMatch(/All migrations up to date[\s\S]{0,80}process\.exit\(0\)/);
});
});
// #1530: apply-migrations must not report "All migrations up to date" (exit 0)
// while the SCHEMA is behind. --yes runs the schema migrations in the
// pre-flight; interactive runs flag schemaBehind and exit 1.
describe('resolveSchemaBehind (#1530)', () => {
test('schema up to date → false, migrations not run', async () => {
let ran = false;
const behind = await resolveSchemaBehind({
schemaVer: 5,
latest: 5,
autoApply: true,
run: async () => { ran = true; return { applied: 0, current: 5 }; },
});
expect(behind).toBe(false);
expect(ran).toBe(false);
});
test('behind + autoApply → runs schema migrations, no longer behind', async () => {
let ran = false;
const behind = await resolveSchemaBehind({
schemaVer: 3,
latest: 5,
autoApply: true,
run: async () => { ran = true; return { applied: 2, current: 5 }; },
});
expect(behind).toBe(false);
expect(ran).toBe(true);
});
test('behind + interactive → warns and stays behind, migrations not run', async () => {
let ran = false;
const behind = await resolveSchemaBehind({
schemaVer: 3,
latest: 5,
autoApply: false,
run: async () => { ran = true; return { applied: 2, current: 5 }; },
});
expect(behind).toBe(true);
expect(ran).toBe(false);
});
test('behind + autoApply + migration failure → stays behind', async () => {
const behind = await resolveSchemaBehind({
schemaVer: 3,
latest: 5,
autoApply: true,
run: async () => { throw new Error('boom'); },
});
expect(behind).toBe(true);
});
test('up-to-date branch exits 1 when schemaBehind (source shape)', async () => {
const { readFileSync } = await import('fs');
const src = readFileSync('src/commands/apply-migrations.ts', 'utf8');
expect(src).toMatch(/if \(schemaBehind\)[\s\S]{0,300}process\.exit\(1\)[\s\S]{0,120}All migrations up to date/);
});
});
@@ -1,40 +0,0 @@
import { describe, expect, test } from 'bun:test';
import { mkdtempSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join, resolve } from 'path';
import { bootstrapDestinationConfig } from '../src/commands/migrate-engine.ts';
import { loadConfigFileOnly } from '../src/core/config.ts';
import { withEnv } from './helpers/with-env.ts';
describe('migrate --to pglite destination bootstrap (#1271)', () => {
test('writes <path>/.gbrain/config.json so GBRAIN_HOME=<path> resolves a brain', async () => {
const dest = mkdtempSync(join(tmpdir(), 'gbrain-dest-'));
const written = bootstrapDestinationConfig(dest);
const file = join(dest, '.gbrain', 'config.json');
expect(written).toBe(file);
const cfg = JSON.parse(readFileSync(file, 'utf-8'));
expect(cfg.engine).toBe('pglite');
expect(cfg.database_path).toBe(resolve(dest));
expect(statSync(file).mode & 0o777).toBe(0o600);
// worktree safety: destination home is git-ignored like saveConfig()'s home
expect(readFileSync(join(dest, '.gbrain', '.gitignore'), 'utf-8')).toBe('*\n');
// The exact failure mode from #1271: config resolution under
// GBRAIN_HOME=<path> used to find nothing ("No brain configured").
await withEnv({ GBRAIN_HOME: dest }, () => {
const loaded = loadConfigFileOnly();
expect(loaded?.engine).toBe('pglite');
expect(loaded?.database_path).toBe(resolve(dest));
});
});
test('never clobbers an existing destination config', () => {
const dest = mkdtempSync(join(tmpdir(), 'gbrain-dest-'));
mkdirSync(join(dest, '.gbrain'), { recursive: true });
writeFileSync(join(dest, '.gbrain', 'config.json'), '{"engine":"postgres"}\n');
expect(bootstrapDestinationConfig(dest)).toBe(null);
expect(JSON.parse(readFileSync(join(dest, '.gbrain', 'config.json'), 'utf-8')).engine).toBe('postgres');
});
});