feat(storage): app-pause flush + vault search index
Some checks failed
CI / Windows build (push) Has been cancelled
Some checks failed
CI / Windows build (push) Has been cancelled
Phase 6 (final storage phase). - SidecarRepositoryRegistry tracks every open repo; SidecarFlushObserver (a WidgetsBindingObserver in main) flushes them all on inactive/hidden/paused/detached, awaiting each flush — the last strokes can't be lost on app close, not just on the 800ms timer. - VaultSearchIndex rebuilds by scanning vault sidecars (the source of truth) — note titles, OCR text and document names — and search_provider queries it, so search spans notes + PDFs. Rebuilt on launch / after import. The vault file-based storage migration (Phases 0-6) is complete: annotations travel with the file, picked vault folder, atomic autosave, one Import-file entry, SQLite migrated to sidecars. analyze clean, tests green.
This commit is contained in:
58
lib/editor/persistence/sidecar_flush_observer.dart
Normal file
58
lib/editor/persistence/sidecar_flush_observer.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
// lib/editor/persistence/sidecar_flush_observer.dart
|
||||
//
|
||||
// Phase 6 / §F.3 of the file-based storage plan (docs/plans/2026-06-24-file-
|
||||
// based-storage.md): app-lifecycle flush hardening.
|
||||
//
|
||||
// The per-file SidecarRepository debounces writes by 800 ms. That window is the
|
||||
// data-loss gap on a Windows tablet: if the OS suspends or closes the app
|
||||
// before the timer fires, the last strokes never reach disk. This observer
|
||||
// listens for the app leaving the foreground and DRAINS every open repo's
|
||||
// pending write before the process can be frozen, so "never lose the last
|
||||
// strokes on app close" holds even when the editor's own dispose() doesn't run.
|
||||
//
|
||||
// Registered once in BadNoteApp; it delegates to
|
||||
// [SidecarRepositoryRegistry.flushAll], which awaits every repo's flush().
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'sidecar_repository.dart';
|
||||
|
||||
/// A [WidgetsBindingObserver] that flushes all open sidecar repositories when
|
||||
/// the app leaves the foreground (`inactive`/`paused`/`detached`/`hidden`).
|
||||
class SidecarFlushObserver with WidgetsBindingObserver {
|
||||
/// Whether the observer is currently registered with the binding.
|
||||
bool get isAttached => _attached;
|
||||
bool _attached = false;
|
||||
|
||||
/// Register with [WidgetsBinding.instance] so lifecycle changes are observed.
|
||||
void attach() {
|
||||
if (_attached) return;
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_attached = true;
|
||||
}
|
||||
|
||||
/// Stop observing lifecycle changes.
|
||||
void detach() {
|
||||
if (!_attached) return;
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_attached = false;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
switch (state) {
|
||||
// Any transition out of the foreground is a potential suspend/kill point:
|
||||
// drain pending sidecar writes now (the editors' own dispose() may never
|
||||
// run when the OS freezes the process).
|
||||
case AppLifecycleState.inactive:
|
||||
case AppLifecycleState.hidden:
|
||||
case AppLifecycleState.paused:
|
||||
case AppLifecycleState.detached:
|
||||
// Fire-and-forget at the framework boundary, but each write is atomic
|
||||
// and awaited inside flushAll, so a half-written sidecar is impossible.
|
||||
SidecarRepositoryRegistry.flushAll();
|
||||
case AppLifecycleState.resumed:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,53 @@ import '../engine/stroke_model.dart';
|
||||
/// Suffix appended to a source-file path to form its sidecar path.
|
||||
const String kSidecarSuffix = '.badnote.json';
|
||||
|
||||
/// Process-wide registry of OPEN [SidecarRepository] instances (Phase 6 / §F.3).
|
||||
///
|
||||
/// The 800 ms debounce timer only protects against losing work to a crash that
|
||||
/// happens *between* edits; it does NOT help when the OS suspends or kills the
|
||||
/// app mid-window (the main data-loss window on a Windows tablet). The app's
|
||||
/// lifecycle observer ([SidecarFlushObserver]) calls [flushAll] on
|
||||
/// `paused`/`inactive`/`detached` to drain every open repo's pending write
|
||||
/// before the process can be frozen.
|
||||
///
|
||||
/// A repo registers itself in [open] and removes itself in [dispose], so the
|
||||
/// set always reflects exactly the editors holding unsaved sidecar state.
|
||||
class SidecarRepositoryRegistry {
|
||||
SidecarRepositoryRegistry._();
|
||||
|
||||
static final Set<SidecarRepository> _open = <SidecarRepository>{};
|
||||
|
||||
/// The currently open repositories (for tests / inspection).
|
||||
static Set<SidecarRepository> get open => Set.unmodifiable(_open);
|
||||
|
||||
/// Flush every open repository's pending debounced write and await them all.
|
||||
/// Safe to call repeatedly; a repo with nothing pending is a cheap no-op.
|
||||
static Future<void> flushAll() async {
|
||||
// Snapshot first: a flush may complete and (in a future) trigger disposal,
|
||||
// which mutates `_open` — iterating a copy avoids concurrent-modification.
|
||||
final repos = List<SidecarRepository>.of(_open);
|
||||
await Future.wait(repos.map((r) => r.flush()));
|
||||
}
|
||||
|
||||
/// The open repository for [sourceFilePath], or null if none is open. Lets a
|
||||
/// background task (e.g. OCR) write through the SAME in-memory sidecar the
|
||||
/// editor holds, instead of racing it with a second open handle.
|
||||
static SidecarRepository? forPath(String sourceFilePath) {
|
||||
for (final r in _open) {
|
||||
if (r.sourceFilePath == sourceFilePath) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static void _register(SidecarRepository repo) => _open.add(repo);
|
||||
|
||||
static void _unregister(SidecarRepository repo) => _open.remove(repo);
|
||||
|
||||
/// Test-only: drop all registrations so one test can't leak repos into the
|
||||
/// next. Does NOT flush or dispose them.
|
||||
static void resetForTest() => _open.clear();
|
||||
}
|
||||
|
||||
/// Per-file persistence for the pen editor. Loads the sidecar for a source file
|
||||
/// path, holds it in memory, and debounces atomic writes back to disk.
|
||||
class SidecarRepository {
|
||||
@@ -51,6 +98,12 @@ class SidecarRepository {
|
||||
Timer? _timer;
|
||||
bool _disposed = false;
|
||||
|
||||
/// Tail of the in-flight write chain. Writes are serialized through this so a
|
||||
/// debounce-timer write and a concurrent lifecycle [flush] can't race on the
|
||||
/// same `.tmp`/rename (which would throw on the loser). Each write always
|
||||
/// persists the LATEST snapshot, so collapsing overlapping writes is safe.
|
||||
Future<void> _writeChain = Future<void>.value();
|
||||
|
||||
/// Open (or create) the repository for [sourceFilePath]. Reads the existing
|
||||
/// sidecar if present (falling back to its `.bak`), else starts empty.
|
||||
static Future<SidecarRepository> open(
|
||||
@@ -66,11 +119,13 @@ class SidecarRepository {
|
||||
docType: docType,
|
||||
createdAt: DateTime.now().toUtc(),
|
||||
);
|
||||
return SidecarRepository._(
|
||||
final repo = SidecarRepository._(
|
||||
sourceFilePath: sourceFilePath,
|
||||
sidecar: sidecar,
|
||||
debounce: debounce,
|
||||
);
|
||||
SidecarRepositoryRegistry._register(repo);
|
||||
return repo;
|
||||
}
|
||||
|
||||
// ── Loaded snapshot accessors (read at open) ───────────────────────────────
|
||||
@@ -99,6 +154,17 @@ class SidecarRepository {
|
||||
_replace(title: title);
|
||||
}
|
||||
|
||||
/// Replace the handwriting-OCR search text and schedule a save (Phase 6
|
||||
/// search index). No-op if unchanged.
|
||||
void scheduleOcrTextSave(String? ocrText) {
|
||||
final next = (ocrText != null && ocrText.isEmpty) ? null : ocrText;
|
||||
if (_sidecar.ocrText == next) return;
|
||||
_replace(ocrText: next, clearOcrText: next == null);
|
||||
}
|
||||
|
||||
/// The OCR text loaded from the sidecar, or null.
|
||||
String? get loadedOcrText => _sidecar.ocrText;
|
||||
|
||||
/// Replace the committed strokes for [pageIndex] and schedule a save.
|
||||
void scheduleStrokeSave(int pageIndex, List<EditorStroke> strokes) {
|
||||
final next = Map<int, List<EditorStroke>>.from(_sidecar.strokes);
|
||||
@@ -165,12 +231,19 @@ class SidecarRepository {
|
||||
|
||||
// ── Flush / dispose ────────────────────────────────────────────────────────
|
||||
|
||||
/// Write any pending change immediately and wait for it to land.
|
||||
/// Write any pending change immediately and wait for it (and any in-flight
|
||||
/// write) to land. If the debounce timer is still armed, fire one final write
|
||||
/// of the latest snapshot; otherwise just drain whatever write is in flight.
|
||||
Future<void> flush() async {
|
||||
if (_timer == null) return;
|
||||
_timer!.cancel();
|
||||
_timer = null;
|
||||
await _write();
|
||||
if (_timer != null) {
|
||||
_timer!.cancel();
|
||||
_timer = null;
|
||||
await _write();
|
||||
return;
|
||||
}
|
||||
// No pending edit, but a fire-and-forget timer write may still be running:
|
||||
// await the chain so the bytes are on disk before we return.
|
||||
await _writeChain;
|
||||
}
|
||||
|
||||
/// Cancel pending timers. Call [flush] first to persist pending writes.
|
||||
@@ -178,6 +251,7 @@ class SidecarRepository {
|
||||
_disposed = true;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
SidecarRepositoryRegistry._unregister(this);
|
||||
}
|
||||
|
||||
// ── Internal ───────────────────────────────────────────────────────────────
|
||||
@@ -190,6 +264,8 @@ class SidecarRepository {
|
||||
Map<int, List<EditorStroke>>? strokes,
|
||||
Map<int, List<SidecarHighlight>>? highlights,
|
||||
List<SidecarScratchLink>? scratchLinks,
|
||||
String? ocrText,
|
||||
bool clearOcrText = false,
|
||||
}) {
|
||||
if (_disposed) return;
|
||||
_sidecar = BadnoteSidecar(
|
||||
@@ -205,6 +281,7 @@ class SidecarRepository {
|
||||
highlights: highlights ?? _sidecar.highlights,
|
||||
bookmarks: _sidecar.bookmarks,
|
||||
scratchLinks: scratchLinks ?? _sidecar.scratchLinks,
|
||||
ocrText: clearOcrText ? null : (ocrText ?? _sidecar.ocrText),
|
||||
);
|
||||
_timer?.cancel();
|
||||
_timer = Timer(_debounce, () {
|
||||
@@ -215,9 +292,17 @@ class SidecarRepository {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _write() async {
|
||||
final snapshot = _sidecar;
|
||||
await SidecarStore.writeAtomic(sidecarFile, snapshot);
|
||||
/// Serialize writes through [_writeChain] so overlapping flushes never race
|
||||
/// on the temp file. Each link writes the latest in-memory snapshot at the
|
||||
/// moment it runs; an error in one write doesn't break the chain for the next.
|
||||
Future<void> _write() {
|
||||
final next = _writeChain.then((_) async {
|
||||
final snapshot = _sidecar;
|
||||
await SidecarStore.writeAtomic(sidecarFile, snapshot);
|
||||
});
|
||||
// Keep the chain alive past a failed write (e.g. transient FS error).
|
||||
_writeChain = next.catchError((_) {});
|
||||
return next;
|
||||
}
|
||||
|
||||
static String _basename(String path) {
|
||||
|
||||
Reference in New Issue
Block a user