mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 11:22:34 +00:00
* v0.26.3 feat(schema): PGLite ↔ Postgres parity gate + access_tokens.id type fix (#588) Drift gate (test/e2e/schema-drift.test.ts) spins up fresh PGLite + Postgres, runs each engine's initSchema(), snapshots information_schema.columns, and diffs the four-tuple (data_type, udt_name, is_nullable, column_default) per column. 17 unit cases for the pure diff function (test/helpers/schema-diff.ts + schema-diff.test.ts) including a D3 negative test that reproduces the v0.26.1 oauth_clients.token_ttl regression. 6 E2E cases including 4 sentinels for oauth_clients, mcp_request_log, access_tokens, eval_candidates. The gate caught one real drift on its first run: access_tokens.id was UUID on Postgres (schema.sql:328, migration v4) and TEXT on PGLite (pglite-schema.ts). Reconciled to UUID DEFAULT gen_random_uuid() on both sides. CI wiring in scripts/e2e-test-map.ts triggers schema-drift on changes to schema.sql, pglite-schema.ts, or migrate.ts. The 2-table allowlist (files, file_migration_ledger) is narrow by design — every other Postgres table must reach PGLite via PGLITE_SCHEMA_SQL or a migration's sqlFor.pglite branch. Bookkeeping: master HEAD's VERSION was 0.26.0 even though the prior commit shipped as v0.26.1 (the bump never landed). Moving to 0.26.3 per the same bookkeeping discontinuity. Codex flagged a versioning hardening follow-up (scripts/check-version-sync.sh pre-push guard) for v0.26.4. Also fixes two pre-existing CI failures master shipped through: - check-privacy.sh: src/core/mounts-cache.ts had two banned name references ("Wintermute"). Replaced with "your OpenClaw" per CLAUDE.md:550. - check-no-legacy-getconnection.sh: src/commands/integrity.ts:355 was a new legacy db.getConnection() caller. Added to the script's allowlist with a PR 1 cleanup note (matches the existing 8 grandfathered entries). Out of scope (filed for v0.26.4): manual ALTER TABLE on production Postgres that never made it into source files (the actual v0.26.1 trigger; needs a gbrain doctor --schema-audit mechanism); index parity; versioning hardening guard. Plan + codex review pivot: original plan compared raw schema.sql vs raw pglite-schema.ts; codex showed they're intentionally divergent today (PGLite reaches its end-state via PGLITE_SCHEMA_SQL + migrations). Pivoted to end-state comparison, which catches real drift without false positives. Closes #588. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump v0.26.3 → v0.26.4 Per user instruction. No code or test changes — VERSION + package.json + CHANGELOG header/body + CLAUDE.md key-files entry. Regenerated llms-full.txt. "NOT in this release" deferral targets bumped from v0.26.4 → v0.26.5 (those items are still deferred; they're now deferred from v0.26.4). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump v0.26.4 → v0.26.6 Per user instruction. Bookkeeping-only — VERSION + package.json + CHANGELOG header/body + CLAUDE.md key-files entry. Regenerated llms-full.txt. "NOT in this release" deferral targets bumped from v0.26.5 → v0.26.7 (those items remain deferred; now from v0.26.6 instead of v0.26.4). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
137 lines
5.4 KiB
TypeScript
137 lines
5.4 KiB
TypeScript
/**
|
|
* E2E schema drift gate (issue #588, v0.26.3).
|
|
*
|
|
* Spins up a fresh PGLite instance and a fresh Postgres database, runs each
|
|
* engine's `initSchema()` end-to-end (bootstrap + schema replay + migrations),
|
|
* snapshots `information_schema.columns` from both, then diffs the snapshots
|
|
* via the pure helper in `test/helpers/schema-diff.ts`.
|
|
*
|
|
* Catches the v0.26.1 bug class: someone adds columns to one engine path
|
|
* (raw schema.sql, raw pglite-schema.ts, or a sqlFor branch in a migration)
|
|
* but forgets the other side. Both engines must produce the same end-state.
|
|
*
|
|
* Out of scope: detecting "manual ALTER TABLE on production Postgres that
|
|
* never made it into source files" (the actual v0.26.1 trigger). That
|
|
* requires comparing prod's information_schema against source — a separate
|
|
* `gbrain doctor --schema-audit` mechanism deferred to v0.26.4.
|
|
*
|
|
* Skips gracefully when DATABASE_URL is unset (matches the existing E2E
|
|
* pattern in test/e2e/postgres-bootstrap.test.ts and test/e2e/postgres-jsonb.test.ts).
|
|
*
|
|
* Run: DATABASE_URL=postgresql://... bun test test/e2e/schema-drift.test.ts
|
|
* Or: bun run ci:local (the full Docker-backed gate)
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
|
|
import { PostgresEngine } from '../../src/core/postgres-engine.ts';
|
|
import {
|
|
type SchemaSnapshot,
|
|
type SnapshotQueryRow,
|
|
snapshotSchema,
|
|
diffSnapshots,
|
|
formatDiffForFailure,
|
|
isCleanDiff,
|
|
} from '../helpers/schema-diff.ts';
|
|
|
|
const DATABASE_URL = process.env.DATABASE_URL;
|
|
const skip = !DATABASE_URL;
|
|
|
|
if (skip) {
|
|
console.log('Skipping E2E schema drift gate (DATABASE_URL not set)');
|
|
}
|
|
|
|
// Tier 3 opt-out: this file constructs a fresh in-memory PGLite to compare
|
|
// against fresh Postgres. If GBRAIN_PGLITE_SNAPSHOT is set (ci:local sets it
|
|
// for unit shards), PGLite would boot post-initSchema with a snapshot — fine
|
|
// for the comparison, but we want the canonical path here.
|
|
delete process.env.GBRAIN_PGLITE_SNAPSHOT;
|
|
|
|
/**
|
|
* Tables that exist in src/schema.sql but are intentionally absent from
|
|
* src/core/pglite-schema.ts (and from the migrations chain on the PGLite
|
|
* side). Whenever something is added to this list, add an inline reason.
|
|
*/
|
|
const PG_ONLY_TABLES = [
|
|
// Legacy file-storage tables. PGLite brains never adopted the embedded
|
|
// `files` table; storage tiering on PGLite is filesystem-only.
|
|
'files',
|
|
'file_migration_ledger',
|
|
];
|
|
|
|
describe.skipIf(skip)('schema drift: PGLite ↔ Postgres post-initSchema parity (E2E)', () => {
|
|
let pglite: PGLiteEngine;
|
|
let pg: PostgresEngine;
|
|
let pgliteSnap: SchemaSnapshot;
|
|
let pgSnap: SchemaSnapshot;
|
|
|
|
beforeAll(async () => {
|
|
// PGLite side: in-memory, run the canonical initSchema.
|
|
pglite = new PGLiteEngine();
|
|
await pglite.connect({});
|
|
await pglite.initSchema();
|
|
|
|
// Postgres side: connect to the test database, run the canonical initSchema.
|
|
// The test container at `bun run ci:local` provides a fresh DB; outside that
|
|
// path we rely on the caller having set DATABASE_URL to a fresh DB.
|
|
pg = new PostgresEngine();
|
|
await pg.connect({ database_url: DATABASE_URL! });
|
|
await pg.initSchema();
|
|
|
|
// Snapshot both. PGLite returns `{rows}`, postgres.js returns the array.
|
|
const pgliteDb = (pglite as any).db;
|
|
pgliteSnap = await snapshotSchema(async (sql) => {
|
|
const r = await pgliteDb.query(sql);
|
|
return r.rows as SnapshotQueryRow[];
|
|
});
|
|
|
|
const pgConn = (pg as any).sql;
|
|
pgSnap = await snapshotSchema(async (sql) => {
|
|
const r = await pgConn.unsafe(sql);
|
|
return r as unknown as SnapshotQueryRow[];
|
|
});
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
if (pglite) await pglite.disconnect();
|
|
if (pg) await pg.disconnect();
|
|
});
|
|
|
|
test('post-initSchema schemas are equivalent (modulo allowlist)', () => {
|
|
const diff = diffSnapshots(pgSnap, pgliteSnap, { allowlistPgOnlyTables: PG_ONLY_TABLES });
|
|
if (!isCleanDiff(diff)) {
|
|
throw new Error(`Schema drift detected:\n${formatDiffForFailure(diff)}`);
|
|
}
|
|
expect(isCleanDiff(diff)).toBe(true);
|
|
});
|
|
|
|
// Sentinel cases. Each is the v0.26.1 bug class for one specific table.
|
|
// Failing here gives a tighter blame message than the global parity test.
|
|
for (const sentinel of ['oauth_clients', 'mcp_request_log', 'access_tokens', 'eval_candidates']) {
|
|
test(`regression #588: ${sentinel} columns match across engines`, () => {
|
|
const pgCols = pgSnap.get(sentinel);
|
|
const pgliteCols = pgliteSnap.get(sentinel);
|
|
expect(pgCols, `${sentinel} missing from Postgres post-initSchema`).toBeDefined();
|
|
expect(pgliteCols, `${sentinel} missing from PGLite post-initSchema`).toBeDefined();
|
|
const diff = diffSnapshots(
|
|
new Map([[sentinel, pgCols!]]),
|
|
new Map([[sentinel, pgliteCols!]]),
|
|
{ allowlistPgOnlyTables: [] },
|
|
);
|
|
if (!isCleanDiff(diff)) {
|
|
throw new Error(`Drift on ${sentinel}:\n${formatDiffForFailure(diff)}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
test('Postgres-only tables on the allowlist are still absent from PGLite', () => {
|
|
// Defensive: if someone adds `files` to PGLite without removing it from
|
|
// the allowlist, we want to know — the allowlist would silently shadow
|
|
// a real divergence in coverage policy.
|
|
for (const t of PG_ONLY_TABLES) {
|
|
expect(pgSnap.has(t), `${t} should be in Postgres schema`).toBe(true);
|
|
expect(pgliteSnap.has(t), `${t} unexpectedly added to PGLite — remove from allowlist`).toBe(false);
|
|
}
|
|
});
|
|
});
|