mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 19:49:14 +00:00
fetchCountRows and detectDeadPrefixes in src/core/schema-pack/stats.ts swallowed EVERY engine error into empty results, so any real failure printed 'Total pages: 0' + a vacuous 100% coverage on a populated brain. Both catches now swallow only isUndefinedTableError (pre-init brain, missing pages table) and rethrow everything else. Four regression tests: real non-zero count on a populated PGLite brain, rethrow on non-missing- table errors in both catch sites, and the missing-table degrade path. Takeover of #2493. Co-authored-by: Garry Tan <garrytan@gmail.com> Co-authored-by: javieraldape <javieraldape@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
334 lines
15 KiB
TypeScript
334 lines
15 KiB
TypeScript
// v0.40.6.0 — stats.ts contract tests.
|
|
//
|
|
// Multi-source aware, soft-delete exclusion, dead-prefix detection,
|
|
// PGLite parity. Phase 3 of the schema cathedral v3 plan.
|
|
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
|
import { runStatsCore } from '../src/core/schema-pack/stats.ts';
|
|
import {
|
|
__setPackLocatorForTests,
|
|
_resetPackLocatorForTests,
|
|
} from '../src/core/schema-pack/load-active.ts';
|
|
import { _resetPackCacheForTests } from '../src/core/schema-pack/registry.ts';
|
|
import type { OperationContext } from '../src/core/operations.ts';
|
|
import { withEnv } from './helpers/with-env.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
let tmpDir: string;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
_resetPackCacheForTests();
|
|
_resetPackLocatorForTests();
|
|
tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-stats-test-'));
|
|
});
|
|
|
|
function ctxOf(remote = false): OperationContext {
|
|
return {
|
|
engine,
|
|
config: {},
|
|
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
dryRun: false,
|
|
remote,
|
|
} as unknown as OperationContext;
|
|
}
|
|
|
|
async function ensureSource(id: string): Promise<void> {
|
|
if (id === 'default') return; // seeded by schema bootstrap
|
|
await engine.executeRaw(
|
|
`INSERT INTO sources (id, name) VALUES ($1, $1) ON CONFLICT (id) DO NOTHING`,
|
|
[id],
|
|
);
|
|
}
|
|
|
|
async function seedPage(slug: string, opts: { type?: string; sourceId?: string; sourcePath?: string | null; deleted?: boolean } = {}): Promise<void> {
|
|
// pages.type is NOT NULL; use empty string for "untyped".
|
|
// pages.title is NOT NULL.
|
|
// pages.source_id FKs sources(id) — seed source first.
|
|
const sourceId = opts.sourceId ?? 'default';
|
|
await ensureSource(sourceId);
|
|
await engine.executeRaw(
|
|
`INSERT INTO pages (slug, source_id, source_path, type, title, compiled_truth, timeline, content_hash, deleted_at)
|
|
VALUES ($1, $2, $3, $4, $5, '', '', '', $6)`,
|
|
[slug, sourceId, opts.sourcePath === undefined ? `${slug}.md` : opts.sourcePath, opts.type ?? '', slug, opts.deleted ? new Date() : null],
|
|
);
|
|
}
|
|
|
|
function seedTinyPack(packName: string, types: Array<{ name: string; prefix: string }>): void {
|
|
const dir = join(tmpDir, packName);
|
|
mkdirSync(dir, { recursive: true });
|
|
const path = join(dir, 'pack.yaml');
|
|
let body = `api_version: gbrain-schema-pack-v1\nname: ${packName}\nversion: 1.0.0\ndescription: ""\ngbrain_min_version: 0.38.0\nextends: null\nborrow_from: []\npage_types:\n`;
|
|
for (const t of types) {
|
|
body += ` - name: ${t.name}\n primitive: entity\n path_prefixes:\n - ${t.prefix}\n aliases: []\n extractable: false\n expert_routing: false\n`;
|
|
}
|
|
body += `link_types: []\nfrontmatter_links: []\ntakes_kinds:\n - fact\n - take\n - bet\n - hunch\nenrichable_types: []\nfiling_rules: []\n`;
|
|
writeFileSync(path, body, 'utf-8');
|
|
__setPackLocatorForTests((name) => (name === packName ? path : null));
|
|
}
|
|
|
|
describe('runStatsCore — empty brain', () => {
|
|
it('reports coverage:1.0 (vacuous truth)', async () => {
|
|
await withEnv({ GBRAIN_SCHEMA_PACK: undefined }, async () => {
|
|
const result = await runStatsCore(ctxOf());
|
|
expect(result.aggregate.total_pages).toBe(0);
|
|
expect(result.aggregate.coverage).toBe(1.0);
|
|
expect(result.per_source).toEqual([]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('runStatsCore — single source', () => {
|
|
it('counts typed + untyped pages and computes coverage', async () => {
|
|
await withEnv({ GBRAIN_SCHEMA_PACK: undefined }, async () => {
|
|
await seedPage('a', { type: 'person' });
|
|
await seedPage('b', { type: 'person' });
|
|
await seedPage('c', { type: 'company' });
|
|
await seedPage('d'); // untyped
|
|
const result = await runStatsCore(ctxOf());
|
|
expect(result.aggregate.total_pages).toBe(4);
|
|
expect(result.aggregate.typed_pages).toBe(3);
|
|
expect(result.aggregate.untyped_pages).toBe(1);
|
|
expect(result.aggregate.coverage).toBe(0.75);
|
|
expect(result.aggregate.by_type).toEqual([
|
|
{ type: 'person', count: 2 },
|
|
{ type: 'company', count: 1 },
|
|
]);
|
|
});
|
|
});
|
|
|
|
it('excludes soft-deleted pages', async () => {
|
|
await withEnv({ GBRAIN_SCHEMA_PACK: undefined }, async () => {
|
|
await seedPage('a', { type: 'person' });
|
|
await seedPage('b', { type: 'person', deleted: true });
|
|
const result = await runStatsCore(ctxOf());
|
|
expect(result.aggregate.total_pages).toBe(1);
|
|
});
|
|
});
|
|
|
|
it('respects sourceId scoping', async () => {
|
|
await withEnv({ GBRAIN_SCHEMA_PACK: undefined }, async () => {
|
|
await seedPage('a', { type: 'person', sourceId: 'src-a' });
|
|
await seedPage('b', { type: 'person', sourceId: 'src-b' });
|
|
const result = await runStatsCore(ctxOf(), { sourceId: 'src-a' });
|
|
expect(result.aggregate.total_pages).toBe(1);
|
|
expect(result.per_source.length).toBe(1);
|
|
expect(result.per_source[0]!.source_id).toBe('src-a');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('runStatsCore — federated', () => {
|
|
it('aggregates across sourceIds array', async () => {
|
|
await withEnv({ GBRAIN_SCHEMA_PACK: undefined }, async () => {
|
|
await seedPage('a', { type: 'person', sourceId: 'src-a' });
|
|
await seedPage('b', { type: 'person', sourceId: 'src-b' });
|
|
await seedPage('c', { type: 'person', sourceId: 'src-c' });
|
|
const result = await runStatsCore(ctxOf(), { sourceIds: ['src-a', 'src-b'] });
|
|
expect(result.aggregate.total_pages).toBe(2);
|
|
expect(result.per_source.length).toBe(2);
|
|
const sources = result.per_source.map((s) => s.source_id).sort();
|
|
expect(sources).toEqual(['src-a', 'src-b']);
|
|
});
|
|
});
|
|
|
|
it('per-source breakdown sorted alphabetically', async () => {
|
|
await withEnv({ GBRAIN_SCHEMA_PACK: undefined }, async () => {
|
|
await seedPage('a', { type: 'person', sourceId: 'src-zzz' });
|
|
await seedPage('b', { type: 'person', sourceId: 'src-aaa' });
|
|
const result = await runStatsCore(ctxOf());
|
|
expect(result.per_source.map((s) => s.source_id)).toEqual(['src-aaa', 'src-zzz']);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('runStatsCore — dead-prefix detection', () => {
|
|
it('flags pack-declared prefixes with zero matching pages', async () => {
|
|
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: 'tiny' }, async () => {
|
|
seedTinyPack('tiny', [
|
|
{ name: 'person', prefix: 'people/' },
|
|
{ name: 'company', prefix: 'companies/' },
|
|
]);
|
|
await seedPage('people/alice', { type: 'person', sourcePath: 'people/alice.md' });
|
|
// No companies/* pages → dead prefix.
|
|
const result = await runStatsCore(ctxOf());
|
|
expect(result.pack_identity).not.toBeNull();
|
|
expect(result.dead_prefixes).toEqual([{ type: 'company', prefix: 'companies/' }]);
|
|
});
|
|
});
|
|
|
|
it('no dead-prefix hints when every declared prefix has pages', async () => {
|
|
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: 'tiny' }, async () => {
|
|
seedTinyPack('tiny', [{ name: 'person', prefix: 'people/' }]);
|
|
await seedPage('people/alice', { type: 'person', sourcePath: 'people/alice.md' });
|
|
const result = await runStatsCore(ctxOf());
|
|
expect(result.dead_prefixes).toEqual([]);
|
|
});
|
|
});
|
|
|
|
it('matches declared prefixes by slug even when source_path is NULL (#2664)', async () => {
|
|
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: 'tiny' }, async () => {
|
|
seedTinyPack('tiny', [{ name: 'ops', prefix: 'ops/' }]);
|
|
await seedPage('ops/tasks', { type: 'ops', sourcePath: null });
|
|
const result = await runStatsCore(ctxOf());
|
|
expect(result.dead_prefixes).toEqual([]);
|
|
});
|
|
});
|
|
|
|
it('returns empty dead_prefixes when pack load fails', async () => {
|
|
await withEnv({ GBRAIN_SCHEMA_PACK: 'never-installed' }, async () => {
|
|
__setPackLocatorForTests(() => null);
|
|
const result = await runStatsCore(ctxOf());
|
|
expect(result.pack_identity).toBeNull();
|
|
expect(result.dead_prefixes).toEqual([]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('runStatsCore — JSON envelope shape', () => {
|
|
it('schema_version stays 1 (stable contract)', async () => {
|
|
await withEnv({ GBRAIN_SCHEMA_PACK: undefined }, async () => {
|
|
const result = await runStatsCore(ctxOf());
|
|
expect(result.schema_version).toBe(1);
|
|
});
|
|
});
|
|
|
|
it('aggregate fields match the per-source merge', async () => {
|
|
await withEnv({ GBRAIN_SCHEMA_PACK: undefined }, async () => {
|
|
await seedPage('a', { type: 'person', sourceId: 'src-a' });
|
|
await seedPage('b', { sourceId: 'src-b' }); // untyped
|
|
const result = await runStatsCore(ctxOf());
|
|
const totalFromPer = result.per_source.reduce((acc, s) => acc + s.total_pages, 0);
|
|
expect(result.aggregate.total_pages).toBe(totalFromPer);
|
|
});
|
|
});
|
|
|
|
it('by_type sorted by count desc, ties by name asc', async () => {
|
|
await withEnv({ GBRAIN_SCHEMA_PACK: undefined }, async () => {
|
|
await seedPage('a', { type: 'company' });
|
|
await seedPage('b', { type: 'person' });
|
|
await seedPage('c', { type: 'person' });
|
|
await seedPage('d', { type: 'person' });
|
|
const result = await runStatsCore(ctxOf());
|
|
expect(result.aggregate.by_type[0]!.type).toBe('person');
|
|
expect(result.aggregate.by_type[1]!.type).toBe('company');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('runStatsCore — #2466 catch-narrowing (real count + error surfacing)', () => {
|
|
// #2466: `gbrain schema stats` reported "Total pages: 0" on a populated
|
|
// PGLite brain. The bug was a bare `catch {}` in fetchCountRows (and a
|
|
// sibling in detectDeadPrefixes) that converted ANY engine error into 0
|
|
// rows. The COUNT query itself is valid on PGLite (proven below), so the
|
|
// regression pins two things: (a) a populated brain reports the real,
|
|
// non-zero count through the full runStatsCore path; (b) a non-missing-
|
|
// table engine error is rethrown, not masked into a fake zero.
|
|
|
|
it('reports the real non-zero count on a populated PGLite brain (no false 0)', async () => {
|
|
await withEnv({ GBRAIN_SCHEMA_PACK: undefined }, async () => {
|
|
// Seed a realistic mix: typed, untyped, multiple types — like the
|
|
// 169-page brain in the bug report (scaled down).
|
|
for (let i = 0; i < 12; i++) {
|
|
const type = i % 3 === 0 ? '' : (i % 3 === 1 ? 'person' : 'company');
|
|
await seedPage(`notes/p${i}`, { type, sourcePath: `notes/p${i}.md` });
|
|
}
|
|
const result = await runStatsCore(ctxOf());
|
|
// The core regression: NOT zero.
|
|
expect(result.aggregate.total_pages).toBe(12);
|
|
expect(result.aggregate.typed_pages).toBe(8);
|
|
expect(result.aggregate.untyped_pages).toBe(4);
|
|
// And coverage is the honest ratio, not the vacuous 1.0 a 0/0 prints.
|
|
expect(result.aggregate.coverage).not.toBe(1.0);
|
|
});
|
|
});
|
|
|
|
it('fetchCountRows rethrows a non-missing-table engine error instead of masking it as 0 pages', async () => {
|
|
await withEnv({ GBRAIN_SCHEMA_PACK: undefined }, async () => {
|
|
// No pack → detectDeadPrefixes is skipped, isolating the throw to the
|
|
// fetchCountRows catch we narrowed. The count query (the GROUP BY one)
|
|
// throws a column-level error (SQLSTATE 42703) — the exact class the
|
|
// old bare `catch {}` swallowed into 0 rows; everything else succeeds.
|
|
__setPackLocatorForTests(() => null);
|
|
const boom = Object.assign(new Error('column "type" does not exist'), { code: '42703' });
|
|
const stubEngine = {
|
|
executeRaw: async (sql: string) => {
|
|
if (/GROUP BY source_id/.test(sql)) throw boom; // the fetchCountRows query
|
|
return [];
|
|
},
|
|
} as unknown as PGLiteEngine;
|
|
const ctx = { ...ctxOf(), engine: stubEngine } as unknown as OperationContext;
|
|
await expect(runStatsCore(ctx)).rejects.toThrow('column "type" does not exist');
|
|
});
|
|
});
|
|
|
|
it('fetchCountRows still degrades to empty (no throw) on a genuine missing pages table', async () => {
|
|
await withEnv({ GBRAIN_SCHEMA_PACK: undefined }, async () => {
|
|
// Pre-init brain shape: the count query hits a missing pages table
|
|
// (SQLSTATE 42P01). This is the ONLY case the narrowed catch swallows.
|
|
__setPackLocatorForTests(() => null);
|
|
const missing = Object.assign(new Error('relation "pages" does not exist'), { code: '42P01' });
|
|
const stubEngine = {
|
|
executeRaw: async (sql: string) => {
|
|
if (/GROUP BY source_id/.test(sql)) throw missing;
|
|
return [];
|
|
},
|
|
} as unknown as PGLiteEngine;
|
|
const ctx = { ...ctxOf(), engine: stubEngine } as unknown as OperationContext;
|
|
const result = await runStatsCore(ctx);
|
|
expect(result.aggregate.total_pages).toBe(0);
|
|
expect(result.per_source).toEqual([]);
|
|
});
|
|
});
|
|
|
|
it('detectDeadPrefixes rethrows a non-missing-table error (sibling catch)', async () => {
|
|
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: 'tiny' }, async () => {
|
|
seedTinyPack('tiny', [{ name: 'person', prefix: 'people/' }]);
|
|
// fetchCountRows (the GROUP BY query) succeeds → []; the per-prefix
|
|
// dead-prefix LIKE query then throws a non-missing-table error, which
|
|
// must surface through the narrowed sibling catch.
|
|
const stubEngine = {
|
|
executeRaw: async (sql: string) => {
|
|
if (/GROUP BY source_id/.test(sql)) return []; // count query: empty brain, fine
|
|
throw Object.assign(new Error('division by zero'), { code: '22012' }); // the LIKE query
|
|
},
|
|
} as unknown as PGLiteEngine;
|
|
const ctx = { ...ctxOf(), engine: stubEngine } as unknown as OperationContext;
|
|
await expect(runStatsCore(ctx)).rejects.toThrow('division by zero');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('runStatsCore — type/untyped split', () => {
|
|
it('treats empty-string type as untyped (not its own bucket)', async () => {
|
|
await withEnv({ GBRAIN_SCHEMA_PACK: undefined }, async () => {
|
|
// Some legacy rows might have type='' rather than NULL.
|
|
await engine.executeRaw(
|
|
`INSERT INTO pages (slug, source_id, source_path, type, title, compiled_truth, timeline, content_hash)
|
|
VALUES ('a', 'default', 'a.md', '', 'a', '', '', '')`,
|
|
);
|
|
await seedPage('b', { type: 'person' });
|
|
const result = await runStatsCore(ctxOf());
|
|
expect(result.aggregate.untyped_pages).toBe(1);
|
|
expect(result.aggregate.typed_pages).toBe(1);
|
|
// empty-string type does NOT appear as its own type bucket.
|
|
expect(result.aggregate.by_type.find((t) => t.type === '')).toBeUndefined();
|
|
});
|
|
});
|
|
});
|