mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* refactor: extract shared atomic writePageThrough helper Lift the v0.38 put_page disk write-through (operations.ts) into a shared src/core/write-through.ts helper and upgrade it to write atomically (unique temp file + rename) so a crash or a concurrent gbrain sync can never read a half-written .md. put_page now calls the helper; behavior is preserved (repo guards, source-awareness, provenance overrides) and its write is now atomic. brainstorm/lsd --save will call the same helper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(brainstorm/lsd): --save writes the advertised file via canonical ingestion --save printed 'Saved to <slug>' unconditionally but only did a raw DB putPage: the promised wiki/ideas/<slug>.md file was never written, the page had no chunks (unsearchable, and churned by the next sync), and a failed DB write under PgBouncer still claimed success. Route save through importFromContent({noEmbed:true}) for a canonical row, then the shared writePageThrough helper renders the file from that row. persistSavedIdea + formatSaveOutcome report honestly which sinks landed and exit nonzero when nothing persisted. buildIdeaSlug gets a random nonce so same-day ideas don't clobber. New buildBrainstormFrontmatterObject feeds serializeMarkdown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.41.30.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: document write-through.ts + brainstorm --save canonical path (v0.41.30.0) Add Key Files entry for the new shared atomic src/core/write-through.ts helper, note the brainstorm/lsd --save canonical-ingestion rewrite on the brainstorm entry, and note put_page now calls the shared atomic helper on the operations.ts entry. Regenerate llms-full.txt to match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
188 lines
7.2 KiB
TypeScript
188 lines
7.2 KiB
TypeScript
/**
|
|
* brainstorm/lsd --save persistence tests.
|
|
*
|
|
* Covers persistSavedIdea (canonical importFromContent + shared write-through),
|
|
* formatSaveOutcome (honest message + exit code), and buildIdeaSlug
|
|
* (collision-resistant nonce). Includes the two regression cases for the
|
|
* silent-false-success bug class: DB import throws → exit 1 "NOT persisted";
|
|
* file write fails but DB row landed → exit 0 (durable, sync reconciles).
|
|
*/
|
|
|
|
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 type { BrainEngine } from '../../src/core/engine.ts';
|
|
import {
|
|
persistSavedIdea,
|
|
formatSaveOutcome,
|
|
buildIdeaSlug,
|
|
type SaveOutcome,
|
|
} from '../../src/commands/brainstorm.ts';
|
|
import { serializeMarkdown } from '../../src/core/markdown.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-bs-save-'));
|
|
brainDir = path.join(tmpRoot, 'brain');
|
|
fs.mkdirSync(brainDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
const sampleContent = () =>
|
|
serializeMarkdown({ mode: 'lsd', question: 'why X' }, '# LSD: why X\n\nbody', '', {
|
|
type: 'note',
|
|
title: 'LSD: why X',
|
|
tags: [],
|
|
});
|
|
|
|
describe('persistSavedIdea', () => {
|
|
test('both sinks: canonical DB import (chunks written) + file rendered from row', async () => {
|
|
await engine.setConfig('sync.repo_path', brainDir);
|
|
const slug = buildIdeaSlug('why X', 'lsd', 'nonce01');
|
|
const o = await persistSavedIdea(engine, { slug, content: sampleContent(), provenanceVia: 'lsd' });
|
|
|
|
expect(o.dbSaved).toBe(true);
|
|
expect(o.writeThrough.written).toBe(true);
|
|
expect(fs.existsSync(o.writeThrough.path!)).toBe(true);
|
|
|
|
// Canonical proof: importFromContent wrote chunks. Raw engine.putPage (the
|
|
// pre-fix path) would write ZERO chunks, so the page would be unsearchable
|
|
// and the next sync would churn the row.
|
|
const rows = await engine.executeRaw<{ n: number }>(
|
|
'SELECT COUNT(*)::int AS n FROM content_chunks ch JOIN pages p ON p.id = ch.page_id WHERE p.slug = $1',
|
|
[slug],
|
|
);
|
|
expect(Number(rows[0].n)).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('no repo configured → DB canonical, writeThrough skipped, exit 0', async () => {
|
|
await engine.setConfig('sync.repo_path', '');
|
|
const slug = buildIdeaSlug('why Y', 'lsd', 'nonce02');
|
|
const o = await persistSavedIdea(engine, { slug, content: sampleContent(), provenanceVia: 'lsd' });
|
|
|
|
expect(o.dbSaved).toBe(true);
|
|
expect(o.writeThrough.skipped).toBe('no_repo_configured');
|
|
const m = formatSaveOutcome(o, { profileLabel: 'lsd', slug });
|
|
expect(m.exitCode).toBe(0);
|
|
expect(m.stdout).toContain('no `sync.repo_path`');
|
|
});
|
|
|
|
test('[REGRESSION] DB import throws → nothing persisted → exit 1 NOT persisted', async () => {
|
|
// A dead/garbage engine makes importFromContent throw immediately — the
|
|
// shape of the original PgBouncer "DB write failed but said saved" bug.
|
|
const deadEngine = {} as unknown as BrainEngine;
|
|
const slug = buildIdeaSlug('why Z', 'lsd', 'nonce03');
|
|
const o = await persistSavedIdea(deadEngine, { slug, content: sampleContent(), provenanceVia: 'lsd' });
|
|
|
|
expect(o.dbSaved).toBe(false);
|
|
expect(typeof o.dbError).toBe('string');
|
|
expect(o.writeThrough.written).toBe(false);
|
|
|
|
const m = formatSaveOutcome(o, { profileLabel: 'lsd', slug });
|
|
expect(m.exitCode).toBe(1);
|
|
expect(m.stdout).toBeUndefined();
|
|
expect(m.stderr.some((l) => l.includes('NOT persisted'))).toBe(true);
|
|
});
|
|
|
|
test('[REGRESSION] repo set but file write fails (ENOTDIR) → DB saved, exit 0, warns', async () => {
|
|
await engine.setConfig('sync.repo_path', brainDir);
|
|
fs.writeFileSync(path.join(brainDir, 'wiki'), 'blocker'); // blocks wiki/ideas/
|
|
const slug = buildIdeaSlug('why W', 'lsd', 'nonce04'); // wiki/ideas/...-nonce04
|
|
const o = await persistSavedIdea(engine, { slug, content: sampleContent(), provenanceVia: 'lsd' });
|
|
|
|
expect(o.dbSaved).toBe(true);
|
|
expect(o.writeThrough.written).toBe(false);
|
|
expect(typeof o.writeThrough.error).toBe('string');
|
|
|
|
const m = formatSaveOutcome(o, { profileLabel: 'lsd', slug });
|
|
expect(m.exitCode).toBe(0); // row is durable; sync reconciles the file
|
|
expect(m.stdout).toContain('file NOT written');
|
|
expect(m.stderr.some((l) => l.includes('file write failed'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('formatSaveOutcome (pure)', () => {
|
|
const ctx = { profileLabel: 'lsd', slug: 'wiki/ideas/x' };
|
|
|
|
test('both written → exit 0, names both sinks', () => {
|
|
const o: SaveOutcome = { dbSaved: true, writeThrough: { written: true, path: '/r/x.md' } };
|
|
const m = formatSaveOutcome(o, ctx);
|
|
expect(m.exitCode).toBe(0);
|
|
expect(m.stdout).toContain('and file');
|
|
expect(m.stderr).toEqual([]);
|
|
});
|
|
|
|
test('db only, repo_not_found → exit 0', () => {
|
|
const o: SaveOutcome = { dbSaved: true, writeThrough: { written: false, skipped: 'repo_not_found' } };
|
|
const m = formatSaveOutcome(o, ctx);
|
|
expect(m.exitCode).toBe(0);
|
|
expect(m.stdout).toContain('not a directory');
|
|
});
|
|
|
|
test('db saved but file errored → exit 0, warns on stderr', () => {
|
|
const o: SaveOutcome = { dbSaved: true, writeThrough: { written: false, error: 'EACCES' } };
|
|
const m = formatSaveOutcome(o, ctx);
|
|
expect(m.exitCode).toBe(0);
|
|
expect(m.stdout).toContain('file NOT written');
|
|
expect(m.stderr.some((l) => l.includes('EACCES'))).toBe(true);
|
|
});
|
|
|
|
test('nothing persisted → exit 1, both error lines on stderr', () => {
|
|
const o: SaveOutcome = {
|
|
dbSaved: false,
|
|
dbError: 'boom',
|
|
writeThrough: { written: false, skipped: 'page_not_found_after_write' },
|
|
};
|
|
const m = formatSaveOutcome(o, ctx);
|
|
expect(m.exitCode).toBe(1);
|
|
expect(m.stdout).toBeUndefined();
|
|
expect(m.stderr.some((l) => l.includes('DB save failed: boom'))).toBe(true);
|
|
expect(m.stderr.some((l) => l.includes('NOT persisted'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('buildIdeaSlug', () => {
|
|
test('distinct nonces → distinct slugs (no same-day clobber)', () => {
|
|
const a = buildIdeaSlug('same question', 'lsd', 'aaa');
|
|
const b = buildIdeaSlug('same question', 'lsd', 'bbb');
|
|
expect(a).not.toBe(b);
|
|
expect(a.startsWith('wiki/ideas/')).toBe(true);
|
|
});
|
|
|
|
test('empty question → untitled stem, still unique by nonce', () => {
|
|
const a = buildIdeaSlug('', 'lsd', 'aaa');
|
|
const b = buildIdeaSlug('', 'lsd', 'bbb');
|
|
expect(a).toContain('-untitled-');
|
|
expect(a).not.toBe(b);
|
|
});
|
|
|
|
test('production nonce (no arg) is random → two calls differ', () => {
|
|
const a = buildIdeaSlug('q', 'brainstorm');
|
|
const b = buildIdeaSlug('q', 'brainstorm');
|
|
expect(a).not.toBe(b);
|
|
});
|
|
});
|