mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
Adds the missing timeline_entries.event_page_id forward-reference bootstrap probe + bare-column repair to both engines so pre-v121 brains can replay the current schema and reach migration v121. Fixes #2724.
257 lines
10 KiB
TypeScript
257 lines
10 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';
|
|
|
|
// Tier 3 opt-out: this file tests the cold init / bootstrap path explicitly.
|
|
// If GBRAIN_PGLITE_SNAPSHOT is set (ci:local sets it for unit shards), every
|
|
// PGlite would boot post-initSchema and these assertions ("0 tables on fresh
|
|
// install", "bootstrap converts pre-v0.18 brain to LATEST") would fail
|
|
// trivially. Unset for this file's process.
|
|
delete process.env.GBRAIN_PGLITE_SNAPSHOT;
|
|
|
|
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);
|
|
|
|
test('pre-v121 timeline shape reaches LATEST through full initSchema', async () => {
|
|
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_timeline_event_dedup;
|
|
DROP INDEX IF EXISTS idx_timeline_event_page;
|
|
ALTER TABLE timeline_entries DROP CONSTRAINT IF EXISTS timeline_entries_event_page_id_fkey;
|
|
ALTER TABLE timeline_entries DROP COLUMN IF EXISTS event_page_id;
|
|
`);
|
|
await engine.setConfig('version', '119');
|
|
|
|
await engine.initSchema();
|
|
await engine.initSchema();
|
|
|
|
expect(await engine.getConfig('version')).toBe(String(LATEST_VERSION));
|
|
const { rows } = await db.query(`
|
|
SELECT column_name FROM information_schema.columns
|
|
WHERE table_name = 'timeline_entries' AND column_name = 'event_page_id'
|
|
`);
|
|
expect(rows).toHaveLength(1);
|
|
} finally {
|
|
await engine.disconnect();
|
|
}
|
|
}, 30000);
|
|
|
|
test('pre-v121 partial bootstrap resumes without skipping migration work', async () => {
|
|
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_timeline_event_dedup;
|
|
DROP INDEX IF EXISTS idx_timeline_event_page;
|
|
ALTER TABLE timeline_entries DROP CONSTRAINT IF EXISTS timeline_entries_event_page_id_fkey;
|
|
`);
|
|
await engine.setConfig('version', '119');
|
|
|
|
// Simulates interruption after bootstrap added the column but before
|
|
// schema-blob replay and migration v121 completed the FK/index work.
|
|
await engine.initSchema();
|
|
|
|
expect(await engine.getConfig('version')).toBe(String(LATEST_VERSION));
|
|
const { rows } = await db.query(`
|
|
SELECT to_regclass('idx_timeline_event_page') AS lookup_idx,
|
|
to_regclass('idx_timeline_event_dedup') AS dedup_idx
|
|
`);
|
|
expect(rows[0]?.lookup_idx).not.toBeNull();
|
|
expect(rows[0]?.dedup_idx).not.toBeNull();
|
|
} finally {
|
|
await engine.disconnect();
|
|
}
|
|
}, 30000);
|
|
});
|