mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
The forward-reference bootstrap (PostgresEngine + PGLiteEngine applyForwardReferenceBootstrap) covered v0.18 + v0.19 + v0.26.5 columns but missed two later groups. Brains upgrading from v0.14-era to current master crash before the migration ladder runs: 1. v0.20 Cathedral II — content_chunks.search_vector, parent_symbol_path, doc_comment, symbol_name_qualified. `CREATE INDEX idx_chunks_search_vector` and `CREATE INDEX idx_chunks_symbol_qualified` in schema.sql/PGLITE_SCHEMA_SQL crash with "column search_vector does not exist" / "column symbol_name_qualified does not exist". 2. v0.26.3 — mcp_request_log.agent_name, params, error_message. `CREATE INDEX idx_mcp_log_agent_time ON mcp_request_log(agent_name,...)` crashes with "column agent_name does not exist". Reproduces deterministically on a v0.13/v0.14 brain upgraded straight to current master. The user hits the wall before any of v15-v36 can run. Both engines now probe for these columns and pre-add them via `ALTER TABLE ADD COLUMN IF NOT EXISTS` before SCHEMA_SQL runs. Migrations v26, v27, v33 still run later via runMigrations and remain idempotent (they handle backfill on top of the bootstrap-added columns). Test coverage extended in test/schema-bootstrap-coverage.test.ts: REQUIRED_BOOTSTRAP_COVERAGE now lists 6 new forward references; the strip-and-rebuild block drops the corresponding indexes/triggers so the test exercises a brain that pre-dates v0.20 + v0.26.3 migrations. Repro: brain on schema v13/v14 + run `gbrain init --migrate-only` against current master → fails. With this patch → succeeds; ladder runs to v36.
190 lines
9.2 KiB
TypeScript
190 lines
9.2 KiB
TypeScript
/**
|
|
* CI guard: PGLITE_SCHEMA_SQL must not forward-reference state that
|
|
* `applyForwardReferenceBootstrap` doesn't know how to create.
|
|
*
|
|
* Background: gbrain ships an "embedded latest schema" blob
|
|
* (`pglite-schema.ts`) for fast bootstraps, alongside a numbered migration
|
|
* chain (`migrate.ts`) for incremental upgrades. Across 2 years and 6 schema
|
|
* versions, every release that added a column-with-index in the schema blob
|
|
* without a corresponding bootstrap addition has triggered the same wedge
|
|
* incident class (#239, #243, #266, #266, #357, #366, #374, #375, #378,
|
|
* #395, #396).
|
|
*
|
|
* The bootstrap is the structural fix. This test enforces the contract:
|
|
* for every "forward reference" the schema blob makes (FK or indexed column
|
|
* defined later than its reference site, or any column that older brains
|
|
* lack), the bootstrap MUST add enough state so that running the schema
|
|
* blob is replay-safe on a brain that lacks every member of
|
|
* `REQUIRED_BOOTSTRAP_COVERAGE`.
|
|
*
|
|
* **When you add a new schema-blob forward reference:**
|
|
* 1. Extend `applyForwardReferenceBootstrap` in pglite-engine.ts +
|
|
* postgres-engine.ts to add the new state.
|
|
* 2. Add an entry to `REQUIRED_BOOTSTRAP_COVERAGE` below.
|
|
* 3. This test will pass.
|
|
*
|
|
* If you add a forward reference but skip step 1, this test fails. If you
|
|
* skip step 2, this test passes but the bootstrap silently drifts behind
|
|
* the schema. The eng-review polish notes recommended layered coverage
|
|
* (per-engine integration tests in `test/bootstrap.test.ts` +
|
|
* `test/e2e/postgres-bootstrap.test.ts`) to catch step 2 oversights.
|
|
*/
|
|
|
|
import { test, expect } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
|
|
// Tier 3 opt-out: this file tests the bootstrap coverage contract explicitly,
|
|
// running applyForwardReferenceBootstrap against fresh PGlite instances. A
|
|
// snapshot-loaded engine would skip the bootstrap entirely.
|
|
delete process.env.GBRAIN_PGLITE_SNAPSHOT;
|
|
|
|
// Forward-reference targets that PGLITE_SCHEMA_SQL requires.
|
|
// When you add a new one, extend this list AND the bootstrap.
|
|
type ForwardReference =
|
|
| { kind: 'table'; name: string }
|
|
| { kind: 'column'; table: string; column: string };
|
|
|
|
const REQUIRED_BOOTSTRAP_COVERAGE: ForwardReference[] = [
|
|
// Forward-referenced by `pages.source_id REFERENCES sources(id)` and the
|
|
// `INSERT INTO sources (id, name, config) VALUES ('default', ...)` seed.
|
|
{ kind: 'table', name: 'sources' },
|
|
// Forward-referenced by `CREATE INDEX idx_pages_source_id ON pages(source_id)`.
|
|
{ kind: 'column', table: 'pages', column: 'source_id' },
|
|
// Forward-referenced by `CREATE INDEX idx_links_source ON links(link_source)`.
|
|
{ kind: 'column', table: 'links', column: 'link_source' },
|
|
// Forward-referenced by `CREATE INDEX idx_links_origin ON links(origin_page_id)`.
|
|
{ kind: 'column', table: 'links', column: 'origin_page_id' },
|
|
// v0.19+ — forward-referenced by `CREATE INDEX idx_chunks_symbol_name
|
|
// ON content_chunks(symbol_name) WHERE symbol_name IS NOT NULL`.
|
|
{ kind: 'column', table: 'content_chunks', column: 'symbol_name' },
|
|
// v0.19+ — forward-referenced by `CREATE INDEX idx_chunks_language
|
|
// ON content_chunks(language) WHERE language IS NOT NULL`.
|
|
{ kind: 'column', table: 'content_chunks', column: 'language' },
|
|
// v0.20+ Cathedral II — forward-referenced by `CREATE INDEX
|
|
// idx_chunks_search_vector ON content_chunks USING GIN(search_vector)`.
|
|
{ kind: 'column', table: 'content_chunks', column: 'search_vector' },
|
|
// v0.20+ Cathedral II — forward-referenced by `CREATE INDEX
|
|
// idx_chunks_symbol_qualified ON content_chunks(symbol_name_qualified)`.
|
|
{ kind: 'column', table: 'content_chunks', column: 'symbol_name_qualified' },
|
|
// v0.20+ Cathedral II — populated by update_chunk_search_vector trigger;
|
|
// present in PGLITE_SCHEMA_SQL CREATE TABLE definition.
|
|
{ kind: 'column', table: 'content_chunks', column: 'parent_symbol_path' },
|
|
{ kind: 'column', table: 'content_chunks', column: 'doc_comment' },
|
|
// v0.26.5 — forward-referenced by `CREATE INDEX pages_deleted_at_purge_idx
|
|
// ON pages (deleted_at) WHERE deleted_at IS NOT NULL`.
|
|
{ kind: 'column', table: 'pages', column: 'deleted_at' },
|
|
// v0.26.3 (v33) — forward-referenced by `CREATE INDEX idx_mcp_log_agent_time
|
|
// ON mcp_request_log(agent_name, created_at DESC)`.
|
|
{ kind: 'column', table: 'mcp_request_log', column: 'agent_name' },
|
|
];
|
|
|
|
test('applyForwardReferenceBootstrap covers every forward reference declared in REQUIRED_BOOTSTRAP_COVERAGE', async () => {
|
|
const engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
try {
|
|
await engine.initSchema();
|
|
const db = (engine as any).db;
|
|
|
|
// Strip every required forward-reference target so the brain looks like
|
|
// it pre-dates the migrations that introduced these objects. Drop columns
|
|
// before the table-level constraints that depend on them.
|
|
await db.exec(`
|
|
ALTER TABLE pages DROP CONSTRAINT IF EXISTS pages_source_slug_key;
|
|
ALTER TABLE pages ADD CONSTRAINT pages_slug_key UNIQUE (slug);
|
|
DROP INDEX IF EXISTS idx_pages_source_id;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS source_id;
|
|
DROP TABLE IF EXISTS sources CASCADE;
|
|
|
|
DROP INDEX IF EXISTS idx_links_source;
|
|
DROP INDEX IF EXISTS idx_links_origin;
|
|
ALTER TABLE links DROP CONSTRAINT IF EXISTS links_from_to_type_source_origin_unique;
|
|
ALTER TABLE links DROP COLUMN IF EXISTS link_source;
|
|
ALTER TABLE links DROP COLUMN IF EXISTS origin_page_id;
|
|
|
|
DROP INDEX IF EXISTS idx_chunks_symbol_name;
|
|
DROP INDEX IF EXISTS idx_chunks_language;
|
|
DROP INDEX IF EXISTS idx_chunks_search_vector;
|
|
DROP INDEX IF EXISTS idx_chunks_symbol_qualified;
|
|
DROP TRIGGER IF EXISTS chunk_search_vector_trigger ON content_chunks;
|
|
DROP FUNCTION IF EXISTS update_chunk_search_vector;
|
|
ALTER TABLE content_chunks DROP COLUMN IF EXISTS symbol_name;
|
|
ALTER TABLE content_chunks DROP COLUMN IF EXISTS language;
|
|
ALTER TABLE content_chunks DROP COLUMN IF EXISTS parent_symbol_path;
|
|
ALTER TABLE content_chunks DROP COLUMN IF EXISTS doc_comment;
|
|
ALTER TABLE content_chunks DROP COLUMN IF EXISTS symbol_name_qualified;
|
|
ALTER TABLE content_chunks DROP COLUMN IF EXISTS search_vector;
|
|
|
|
DROP INDEX IF EXISTS pages_deleted_at_purge_idx;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS deleted_at;
|
|
|
|
DROP INDEX IF EXISTS idx_mcp_log_agent_time;
|
|
DROP INDEX IF EXISTS idx_mcp_log_time_agent;
|
|
ALTER TABLE mcp_request_log DROP COLUMN IF EXISTS agent_name;
|
|
ALTER TABLE mcp_request_log DROP COLUMN IF EXISTS params;
|
|
ALTER TABLE mcp_request_log DROP COLUMN IF EXISTS error_message;
|
|
`);
|
|
|
|
// Run bootstrap in isolation (NOT initSchema). This is what we're testing.
|
|
await (engine as any).applyForwardReferenceBootstrap();
|
|
|
|
// Assert every required forward-reference target now satisfies the
|
|
// schema-blob's expectations.
|
|
for (const ref of REQUIRED_BOOTSTRAP_COVERAGE) {
|
|
if (ref.kind === 'table') {
|
|
const { rows } = await db.query(
|
|
`SELECT 1 FROM information_schema.tables
|
|
WHERE table_schema = 'public' AND table_name = $1`,
|
|
[ref.name],
|
|
);
|
|
expect(rows.length).toBeGreaterThan(0);
|
|
} else {
|
|
const { rows } = await db.query(
|
|
`SELECT 1 FROM information_schema.columns
|
|
WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2`,
|
|
[ref.table, ref.column],
|
|
);
|
|
expect(rows.length).toBeGreaterThan(0);
|
|
}
|
|
}
|
|
} finally {
|
|
await engine.disconnect();
|
|
}
|
|
}, 30000);
|
|
|
|
test('after bootstrap, PGLITE_SCHEMA_SQL replays without crashing on missing forward references', async () => {
|
|
// End-to-end contract: bootstrap → SCHEMA_SQL must succeed even on a brain
|
|
// that lacks every forward-referenced target. This catches the case where
|
|
// REQUIRED_BOOTSTRAP_COVERAGE drifts behind PGLITE_SCHEMA_SQL — if the
|
|
// schema blob added a new index on a column the bootstrap doesn't create,
|
|
// the SCHEMA_SQL exec below would crash even though the per-target asserts
|
|
// above pass.
|
|
const engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
try {
|
|
await engine.initSchema();
|
|
const db = (engine as any).db;
|
|
|
|
await db.exec(`
|
|
ALTER TABLE pages DROP CONSTRAINT IF EXISTS pages_source_slug_key;
|
|
ALTER TABLE pages ADD CONSTRAINT pages_slug_key UNIQUE (slug);
|
|
DROP INDEX IF EXISTS idx_pages_source_id;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS source_id;
|
|
DROP TABLE IF EXISTS sources CASCADE;
|
|
DROP INDEX IF EXISTS idx_links_source;
|
|
DROP INDEX IF EXISTS idx_links_origin;
|
|
ALTER TABLE links DROP CONSTRAINT IF EXISTS links_from_to_type_source_origin_unique;
|
|
ALTER TABLE links DROP COLUMN IF EXISTS link_source;
|
|
ALTER TABLE links DROP COLUMN IF EXISTS origin_page_id;
|
|
DROP INDEX IF EXISTS pages_deleted_at_purge_idx;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS deleted_at;
|
|
`);
|
|
|
|
// Bootstrap, then schema replay. Either step crashing fails the test.
|
|
const { PGLITE_SCHEMA_SQL } = await import('../src/core/pglite-schema.ts');
|
|
await (engine as any).applyForwardReferenceBootstrap();
|
|
await db.exec(PGLITE_SCHEMA_SQL);
|
|
} finally {
|
|
await engine.disconnect();
|
|
}
|
|
}, 30000);
|