/** * GraphStore — the fs JSONL adapter: five 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) * edges.jsonl GraphEdge — one line per edge ASSERTION (a second * session asserting the same (from,to,type) * appends another line; reads merge) * events.jsonl LedgerEvent — quiz outcomes + understanding observations * mappings.jsonl NodeMapping — the session→graph identity map: * (chatId, sourceId) → graphNodeId * sessions.jsonl SessionCheckpoint— emission checkpoints (foldedAt) * * 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 type { GraphEdge, GraphNode, ExtractionRunUsage, LedgerEvent, LedgerEventKind, NotesSource, NodeMapping, 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 }); 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[] { 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); const EDGE_TYPES = ["follows", "supports"] as const; const isGraphEdge = (v: unknown): v is GraphEdge => isRecord(v) && isString(v.from) && isString(v.to) && (EDGE_TYPES as readonly string[]).includes(v.type as string) && Array.isArray(v.provenance) && v.provenance.every(isProvenancePoint); const EVENT_KINDS: readonly LedgerEventKind[] = ["correct", "miss", "dont-know", "observation"]; const isLedgerEvent = (v: unknown): v is LedgerEvent => isRecord(v) && (v.nodeId === null || isString(v.nodeId)) && (EVENT_KINDS as readonly string[]).includes(v.kind as string) && isString(v.chatId) && isString(v.at); const isNodeMapping = (v: unknown): v is NodeMapping => isRecord(v) && isString(v.chatId) && isString(v.sourceId) && isString(v.graphNodeId); 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 edge ASSERTION; readers merge lines by (from,to,type). */ appendEdge(assertion: GraphEdge): void { appendLine(LEDGER_FILES.edges, assertion); }, readEdges(): GraphEdge[] { return readValid(LEDGER_FILES.edges, isGraphEdge); }, appendEvent(event: LedgerEvent): void { appendLine(LEDGER_FILES.events, event); }, readEvents(): LedgerEvent[] { return readValid(LEDGER_FILES.events, isLedgerEvent); }, appendMapping(mapping: NodeMapping): void { appendLine(LEDGER_FILES.mappings, mapping); }, readMappings(): NodeMapping[] { return readValid(LEDGER_FILES.mappings, isNodeMapping); }, 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[]; edges?: GraphEdge[]; events?: LedgerEvent[]; mappings?: NodeMapping[]; coverage?: CoverageEntry[]; checkpoints?: SessionCheckpoint[]; }): void { const files = { nodes: LEDGER_FILES.nodes, edges: LEDGER_FILES.edges, events: LEDGER_FILES.events, mappings: LEDGER_FILES.mappings, 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 { 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 }); fs.writeFileSync(SOURCES_FILE, JSON.stringify(map, null, "\t"), "utf-8"); } const isNotesSource = (v: unknown): v is NotesSource => isRecord(v) && isString(v.id) && isString(v.filePath) && isString(v.importedAt);