todo-app/lib/sync/sync_service.dart
Krzysztof kuhy Rudnicki 7564a6569f Surface sync outcomes so devices can't silently diverge
The desktop and phone split-brained for 5 days (39 vs 30 notes) after the
crdt_sync cutover because one device kept running the pre-migration build
— and nothing in the UI ever said a sync had failed or skipped a peer:

- SyncResult now reports peer files that were listed but couldn't be
  fetched/decoded (skippedFiles) instead of dropping them silently.
- Auto-sync no longer swallows errors: every sync (manual or automatic)
  records a persisted last-sync status shown in a status line under the
  editor, with failures in the error color.
- Desktop actually background-syncs now: focus loss (inactive/hidden,
  debounced 5s) triggers a sync — Flutter Linux rarely delivers the
  `paused` event the old code relied on. `paused` still syncs immediately
  for Android.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5C5jZ2c4mTaGedK7C1dDv
2026-07-16 17:58:41 +02:00

99 lines
3.4 KiB
Dart

import 'package:crdt_sync/crdt_sync.dart';
import '../data/note_repository.dart';
/// Outcome of a sync run, for surfacing in the UI.
class SyncResult {
const SyncResult({
required this.mergedDevices,
required this.pushed,
this.skippedFiles = const [],
});
/// How many other devices' logs were pulled and merged.
final int mergedDevices;
/// Whether this device pushed its own log.
final bool pushed;
/// Peer files that were listed but could not be fetched or decoded
/// (vanished, corrupt, or foreign format) and were left out of the merge.
/// Surfaced so a device silently dropping out of sync is visible.
final List<String> skippedFiles;
@override
String toString() =>
'SyncResult(mergedDevices: $mergedDevices, pushed: $pushed, '
'skippedFiles: $skippedFiles)';
}
/// Synchronises a [NoteRepository] with a GitHub repo used as dumb storage.
///
/// Design: each device owns exactly one file, `todo-sync/notes/<nodeId>.json`,
/// holding its full crdt_sync note [Log]. Because no two devices ever write the
/// same file, there are **no git-level merge conflicts**; convergence is the
/// CRDT layer's job — pulling every other device's log and [NoteRepository]
/// merging it via `mergeLogs` is commutative and idempotent, so repeated syncs
/// in any order converge to the same state.
///
/// The directory is `notes/` (not the legacy `changesets/`): the on-the-wire
/// format changed from a `sqlite_crdt` changeset to a crdt_sync `Log`, so the
/// two never share a path and an old client can't misread a new file.
class SyncService {
const SyncService({this.notesDir = 'todo-sync/notes'});
/// Directory in the repo under which per-device note-log files live.
final String notesDir;
/// Runs a full pull-merge-push cycle. Safe to call repeatedly.
Future<SyncResult> sync(NoteRepository repo, GitHubClient github) async {
final nodeId = repo.nodeId;
final ownFileName = '$nodeId.json';
// 1. Pull: list all device log files, merge everyone else's.
final names = await github.listDirectory(notesDir);
var merged = 0;
final skipped = <String>[];
for (final name in names) {
if (name == ownFileName) continue; // our own file; skip it.
final text = await github.getFileText('$notesDir/$name');
final remote = text == null ? null : _decodeLog(text);
if (remote == null) {
// A vanished or undecodable peer file must not abort the sync, but
// it must not vanish silently either — that's how a device drops
// out of sync unnoticed. Report it in the result instead.
skipped.add(name);
continue;
}
await repo.importLog(remote);
merged++;
}
// 2. Push: upload our own (now-merged) log under our node id.
// putFileText resolves our file's current sha itself.
await github.putFileText(
'$notesDir/$ownFileName',
logToJson(repo.exportLog()),
message: 'sync: $nodeId @ ${DateTime.now().toUtc().toIso8601String()}',
);
return SyncResult(
mergedDevices: merged,
pushed: true,
skippedFiles: skipped,
);
}
/// Parses a remote note log, returning `null` for a corrupt or wrong-shape
/// payload so one bad device file never aborts the whole sync.
Log? _decodeLog(String text) {
try {
return logFromJson(text);
} on FormatException {
return null;
} on TypeError {
return null;
}
}
}