mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 03:12:32 +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>
115 lines
4.6 KiB
TypeScript
115 lines
4.6 KiB
TypeScript
/**
|
|
* Shared disk write-through for the canonical ingestion path.
|
|
*
|
|
* After a page row lands in the DB (via importFromContent / putPage), this
|
|
* renders the row to markdown via `serializePageToMarkdown` and writes it to
|
|
* `sync.repo_path` so the brain repo has a committable `.md` artifact that
|
|
* round-trips cleanly through `gbrain sync`. The file is rendered FROM the DB
|
|
* row, so the two sinks cannot diverge.
|
|
*
|
|
* Extracted from the v0.38 `put_page` write-through (operations.ts) so the
|
|
* `put_page` op AND `gbrain brainstorm/lsd --save` share one implementation
|
|
* instead of hand-rolling parallel (and divergent) copies. The extraction also
|
|
* upgraded the write to be ATOMIC — the original used a bare `writeFileSync`
|
|
* into a live git tree that `gbrain sync` / autopilot actively walk, so a crash
|
|
* mid-write left a partial `.md` that sync would fail to parse. We now write to
|
|
* a unique temp sibling and `rename` into place (rename is atomic on the same
|
|
* filesystem), matching the `.tmp + rename` convention used by
|
|
* import-checkpoint.ts / op-checkpoint.ts.
|
|
*
|
|
* Trust gating (subagent sandbox, dry-run) stays at the CALLER — this helper
|
|
* only does "row exists + repo is a real dir → render + atomic write".
|
|
*/
|
|
|
|
import { existsSync, statSync, mkdirSync, writeFileSync, renameSync, unlinkSync } from 'fs';
|
|
import { dirname } from 'path';
|
|
import { randomBytes } from 'crypto';
|
|
import type { BrainEngine } from './engine.ts';
|
|
import { serializePageToMarkdown, resolvePageFilePath } from './markdown.ts';
|
|
|
|
/** Minimal logger surface — structurally compatible with operations.ts `Logger`. */
|
|
export interface WriteThroughLogger {
|
|
warn(msg: string): void;
|
|
}
|
|
|
|
export interface WriteThroughResult {
|
|
written: boolean;
|
|
path?: string;
|
|
/**
|
|
* Non-error reasons the file was not written:
|
|
* - no_repo_configured: `sync.repo_path` is unset (DB-only by design).
|
|
* - repo_not_found: `sync.repo_path` set but missing / not a directory.
|
|
* - page_not_found_after_write: the DB row isn't readable back (the caller's
|
|
* DB write failed or targeted a different source).
|
|
*/
|
|
skipped?: 'no_repo_configured' | 'repo_not_found' | 'page_not_found_after_write';
|
|
/** Set when the render/write/rename itself threw (EACCES, ENOTDIR, disk full). */
|
|
error?: string;
|
|
}
|
|
|
|
export interface WritePageThroughOpts {
|
|
sourceId?: string;
|
|
/** Merged over the page's own frontmatter at render time (e.g. provenance). */
|
|
frontmatterOverrides?: Record<string, unknown>;
|
|
logger?: WriteThroughLogger;
|
|
}
|
|
|
|
/**
|
|
* Render the DB row for `slug` to markdown and atomically write it under
|
|
* `sync.repo_path`. Never throws — failures are reported via the result's
|
|
* `skipped` / `error` fields (the DB write is the durable sink; the file is
|
|
* best-effort and reconciled by the next `gbrain sync`).
|
|
*/
|
|
export async function writePageThrough(
|
|
engine: BrainEngine,
|
|
slug: string,
|
|
opts: WritePageThroughOpts = {},
|
|
): Promise<WriteThroughResult> {
|
|
const sourceId = opts.sourceId ?? 'default';
|
|
try {
|
|
const repoPath = await engine.getConfig('sync.repo_path');
|
|
if (!repoPath) {
|
|
return { written: false, skipped: 'no_repo_configured' };
|
|
}
|
|
if (!existsSync(repoPath) || !statSync(repoPath).isDirectory()) {
|
|
return { written: false, skipped: 'repo_not_found' };
|
|
}
|
|
|
|
const writtenPage = await engine.getPage(slug, { sourceId });
|
|
if (!writtenPage) {
|
|
return { written: false, skipped: 'page_not_found_after_write' };
|
|
}
|
|
|
|
const tags = await engine.getTags(slug, { sourceId });
|
|
const md = serializePageToMarkdown(writtenPage, tags, {
|
|
frontmatterOverrides: opts.frontmatterOverrides,
|
|
});
|
|
|
|
const filePath = resolvePageFilePath(repoPath, slug, sourceId);
|
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
|
|
// Atomic write: unique temp sibling + rename. Unique name (pid + random)
|
|
// so two concurrent saves to the same target can't clobber each other's
|
|
// temp file. Clean up the temp on any failure so we never leak a stray
|
|
// `.tmp` next to the real file.
|
|
const tmpPath = `${filePath}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`;
|
|
try {
|
|
writeFileSync(tmpPath, md, 'utf8');
|
|
renameSync(tmpPath, filePath);
|
|
} catch (writeErr) {
|
|
try {
|
|
if (existsSync(tmpPath)) unlinkSync(tmpPath);
|
|
} catch {
|
|
// best-effort cleanup; surface the original write error below
|
|
}
|
|
throw writeErr;
|
|
}
|
|
|
|
return { written: true, path: filePath };
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
opts.logger?.warn(`[write-through] failed for ${slug}: ${msg}`);
|
|
return { written: false, error: msg };
|
|
}
|
|
}
|