mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-17 02:12:40 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04577dd7b1 | ||
|
|
2bfbb4104d |
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user