Files
BadNote/lib/storage/sidecar_store.dart

85 lines
3.0 KiB
Dart
Raw Normal View History

// lib/storage/sidecar_store.dart
//
// Atomic read/write for `<file>.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 `<target>.tmp` with flush:true.
// 2. Before clobbering, copy the current good `<target>` to `<target>.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 `<target>`; if it is missing OR fails to parse, fall back
// to `<target>.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<void> 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<void> 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 `<target>.bak`
/// if the primary is missing or corrupt. Returns null if neither is readable.
static Future<BadnoteSidecar?> 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<BadnoteSidecar?> _tryRead(File file) async {
try {
if (!await file.exists()) return null;
final raw = await file.readAsString();
final decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic>) return null;
return BadnoteSidecar.fromJson(decoded);
} catch (_) {
return null;
}
}
}