mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +00:00
A page write is not 'done' until it is readable back. After the import transaction commits, verify the page resolves via getPage and its content_hash matches what was just written. On mismatch or miss, fail LOUDLY instead of reporting success, and record the failure in ingest_log (best-effort) so it is durable and agent-inspectable rather than a transient stderr message. This catches the silent-desync class: the page file exists on disk (or the git commit landed) but the DB index never picked the write up — the operation previously reported success while the page stayed invisible to every read path (get_page, search, query) until someone noticed the gap manually. Guard applies to both importFromContent (markdown) and importCodeFile. Tests: new write-verify-guard suite (hermetic PGLite) covering the happy path, index-miss, stale-hash, ingest_log record, and the put_page operation surface; import-file.test.ts mock upgraded to simulate a readable DB (writes are read-backable), matching the new guard. PRJ-2026-032 Co-authored-by: merlin-drizzyenterprises[bot] <144527811+merlin-drizzyenterprises[bot]@users.noreply.github.com>
250 lines
9.3 KiB
TypeScript
250 lines
9.3 KiB
TypeScript
/**
|
|
* Post-write read-back verification tests.
|
|
*
|
|
* Reproduces the silent-desync case: a page write commits but the DB index
|
|
* silently never picks it up. Without the guard, importFromContent reports
|
|
* success. With the guard, it fails loudly (throws).
|
|
*
|
|
* Also verifies the happy path: a normal write passes read-back.
|
|
*/
|
|
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import * as os from 'node:os';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
|
import { resetGateway } from '../src/core/ai/gateway.ts';
|
|
import { importFromContent } from '../src/core/import-file.ts';
|
|
import { operations } from '../src/core/operations.ts';
|
|
import type { OperationContext } from '../src/core/operations.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
let tmpRoot: string;
|
|
let brainDir: string;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
resetGateway();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
resetGateway();
|
|
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-verify-'));
|
|
brainDir = path.join(tmpRoot, 'brain');
|
|
fs.mkdirSync(brainDir, { recursive: true });
|
|
await engine.setConfig('sync.repo_path', brainDir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('post-write read-back verification', () => {
|
|
test('normal write passes read-back and returns imported', async () => {
|
|
const slug = 'inbox/verify-happy';
|
|
const content = '---\ntitle: Happy\n---\n\n# Body that should round-trip cleanly';
|
|
const result = await importFromContent(engine, slug, content, {
|
|
noEmbed: true,
|
|
sourceId: 'default',
|
|
});
|
|
|
|
expect(result.status).toBe('imported');
|
|
expect(result.slug).toBe(slug);
|
|
|
|
// The page is resolvable via getPage in the same operation.
|
|
const page = await engine.getPage(slug, { sourceId: 'default' });
|
|
expect(page).not.toBeNull();
|
|
expect(page!.title).toBe('Happy');
|
|
});
|
|
|
|
test('silent desync (page on disk, absent from index) fails loudly', async () => {
|
|
// Simulate the desync: write a page, then DELETE it from the DB
|
|
// between the transaction and the read-back. In production, this
|
|
// happens when the DB index silently fails to pick up the write
|
|
// (e.g. a trigger dropped, a partition routing error, or an index
|
|
// corruption). The guard must catch this and throw.
|
|
//
|
|
// We achieve this by wrapping the engine to intercept getPage
|
|
// after the transaction and return null (simulating an index miss).
|
|
|
|
const slug = 'inbox/verify-desync';
|
|
const content = '---\ntitle: Desync\n---\n\n# Body that will be silently lost';
|
|
|
|
// First, write the page normally to prove it works.
|
|
const result1 = await importFromContent(engine, slug, content, {
|
|
noEmbed: true,
|
|
sourceId: 'default',
|
|
});
|
|
expect(result1.status).toBe('imported');
|
|
|
|
// Now simulate the desync: delete the page from the DB, then
|
|
// try to write it again. But this time, we intercept getPage
|
|
// to return null after the write (simulating an index miss).
|
|
//
|
|
// We do this by monkey-patching the engine's getPage method
|
|
// to return null for this specific slug on the NEXT call
|
|
// (which is the read-back).
|
|
await engine.executeRaw(`DELETE FROM pages WHERE slug = $1`, [slug]);
|
|
// Verify the page is gone (simulating the index miss state).
|
|
const gone = await engine.getPage(slug, { sourceId: 'default' });
|
|
expect(gone).toBeNull();
|
|
|
|
// Now write again, but intercept getPage to simulate the desync.
|
|
// We need to intercept the read-back call specifically. Since
|
|
// importFromContent calls getPage twice (once for existing check
|
|
// at the top, once for read-back), we intercept the second call
|
|
// for this specific slug.
|
|
const slugCallCount = new Map<string, number>();
|
|
const originalGetPage = engine.getPage.bind(engine);
|
|
const interceptingGetPage = async (
|
|
s: string,
|
|
opts?: { sourceId?: string },
|
|
) => {
|
|
const count = (slugCallCount.get(s) ?? 0) + 1;
|
|
slugCallCount.set(s, count);
|
|
// The first getPage call is the "existing" check at the top of
|
|
// importFromContent (line 561). The second is the read-back.
|
|
// We let the first call through (returning null since we deleted
|
|
// the page), but intercept the read-back to return null.
|
|
if (s === slug && count === 2) {
|
|
return null; // Simulate index miss on read-back.
|
|
}
|
|
return originalGetPage(s, opts);
|
|
};
|
|
engine.getPage = interceptingGetPage as typeof engine.getPage;
|
|
|
|
try {
|
|
// This should throw — the read-back fails, so the write is not "done".
|
|
await expect(
|
|
importFromContent(engine, slug, content, {
|
|
noEmbed: true,
|
|
sourceId: 'default',
|
|
}),
|
|
).rejects.toThrow(/post-write read-back failed/);
|
|
} finally {
|
|
// Restore the original getPage.
|
|
engine.getPage = originalGetPage;
|
|
}
|
|
});
|
|
|
|
test('stale content_hash fails loudly', async () => {
|
|
// Simulate a stale hash: write a page, then corrupt the content_hash
|
|
// before the read-back. The guard should detect the mismatch.
|
|
const slug = 'inbox/verify-stale-hash';
|
|
const content = '---\ntitle: Stale\n---\n\n# Body with stale hash';
|
|
|
|
// Write the page normally first.
|
|
await importFromContent(engine, slug, content, {
|
|
noEmbed: true,
|
|
sourceId: 'default',
|
|
});
|
|
|
|
// Corrupt the content_hash to simulate a stale row.
|
|
await engine.executeRaw(
|
|
`UPDATE pages SET content_hash = $1 WHERE slug = $2`,
|
|
['stale-hash-value-that-does-not-match', slug],
|
|
);
|
|
|
|
// Now try to write again. The existing check will find the page
|
|
// (with the stale hash), so it won't be skipped. The write will
|
|
// commit, but the read-back should detect the stale hash...
|
|
// except wait — the write will UPDATE the hash, so the read-back
|
|
// will see the NEW hash. We need to intercept getPage to return
|
|
// the stale hash instead.
|
|
const slugCallCount = new Map<string, number>();
|
|
const originalGetPage = engine.getPage.bind(engine);
|
|
const stalePage = await originalGetPage(slug, { sourceId: 'default' });
|
|
|
|
// Write the stale hash back so the existing check sees a mismatch.
|
|
await engine.executeRaw(
|
|
`UPDATE pages SET content_hash = $1 WHERE slug = $2`,
|
|
['stale-hash-value-that-does-not-match', slug],
|
|
);
|
|
|
|
const interceptingGetPage = async (
|
|
s: string,
|
|
opts?: { sourceId?: string },
|
|
) => {
|
|
const count = (slugCallCount.get(s) ?? 0) + 1;
|
|
slugCallCount.set(s, count);
|
|
// The read-back call (second getPage for this slug) returns the
|
|
// stale page with the old hash.
|
|
if (s === slug && count === 2 && stalePage) {
|
|
return { ...stalePage, content_hash: 'stale-hash-value-that-does-not-match' };
|
|
}
|
|
return originalGetPage(s, opts);
|
|
};
|
|
engine.getPage = interceptingGetPage as typeof engine.getPage;
|
|
|
|
try {
|
|
await expect(
|
|
importFromContent(engine, slug, content, {
|
|
noEmbed: true,
|
|
sourceId: 'default',
|
|
}),
|
|
).rejects.toThrow(/stale content_hash/);
|
|
} finally {
|
|
engine.getPage = originalGetPage;
|
|
}
|
|
});
|
|
|
|
test('put_page operation surfaces read-back failure to the caller', async () => {
|
|
// Verify that the put_page MCP operation also surfaces the read-back
|
|
// failure (not just importFromContent directly). This is the path
|
|
// agents actually call.
|
|
const putPage = operations.find((o) => o.name === 'put_page')!;
|
|
const slug = 'inbox/verify-putpage-desync';
|
|
const content = '---\ntitle: Desync\n---\n\n# Body that will be silently lost';
|
|
|
|
// Intercept getPage to simulate index miss on the read-back.
|
|
// Use a slug-specific counter to avoid interference from other tests
|
|
// that might call getPage on the same engine.
|
|
//
|
|
// The put_page handler calls getPage via importFromContent, which makes
|
|
// two getPage calls for this slug:
|
|
// 1. importFromContent's existing-page check
|
|
// 2. verifyPageReadable's read-back (the one we intercept)
|
|
const slugCallCount = new Map<string, number>();
|
|
const originalGetPage = engine.getPage.bind(engine);
|
|
const interceptingGetPage = async (
|
|
s: string,
|
|
opts?: { sourceId?: string },
|
|
) => {
|
|
const count = (slugCallCount.get(s) ?? 0) + 1;
|
|
slugCallCount.set(s, count);
|
|
// Intercept the read-back call (callCount == 2 for this slug).
|
|
if (s === slug && count === 2) {
|
|
return null;
|
|
}
|
|
return originalGetPage(s, opts);
|
|
};
|
|
engine.getPage = interceptingGetPage as typeof engine.getPage;
|
|
|
|
const ctx: OperationContext = {
|
|
engine,
|
|
config: { engine: 'pglite' as const },
|
|
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
dryRun: false,
|
|
remote: false,
|
|
sourceId: 'default',
|
|
agentIdentity: { name: 'Test Agent', email: 'test-agent@example.com' },
|
|
} as OperationContext;
|
|
|
|
try {
|
|
await expect(
|
|
putPage.handler(ctx, { slug, content }),
|
|
).rejects.toThrow(/post-write read-back failed/);
|
|
} finally {
|
|
engine.getPage = originalGetPage;
|
|
}
|
|
});
|
|
});
|