Files
gbrain/test/e2e/schema-drift.test.ts
T
9a4ae0962e v0.37.2.0: takes_resolution_consistency CHECK accepts 'unresolvable' (#1211)
* v0.36.1.1 hotfix: takes_resolution_consistency CHECK accepts 'unresolvable'

Unblocks production grading scripts that write the judge's 4th verdict
type. Before this fix, every quality='unresolvable' INSERT/UPDATE hit
a CHECK violation — 0 of 34 writes landed in a recent prod run.

Migration v74 widens BOTH:
  - takes_resolution_consistency (table-level CHECK) — admits the
    ('unresolvable', NULL) pair alongside the existing 4 legal shapes
  - resolved_quality column-level CHECK — drops the auto-generated
    name from v37, re-adds as takes_resolved_quality_values with the
    4-state enum

Backward compatible. Existing rows with quality IN (NULL, 'correct',
'incorrect', 'partial') all satisfy the new CHECKs unchanged.

TakesScorecard gains sibling fields unresolvable_count + unresolvable_rate;
the existing `resolved` field deliberately keeps its 3-state meaning
so historical scorecards compare apples-to-apples (T1c sibling-field
design from the eng review).

Pinned by:
  - test/takes-resolution.test.ts — R1-R5 round-trip
  - test/migrate.test.ts — v74 structural assertions + PGLite E2E
    suite exercising all valid + invalid (quality, outcome) shapes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(e2e/schema-drift): reset public schema in beforeAll to isolate from caller bootstrap state

Previously the test trusted caller-provided DATABASE_URL to point at a fresh
database. CLAUDE.md's E2E lifecycle prescribes 'gbrain doctor --json' as the
bootstrap step (needed by oauth-related tests for table creation), but doctor
configures the gateway and bakes the configured embedding model into
content_chunks.model DEFAULT during the initial CREATE TABLE.

On re-run, CREATE TABLE IF NOT EXISTS is a no-op and the bootstrapped default
sticks. PGLite (always fresh-in-memory) gets the unconfigured-gateway fallback
'text-embedding-3-large'. The test reported phantom drift:
  pg.default="'zembed-1'::text"  pglite.default="'text-embedding-3-large'::text"

Fix: DROP SCHEMA public CASCADE + CREATE SCHEMA public before pg.initSchema.
Resets every table/index/sequence/constraint added by prior tooling. The PGLite
side is already fresh-per-test by construction.

Verified order-independent:
  - Fresh DB → 6/0 pass
  - After 'gbrain doctor' bootstrap → 6/0 pass
  - Full E2E suite (mechanical + schema-drift) → 84/0 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(safety): gate schema-drift DROP SCHEMA + relax TakesScorecard interface

Two findings from Codex adversarial review on the v0.37.0.1 hotfix:

1. **DROP SCHEMA safety gate (P0).** test/e2e/schema-drift.test.ts had an
   unguarded DROP SCHEMA public CASCADE. A developer running the E2E with
   DATABASE_URL pointing at a real brain or staging DB would lose the entire
   public schema. The fix: triple-check before destruction.
   - Parse the DATABASE_URL hostname + db name
   - Allow reset only when: explicit GBRAIN_TEST_DB=1 OR (localhost host AND
     test-shaped db name like gbrain_test, *_test, test_*, *_e2e)
   - Refuse otherwise with a loud paste-ready warning
   - The test still proceeds (the parity check is the fail-safe — if the
     caller already had a fresh DB, parity passes; if not, parity fails
     LOUDLY instead of nuking their data)

   Verified all three branches: localhost+gbrain_test resets (6/0 pass);
   localhost+production_brain refuses + warns (6/0 pass against pre-existing
   schema); GBRAIN_TEST_DB=1 override on production_brain name allows reset.

2. **TakesScorecard interface compat.** Making `unresolvable_count` +
   `unresolvable_rate` required fields on the public TakesScorecard
   interface broke downstream SDK consumers who construct scorecard
   fixtures (gbrain-evals, custom engines). The hotfix shouldn't impose
   a compile-break on hotfix users.

   Fix: make both fields optional (`?: number` / `?: number | null`).
   `finalizeScorecard` still always populates them, so all internal code
   sees the real values. External fixtures that omit them compile cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(safety,ux): tighten DROP SCHEMA gate + surface unresolvable in scorecard CLI

Second codex adversarial pass on v0.37.0.1 surfaced two residual findings.

**P0 — Safety gate still bypassable.** First-pass safety gate used
`explicitOptIn || (isLocalhost && looksLikeTestDb)` — meaning
`GBRAIN_TEST_DB=1` bypassed BOTH the host check AND the db-name check.
Someone running the E2E with that env set against a production DATABASE_URL
would still nuke their schema. Codex re-flagged it as P0.

Tightened logic: `looksLikeTestDb && (isLocalhost || ciOptIn)`. The db-name
pattern is now the hard floor — `gbrain_test`, `*_test`, `test_*`, `*_e2e`.
GBRAIN_TEST_DB=1 only relaxes the localhost requirement (for CI service-name
hosts). Setting the env on a DATABASE_URL pointing at `production_data` is
explicitly refused with a paste-ready message naming the failed check.

Verified 3 ways:
  - gbrain_test + localhost → resets (6/0 pass)
  - production_data + GBRAIN_TEST_DB=1 → REFUSES with clear message
  - foo_e2e + GBRAIN_TEST_DB=1 → resets (test-shaped name passes)

**P2 — gbrain takes scorecard hides the unresolvable signal.** Early-return
on `resolved === 0` was triggered before the new sibling fields rendered.
A brain with only `quality='unresolvable'` verdicts — the spec's whole
production case — printed "No resolved bets yet" and exited. The
unresolvable_rate field was unreachable from the human CLI unless the user
knew to pass `--json`.

Fix: gate the early-return on `resolved === 0 AND unresolvable_count === 0`.
Render `unresolvable` count + `unresolvable_rate` alongside `partial_rate`
when present. Threshold warn at 30% (mirrors PARTIAL_RATE_WARNING_THRESHOLD)
pointing at retrieval coverage, not prediction accuracy — the actionable
read for high-unresolvable brains.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: renumber v0.37.0.1 → v0.37.2.0 (v0.37.1.0 claimed by other PRs)

PRs #1214 and #1215 both claim v0.37.1.0; bumping past to the next free
slot. Migration v79 renamed `takes_unresolvable_quality_v0_37_0_1` →
`takes_unresolvable_quality_v0_37_2_0`. VERSION + package.json +
CHANGELOG + llms bundles + inline doc references all swept.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:19:18 -07:00

181 lines
8.0 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.
*
* v0.27.1: `files` removed from this list — multimodal ingestion needed
* binary-asset metadata on PGLite, and migration v36 adds the table on
* the PGLite side mirroring the Postgres v0.18 shape verbatim. Now a
* parity-required table on both engines.
*/
const PG_ONLY_TABLES = [
// file_migration_ledger drives the v0.18 storage-object rewrite on
// Postgres. PGLite never had blob storage so the ledger has no consumer.
'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: ensure the test database is FRESH before initSchema.
// v0.37.2.0 fix: previously the test trusted the caller to pass a fresh
// DATABASE_URL, but `gbrain doctor` (used by the CLAUDE.md E2E bootstrap
// ritual) populates `content_chunks.model DEFAULT` from the configured
// gateway model. On a re-run, `CREATE TABLE IF NOT EXISTS` is a no-op so
// the stale default sticks while PGLite (always fresh-in-memory) gets the
// engine fallback. That produced a phantom drift unrelated to schema
// parity.
//
// SAFETY GATE (codex P0, tightened in v0.37.2.0): DROP SCHEMA public CASCADE is
// destructive. The db name MUST always look test-shaped — no env-var override
// bypasses that floor. GBRAIN_TEST_DB=1 only relaxes the localhost requirement
// so CI environments where the host is a service name (e.g. "postgres") can
// still reset. If the db name doesn't match the test pattern, nothing nukes it.
pg = new PostgresEngine();
await pg.connect({ database_url: DATABASE_URL! });
const pgConnPre = (pg as any).sql;
const url = new URL(DATABASE_URL!);
const dbName = url.pathname.replace(/^\//, '');
const host = url.hostname;
const isLocalhost = host === 'localhost' || host === '127.0.0.1' || host.endsWith('.local');
// db-name pattern is the floor: gbrain_test, *_test, test_*, *_e2e.
// Required REGARDLESS of any override — a production db named "production_data"
// cannot be reset even with GBRAIN_TEST_DB=1.
const looksLikeTestDb = /^(gbrain_test|.*_test|test_.*|.*_e2e)$/i.test(dbName);
const ciOptIn = process.env.GBRAIN_TEST_DB === '1';
// resetAllowed semantics: db name is test-shaped AND (localhost OR ci-opt-in).
// Neither host nor env-var alone is sufficient.
const resetAllowed = looksLikeTestDb && (isLocalhost || ciOptIn);
if (resetAllowed) {
await pgConnPre.unsafe('DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;');
} else {
// Surface a loud, paste-ready hint. The test will still try initSchema;
// if the caller already had a fresh DB the parity check passes anyway.
const reason = !looksLikeTestDb
? `db name "${dbName}" doesn't match the test pattern (gbrain_test, *_test, test_*, *_e2e). ` +
`GBRAIN_TEST_DB=1 does NOT override this — db name is the hard floor.`
: `host="${host}" is non-local AND GBRAIN_TEST_DB=1 is not set. ` +
`Set GBRAIN_TEST_DB=1 to allow non-local hosts (e.g. CI service names) — ` +
`but only when the db name is already test-shaped.`;
console.warn(`[schema-drift] Skipping DROP SCHEMA — ${reason}`);
}
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();
}, 30_000);
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);
}
});
});