Files
gbrain/test/helpers/schema-diff.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

250 lines
8.8 KiB
TypeScript

/**
* Schema parity helpers for the v0.26.3 drift gate (issue #588).
*
* Strategy: snapshot `information_schema.columns` from a freshly-initialised
* engine (PGLite or Postgres), then diff. The original v0.26.3 plan compared
* raw `src/schema.sql` against raw `src/core/pglite-schema.ts`; codex review
* showed those files are intentionally divergent today (PGLite reaches its
* end-state via PGLITE_SCHEMA_SQL + migrations, not the raw blob alone).
* Comparing post-`initSchema()` end-states is what production actually runs,
* so it's what we test.
*
* The pure functions in this file have no engine dependency. The E2E test at
* `test/e2e/schema-drift.test.ts` wires them up to real engines; the unit
* tests in `test/helpers/schema-diff.test.ts` exercise them with synthetic
* snapshots (including the D3 negative case for the v0.26.1 token_ttl bug).
*/
export interface ColumnInfo {
dataType: string;
udtName: string;
isNullable: boolean;
columnDefault: string | null;
}
export type SchemaSnapshot = Map<string, Map<string, ColumnInfo>>;
export interface SchemaDiff {
tablesMissingInPGLite: string[];
tablesUnexpectedlyInPGLite: string[];
columnsMissingInPGLite: Array<{ table: string; columns: string[] }>;
columnsMissingInPostgres: Array<{ table: string; columns: string[] }>;
typeMismatches: Array<{
table: string;
column: string;
pg: ColumnInfo;
pglite: ColumnInfo;
reason: 'udt_name' | 'is_nullable' | 'column_default';
}>;
}
export interface SnapshotQueryRow {
table_name: string;
column_name: string;
data_type: string;
udt_name: string;
is_nullable: string;
column_default: string | null;
}
export type SnapshotQueryFn = (sql: string) => Promise<SnapshotQueryRow[]>;
const SNAPSHOT_SQL = `
SELECT
table_name,
column_name,
data_type,
udt_name,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_schema = 'public'
ORDER BY table_name, ordinal_position
`;
/**
* Pull a SchemaSnapshot from any engine that exposes a SQL query callback.
* Caller adapts the engine's native query shape to `SnapshotQueryFn` (PGLite
* returns `{rows}`, postgres.js returns the array directly).
*/
export async function snapshotSchema(query: SnapshotQueryFn): Promise<SchemaSnapshot> {
const rows = await query(SNAPSHOT_SQL);
const snap: SchemaSnapshot = new Map();
for (const row of rows) {
let cols = snap.get(row.table_name);
if (!cols) {
cols = new Map();
snap.set(row.table_name, cols);
}
cols.set(row.column_name, {
dataType: row.data_type,
udtName: row.udt_name,
isNullable: row.is_nullable === 'YES',
columnDefault: row.column_default ?? null,
});
}
return snap;
}
/**
* Defaults to be normalised before comparison. Postgres and PGLite render
* `gen_random_uuid()` consistently as of pgvector pgvector:pg16 + PGLite ≥0.2,
* but they sometimes differ on NULL representation and on type-cast
* formatting (`'value'::text` vs `'value'`). We collapse the obvious ones.
*/
function normaliseDefault(d: string | null): string | null {
if (d === null) return null;
// Order matters: collapse whitespace FIRST so the trailing-type-cast strip
// matches at end-of-string regardless of trailing spaces.
let normalised = d.trim().replace(/\s+/g, ' ');
// Strip trailing type casts like ::text, ::jsonb, ::uuid — PGLite sometimes
// omits them and Postgres sometimes includes them for string defaults.
normalised = normalised.replace(/::[a-z_][a-z0-9_]*(\[\])?$/i, '');
return normalised;
}
/**
* Compare two snapshots and produce a structured diff. Tables on the
* allowlist are excluded entirely from the comparison (intentional
* Postgres-only tables).
*/
export function diffSnapshots(
pg: SchemaSnapshot,
pglite: SchemaSnapshot,
opts: { allowlistPgOnlyTables: string[] },
): SchemaDiff {
const allowlist = new Set(opts.allowlistPgOnlyTables);
const diff: SchemaDiff = {
tablesMissingInPGLite: [],
tablesUnexpectedlyInPGLite: [],
columnsMissingInPGLite: [],
columnsMissingInPostgres: [],
typeMismatches: [],
};
for (const [table, pgCols] of pg) {
if (allowlist.has(table)) continue;
const pgliteCols = pglite.get(table);
if (!pgliteCols) {
diff.tablesMissingInPGLite.push(table);
continue;
}
const missingInPGLite: string[] = [];
for (const [col, pgInfo] of pgCols) {
const pgliteInfo = pgliteCols.get(col);
if (!pgliteInfo) {
missingInPGLite.push(col);
continue;
}
// udt_name is the canonical type identity (catches `_text` vs `_int4`,
// vector dimensions, etc.). data_type is the human-readable category.
if (pgInfo.udtName !== pgliteInfo.udtName) {
diff.typeMismatches.push({ table, column: col, pg: pgInfo, pglite: pgliteInfo, reason: 'udt_name' });
continue;
}
if (pgInfo.isNullable !== pgliteInfo.isNullable) {
diff.typeMismatches.push({ table, column: col, pg: pgInfo, pglite: pgliteInfo, reason: 'is_nullable' });
continue;
}
if (normaliseDefault(pgInfo.columnDefault) !== normaliseDefault(pgliteInfo.columnDefault)) {
diff.typeMismatches.push({ table, column: col, pg: pgInfo, pglite: pgliteInfo, reason: 'column_default' });
}
}
if (missingInPGLite.length > 0) {
diff.columnsMissingInPGLite.push({ table, columns: missingInPGLite });
}
}
// PGLite-only tables are suspicious but not auto-fail. Surface them so a
// reviewer can decide.
for (const [table, pgliteCols] of pglite) {
if (!pg.has(table)) {
diff.tablesUnexpectedlyInPGLite.push(table);
continue;
}
if (allowlist.has(table)) continue;
const pgCols = pg.get(table)!;
const missingInPostgres: string[] = [];
for (const col of pgliteCols.keys()) {
if (!pgCols.has(col)) missingInPostgres.push(col);
}
if (missingInPostgres.length > 0) {
diff.columnsMissingInPostgres.push({ table, columns: missingInPostgres });
}
}
return diff;
}
/**
* Build the failure message used in test assertions. Names every issue with
* a copy-paste-ready hint so a future contributor can paste the fix straight
* into pglite-schema.ts (or into a migration sqlFor.pglite branch).
*/
export function formatDiffForFailure(diff: SchemaDiff): string {
const lines: string[] = [];
if (diff.tablesMissingInPGLite.length > 0) {
lines.push('Tables present on Postgres but missing from PGLite end-state:');
for (const t of diff.tablesMissingInPGLite) {
lines.push(` - ${t}`);
lines.push(` Hint: add CREATE TABLE for "${t}" to src/core/pglite-schema.ts, or add it to the allowlist if intentionally Postgres-only.`);
}
}
if (diff.columnsMissingInPGLite.length > 0) {
lines.push('Columns missing from PGLite end-state:');
for (const { table, columns } of diff.columnsMissingInPGLite) {
for (const col of columns) {
lines.push(` - ${table}.${col}`);
lines.push(` Hint: add "${col}" to the ${table} CREATE TABLE in src/core/pglite-schema.ts, or add a sqlFor.pglite branch in the relevant migration.`);
}
}
}
if (diff.columnsMissingInPostgres.length > 0) {
lines.push('Columns present on PGLite but missing from Postgres:');
for (const { table, columns } of diff.columnsMissingInPostgres) {
for (const col of columns) {
lines.push(` - ${table}.${col}`);
lines.push(` Hint: either add "${col}" to ${table} in src/schema.sql + the migrations chain, or remove it from src/core/pglite-schema.ts.`);
}
}
}
if (diff.typeMismatches.length > 0) {
lines.push('Type / nullability / default mismatches:');
for (const m of diff.typeMismatches) {
lines.push(` - ${m.table}.${m.column} (${m.reason})`);
if (m.reason === 'udt_name') {
lines.push(` pg=${m.pg.dataType}/${m.pg.udtName} pglite=${m.pglite.dataType}/${m.pglite.udtName}`);
} else if (m.reason === 'is_nullable') {
lines.push(` pg.isNullable=${m.pg.isNullable} pglite.isNullable=${m.pglite.isNullable}`);
} else {
lines.push(` pg.default=${JSON.stringify(m.pg.columnDefault)} pglite.default=${JSON.stringify(m.pglite.columnDefault)}`);
}
}
}
if (diff.tablesUnexpectedlyInPGLite.length > 0) {
lines.push('Tables in PGLite that have no Postgres counterpart (suspicious — verify intentional):');
for (const t of diff.tablesUnexpectedlyInPGLite) {
lines.push(` - ${t}`);
}
}
if (lines.length === 0) return 'no diff';
return lines.join('\n');
}
/**
* Returns true when the diff has zero issues.
*/
export function isCleanDiff(diff: SchemaDiff): boolean {
return diff.tablesMissingInPGLite.length === 0
&& diff.columnsMissingInPGLite.length === 0
&& diff.columnsMissingInPostgres.length === 0
&& diff.typeMismatches.length === 0
&& diff.tablesUnexpectedlyInPGLite.length === 0;
}