mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +00:00
migrate --to now copies the complete source catalog before pages (fixes the pages_source_id_fkey failure on multi-source brains), and resume manifests carry an opaque target identity so a checkpoint from one target is discarded for a different target. Fixes #2677.
273 lines
12 KiB
TypeScript
273 lines
12 KiB
TypeScript
/**
|
|
* v0.32.8 — multi-source bug class regression suite.
|
|
*
|
|
* Pins the behaviors that were silently broken pre-v0.32.8 when a brain has
|
|
* more than one source. Pre-fix, every cycle phase and extract pass called
|
|
* slug-only engine methods inside a loop over pages and silently defaulted
|
|
* to source_id='default' for every non-default-source page.
|
|
*
|
|
* Fixture: 2 sources ('default' + 'media-corpus') with overlapping slugs.
|
|
* - people/alice exists in BOTH
|
|
* - concepts/widget exists ONLY in default
|
|
* - media/x/post-123 exists ONLY in media-corpus
|
|
*
|
|
* Coverage targets (one test per bug site):
|
|
* 1. listAllPageRefs returns one row per (slug, source_id), ordered.
|
|
* 2. extract-takes processes BOTH alice rows independently.
|
|
* 3. integrity scan covers BOTH sources.
|
|
* 4. extract-links F10 cross-source resolution:
|
|
* a. alice@media-corpus → widget falls back to default
|
|
* b. alice@media-corpus → post-123 stays in media-corpus
|
|
* c. alice@default → ghost-slug records nothing (skip)
|
|
* 5. validateSourceId rejects path-traversal attempts.
|
|
* 6. Reverse-write disk layout: .sources/<id>/<slug>.md for non-default.
|
|
*
|
|
* PGLite in-memory — no DATABASE_URL required. Canonical R3+R4 pattern.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { tmpdir } from 'node:os';
|
|
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
|
|
import { resetPgliteState } from '../helpers/reset-pglite.ts';
|
|
import { validateSourceId } from '../../src/core/utils.ts';
|
|
import { extractTakesFromDb } from '../../src/core/cycle/extract-takes.ts';
|
|
import { copyMigrationSources } from '../../src/commands/migrate-engine.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
let migrationTarget: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({} as never);
|
|
await engine.initSchema();
|
|
migrationTarget = new PGLiteEngine();
|
|
await migrationTarget.connect({} as never);
|
|
await migrationTarget.initSchema();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (engine) await engine.disconnect();
|
|
if (migrationTarget) await migrationTarget.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
await resetPgliteState(migrationTarget);
|
|
// Seed second source row. Default source is seeded by resetPgliteState.
|
|
await engine.executeRaw(
|
|
`INSERT INTO sources (id, name, config)
|
|
VALUES ('media-corpus', 'media-corpus', '{}'::jsonb)
|
|
ON CONFLICT (id) DO NOTHING`,
|
|
);
|
|
// Overlapping-slug seed.
|
|
await engine.putPage('people/alice', {
|
|
type: 'person', title: 'Alice (default)',
|
|
compiled_truth: 'Default-source alice page.',
|
|
}, { sourceId: 'default' });
|
|
await engine.putPage('people/alice', {
|
|
type: 'person', title: 'Alice (media-corpus)',
|
|
compiled_truth: 'Media-corpus alice page.',
|
|
}, { sourceId: 'media-corpus' });
|
|
// Distinct slugs per source.
|
|
await engine.putPage('concepts/widget', {
|
|
type: 'concept', title: 'Widget',
|
|
compiled_truth: 'Default-source-only widget.',
|
|
}, { sourceId: 'default' });
|
|
await engine.putPage('media/x/post-123', {
|
|
type: 'media', title: 'Post 123',
|
|
compiled_truth: 'Media-corpus-only post.',
|
|
}, { sourceId: 'media-corpus' });
|
|
});
|
|
|
|
describe('multi-source bug class', () => {
|
|
test('migration copies source metadata before overlapping-slug pages', async () => {
|
|
await engine.executeRaw(`
|
|
UPDATE sources SET
|
|
local_path = '/tmp/media-corpus', last_commit = 'commit-media',
|
|
last_sync_at = '2026-07-02T00:00:00Z',
|
|
config = '{"federated":false,"custom":"preserved"}'::jsonb,
|
|
archived = true, archived_at = '2026-07-03T00:00:00Z',
|
|
archive_expires_at = '2026-08-03T00:00:00Z',
|
|
contextual_retrieval_mode = 'tokenmax',
|
|
trust_frontmatter_overrides = true,
|
|
newest_content_at = '2026-07-01T00:00:00Z'
|
|
WHERE id = 'media-corpus'`);
|
|
|
|
await copyMigrationSources(engine, migrationTarget);
|
|
const sourceRows = await migrationTarget.executeRaw<any>(
|
|
`SELECT * FROM sources WHERE id = 'media-corpus'`,
|
|
);
|
|
expect(sourceRows).toHaveLength(1);
|
|
expect(sourceRows[0].last_commit).toBe('commit-media');
|
|
expect(sourceRows[0].archived).toBe(true);
|
|
expect(sourceRows[0].contextual_retrieval_mode).toBe('tokenmax');
|
|
expect(sourceRows[0].trust_frontmatter_overrides).toBe(true);
|
|
const config = typeof sourceRows[0].config === 'string'
|
|
? JSON.parse(sourceRows[0].config)
|
|
: sourceRows[0].config;
|
|
expect(config.custom).toBe('preserved');
|
|
|
|
const overlapping = await engine.listPages({ limit: 100 });
|
|
for (const page of overlapping) {
|
|
await migrationTarget.putPage(page.slug, {
|
|
type: page.type,
|
|
title: page.title,
|
|
compiled_truth: page.compiled_truth,
|
|
timeline: page.timeline,
|
|
frontmatter: page.frontmatter,
|
|
content_hash: page.content_hash,
|
|
}, { sourceId: page.source_id });
|
|
}
|
|
expect(await migrationTarget.getPage('people/alice', { sourceId: 'default' })).not.toBeNull();
|
|
expect(await migrationTarget.getPage('people/alice', { sourceId: 'media-corpus' })).not.toBeNull();
|
|
});
|
|
|
|
test('listAllPageRefs returns one row per (slug, source_id), ordered (F11)', async () => {
|
|
const refs = await engine.listAllPageRefs();
|
|
// 4 rows: alice@default, alice@media-corpus, widget@default, post-123@media-corpus
|
|
expect(refs.length).toBe(4);
|
|
// Sorted by (source_id, slug)
|
|
const ordered = refs.map(r => `${r.source_id}::${r.slug}`);
|
|
expect(ordered).toEqual([
|
|
'default::concepts/widget',
|
|
'default::people/alice',
|
|
'media-corpus::media/x/post-123',
|
|
'media-corpus::people/alice',
|
|
]);
|
|
});
|
|
|
|
test('getPage with sourceId picks the right (source, slug) row', async () => {
|
|
const aliceDefault = await engine.getPage('people/alice', { sourceId: 'default' });
|
|
const aliceMedia = await engine.getPage('people/alice', { sourceId: 'media-corpus' });
|
|
expect(aliceDefault?.title).toBe('Alice (default)');
|
|
expect(aliceMedia?.title).toBe('Alice (media-corpus)');
|
|
expect(aliceDefault?.source_id).toBe('default');
|
|
expect(aliceMedia?.source_id).toBe('media-corpus');
|
|
});
|
|
|
|
test('extract-takes processes both alice pages independently', async () => {
|
|
// Re-seed with takes fences so extract-takes has something to find.
|
|
// Fence markers come from src/core/takes-fence.ts (`<!--- gbrain:takes:begin -->`).
|
|
const TAKES_BODY = `<!--- gbrain:takes:begin -->
|
|
| # | claim | kind | who | weight | since | source |
|
|
| --- | --- | --- | --- | --- | --- | --- |
|
|
| 1 | Alice founded the thing | fact | garry | 0.9 | 2024-01-01 | |
|
|
<!--- gbrain:takes:end -->`;
|
|
await engine.putPage('people/alice', {
|
|
type: 'person', title: 'Alice (default)',
|
|
compiled_truth: 'Default alice.\n' + TAKES_BODY,
|
|
}, { sourceId: 'default' });
|
|
await engine.putPage('people/alice', {
|
|
type: 'person', title: 'Alice (media-corpus)',
|
|
compiled_truth: 'Media alice.\n' + TAKES_BODY,
|
|
}, { sourceId: 'media-corpus' });
|
|
|
|
const result = await extractTakesFromDb(engine, {});
|
|
// Pre-fix: only one of the two alice rows had takes extracted because
|
|
// the loop matched by bare slug. Post-fix: both rows get processed.
|
|
// Each alice has 1 take; widget + post-123 have none.
|
|
expect(result.pagesWithTakes).toBe(2);
|
|
expect(result.takesUpserted).toBe(2);
|
|
});
|
|
|
|
test('listPages filters correctly with PageFilters.sourceId', async () => {
|
|
const onlyDefault = await engine.listPages({ sourceId: 'default', limit: 100 });
|
|
const onlyMedia = await engine.listPages({ sourceId: 'media-corpus', limit: 100 });
|
|
expect(onlyDefault.length).toBe(2);
|
|
expect(onlyMedia.length).toBe(2);
|
|
for (const p of onlyDefault) expect(p.source_id).toBe('default');
|
|
for (const p of onlyMedia) expect(p.source_id).toBe('media-corpus');
|
|
});
|
|
|
|
test('addLinksBatch with from/to_source_id targets the right rows (F4)', async () => {
|
|
// Link alice@media-corpus → post-123@media-corpus (in-source link).
|
|
const inserted = await engine.addLinksBatch([
|
|
{
|
|
from_slug: 'people/alice',
|
|
to_slug: 'media/x/post-123',
|
|
link_type: 'mentions',
|
|
link_source: 'markdown',
|
|
from_source_id: 'media-corpus',
|
|
to_source_id: 'media-corpus',
|
|
},
|
|
]);
|
|
expect(inserted).toBe(1);
|
|
|
|
const links = await engine.getLinks('people/alice', { sourceId: 'media-corpus' });
|
|
expect(links.length).toBe(1);
|
|
expect(links[0].to_slug).toBe('media/x/post-123');
|
|
|
|
// alice@default should have NO links — they're scoped to media-corpus.
|
|
const defaultLinks = await engine.getLinks('people/alice', { sourceId: 'default' });
|
|
expect(defaultLinks.length).toBe(0);
|
|
});
|
|
|
|
test('validateSourceId rejects path traversal (F6)', () => {
|
|
// v0.38 (codex P1-D + eng E2): regex TIGHTENED from permissive
|
|
// ^[a-z0-9_-]+$ to strict kebab ^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$.
|
|
// Underscores no longer allowed at the path-safety gate (matches
|
|
// what sources-ops always rejected at creation time). 'jarvis_memory'
|
|
// was a v0.32.8 permissive-regex test case; now lives in the
|
|
// rejected set. Path-traversal rejection unchanged.
|
|
//
|
|
// Allowed (strict kebab):
|
|
expect(() => validateSourceId('default')).not.toThrow();
|
|
expect(() => validateSourceId('media-corpus')).not.toThrow();
|
|
expect(() => validateSourceId('jarvis-memory')).not.toThrow();
|
|
expect(() => validateSourceId('abc123')).not.toThrow();
|
|
expect(() => validateSourceId('a')).not.toThrow();
|
|
// Rejected — path traversal / unsafe chars:
|
|
expect(() => validateSourceId('..')).toThrow();
|
|
expect(() => validateSourceId('../etc')).toThrow();
|
|
expect(() => validateSourceId('foo/bar')).toThrow();
|
|
expect(() => validateSourceId('foo bar')).toThrow();
|
|
expect(() => validateSourceId('Default')).toThrow(); // uppercase
|
|
expect(() => validateSourceId('.hidden')).toThrow();
|
|
expect(() => validateSourceId('')).toThrow();
|
|
// Rejected — strict regex additions (v0.38):
|
|
expect(() => validateSourceId('jarvis_memory')).toThrow(); // underscores
|
|
expect(() => validateSourceId('snake_case')).toThrow();
|
|
expect(() => validateSourceId('-leading')).toThrow(); // edge hyphen
|
|
expect(() => validateSourceId('trailing-')).toThrow();
|
|
const tooLong = 'a' + 'b'.repeat(31) + 'c'; // 33 chars
|
|
expect(() => validateSourceId(tooLong)).toThrow();
|
|
});
|
|
|
|
test('reverse-write disk layout uses .sources/<id>/<slug>.md for non-default (F6)', () => {
|
|
// Pure-function test of the disk-path computation embedded in
|
|
// reverseWriteRefs (patterns.ts + synthesize.ts). We don't drive the
|
|
// full dream cycle here — that requires LLM + subagent infra. Instead
|
|
// we replicate the path computation and verify the file layout matches
|
|
// what the production code would write.
|
|
const tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-multi-source-disk-'));
|
|
try {
|
|
const computePath = (source_id: string, slug: string): string =>
|
|
source_id === 'default'
|
|
? join(tmpDir, `${slug}.md`)
|
|
: join(tmpDir, '.sources', source_id, `${slug}.md`);
|
|
|
|
const defaultPath = computePath('default', 'people/alice');
|
|
const mediaPath = computePath('media-corpus', 'people/alice');
|
|
|
|
// The two paths must NOT collide.
|
|
expect(defaultPath).not.toBe(mediaPath);
|
|
expect(mediaPath).toContain('.sources/media-corpus/');
|
|
|
|
// Actually write to both paths to prove disk separation.
|
|
mkdirSync(join(tmpDir, 'people'), { recursive: true });
|
|
mkdirSync(join(tmpDir, '.sources', 'media-corpus', 'people'), { recursive: true });
|
|
writeFileSync(defaultPath, 'default alice');
|
|
writeFileSync(mediaPath, 'media-corpus alice');
|
|
|
|
expect(readFileSync(defaultPath, 'utf8')).toBe('default alice');
|
|
expect(readFileSync(mediaPath, 'utf8')).toBe('media-corpus alice');
|
|
expect(existsSync(defaultPath)).toBe(true);
|
|
expect(existsSync(mediaPath)).toBe(true);
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|