Files
gbrain/test/extract-incremental.test.ts
a11ec9c468 fix(dream): stamp incremental extraction watermark (#2636)
The Dream cycle disables sync's inline extraction and routes changed
slugs through extractForSlugs, which flushed link/timeline batches but
never stamped links_extracted_at — so incrementally extracted pages
stayed permanently visible to `extract --stale` / doctor.

Collect processedRefs per successfully processed page and stamp them
via stampExtracted (best-effort) after both batch flushes, non-dry-run
mode 'all' only. Source-id threading from the original PR #2637 already
landed on master via #1503/#1747, so this rebase carries only the
missing watermark stamp plus regression tests.

Takeover of #2637.

Co-authored-by: JavanC <JavanC@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:34:22 -07:00

239 lines
9.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Regression guards for the incremental extract path (PR #417).
*
* Eng-review Step 5: 8 unit cases asserting `runExtractCore({ slugs })`
* processes only the requested slugs in the cycle path while
* `slugs: undefined` falls through to the existing full-walk behavior.
*
* All tests use PGLite/in-memory — no DB connection required.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { runExtractCore } from '../src/commands/extract.ts';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import type { BrainEngine } from '../src/core/engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
// One PGLite per file (beforeAll), wipe data per test (beforeEach).
// PGLite cold-start dominates wall-time; sharing the engine across all tests
// in this file cuts ~22s × 8 tests = ~3 min on CI.
let engine: PGLiteEngine;
let tempDir: string;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({ engine: 'pglite' });
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
tempDir = mkdtempSync(join(tmpdir(), 'gbrain-extract-test-'));
mkdirSync(join(tempDir, 'people'), { recursive: true });
mkdirSync(join(tempDir, 'companies'), { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
async function seedPage(slug: string, body: string): Promise<void> {
const [type, name] = slug.split('/');
await engine.putPage(slug, {
type: type as 'person' | 'company',
title: name,
compiled_truth: body,
timeline: '',
frontmatter: {},
content_hash: 'h',
});
// Also write to disk so walkMarkdownFiles can find it
const filePath = join(tempDir, slug + '.md');
mkdirSync(join(tempDir, type), { recursive: true });
writeFileSync(filePath, body);
}
describe('runExtractCore — incremental cycle path (#417)', () => {
test('Dream incremental all-mode stamps the source-scoped extraction watermark (#2636)', async () => {
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path) VALUES ($1, $2, $3)`,
['repo-a', 'repo-a', tempDir],
);
await engine.putPage('people/alice-example', {
type: 'person',
title: 'alice-example',
compiled_truth: '# alice',
timeline: '',
frontmatter: {},
content_hash: 'h',
}, { sourceId: 'repo-a' });
writeFileSync(join(tempDir, 'people/alice-example.md'), '# alice');
await runExtractCore(engine as unknown as BrainEngine, {
mode: 'all',
dir: tempDir,
slugs: ['people/alice-example'],
sourceId: 'repo-a',
});
const rows = await engine.executeRaw<{ links_extracted_at: string | null }>(
`SELECT links_extracted_at FROM pages WHERE slug = $1 AND source_id = $2`,
['people/alice-example', 'repo-a'],
);
expect(rows[0]?.links_extracted_at).not.toBeNull();
expect(await engine.countStalePagesForExtraction({ sourceId: 'repo-a' })).toBe(0);
});
test('Dream incremental dry-run does NOT stamp the watermark', async () => {
await seedPage('people/alice-example', '# alice');
await runExtractCore(engine as unknown as BrainEngine, {
mode: 'all',
dir: tempDir,
slugs: ['people/alice-example'],
dryRun: true,
});
const rows = await engine.executeRaw<{ links_extracted_at: string | null }>(
`SELECT links_extracted_at FROM pages WHERE slug = $1`,
['people/alice-example'],
);
expect(rows[0]?.links_extracted_at ?? null).toBeNull();
});
test('1. slugs: [] returns immediately with zero counts (early-return path)', async () => {
await seedPage('people/alice-example', '# alice');
const result = await runExtractCore(engine as unknown as BrainEngine, {
mode: 'all',
dir: tempDir,
slugs: [],
});
expect(result.links_created).toBe(0);
expect(result.timeline_entries_created).toBe(0);
expect(result.pages_processed).toBe(0);
});
test('2. slugs: undefined falls through to full-walk path', async () => {
await seedPage('people/alice-example', '# alice\n\n[bob](people/bob-example)');
await seedPage('people/bob-example', '# bob');
const result = await runExtractCore(engine as unknown as BrainEngine, {
mode: 'all',
dir: tempDir,
});
// Full walk processes everything found on disk
expect(result.pages_processed).toBeGreaterThan(0);
});
test('3. slugs: [a, b] reads only those two files (incremental processing)', async () => {
await seedPage('people/alice-example', '# alice');
await seedPage('people/bob-example', '# bob');
await seedPage('people/charlie-example', '# charlie');
const result = await runExtractCore(engine as unknown as BrainEngine, {
mode: 'all',
dir: tempDir,
slugs: ['people/alice-example', 'people/bob-example'],
});
// Only 2 files processed even though 3 exist on disk
expect(result.pages_processed).toBe(2);
});
test('4. Slug whose file no longer exists is silently skipped', async () => {
await seedPage('people/alice-example', '# alice');
// people/ghost has no file on disk but is in the slugs list
const result = await runExtractCore(engine as unknown as BrainEngine, {
mode: 'all',
dir: tempDir,
slugs: ['people/alice-example', 'people/ghost'],
});
// alice processed; ghost skipped (no file)
expect(result.pages_processed).toBe(1);
});
test('5. mode: links skips timeline extraction in incremental', async () => {
const body = '# alice\n\n## Timeline\n- 2026-01-01: started';
await seedPage('people/alice-example', body);
const result = await runExtractCore(engine as unknown as BrainEngine, {
mode: 'links',
dir: tempDir,
slugs: ['people/alice-example'],
});
// Timeline extraction skipped even though body contains a timeline
expect(result.timeline_entries_created).toBe(0);
});
test('6. dryRun: true does not invoke addLinksBatch / addTimelineEntriesBatch', async () => {
await seedPage('people/alice-example', '# alice\n\n[bob](people/bob-example)');
await seedPage('people/bob-example', '# bob');
let linksBatchCalled = false;
let timelineBatchCalled = false;
const originalAddLinks = engine.addLinksBatch.bind(engine);
const originalAddTimeline = engine.addTimelineEntriesBatch.bind(engine);
(engine as unknown as { addLinksBatch: typeof originalAddLinks }).addLinksBatch = async (...args) => {
linksBatchCalled = true;
return originalAddLinks(...args);
};
(engine as unknown as { addTimelineEntriesBatch: typeof originalAddTimeline }).addTimelineEntriesBatch = async (...args) => {
timelineBatchCalled = true;
return originalAddTimeline(...args);
};
await runExtractCore(engine as unknown as BrainEngine, {
mode: 'all',
dir: tempDir,
slugs: ['people/alice-example'],
dryRun: true,
});
expect(linksBatchCalled).toBe(false);
expect(timelineBatchCalled).toBe(false);
});
test('7. BATCH_SIZE flush — slugs producing >100 candidate links exercise the mid-iteration flush', async () => {
// BATCH_SIZE in extract.ts is 100. Create one slug with 150 outbound links.
const targets: string[] = [];
for (let i = 0; i < 150; i++) {
const target = `companies/co-${i}`;
targets.push(target);
await seedPage(target, `# co-${i}`);
}
const linkBlock = targets.map(t => `- [${t}](${t})`).join('\n');
await seedPage('people/alice-example', `# alice\n\n${linkBlock}`);
const result = await runExtractCore(engine as unknown as BrainEngine, {
mode: 'links',
dir: tempDir,
slugs: ['people/alice-example'],
});
// The flush happens mid-iteration when batch hits 100; the remaining 50 flush at end.
// No exception means the flush path executed cleanly.
expect(result.pages_processed).toBe(1);
expect(result.links_created).toBeGreaterThanOrEqual(0); // Just confirms the flush path didn't blow up
});
test('8. Full-slug-set resolution — slug references file outside changed set', async () => {
// alice references bob, but only alice is in the incremental slugs list.
// The allSlugs set must still include bob (from walkMarkdownFiles) so
// resolveSlug succeeds; otherwise the link would silently drop.
// Markdown link pattern requires .md target.
await seedPage('people/alice-example', '# alice\n\n[bob](bob-example.md)');
await seedPage('people/bob-example', '# bob');
const result = await runExtractCore(engine as unknown as BrainEngine, {
mode: 'links',
dir: tempDir,
slugs: ['people/alice-example'],
});
// Only alice's file was read, but the resulting link must reference bob
// (resolved via the full allSlugs set built from walkMarkdownFiles).
expect(result.pages_processed).toBe(1);
// Link from alice to bob was extracted successfully via the full allSlugs set
expect(result.links_created).toBeGreaterThan(0);
});
});