// lib/storage/sidecar_store.dart // // Atomic read/write for `.badnote.json` sidecars (Phase 1 / §F.1 of // docs/plans/2026-06-24-file-based-storage.md). Pure dart:io, NO UI. // // Write protocol (§F.1): // 1. Serialize to pretty JSON, write to `.tmp` with flush:true. // 2. Before clobbering, copy the current good `` to `.bak` // (one-deep backup — cheap insurance against a corrupt write). // 3. `rename` tmp → target. rename is atomic on the same filesystem (NTFS / // POSIX), so a reader never observes a half-written sidecar. // // Read protocol: parse ``; if it is missing OR fails to parse, fall back // to `.bak`. If neither yields valid JSON, return null. import 'dart:convert'; import 'dart:io'; import 'badnote_sidecar.dart'; /// Stateless helper namespace for sidecar persistence. class SidecarStore { const SidecarStore._(); static const JsonEncoder _encoder = JsonEncoder.withIndent(' '); /// Suffix for the in-progress temp file. static const String tmpSuffix = '.tmp'; /// Suffix for the one-deep backup of the last good sidecar. static const String bakSuffix = '.bak'; /// Atomically writes [sidecar] to [target] (temp + rename), keeping a `.bak` /// of the previous good file. Never leaves a partial sidecar at [target]: /// either the previous content (on failure before rename) or the new content. static Future writeAtomic(File target, BadnoteSidecar sidecar) async { final json = _encoder.convert(sidecar.toJson()); await writeAtomicJson(target, json); } /// Lower-level variant for callers that already hold the JSON string. static Future writeAtomicJson(File target, String json) async { await target.parent.create(recursive: true); final tmp = File('${target.path}$tmpSuffix'); await tmp.writeAsString(json, flush: true); // Back up the previous good file before clobbering it. if (await target.exists()) { final bak = File('${target.path}$bakSuffix'); try { await target.copy(bak.path); } catch (_) { // A failed backup must not block the write; the atomic rename below // still guarantees the new content lands intact. } } // Atomic on the same filesystem. await tmp.rename(target.path); } /// Reads and parses the sidecar at [target], falling back to `.bak` /// if the primary is missing or corrupt. Returns null if neither is readable. static Future read(File target) async { final primary = await _tryRead(target); if (primary != null) return primary; final bak = File('${target.path}$bakSuffix'); return _tryRead(bak); } static Future _tryRead(File file) async { try { if (!await file.exists()) return null; final raw = await file.readAsString(); final decoded = jsonDecode(raw); if (decoded is! Map) return null; return BadnoteSidecar.fromJson(decoded); } catch (_) { return null; } } }