feat(storage): app-pause flush + vault search index
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:
2026-06-24 23:19:21 +08:00
parent 4886f1b2df
commit 24d13642fd
9 changed files with 749 additions and 68 deletions

View 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;
}
}
}

View File

@@ -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) {