85 lines
3.0 KiB
Dart
85 lines
3.0 KiB
Dart
|
|
// 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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|