Files
gbrain/test/e2e/schema-drift.test.ts
T
9e2093fc9b v0.26.6 feat(schema): PGLite ↔ Postgres parity gate (closes #588) (#590)
* 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>
2026-05-03 20:48:39 -07:00

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);
}
});
});