mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-17 10:22:34 +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,
|
||||
};
|
||||
|
||||
@@ -1743,15 +1743,7 @@ async function extractStaleFromDB(
|
||||
// `page.updated_at.toISOString()` — the JS Date is ms-truncated, so the
|
||||
// µs-precision DB updated_at stayed strictly greater and the page never
|
||||
// cleared on Postgres. Stamping the exact value makes them equal.
|
||||
//
|
||||
// Version-arm floor: a page last edited BEFORE LINK_EXTRACTOR_VERSION_TS
|
||||
// would otherwise be stamped below the version watermark and stay
|
||||
// permanently stale (`links_extracted_at < versionTs` re-fires every run).
|
||||
// Stamp max(updated_at, versionTs) — versionTs is always a past release
|
||||
// date, so a concurrent edit's now() still exceeds the stamp and D4 holds.
|
||||
// Tie at ms precision picks updated_at_iso (its µs ≥ versionTs's .000000).
|
||||
const stampTs = new Date(page.updated_at_iso) >= new Date(versionTs) ? page.updated_at_iso : versionTs;
|
||||
processedRefs.push({ slug: page.slug, source_id: page.source_id, extractedAt: stampTs });
|
||||
processedRefs.push({ slug: page.slug, source_id: page.source_id, extractedAt: page.updated_at_iso });
|
||||
}
|
||||
|
||||
// Flush NON-swallowing (CDX-4): a throw here propagates out of the sweep so
|
||||
|
||||
@@ -28,7 +28,7 @@ import { ensureWellFormed } from './text-safe.ts';
|
||||
* OR updated_at > links_extracted_at`. It is an ISO-8601 string (NOT a number) —
|
||||
* the column is TIMESTAMPTZ and the predicate binds it as `::timestamptz`.
|
||||
*/
|
||||
export const LINK_EXTRACTOR_VERSION_TS = '2026-07-21T00:00:00Z';
|
||||
export const LINK_EXTRACTOR_VERSION_TS = '2026-05-31T00:00:00Z';
|
||||
|
||||
// ─── Entity references ──────────────────────────────────────────
|
||||
|
||||
@@ -80,10 +80,10 @@ export type LinkResolutionType = 'qualified' | 'unqualified';
|
||||
* Directory prefix whitelist. These are the top-level slug dirs the extractor
|
||||
* recognizes as entity references. Upstream canonical + our extensions:
|
||||
* - Gbrain canonical: people, companies, meetings, concepts, deal, civic, project, source, media, yc, projects
|
||||
* - Our domain extensions: tech, finance, personal, openclaw, ops (domain-organized wikis)
|
||||
* - Our domain extensions: tech, finance, personal, openclaw (domain-organized wikis)
|
||||
* - Our entity prefix: entities (we kept some legacy entities/projects/ pages)
|
||||
*/
|
||||
const DIR_PATTERN = '(?:people|companies|meetings|concepts|deal|civic|project|projects|source|media|yc|tech|finance|personal|openclaw|entities|ops)';
|
||||
const DIR_PATTERN = '(?:people|companies|meetings|concepts|deal|civic|project|projects|source|media|yc|tech|finance|personal|openclaw|entities)';
|
||||
|
||||
/**
|
||||
* Match `[Name](path)` markdown links pointing to entity directories.
|
||||
@@ -865,16 +865,7 @@ export function queryBasenameIndex(idx: Map<string, string[]>, name: string): st
|
||||
if (!name || typeof name !== 'string') return [];
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return [];
|
||||
let hit = idx.get(trimmed) ?? idx.get(trimmed.toLowerCase()) ?? idx.get(normalizeBasename(trimmed));
|
||||
// Issue #2576 bug 2: path-style refs (`runbooks/2026-05-01-x`) from dirs
|
||||
// outside DIR_PATTERN reach here, but normalizeBasename strips slashes
|
||||
// into a garbage key (`runbooks2026-05-01-x`) that can never hit the
|
||||
// tail-keyed index. Fall back to the path tail so qualified refs resolve
|
||||
// by basename like everything else.
|
||||
if (!hit && trimmed.includes('/')) {
|
||||
const tail = trimmed.slice(trimmed.lastIndexOf('/') + 1).trim();
|
||||
if (tail) hit = idx.get(tail) ?? idx.get(tail.toLowerCase()) ?? idx.get(normalizeBasename(tail));
|
||||
}
|
||||
const hit = idx.get(trimmed) ?? idx.get(trimmed.toLowerCase()) ?? idx.get(normalizeBasename(trimmed));
|
||||
return hit ? [...hit].sort(basenameSort) : [];
|
||||
}
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -209,24 +209,6 @@ describe('gbrain extract --stale', () => {
|
||||
expect(usRows[0]?.eq).toBe(true);
|
||||
});
|
||||
|
||||
test('version-arm floor: page edited BEFORE LINK_EXTRACTOR_VERSION_TS clears after --stale (issue #2576 bug 3)', async () => {
|
||||
// A page whose updated_at predates the version watermark used to be
|
||||
// stamped at its updated_at (< versionTs), so the version arm re-fired
|
||||
// every run — permanently stale. The sweep now floors the stamp at
|
||||
// versionTs. (The #1768 test above also covers this since the v0.42.x
|
||||
// VERSION_TS bump moved its date below the watermark, but this pins the
|
||||
// behavior explicitly so a date "repair" there can't drop coverage.)
|
||||
await engine.putPage('people/alice', personPage('Alice'));
|
||||
await engine.executeRaw(`UPDATE pages SET updated_at = '2000-01-01T00:00:00Z' WHERE slug = 'people/alice'`);
|
||||
expect(await engine.countStalePagesForExtraction({ versionTs: LINK_EXTRACTOR_VERSION_TS })).toBe(1);
|
||||
|
||||
await runExtract(engine, ['--stale']);
|
||||
// Pre-floor this stayed 1 forever (stamp < versionTs → version arm re-fires).
|
||||
expect(await engine.countStalePagesForExtraction({ versionTs: LINK_EXTRACTOR_VERSION_TS })).toBe(0);
|
||||
await runExtract(engine, ['--stale']);
|
||||
expect(await engine.countStalePagesForExtraction({ versionTs: LINK_EXTRACTOR_VERSION_TS })).toBe(0);
|
||||
});
|
||||
|
||||
test('CDX-4 (D2): a link-flush throw aborts the sweep and leaves pages UNSTAMPED', async () => {
|
||||
await engine.putPage('people/alice', personPage('Alice'));
|
||||
await engine.putPage('companies/acme', companyPage('Acme', '[Alice](people/alice) founded [Acme](companies/acme).'));
|
||||
|
||||
@@ -140,15 +140,6 @@ describe('extractEntityRefs', () => {
|
||||
expect(wikiRefs[0].needsResolution).toBe(true);
|
||||
});
|
||||
|
||||
test('recognizes ops/ qualified wikilinks (issue #2576 bug 2)', () => {
|
||||
// `ops` was missing from DIR_PATTERN, so [[ops/...]] fell through to
|
||||
// the generic 2c pass (needsResolution) instead of being a real ref.
|
||||
const refs = extractEntityRefs('Deployed via [[ops/services/pointer-agent]].');
|
||||
expect(refs.length).toBe(1);
|
||||
expect(refs[0].slug).toBe('ops/services/pointer-agent');
|
||||
expect(refs[0].needsResolution).toBeUndefined();
|
||||
});
|
||||
|
||||
test('skips qualified-syntax tokens (those belong to 2a)', () => {
|
||||
// [[wiki:topics/ai]] looks like 2a's qualified shape — even though
|
||||
// it wouldn't satisfy DIR_PATTERN, 2c must not claim it either
|
||||
@@ -1078,15 +1069,6 @@ describe('makeResolver — fallback chain', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolveBasenameMatches: path-style ref falls back to the tail (issue #2576 bug 2)', async () => {
|
||||
// normalizeBasename strips slashes, so `runbooks/2026-05-01-pointer-agent`
|
||||
// used to normalize to a garbage key that never hit the tail-keyed index.
|
||||
const engine = makeFakeEngineWithSlugs(['ops/changes/2026-05-01-pointer-agent']);
|
||||
const r = makeResolver(engine);
|
||||
expect(await r.resolveBasenameMatches!('runbooks/2026-05-01-pointer-agent'))
|
||||
.toEqual(['ops/changes/2026-05-01-pointer-agent']);
|
||||
});
|
||||
|
||||
test('resolveBasenameMatches: case-insensitive fallback', async () => {
|
||||
const engine = makeFakeEngineWithSlugs(['companies/fast-weigh']);
|
||||
const r = makeResolver(engine);
|
||||
|
||||
Reference in New Issue
Block a user