Files
gbrain/test/e2e/helpers.ts
Garry Tan 08b3698e90
Some checks failed
E2E Tests / Tier 1 (Mechanical) (push) Failing after 29s
Test / gitleaks (push) Failing after 10s
Test / test (push) Failing after 26s
E2E Tests / Tier 2 (LLM Skills) (push) Has been skipped
v0.18.2: migration hardening — integrity fix + reserved-connection primitive (#356)
* 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>
2026-04-23 10:39:28 -07:00

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)]);
}