mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +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>
250 lines
8.8 KiB
TypeScript
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;
|
|
}
|