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

232 lines
10 KiB
TypeScript

/**
* Unit tests for `test/helpers/schema-diff.ts`.
*
* The pure diff function is the load-bearing piece of the v0.26.3 drift gate
* (issue #588). If it has bugs, the gate is a paper gate. These tests use
* synthetic snapshots so they run without a database, and they include the
* D3 negative case: the v0.26.1 token_ttl regression that motivated the
* issue in the first place.
*/
import { describe, test, expect } from 'bun:test';
import {
type SchemaSnapshot,
type ColumnInfo,
diffSnapshots,
formatDiffForFailure,
isCleanDiff,
snapshotSchema,
} from './schema-diff.ts';
function col(partial: Partial<ColumnInfo> = {}): ColumnInfo {
return {
dataType: partial.dataType ?? 'text',
udtName: partial.udtName ?? 'text',
isNullable: partial.isNullable ?? true,
columnDefault: partial.columnDefault ?? null,
};
}
function makeSnap(tables: Record<string, Record<string, ColumnInfo>>): SchemaSnapshot {
const snap: SchemaSnapshot = new Map();
for (const [table, cols] of Object.entries(tables)) {
const m = new Map<string, ColumnInfo>();
for (const [name, info] of Object.entries(cols)) m.set(name, info);
snap.set(table, m);
}
return snap;
}
const NO_ALLOWLIST = { allowlistPgOnlyTables: [] };
describe('diffSnapshots', () => {
test('identical snapshots produce a clean diff', () => {
const a = makeSnap({ pages: { id: col({ udtName: 'uuid' }), title: col() } });
const b = makeSnap({ pages: { id: col({ udtName: 'uuid' }), title: col() } });
const diff = diffSnapshots(a, b, NO_ALLOWLIST);
expect(isCleanDiff(diff)).toBe(true);
});
test('column missing in PGLite reports the exact column name', () => {
const pg = makeSnap({ oauth_clients: { client_id: col(), token_ttl: col({ udtName: 'int4' }) } });
const pglite = makeSnap({ oauth_clients: { client_id: col() } });
const diff = diffSnapshots(pg, pglite, NO_ALLOWLIST);
expect(diff.columnsMissingInPGLite).toEqual([{ table: 'oauth_clients', columns: ['token_ttl'] }]);
expect(diff.columnsMissingInPostgres).toEqual([]);
expect(isCleanDiff(diff)).toBe(false);
});
test('column present only on PGLite reports columnsMissingInPostgres', () => {
const pg = makeSnap({ pages: { id: col({ udtName: 'uuid' }) } });
const pglite = makeSnap({ pages: { id: col({ udtName: 'uuid' }), pglite_only: col() } });
const diff = diffSnapshots(pg, pglite, NO_ALLOWLIST);
expect(diff.columnsMissingInPostgres).toEqual([{ table: 'pages', columns: ['pglite_only'] }]);
});
test('table missing in PGLite reports tablesMissingInPGLite', () => {
const pg = makeSnap({ pages: {}, mcp_request_log: { id: col() } });
const pglite = makeSnap({ pages: {} });
const diff = diffSnapshots(pg, pglite, NO_ALLOWLIST);
expect(diff.tablesMissingInPGLite).toEqual(['mcp_request_log']);
});
test('table on allowlist is excluded from tablesMissingInPGLite', () => {
const pg = makeSnap({ files: { id: col() }, pages: { id: col() } });
const pglite = makeSnap({ pages: { id: col() } });
const diff = diffSnapshots(pg, pglite, { allowlistPgOnlyTables: ['files'] });
expect(diff.tablesMissingInPGLite).toEqual([]);
expect(isCleanDiff(diff)).toBe(true);
});
test('udt_name mismatch is reported as type mismatch', () => {
// The exact codex-flagged regression: access_tokens.id is UUID on Postgres,
// TEXT on PGLite.
const pg = makeSnap({ access_tokens: { id: col({ dataType: 'uuid', udtName: 'uuid' }) } });
const pglite = makeSnap({ access_tokens: { id: col({ dataType: 'text', udtName: 'text' }) } });
const diff = diffSnapshots(pg, pglite, NO_ALLOWLIST);
expect(diff.typeMismatches).toHaveLength(1);
expect(diff.typeMismatches[0]).toMatchObject({
table: 'access_tokens',
column: 'id',
reason: 'udt_name',
});
});
test('array element type mismatch is caught via udt_name', () => {
// information_schema reports `ARRAY` for both `text[]` and `int[]` in
// data_type but distinguishes them via udt_name (`_text` vs `_int4`).
// udt_name catches this; data_type alone would not.
const pg = makeSnap({ t: { x: col({ dataType: 'ARRAY', udtName: '_text' }) } });
const pglite = makeSnap({ t: { x: col({ dataType: 'ARRAY', udtName: '_int4' }) } });
const diff = diffSnapshots(pg, pglite, NO_ALLOWLIST);
expect(diff.typeMismatches).toHaveLength(1);
expect(diff.typeMismatches[0].reason).toBe('udt_name');
});
test('nullable mismatch is reported when udt_name matches', () => {
const pg = makeSnap({ t: { x: col({ udtName: 'text', isNullable: false }) } });
const pglite = makeSnap({ t: { x: col({ udtName: 'text', isNullable: true }) } });
const diff = diffSnapshots(pg, pglite, NO_ALLOWLIST);
expect(diff.typeMismatches).toHaveLength(1);
expect(diff.typeMismatches[0].reason).toBe('is_nullable');
});
test('default mismatch is reported when udt_name and nullable match', () => {
const pg = makeSnap({ t: { x: col({ udtName: 'text', columnDefault: "'a'" }) } });
const pglite = makeSnap({ t: { x: col({ udtName: 'text', columnDefault: "'b'" }) } });
const diff = diffSnapshots(pg, pglite, NO_ALLOWLIST);
expect(diff.typeMismatches).toHaveLength(1);
expect(diff.typeMismatches[0].reason).toBe('column_default');
});
test('default normalisation: ::text type cast is stripped before comparing', () => {
// Postgres often renders string defaults with `::text`, PGLite without.
const pg = makeSnap({ t: { x: col({ udtName: 'text', columnDefault: "'hello'::text" }) } });
const pglite = makeSnap({ t: { x: col({ udtName: 'text', columnDefault: "'hello'" }) } });
const diff = diffSnapshots(pg, pglite, NO_ALLOWLIST);
expect(isCleanDiff(diff)).toBe(true);
});
test('default normalisation: whitespace + ::jsonb cast collapsed', () => {
const pg = makeSnap({ t: { meta: col({ udtName: 'jsonb', columnDefault: " '{}'::jsonb " }) } });
const pglite = makeSnap({ t: { meta: col({ udtName: 'jsonb', columnDefault: "'{}'" }) } });
const diff = diffSnapshots(pg, pglite, NO_ALLOWLIST);
expect(isCleanDiff(diff)).toBe(true);
});
test('multiple issues across multiple tables are all reported', () => {
const pg = makeSnap({
a: { x: col({ udtName: 'int4' }), y: col({ udtName: 'text' }) },
b: { z: col({ udtName: 'uuid' }) },
c: { only_on_pg: col() },
});
const pglite = makeSnap({
a: { x: col({ udtName: 'int8' }) }, // udt mismatch + missing y
b: { z: col({ udtName: 'uuid' }), only_on_pglite: col() },
});
const diff = diffSnapshots(pg, pglite, NO_ALLOWLIST);
expect(diff.tablesMissingInPGLite).toEqual(['c']);
expect(diff.columnsMissingInPGLite).toEqual([{ table: 'a', columns: ['y'] }]);
expect(diff.columnsMissingInPostgres).toEqual([{ table: 'b', columns: ['only_on_pglite'] }]);
expect(diff.typeMismatches).toHaveLength(1);
expect(diff.typeMismatches[0]).toMatchObject({ table: 'a', column: 'x', reason: 'udt_name' });
});
});
describe('formatDiffForFailure', () => {
test('clean diff renders as "no diff"', () => {
const diff = diffSnapshots(makeSnap({ pages: {} }), makeSnap({ pages: {} }), NO_ALLOWLIST);
expect(formatDiffForFailure(diff)).toBe('no diff');
});
test('column missing names the column with a copy-paste hint', () => {
const pg = makeSnap({ oauth_clients: { token_ttl: col({ udtName: 'int4' }) } });
const pglite = makeSnap({ oauth_clients: {} });
const diff = diffSnapshots(pg, pglite, NO_ALLOWLIST);
const out = formatDiffForFailure(diff);
expect(out).toContain('oauth_clients.token_ttl');
expect(out).toContain('src/core/pglite-schema.ts');
});
test('udt mismatch shows both sides', () => {
const pg = makeSnap({ access_tokens: { id: col({ dataType: 'uuid', udtName: 'uuid' }) } });
const pglite = makeSnap({ access_tokens: { id: col({ dataType: 'text', udtName: 'text' }) } });
const diff = diffSnapshots(pg, pglite, NO_ALLOWLIST);
const out = formatDiffForFailure(diff);
expect(out).toContain('access_tokens.id');
expect(out).toContain('uuid');
expect(out).toContain('text');
});
});
describe('D3 regression: oauth_clients.token_ttl drift (the v0.26.1 incident)', () => {
// The bug that motivated issue #588: prod Postgres got `token_ttl` and
// `deleted_at` via manual ALTER TABLE; PGLite did not. All unit tests
// broke with `column "token_ttl" does not exist`. If this gate had
// existed at v0.26.1 it would have caught the divergence at PR time
// (the prod ALTER and the PGLite update would have travelled together
// through CI).
test('drift gate would have caught the v0.26.1 token_ttl + deleted_at bug', () => {
const pgSide = makeSnap({
oauth_clients: {
client_id: col({ udtName: 'text', isNullable: false }),
token_ttl: col({ udtName: 'int4' }),
deleted_at: col({ udtName: 'timestamptz' }),
},
});
const pgliteSide = makeSnap({
oauth_clients: {
client_id: col({ udtName: 'text', isNullable: false }),
// token_ttl + deleted_at MISSING — exactly the v0.26.1 state
},
});
const diff = diffSnapshots(pgSide, pgliteSide, NO_ALLOWLIST);
expect(diff.columnsMissingInPGLite).toHaveLength(1);
expect(diff.columnsMissingInPGLite[0].table).toBe('oauth_clients');
expect(diff.columnsMissingInPGLite[0].columns).toEqual(['token_ttl', 'deleted_at']);
const message = formatDiffForFailure(diff);
expect(message).toContain('token_ttl');
expect(message).toContain('deleted_at');
expect(message).toContain('oauth_clients');
});
});
describe('snapshotSchema', () => {
test('aggregates rows from the query callback into a Map<table, Map<column>>', async () => {
const fakeRows = [
{ table_name: 'pages', column_name: 'id', data_type: 'uuid', udt_name: 'uuid', is_nullable: 'NO', column_default: 'gen_random_uuid()' },
{ table_name: 'pages', column_name: 'title', data_type: 'text', udt_name: 'text', is_nullable: 'YES', column_default: null },
{ table_name: 'tags', column_name: 'tag', data_type: 'text', udt_name: 'text', is_nullable: 'NO', column_default: null },
];
const snap = await snapshotSchema(async () => fakeRows);
expect(snap.get('pages')!.get('id')).toEqual({
dataType: 'uuid',
udtName: 'uuid',
isNullable: false,
columnDefault: 'gen_random_uuid()',
});
expect(snap.get('pages')!.get('title')!.isNullable).toBe(true);
expect(snap.get('tags')!.get('tag')!.columnDefault).toBeNull();
});
});