* fix: migration hardening — timeout handling, lock detection, diagnostics Addresses all 8 issues from the v0.18.0 production upgrade field report: 1. LATEST_VERSION now uses Math.max() instead of array-last (was wrong when MIGRATIONS array is out of order: [.., 23, 22, 21, 20, 15, 16]) 2. Pre-flight lock check: runMigrations() queries pg_stat_activity for idle-in-transaction connections >5min before attempting DDL, prints PIDs and kill advice 3. SET LOCAL statement_timeout = 600s inside migration transactions for Supabase compatibility (server-enforced timeout overrides session SET) 4. Catches Postgres error 57014 (statement_timeout) with actionable diagnostics instead of raw stack trace 5. Better progress output: prints schema version range, migration names before/after, checkmarks on success 6. Migration 21 fix: drops files.page_slug_fkey before swapping the pages unique constraint (guarded for PGLite which has no files table) 7. idle_in_transaction_session_timeout = 5min on all Postgres connections (both instance-level and module-level) to prevent 24h stale locks 8. apply-migrations CLI warns when schema migrations are pending, since it only runs orchestrator migrations (System B) not schema DDL (System A) All 34 migrate tests pass. Typecheck clean. * feat(engine): BrainEngine.withReservedConnection() primitive + DRY session defaults Adds a ReservedConnection interface and withReservedConnection(fn) method to BrainEngine. Postgres uses postgres-js sql.reserve() to pin a single backend for the callback; PGLite passes through its single backing connection. Used immediately for non-transactional DDL timeout handling (next commit) and foundation for the future write-quiesce design. Extracts setSessionDefaults(sql) helper in db.ts, absorbing the duplicated idle_in_transaction_session_timeout block that was copy-pasted between db.ts and postgres-engine.ts (Gap 5 / ER-C1). Single write site, both connect paths call the helper now. Codex plan-review flagged that advisory-lock designs on postgres.js pools require a reserved-connection primitive; this is that primitive. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(migrate): close v21/v23 integrity window + non-transactional DDL timeout Two codex-caught issues that both the initial review and the engineering review missed: 1. Migration 21 integrity window. Original v21 dropped files_page_slug_fkey and persisted config.version=21, leaving files WITHOUT any FK to pages until v23 ran and added the replacement files.page_id. Process death between v21 and v23 left files unconstrained while file_upload / `gbrain files` kept accepting writes. Fix: v21 uses sqlFor to split engines (Postgres gets additive-only, PGLite gets the full UNIQUE swap since it has no concurrent writers). v23's handler now wraps the FK drop + UNIQUE swap + page_id addition + backfill + ledger creation in one engine.transaction(). Atomic. 2. Non-transactional DDL timeout gap. runMigrationSQL's else-branch (for migrations with transaction:false, like CREATE INDEX CONCURRENTLY) ran the DDL on the shared pool with no timeout override. Supabase's 2-min server statement_timeout would abort a CONCURRENTLY index on any large table. Fix: use engine.withReservedConnection + SET statement_timeout='600000' inside the isolated connection. Also: extracted getIdleBlockers(engine) helper — single source of truth for the pg_stat_activity query. Shared by the DDL pre-flight warning and the new `gbrain doctor --locks` CLI (next commit). 57014 diagnostic rewritten to the 4-part "what / why / fix / verify" pattern. No longer references a non-existent CLI flag. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(doctor): gbrain doctor --locks CLI flag The v0.18.0 57014 diagnostic referenced `gbrain doctor --locks` but the flag didn't exist. Users hitting statement_timeout would run the suggested command and get "unknown option". Implemented now. On Postgres: queries pg_stat_activity via the new getIdleBlockers() helper, prints each blocker's PID, state, query_start, truncated query, and the exact `SELECT pg_terminate_backend(<pid>);` command. Exits 1 on blockers, 0 on clean. On PGLite: prints "not applicable" (no pool, no idle-in-tx concept) and exits 0. The flag is a safe no-op there. --json emits structured output: {status, blockers: [...]}. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test: migration hardening regression guards (unit + E2E) test/migrate.test.ts — 10 new regression guards: - LATEST_VERSION equals max(versions) under any array order. Guards against regression to array[-1] (the field report's "told I'm at v16 while 7 migrations behind" bug). - getIdleBlockers shape: pglite returns [], postgres returns rows, query failure returns [] (not throw). - 57014 catch path: mocked engine throws err.code='57014', assert the 4-part diagnostic hits stderr with what/why/fix/verify markers. - apply-migrations pre-flight warning structural check. - setSessionDefaults DRY check: helper defined once in db.ts, postgres-engine calls it, neither path inlines the SET. - runMigrationSQL reserved-connection usage structural check. - Migration 21 test updates for engine-split sqlFor (codex restructure). - Migration 23 atomic-transaction assertion. test/e2e/migrate-chain.test.ts (new): 11 E2E tests against real Postgres: - Post-chain schema invariants (composite UNIQUE exists, old pages_slug_key gone, files_page_slug_fkey gone, files.page_id column present, file_migration_ledger table populated). - doctor --locks real-PG integration (second connection + BEGIN + idle, assert the PID appears in pg_stat_activity). - runMigrationsUpTo advances config.version to target, not past. - withReservedConnection round-trip (executes queries, session GUC visible inside callback). test/e2e/helpers.ts: new runMigrationsUpTo(engine, targetVersion) and setConfigVersion(version) helpers. The v15→v23 chain E2E needed a way to stop at intermediate schema versions; neither `gbrain init --migrate-only` nor the existing setupDB() supported this. Codex caught that the proposed E2E wasn't implementable without new harness work. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: bump version and changelog (v0.18.2) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(changelog): rewrite v0.18.2 entry to match gstack CLAUDE.md format Applied the gstack CHANGELOG style rules from ~/git/gstack/CLAUDE.md: - Two-line bold headline lands a verdict, not a feature list. - Single coherent lead story instead of "Second headline... Third headline..." - "The numbers that matter" table with BEFORE / AFTER / Δ columns, counted against the v0.18.0 field report (the concrete source). - "What this means for your workflow" closing paragraph with the 4-command recovery path. - TODOS.md references removed from user-facing body (explicit rule: never mention TODOS, internal tracking, or contributor-facing details in the user-read portion). - Contributor-only detail (helper extraction, test file paths, interface specifics) moved to a "For contributors" subsection. - Itemized changes reorganized as Added / Changed / Fixed / For contributors. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(changelog): v0.18.2 voice-rule audit — headline, em dashes Audit against ~/git/gstack/CLAUDE.md voice rules: - Headline tightened from 32 words to 19 (rule says 10-14; repo convention on v0.18.1 was 22, this is closer). - Em dashes removed from 7 lines. Replaced with commas, colons, or periods per the "no em dashes" rule. - AI vocabulary audit: clean. - Banned phrases audit: clean. Content unchanged. Only voice/punctuation. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: root <root@localhost> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
274 lines
8.6 KiB
TypeScript
274 lines
8.6 KiB
TypeScript
/**
|
|
* E2E test helpers: DB lifecycle, fixture import, timing, and diagnostics.
|
|
*
|
|
* Usage in test files:
|
|
* import { setupDB, teardownDB, importFixtures, time } from './helpers.ts';
|
|
* beforeAll(async () => { await setupDB(); await importFixtures(); });
|
|
* afterAll(async () => { await teardownDB(); });
|
|
*/
|
|
|
|
import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
import { join, resolve, relative, dirname, basename, extname } from 'path';
|
|
import { PostgresEngine } from '../../src/core/postgres-engine.ts';
|
|
import * as db from '../../src/core/db.ts';
|
|
import { importFromContent } from '../../src/core/import-file.ts';
|
|
import { parseMarkdown } from '../../src/core/markdown.ts';
|
|
|
|
// Load .env.testing if present
|
|
const envPath = resolve(import.meta.dir, '../../.env.testing');
|
|
if (existsSync(envPath)) {
|
|
const lines = readFileSync(envPath, 'utf-8').split('\n');
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
const eq = trimmed.indexOf('=');
|
|
if (eq === -1) continue;
|
|
const key = trimmed.slice(0, eq);
|
|
const val = trimmed.slice(eq + 1);
|
|
if (!process.env[key]) process.env[key] = val;
|
|
}
|
|
}
|
|
|
|
const DATABASE_URL = process.env.DATABASE_URL;
|
|
const FIXTURES_DIR = resolve(import.meta.dir, 'fixtures');
|
|
|
|
let engine: PostgresEngine | null = null;
|
|
|
|
const ALL_TABLES = [
|
|
'content_chunks',
|
|
'links',
|
|
'tags',
|
|
'raw_data',
|
|
'timeline_entries',
|
|
'page_versions',
|
|
'ingest_log',
|
|
'files',
|
|
'pages', // last because of foreign keys
|
|
'config',
|
|
'minion_attachments',
|
|
'minion_inbox',
|
|
'minion_jobs',
|
|
];
|
|
|
|
/**
|
|
* Check if a real database is available for E2E tests.
|
|
*/
|
|
export function hasDatabase(): boolean {
|
|
return !!DATABASE_URL;
|
|
}
|
|
|
|
/**
|
|
* Connect to DB, run schema init, truncate all tables.
|
|
* Call in beforeAll() of each test file.
|
|
*/
|
|
export async function setupDB(): Promise<PostgresEngine> {
|
|
if (!DATABASE_URL) {
|
|
throw new Error('DATABASE_URL not set. Copy .env.testing.example to .env.testing and configure it.');
|
|
}
|
|
|
|
// Disconnect any prior connection (clean slate)
|
|
await db.disconnect();
|
|
|
|
// Connect fresh
|
|
await db.connect({ database_url: DATABASE_URL });
|
|
await db.initSchema();
|
|
|
|
// Truncate all data tables (preserves schema + extensions)
|
|
const conn = db.getConnection();
|
|
for (const table of ALL_TABLES) {
|
|
await conn.unsafe(`TRUNCATE ${table} CASCADE`);
|
|
}
|
|
|
|
// Re-seed config (initSchema inserts default config rows)
|
|
await conn.unsafe(`
|
|
INSERT INTO config (key, value) VALUES ('schema_version', '1')
|
|
ON CONFLICT (key) DO NOTHING
|
|
`);
|
|
|
|
engine = new PostgresEngine();
|
|
await engine.connect({ database_url: DATABASE_URL });
|
|
return engine;
|
|
}
|
|
|
|
/**
|
|
* Disconnect from DB. Call in afterAll() of each test file.
|
|
*/
|
|
export async function teardownDB(): Promise<void> {
|
|
if (engine) {
|
|
await engine.disconnect();
|
|
engine = null;
|
|
}
|
|
await db.disconnect();
|
|
}
|
|
|
|
/**
|
|
* Get the current engine instance.
|
|
*/
|
|
export function getEngine(): PostgresEngine {
|
|
if (!engine) throw new Error('setupDB() must be called first');
|
|
return engine;
|
|
}
|
|
|
|
/**
|
|
* Get a raw DB connection for direct queries.
|
|
*/
|
|
export function getConn() {
|
|
return db.getConnection();
|
|
}
|
|
|
|
/**
|
|
* Import all fixture files from test/e2e/fixtures/ into the brain.
|
|
* Returns the list of import results.
|
|
*/
|
|
export async function importFixtures() {
|
|
const e = getEngine();
|
|
const results: Array<{ slug: string; status: string; chunks: number }> = [];
|
|
|
|
const files = findMarkdownFiles(FIXTURES_DIR);
|
|
for (const filePath of files) {
|
|
const relPath = relative(FIXTURES_DIR, filePath);
|
|
const content = readFileSync(filePath, 'utf-8');
|
|
const parsed = parseMarkdown(content, relPath);
|
|
const result = await importFromContent(e, parsed.slug, content, { noEmbed: true });
|
|
results.push(result);
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Import a single fixture by its relative path within fixtures/.
|
|
*/
|
|
export async function importFixture(relativePath: string) {
|
|
const e = getEngine();
|
|
const filePath = join(FIXTURES_DIR, relativePath);
|
|
const content = readFileSync(filePath, 'utf-8');
|
|
const parsed = parseMarkdown(content, relativePath);
|
|
return importFromContent(e, parsed.slug, content, { noEmbed: true });
|
|
}
|
|
|
|
/**
|
|
* Recursively find all .md files in a directory.
|
|
*/
|
|
function findMarkdownFiles(dir: string): string[] {
|
|
const results: string[] = [];
|
|
for (const entry of readdirSync(dir)) {
|
|
const full = join(dir, entry);
|
|
const stat = statSync(full);
|
|
if (stat.isDirectory()) {
|
|
results.push(...findMarkdownFiles(full));
|
|
} else if (extname(entry) === '.md') {
|
|
results.push(full);
|
|
}
|
|
}
|
|
return results.sort();
|
|
}
|
|
|
|
/**
|
|
* Time a function and return [result, durationMs].
|
|
*/
|
|
export async function time<T>(fn: () => Promise<T>): Promise<[T, number]> {
|
|
const start = performance.now();
|
|
const result = await fn();
|
|
const dur = performance.now() - start;
|
|
return [result, dur];
|
|
}
|
|
|
|
/**
|
|
* Dump DB state for debugging on test failure.
|
|
*/
|
|
export async function dumpDBState(): Promise<string> {
|
|
const conn = db.getConnection();
|
|
const pages = await conn.unsafe(`SELECT slug, type, title FROM pages ORDER BY slug`);
|
|
const chunkCount = await conn.unsafe(`SELECT count(*) as n FROM content_chunks`);
|
|
const linkCount = await conn.unsafe(`SELECT count(*) as n FROM links`);
|
|
const tagCount = await conn.unsafe(`SELECT count(*) as n FROM tags`);
|
|
|
|
const lines = [
|
|
`=== DB STATE DUMP ===`,
|
|
`Pages (${pages.length}):`,
|
|
...pages.map((p: any) => ` ${p.slug} [${p.type}] "${p.title}"`),
|
|
`Chunks: ${chunkCount[0]?.n ?? 0}`,
|
|
`Links: ${linkCount[0]?.n ?? 0}`,
|
|
`Tags: ${tagCount[0]?.n ?? 0}`,
|
|
`=== END DUMP ===`,
|
|
];
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Get the fixtures directory path.
|
|
*/
|
|
export const FIXTURES_PATH = FIXTURES_DIR;
|
|
|
|
/**
|
|
* Rewind schema state to `targetVersion` and re-apply migrations in
|
|
* version order up to (and including) `targetVersion`.
|
|
*
|
|
* Used by the v15→v23 chain E2E to simulate the field report's starting
|
|
* state (schema at v15, ~100 real rows, kick off full migration). Before
|
|
* this helper, no CLI flag or test hook existed to stop the migration
|
|
* chain at an intermediate version — `gbrain init --migrate-only` always
|
|
* ran to latest.
|
|
*
|
|
* Caveats:
|
|
* - Postgres-only (the v15→v23 chain only matters for Postgres anyway;
|
|
* PGLite's schema is monolithic).
|
|
* - Destructive to existing DDL: dropping a migration that created
|
|
* a table leaves tables behind. This helper re-runs migrations from
|
|
* the CURRENT version (whatever config.version says) upward. It
|
|
* does NOT rewind down. Pair with `setupDB()` to truncate first.
|
|
* - After calling this, the schema is whatever `v1..targetVersion`
|
|
* produced. Columns added by v(targetVersion+1)+ will be missing.
|
|
*
|
|
* Call order in a test:
|
|
* const engine = await setupDB(); // latest schema, empty tables
|
|
* await conn.unsafe('ALTER TABLE ...'); // optional: drop forward columns
|
|
* await setConfigVersion(1); // reset schema version
|
|
* await runMigrationsUpTo(engine, 15); // advance to v15
|
|
* // ... seed fixture data at v15 shape ...
|
|
* await runMigrationsFromCurrent(engine); // advance to latest
|
|
*/
|
|
export async function runMigrationsUpTo(
|
|
engine: PostgresEngine,
|
|
targetVersion: number,
|
|
): Promise<void> {
|
|
const { MIGRATIONS } = await import('../../src/core/migrate.ts');
|
|
const sorted = [...MIGRATIONS].sort((a, b) => a.version - b.version);
|
|
|
|
const currentStr = await engine.getConfig('version');
|
|
const current = parseInt(currentStr || '1', 10);
|
|
|
|
const pending = sorted.filter(m => m.version > current && m.version <= targetVersion);
|
|
|
|
for (const m of pending) {
|
|
const sql = m.sqlFor?.[engine.kind] ?? m.sql;
|
|
if (sql) {
|
|
// Inline the transactional wrap from runMigrationSQL so we can
|
|
// stop cleanly at targetVersion without re-invoking the full
|
|
// runMigrations loop.
|
|
await engine.transaction(async (tx) => {
|
|
await tx.runMigration(m.version, sql);
|
|
});
|
|
}
|
|
if (m.handler) {
|
|
await m.handler(engine);
|
|
}
|
|
await engine.setConfig('version', String(m.version));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reset `config.version` to the given value. Used to simulate a brain
|
|
* at an older schema state before applying partial migrations via
|
|
* `runMigrationsUpTo`. Does NOT undo DDL — just moves the version
|
|
* marker that the migration runner uses to decide what's pending.
|
|
*/
|
|
export async function setConfigVersion(version: number): Promise<void> {
|
|
const conn = db.getConnection();
|
|
await conn.unsafe(`
|
|
INSERT INTO config (key, value) VALUES ('version', $1)
|
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
|
`, [String(version)]);
|
|
}
|