/** * GraphStore — the fs JSONL adapter: the append-only ledgers at * config.LEDGER_FILES (contracts.ts "v1 emission"). * nodes.jsonl GraphNode — one line per node write: the FIRST * line for an id defines it; a later * line with a REPEATED id is a * link-append (session phrasings join * aliases, provenance unions); reads * merge by id (core/graph.ts getGraph) * chains.jsonl GraphChain — one line per chain ASSERTION (a second * session asserting the same * (premises, conclusion) appends * another line; readers merge) * sessions.jsonl SessionCheckpoint— emission checkpoints (foldedAt) * coverage.jsonl CoverageEntry — per-message extraction coverage * * CHAINS-PRIMARY (owner, 2026-09-30): there is NO edges ledger — the edge * view (implication nodes + untyped arrows) is derived in core/graph.ts on * every read and never persisted. A stale edges.jsonl left by a pre-chains * build is deleted once, here, at adapter load (same doctrine as the * events-ledger purge below). * * Plus config.SOURCES_FILE (sources.json): the imported-notes bookkeeping * (NOT a ledger) — one JSON map, src-id → NotesSource, read-modify-write * like a chat file. The format-agnostic front door (2026-09-13). * * Implements core/ports.ts GraphStore. data/graph/* is exclusively this * adapter's. Reads are strict against the contracts.ts shapes (the 2026-09-02 * graph redesign): corrupt lines are skipped, never guessed at — the ledgers * are the source of truth and everything else is derived (core/graph.ts, * which takes this port as a parameter). Same idioms as src/store/chats.ts: * sync fs, no framework. */ import * as fs from "node:fs"; import * as path from "node:path"; import { LEDGER_FILES, POSITIONS_FILE, SOURCES_FILE } from "../../config.ts"; import { assertGraphFormat, ensureGraphFormat } from "./graph-format.ts"; import type { GraphChain, GraphNode, ExtractionRunUsage, NotesSource, Position, ProvenancePoint, CoverageEntry, SessionCheckpoint, } from "../core/contracts.ts"; import type { GraphStore } from "../core/ports.ts"; // ─── JSONL primitives ──────────────────────────────────────────────────────── function appendLine(file: string, record: T): void { fs.mkdirSync(path.dirname(file), { recursive: true }); ensureGraphFormat(); // the lazy format stamp (graph-format.ts) fs.appendFileSync(file, `${JSON.stringify(record)}\n`, "utf-8"); } function readRawLines(file: string): unknown[] { let raw: string; try { raw = fs.readFileSync(file, "utf-8"); } catch { return []; // no ledger yet — nothing has ever been emitted } const out: unknown[] = []; for (const line of raw.split("\n")) { const trimmed = line.trim(); if (trimmed.length === 0) continue; try { out.push(JSON.parse(trimmed)); } catch { // Corrupt line: skip rather than fail the whole read — a torn // append must never take the home graph down with it. } } return out; } function isRecord(v: unknown): v is Record { return typeof v === "object" && v !== null; } const isString = (v: unknown): v is string => typeof v === "string"; /** Strict shape guards — anything not matching a W4 ledger shape is skipped. */ function readValid(file: string, isValid: (v: unknown) => v is T): T[] { assertGraphFormat(); // honest refusal on a newer-stamped data dir return readRawLines(file).filter(isValid); } /** Provenance points are (chatId, messageId) pairs — message-granular. */ const isProvenancePoint = (v: unknown): v is ProvenancePoint => isRecord(v) && isString(v.chatId) && isString(v.messageId); const isGraphNode = (v: unknown): v is GraphNode => isRecord(v) && isString(v.id) && isString(v.name) && isString(v.statement) && Array.isArray(v.aliases) && v.aliases.every(isString) && isString(v.createdAt) && Array.isArray(v.provenance) && v.provenance.every(isProvenancePoint); /** A chain line: premises ≥ 1 (a chain IS an argument — bare propositions * ride the node ledger alone), every member a string, conclusion a string. * Endpoint EXISTENCE is the derivation's concern (it drops dangling * chains); the store only shapes. */ const isGraphChain = (v: unknown): v is GraphChain => isRecord(v) && isString(v.id) && Array.isArray(v.premises) && v.premises.length > 0 && v.premises.every(isString) && isString(v.conclusion) && Array.isArray(v.provenance) && v.provenance.every(isProvenancePoint) && isString(v.createdAt); const isCheckpoint = (v: unknown): v is SessionCheckpoint => isRecord(v) && isString(v.chatId) && isString(v.foldedAt); const isCoverageEntry = (v: unknown): v is CoverageEntry => isRecord(v) && isString(v.chatId) && isString(v.fp) && typeof v.tokens === "number" && isString(v.foldedAt); // ─── the port implementation ──────────────────────────────────────────────── export const graphStore: GraphStore = { appendNode(node: GraphNode): void { appendLine(LEDGER_FILES.nodes, node); }, /** Raw node lines in append order. A node id may appear on more than * one line (a later session's LINK-append: phrasings + provenance); the * derivation (core/graph.ts getGraph) merges lines by id. */ readNodes(): GraphNode[] { return readValid(LEDGER_FILES.nodes, isGraphNode); }, /** Append one chain ASSERTION; readers merge lines by (premises, * conclusion). */ appendChain(assertion: GraphChain): void { appendLine(LEDGER_FILES.chains, assertion); }, readChains(): GraphChain[] { return readValid(LEDGER_FILES.chains, isGraphChain); }, appendCheckpoint(checkpoint: SessionCheckpoint): void { appendLine(LEDGER_FILES.sessions, checkpoint); }, readCheckpoints(): SessionCheckpoint[] { return readValid(LEDGER_FILES.sessions, isCheckpoint); }, appendCoverage(entry: CoverageEntry): void { appendLine(LEDGER_FILES.coverage, entry); }, readCoverage(): CoverageEntry[] { return readValid(LEDGER_FILES.coverage, isCoverageEntry); }, // ── imported-notes bookkeeping (config.SOURCES_FILE — a JSON map) ── /** Upsert: keeps the existing record's usage log when re-importing. */ upsertSource(source: NotesSource): void { const all = readSourceMap(); const prev = all[source.id]; all[source.id] = prev ? { ...source, extractions: prev.extractions } : source; writeSourceMap(all); }, readSources(): NotesSource[] { return Object.values(readSourceMap()); }, /** Idempotent per startedAt — same discipline as chats.appendExtractionUsage. */ appendSourceUsage(id: string, entry: ExtractionRunUsage): void { const all = readSourceMap(); const source = all[id]; if (!source) return; // no such source — nothing to bill against source.extractions = source.extractions?.some((r) => r.startedAt === entry.startedAt) ? source.extractions : [...(source.extractions ?? []), entry]; writeSourceMap(all); }, // ── the layout ledger (config.POSITIONS_FILE — a JSON map) ── readPositions(): Record { return readPositionMap(); }, writePositions(positions: Record): void { writePositionMap(positions); }, /** THE DESTRUCTIVE SEAM (wave 6 session deletion): atomic full-content * replacement per provided ledger — write a uniquely-named temp file * beside the ledger, then rename over it (POSIX-atomic: a crash mid- * write leaves the original ledger and an orphan .tmp-*, never a torn * ledger). Empty arrays write an empty file — a purged ledger reads * as "nothing yet", same as never-emitted. */ rewriteLedgers(next: { nodes?: GraphNode[]; chains?: GraphChain[]; coverage?: CoverageEntry[]; checkpoints?: SessionCheckpoint[]; }): void { const files = { nodes: LEDGER_FILES.nodes, chains: LEDGER_FILES.chains, coverage: LEDGER_FILES.coverage, checkpoints: LEDGER_FILES.sessions, } as const; for (const [key, records] of Object.entries(next) as [keyof typeof files, unknown[]][]) { const file = files[key]; fs.mkdirSync(path.dirname(file), { recursive: true }); const tmp = `${file}.tmp-${crypto.randomUUID().slice(0, 8)}`; try { // JSONL: one record per line, trailing newline — a reader // must see exactly what appendLine would have written. const body = records.map((record) => JSON.stringify(record)).join("\n") + (records.length > 0 ? "\n" : ""); fs.writeFileSync(tmp, body, "utf-8"); fs.renameSync(tmp, file); } catch (err) { try { fs.unlinkSync(tmp); } catch { /* the rename either already happened or the tmp never existed */ } throw err; } } }, }; /** positions.json — { positions, updatedAt }. Read-modify-write, NOT * append-only: the settle is a full-graph computation, so it replaces the * whole map. The updatedAt stamp exists for future live-physics conflict * handling (a browser commit must be able to detect "the graph changed * underneath me"); reads just return the map. */ function readPositionMap(): Record { let raw: string; try { raw = fs.readFileSync(POSITIONS_FILE, "utf-8"); } catch { return {}; // no ledger yet — nothing has ever settled } try { const parsed: unknown = JSON.parse(raw); if (!isRecord(parsed) || !isRecord(parsed.positions)) return {}; const out: Record = {}; for (const [id, value] of Object.entries(parsed.positions)) { if (isPosition(value)) out[id] = { x: value.x, y: value.y }; } return out; } catch { return {}; } } function writePositionMap(positions: Record): void { fs.mkdirSync(path.dirname(POSITIONS_FILE), { recursive: true }); // Tmp + rename — the same crash-safety rewriteLedgers uses two blocks // up (a9339b3 UI flag): a torn write must never replace the ledger // with a half-written file the reader parses as empty. const tmp = `${POSITIONS_FILE}.tmp-${crypto.randomUUID().slice(0, 8)}`; try { fs.writeFileSync(tmp, JSON.stringify({ positions, updatedAt: new Date().toISOString() }, null, "\t"), "utf-8"); fs.renameSync(tmp, POSITIONS_FILE); } catch (err) { try { fs.unlinkSync(tmp); } catch { /* the rename either already happened or the tmp never existed */ } throw err; } } const isPosition = (v: unknown): v is Position => isRecord(v) && typeof v.x === "number" && Number.isFinite(v.x) && typeof v.y === "number" && Number.isFinite(v.y); /** sources.json — { id: NotesSource }. Read-modify-write, NOT append-only: * the usage log lives inside the record (like a chat's JSON file). */ function readSourceMap(): Record { assertGraphFormat(); // honest refusal on a newer-stamped data dir let raw: string; try { raw = fs.readFileSync(SOURCES_FILE, "utf-8"); } catch { return {}; } try { const parsed: unknown = JSON.parse(raw); if (!isRecord(parsed)) return {}; const out: Record = {}; for (const [id, value] of Object.entries(parsed)) { if (isNotesSource(value)) out[id] = value; } return out; } catch { return {}; } } function writeSourceMap(map: Record): void { fs.mkdirSync(path.dirname(SOURCES_FILE), { recursive: true }); ensureGraphFormat(); // the lazy format stamp (graph-format.ts) fs.writeFileSync(SOURCES_FILE, JSON.stringify(map, null, "\t"), "utf-8"); } // ─── one-way purges at adapter load: dead ledgers are exactly the confusion // the cleanup removes (owner doctrine — nothing reads them, nothing writes // them, a stale file lying around is a lie). ──────────────────────────────── { const graphDir = path.dirname(LEDGER_FILES.nodes); for (const legacy of ["edges.jsonl", "events.jsonl"]) { const file = path.join(graphDir, legacy); try { if (fs.existsSync(file)) { fs.rmSync(file); console.info(`[graph-store] purged legacy ledger: ${file}`); } } catch (err) { console.warn(`[graph-store] could not purge legacy ledger ${file}:`, err); } } } const isNotesSource = (v: unknown): v is NotesSource => isRecord(v) && isString(v.id) && isString(v.filePath) && isString(v.importedAt);