mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +00:00
* fix(initSchema): narrow pre-schema bootstrap + v24 PGLite no-op Closes a 2-year-old wedge cycle that hit users 10+ times across 6 schema versions (#239, #243, #266, #357, #366, #374, #375, #378, #395, #396). Bug class: gbrain ships an embedded schema blob (PGLITE_SCHEMA_SQL + SCHEMA_SQL) that runs before numbered migrations on every initSchema(). The blob references columns that newer migrations introduce. On any brain older than the migration that adds those columns, the blob crashes before the migration can run. Fix: PGLiteEngine.initSchema() and PostgresEngine.initSchema() now call a new private applyForwardReferenceBootstrap() before the schema blob. The bootstrap probes for missing forward-referenced state and adds only what's needed (sources table + pages.source_id, links.link_source + links.origin_page_id, content_chunks.symbol_name + content_chunks.language). Fresh installs and modern brains both no-op. A CI guard test/schema-bootstrap-coverage.test.ts enforces that the bootstrap covers every forward reference in PGLITE_SCHEMA_SQL. Future migrations that add column-with-index in the schema blob must extend the bootstrap; the test fails loudly otherwise. Migration v24 (rls_backfill_missing_tables) now no-ops on PGLite via sqlFor.pglite: '' since PGLite has no RLS engine and is single-tenant. Closes #395. The plan went through CEO + Eng + Codex review. Codex caught a critical bug in the original "run all migrations early" approach: it would crash on v24 trying to ALTER subagent tables that the schema blob hadn't created yet. The narrow bootstrap shape resolves that. Wave incorporates community PRs #398 (@vinsew), #399 (@jdcastro2), #402 (@schnubb-web). Co-Authored-By: vinsew <yiyangchaishu@gmail.com> Co-Authored-By: Julián David Castro <juliancastro@Mac-mini-de-Julian.local> Co-Authored-By: schnubb-web <info@mia-mai.de> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(test): bump beforeAll timeout on minions-shell-pglite for parallel-load flake Default 5s beforeAll timeout occasionally trips under the parallel test runner when many test files initialize PGLite concurrently. The same pattern is documented as a P0 TODO for v0.21 Code Cathedral tests; this is the one instance the upgrade-hardening wave directly exposed (CPU pressure from new bootstrap test files). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.21.1) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: update project documentation for v0.21.1 - CLAUDE.md: PGLite + Postgres engine entries note new applyForwardReferenceBootstrap() in initSchema(), v24 sqlFor.pglite no-op, and the new bootstrap test files (test/bootstrap.test.ts, test/schema-bootstrap-coverage.test.ts, test/e2e/postgres-bootstrap.test.ts). - CHANGELOG.md: voice polish on the v0.21.1 headline (drop stray ## prefixes so the bold two-line headline renders as bold prose, not h2 sub-headers that break the version-entry hierarchy). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: correct version slot from v0.22.5 to v0.21.6 Slot allocation correction. v0.21.6 is the actual landing slot for this wave on the v0.21.x patch line. VERSION, package.json, CHANGELOG.md (header + table + take-advantage section), CLAUDE.md (engine entries, migrate.ts entry, test descriptions) all updated together. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: correct version slot to v0.22.7 VERSION, package.json, CHANGELOG.md (header + table + take-advantage section), CLAUDE.md (engine entries, migrate.ts entry, test descriptions) all updated together. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: regenerate llms.txt + llms-full.txt for v0.22.7 CLAUDE.md changed (engine entries describe the bootstrap, migrate.ts entry describes the v24 PGLite no-op). The build:llms regen-drift guard caught the staleness in CI. Running `bun run build:llms` propagates the same content into the AI-consumable bundles. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: change version slot from v0.22.7 to v0.22.6-hotfix.1 PR #483 (fix/mcp-registration-auth) claimed v0.22.7. Moved this wave to v0.22.6-hotfix.1 to avoid the collision. Note: semver-orders BEFORE 0.22.6 (pre-release suffix), so the hotfix tag is informational, not ordering-correct. Acceptable here because the wave's content predates master's 0.22.6 and is being landed as a parallel hotfix slot. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: change version slot to v0.22.6.1 4-digit hotfix slot under master's v0.22.6. bun + bun:test accept the format; the build-llms regen-drift guard and bootstrap tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: vinsew <yiyangchaishu@gmail.com> Co-authored-by: Julián David Castro <juliancastro@Mac-mini-de-Julian.local> Co-authored-by: schnubb-web <info@mia-mai.de> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
193 lines
7.5 KiB
TypeScript
193 lines
7.5 KiB
TypeScript
/**
|
|
* PGLite forward-reference bootstrap tests.
|
|
*
|
|
* Validates the contract of `PGLiteEngine#applyForwardReferenceBootstrap`:
|
|
* given a brain that lacks the schema-blob's forward-referenced state, the
|
|
* bootstrap adds enough state for PGLITE_SCHEMA_SQL to replay safely.
|
|
*
|
|
* The bootstrap covers the wedge incidents from issues
|
|
* #239/#266/#357/#366/#374/#375/#378/#396 — every gbrain release that added
|
|
* a column-with-index in the schema blob without a corresponding bootstrap
|
|
* triggered the same wedge family.
|
|
*
|
|
* Honest limitation: test 4 simulates a v20 brain by dropping known forward
|
|
* state from a fresh-LATEST instance. This is the same down-mutation pattern
|
|
* codex flagged as "weak simulation" — it can't simulate every possible
|
|
* historical state. Acceptable here because the bootstrap's contract is
|
|
* narrow ("given a brain that lacks the specific forward-references,
|
|
* initSchema produces a brain at LATEST"), and that contract is exactly
|
|
* what this test exercises.
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { LATEST_VERSION } from '../src/core/migrate.ts';
|
|
|
|
describe('PGLiteEngine#applyForwardReferenceBootstrap', () => {
|
|
test('no-op on fresh install (no pages or links table)', async () => {
|
|
const engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
try {
|
|
// Don't call initSchema — verify bootstrap alone does nothing on empty DB
|
|
await (engine as any).applyForwardReferenceBootstrap();
|
|
const { rows } = await (engine as any).db.query(`
|
|
SELECT COUNT(*)::int AS c FROM information_schema.tables
|
|
WHERE table_schema = 'public'
|
|
`);
|
|
expect(rows[0].c).toBe(0);
|
|
} finally {
|
|
await engine.disconnect();
|
|
}
|
|
}, 30000);
|
|
|
|
test('idempotent: calling twice produces same result', async () => {
|
|
const engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
try {
|
|
await engine.initSchema();
|
|
const db = (engine as any).db;
|
|
|
|
// Mutate to pre-v0.18 shape: drop source_id and the sources FK target
|
|
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;
|
|
`);
|
|
|
|
// First call: applies bootstrap
|
|
await (engine as any).applyForwardReferenceBootstrap();
|
|
// Second call: must not error, must not duplicate state
|
|
await (engine as any).applyForwardReferenceBootstrap();
|
|
|
|
const { rows: cols } = await db.query(`
|
|
SELECT column_name FROM information_schema.columns
|
|
WHERE table_name = 'pages' AND column_name = 'source_id'
|
|
`);
|
|
expect(cols).toHaveLength(1);
|
|
|
|
const { rows: src } = await db.query(`SELECT COUNT(*)::int AS c FROM sources`);
|
|
expect(src[0].c).toBe(1); // 'default' seed not duplicated
|
|
} finally {
|
|
await engine.disconnect();
|
|
}
|
|
}, 30000);
|
|
|
|
test('no-op on modern brain (source_id and links provenance already present)', async () => {
|
|
const engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
try {
|
|
await engine.initSchema();
|
|
const db = (engine as any).db;
|
|
|
|
const before = await db.query(`SELECT COUNT(*)::int AS c FROM sources`);
|
|
await (engine as any).applyForwardReferenceBootstrap();
|
|
const after = await db.query(`SELECT COUNT(*)::int AS c FROM sources`);
|
|
|
|
// Bootstrap probe should detect the brain is modern and skip the seed insert
|
|
expect(after.rows[0].c).toBe(before.rows[0].c);
|
|
} finally {
|
|
await engine.disconnect();
|
|
}
|
|
}, 30000);
|
|
|
|
test('full path: pre-v0.18 brain reaches LATEST_VERSION via initSchema', async () => {
|
|
const engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
try {
|
|
await engine.initSchema();
|
|
const db = (engine as any).db;
|
|
|
|
// Mutate to pre-v0.18 shape: strip the forward-referenced state.
|
|
// Match the shape from #399's regression fixture; constraints first
|
|
// (so dropping columns succeeds).
|
|
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;
|
|
ALTER TABLE links DROP CONSTRAINT IF EXISTS links_resolution_type_check;
|
|
ALTER TABLE links DROP COLUMN IF EXISTS resolution_type;
|
|
`);
|
|
await engine.setConfig('version', '20');
|
|
|
|
// Path under test: bootstrap → SCHEMA_SQL → runMigrations
|
|
await engine.initSchema();
|
|
|
|
expect(await engine.getConfig('version')).toBe(String(LATEST_VERSION));
|
|
|
|
const { rows: srcCol } = await db.query(`
|
|
SELECT column_name FROM information_schema.columns
|
|
WHERE table_name = 'pages' AND column_name = 'source_id'
|
|
`);
|
|
expect(srcCol).toHaveLength(1);
|
|
|
|
const { rows: defaultSrc } = await db.query(`SELECT id FROM sources WHERE id = 'default'`);
|
|
expect(defaultSrc).toHaveLength(1);
|
|
} finally {
|
|
await engine.disconnect();
|
|
}
|
|
}, 30000);
|
|
|
|
test('fresh install regression: initSchema on empty DB produces LATEST', async () => {
|
|
// The bootstrap's table-existence probe must not mis-classify "no table"
|
|
// as "pre-v0.18 brain." Without the table-existence guard, the bootstrap
|
|
// would call runMigrations against an empty DB and crash on
|
|
// `relation "config" does not exist`. Regression test for that path.
|
|
const engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
try {
|
|
await engine.initSchema();
|
|
expect(await engine.getConfig('version')).toBe(String(LATEST_VERSION));
|
|
|
|
const db = (engine as any).db;
|
|
const pages = await db.query(`SELECT 1 FROM pages LIMIT 0`);
|
|
const sources = await db.query(`SELECT 1 FROM sources LIMIT 0`);
|
|
const config = await db.query(`SELECT 1 FROM config LIMIT 0`);
|
|
expect(pages).toBeDefined();
|
|
expect(sources).toBeDefined();
|
|
expect(config).toBeDefined();
|
|
} finally {
|
|
await engine.disconnect();
|
|
}
|
|
}, 30000);
|
|
|
|
test('pre-v0.13 links shape: bootstrap adds link_source + origin_page_id', async () => {
|
|
// Issues #266 / #357 — pre-v0.13 brains had `links` without
|
|
// `link_source` / `origin_page_id`. Schema blob's
|
|
// `CREATE INDEX idx_links_source` would crash before v11 ran.
|
|
const engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
try {
|
|
await engine.initSchema();
|
|
const db = (engine as any).db;
|
|
|
|
await db.exec(`
|
|
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;
|
|
`);
|
|
|
|
await (engine as any).applyForwardReferenceBootstrap();
|
|
|
|
const { rows: lsCol } = await db.query(`
|
|
SELECT column_name FROM information_schema.columns
|
|
WHERE table_name = 'links' AND column_name = 'link_source'
|
|
`);
|
|
expect(lsCol).toHaveLength(1);
|
|
|
|
const { rows: opCol } = await db.query(`
|
|
SELECT column_name FROM information_schema.columns
|
|
WHERE table_name = 'links' AND column_name = 'origin_page_id'
|
|
`);
|
|
expect(opCol).toHaveLength(1);
|
|
} finally {
|
|
await engine.disconnect();
|
|
}
|
|
}, 30000);
|
|
});
|