mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* 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>
181 lines
8.0 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|