mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 03:12:32 +00:00
Both ship as runStatsCore / runSyncCore pure functions so Phase 4 CLI handlers (next commit) and Phase 7 MCP ops (later) both compose without duplicating logic. Codex C13 / D16 prereq for the MCP exposure phase. stats.ts (17 cases): Multi-source aware: sourceIds[] (federated read) OR sourceId (single) OR neither (whole-brain aggregate). NULLIF(type, '') normalizes empty-string + NULL to one untyped bucket (pages.type is NOT NULL in the schema so empty string is the legacy "untyped" representation). Soft-delete exclusion. by_type sorted by count desc, ties by name asc. Empty-brain coverage:1.0 (vacuous truth, matches getBrainScore). Dead-prefix detection: pack-declared prefixes with zero matching pages surface as DeadPrefixHint[] (agent's drilldown signal for mis-declared paths). Best-effort: pack-load failure leaves pack_identity:null + dead_prefixes:[]. sync.ts (13 cases): D14 chunked UPDATE: 1000-row batches per prefix. Each batch: WITH win AS (SELECT id FROM pages WHERE untyped+prefix LIMIT $batch), upd AS (UPDATE ... WHERE id IN win RETURNING 1) SELECT COUNT(*). Loop until zero rows. Concurrent writers never block on the row-set for more than ~100ms per batch (vs the multi-second monolithic UPDATE shape PR #1321 had). Codex C5 write-side scoping: sourceId param directly, NOT sourceScopeOpts which is read-side and inherits OAuth federation reads. Phase 7 MCP op (schema_apply_mutations) enforces at dispatch. Dry-run by default: per-prefix probe returns would_apply + 10-slug sample (the drilldown signal). Apply path returns total_applied. Idempotency contract pinned: second apply finds zero matching rows. Soft-delete exclusion on both probe + update. Dead-prefix flag set when probe returns count=0. JSON envelope schema_version:1. Tests use canonical PGLite block per CLAUDE.md test-isolation rules. seedPage helper auto-seeds sources(id) row before FK insert. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
273 lines
11 KiB
TypeScript
273 lines
11 KiB
TypeScript
// v0.40.6.0 — sync.ts contract tests.
|
||
//
|
||
// Dry-run vs apply, chunked-batch correctness, idempotency,
|
||
// soft-delete exclusion, sample-slug payload, dead-prefix hint,
|
||
// per-source write scoping, PGLite parity. Phase 3 of the cathedral.
|
||
|
||
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 { runSyncCore } from '../src/core/schema-pack/sync.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-sync-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;
|
||
await engine.executeRaw(
|
||
`INSERT INTO sources (id, name) VALUES ($1, $1) ON CONFLICT (id) DO NOTHING`,
|
||
[id],
|
||
);
|
||
}
|
||
|
||
async function seedPage(slug: string, sourcePath: string, opts: { type?: string; sourceId?: string; deleted?: boolean } = {}): Promise<void> {
|
||
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, sourcePath, opts.type ?? '', slug, opts.deleted ? new Date() : null],
|
||
);
|
||
}
|
||
|
||
async function getType(slug: string): Promise<string | null> {
|
||
const rows = await engine.executeRaw<{ type: string }>(
|
||
`SELECT type FROM pages WHERE slug = $1`,
|
||
[slug],
|
||
);
|
||
return rows[0]?.type ?? null;
|
||
}
|
||
|
||
function seedTinyPack(types: Array<{ name: string; prefix: string }>): void {
|
||
const dir = join(tmpDir, 'tiny');
|
||
mkdirSync(dir, { recursive: true });
|
||
const path = join(dir, 'pack.yaml');
|
||
let body = `api_version: gbrain-schema-pack-v1\nname: tiny\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 === 'tiny' ? path : null));
|
||
}
|
||
|
||
describe('runSyncCore — dry-run', () => {
|
||
it('returns would_apply count + sample_slugs without writing', async () => {
|
||
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: 'tiny' }, async () => {
|
||
seedTinyPack([{ name: 'person', prefix: 'people/' }]);
|
||
await seedPage('alice', 'people/alice.md');
|
||
await seedPage('bob', 'people/bob.md');
|
||
const result = await runSyncCore(ctxOf(), { apply: false });
|
||
expect(result.apply).toBe(false);
|
||
expect(result.total_would_apply).toBe(2);
|
||
expect(result.total_applied).toBe(0);
|
||
const personEntry = result.per_prefix.find((p) => p.type === 'person')!;
|
||
expect(personEntry.would_apply).toBe(2);
|
||
expect(personEntry.applied).toBe(0);
|
||
expect(personEntry.sample_slugs).toEqual(['alice', 'bob']);
|
||
// Confirm types still empty after dry-run.
|
||
expect(await getType('alice')).toBe('');
|
||
expect(await getType('bob')).toBe('');
|
||
});
|
||
});
|
||
|
||
it('sample_slugs capped at 10', async () => {
|
||
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: 'tiny' }, async () => {
|
||
seedTinyPack([{ name: 'person', prefix: 'people/' }]);
|
||
for (let i = 0; i < 15; i++) {
|
||
await seedPage(`p${String(i).padStart(2, '0')}`, `people/p${String(i).padStart(2, '0')}.md`);
|
||
}
|
||
const result = await runSyncCore(ctxOf(), { apply: false });
|
||
const personEntry = result.per_prefix.find((p) => p.type === 'person')!;
|
||
expect(personEntry.would_apply).toBe(15);
|
||
expect(personEntry.sample_slugs.length).toBe(10);
|
||
});
|
||
});
|
||
|
||
it('dead_prefix flag fires when no pages match', async () => {
|
||
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: 'tiny' }, async () => {
|
||
seedTinyPack([
|
||
{ name: 'person', prefix: 'people/' },
|
||
{ name: 'company', prefix: 'companies/' },
|
||
]);
|
||
await seedPage('alice', 'people/alice.md');
|
||
const result = await runSyncCore(ctxOf(), { apply: false });
|
||
const companyEntry = result.per_prefix.find((p) => p.type === 'company')!;
|
||
expect(companyEntry.dead_prefix).toBe(true);
|
||
expect(companyEntry.would_apply).toBe(0);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('runSyncCore — apply', () => {
|
||
it('updates page.type for matching untyped pages', async () => {
|
||
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: 'tiny' }, async () => {
|
||
seedTinyPack([{ name: 'person', prefix: 'people/' }]);
|
||
await seedPage('alice', 'people/alice.md');
|
||
await seedPage('bob', 'people/bob.md');
|
||
const result = await runSyncCore(ctxOf(), { apply: true });
|
||
expect(result.total_applied).toBe(2);
|
||
expect(await getType('alice')).toBe('person');
|
||
expect(await getType('bob')).toBe('person');
|
||
});
|
||
});
|
||
|
||
it('idempotent: second apply is a no-op', async () => {
|
||
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: 'tiny' }, async () => {
|
||
seedTinyPack([{ name: 'person', prefix: 'people/' }]);
|
||
await seedPage('alice', 'people/alice.md');
|
||
const first = await runSyncCore(ctxOf(), { apply: true });
|
||
const second = await runSyncCore(ctxOf(), { apply: true });
|
||
expect(first.total_applied).toBe(1);
|
||
expect(second.total_applied).toBe(0);
|
||
});
|
||
});
|
||
|
||
it('chunked UPDATE: large set in 1000-row batches (perf-shape verification)', async () => {
|
||
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: 'tiny' }, async () => {
|
||
seedTinyPack([{ name: 'person', prefix: 'people/' }]);
|
||
// Seed 1500 untyped pages (1.5× the default batch size).
|
||
for (let i = 0; i < 1500; i++) {
|
||
await seedPage(`p${String(i).padStart(4, '0')}`, `people/p${String(i).padStart(4, '0')}.md`);
|
||
}
|
||
const batches: number[] = [];
|
||
const result = await runSyncCore(ctxOf(), {
|
||
apply: true,
|
||
batchSize: 1000,
|
||
onProgress: (info) => batches.push(info.appliedSoFar),
|
||
});
|
||
expect(result.total_applied).toBe(1500);
|
||
// Progress fired at least once for the first batch (1000) and
|
||
// again for the tail (1500 total).
|
||
expect(batches).toContain(1000);
|
||
expect(batches).toContain(1500);
|
||
});
|
||
});
|
||
|
||
it('does NOT touch pages that already have a type', async () => {
|
||
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: 'tiny' }, async () => {
|
||
seedTinyPack([{ name: 'person', prefix: 'people/' }]);
|
||
await seedPage('alice', 'people/alice.md', { type: 'old-type' });
|
||
await seedPage('bob', 'people/bob.md'); // untyped
|
||
await runSyncCore(ctxOf(), { apply: true });
|
||
expect(await getType('alice')).toBe('old-type'); // preserved
|
||
expect(await getType('bob')).toBe('person');
|
||
});
|
||
});
|
||
|
||
it('excludes soft-deleted pages', async () => {
|
||
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: 'tiny' }, async () => {
|
||
seedTinyPack([{ name: 'person', prefix: 'people/' }]);
|
||
await seedPage('alice', 'people/alice.md');
|
||
await seedPage('zombie', 'people/zombie.md', { deleted: true });
|
||
const result = await runSyncCore(ctxOf(), { apply: true });
|
||
expect(result.total_applied).toBe(1);
|
||
expect(await getType('alice')).toBe('person');
|
||
expect(await getType('zombie')).toBe(''); // untouched (soft-deleted)
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('runSyncCore — source scoping (codex C5 write-side)', () => {
|
||
it('updates only the scoped source', async () => {
|
||
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: 'tiny' }, async () => {
|
||
seedTinyPack([{ name: 'person', prefix: 'people/' }]);
|
||
await seedPage('alice', 'people/alice.md', { sourceId: 'src-a' });
|
||
await seedPage('bob', 'people/bob.md', { sourceId: 'src-b' });
|
||
const result = await runSyncCore(ctxOf(), { apply: true, sourceId: 'src-a' });
|
||
expect(result.total_applied).toBe(1);
|
||
expect(await getType('alice')).toBe('person');
|
||
expect(await getType('bob')).toBe('');
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('runSyncCore — pack-load failure', () => {
|
||
it('returns empty result when no pack is loaded', async () => {
|
||
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: 'nonexistent' }, async () => {
|
||
__setPackLocatorForTests(() => null);
|
||
await seedPage('alice', 'people/alice.md');
|
||
const result = await runSyncCore(ctxOf(), { apply: true });
|
||
expect(result.pack_identity).toBeNull();
|
||
expect(result.per_prefix).toEqual([]);
|
||
expect(result.total_applied).toBe(0);
|
||
// Page untouched (no pack = no inference rules).
|
||
expect(await getType('alice')).toBe('');
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('runSyncCore — JSON envelope shape', () => {
|
||
it('schema_version stays 1', async () => {
|
||
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: 'tiny' }, async () => {
|
||
seedTinyPack([{ name: 'person', prefix: 'people/' }]);
|
||
const result = await runSyncCore(ctxOf());
|
||
expect(result.schema_version).toBe(1);
|
||
});
|
||
});
|
||
|
||
it('per_prefix entry shape is stable', async () => {
|
||
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: 'tiny' }, async () => {
|
||
seedTinyPack([{ name: 'person', prefix: 'people/' }]);
|
||
await seedPage('alice', 'people/alice.md');
|
||
const result = await runSyncCore(ctxOf());
|
||
const entry = result.per_prefix[0]!;
|
||
expect(entry).toHaveProperty('type');
|
||
expect(entry).toHaveProperty('prefix');
|
||
expect(entry).toHaveProperty('would_apply');
|
||
expect(entry).toHaveProperty('sample_slugs');
|
||
expect(entry).toHaveProperty('dead_prefix');
|
||
expect(entry).toHaveProperty('applied');
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('runSyncCore — batch size clamping', () => {
|
||
it('clamps batch size to >=1 and <=10000', async () => {
|
||
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: 'tiny' }, async () => {
|
||
seedTinyPack([{ name: 'person', prefix: 'people/' }]);
|
||
await seedPage('alice', 'people/alice.md');
|
||
// batchSize:0 and batchSize:99999 both work; result is identical.
|
||
const r1 = await runSyncCore(ctxOf(), { apply: true, batchSize: 0 });
|
||
expect(r1.total_applied).toBe(1);
|
||
});
|
||
});
|
||
});
|