feat: cross-protocol sync log — record success/failure for every sync cycle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Güttler
2026-04-19 17:09:13 +02:00
co-authored by Claude Sonnet 4.6
parent 8a0e09301b
commit db548a7d8b
6 changed files with 184 additions and 9 deletions
@@ -0,0 +1,22 @@
abstract class SyncLogRepository {
Future<void> log({
required String accountId,
required bool success,
String? errorMessage,
required DateTime startedAt,
required DateTime finishedAt,
});
}
class NoOpSyncLogRepository implements SyncLogRepository {
const NoOpSyncLogRepository();
@override
Future<void> log({
required String accountId,
required bool success,
String? errorMessage,
required DateTime startedAt,
required DateTime finishedAt,
}) async {}
}
+42 -7
View File
@@ -6,6 +6,7 @@ import '../models/account.dart';
import '../repositories/account_repository.dart';
import '../repositories/email_repository.dart';
import '../repositories/mailbox_repository.dart';
import '../repositories/sync_log_repository.dart';
import '../utils/logger.dart';
import '../../data/imap/imap_client_factory.dart';
@@ -19,12 +20,15 @@ class AccountSyncManager {
this._mailboxes,
this._emails, {
ImapConnectFn imapConnect = connectImap,
}) : _imapConnect = imapConnect;
SyncLogRepository syncLog = const NoOpSyncLogRepository(),
}) : _imapConnect = imapConnect,
_syncLog = syncLog;
final AccountRepository _accounts;
final MailboxRepository _mailboxes;
final EmailRepository _emails;
final ImapConnectFn _imapConnect;
final SyncLogRepository _syncLog;
final Map<String, _SyncLoop> _active = {};
StreamSubscription<List<Account>>? _accountsSub;
@@ -36,10 +40,10 @@ class AccountSyncManager {
for (final account in accounts) {
if (_active.containsKey(account.id)) continue;
final loop = switch (account.type) {
AccountType.imap =>
_AccountSync(account, _accounts, _mailboxes, _emails, _imapConnect),
AccountType.jmap =>
_JmapAccountSync(account, _mailboxes, _emails, _accounts),
AccountType.imap => _AccountSync(
account, _accounts, _mailboxes, _emails, _imapConnect, _syncLog),
AccountType.jmap => _JmapAccountSync(
account, _mailboxes, _emails, _accounts, _syncLog),
};
_active[account.id] = loop;
loop.start();
@@ -73,13 +77,14 @@ abstract class _SyncLoop {
class _AccountSync implements _SyncLoop {
_AccountSync(this.account, this._accounts, this._mailboxes, this._emails,
this._imapConnect);
this._imapConnect, this._syncLog);
final Account account;
final AccountRepository _accounts;
final MailboxRepository _mailboxes;
final EmailRepository _emails;
final ImapConnectFn _imapConnect;
final SyncLogRepository _syncLog;
imap.ImapClient? _idleClient;
bool _running = false;
@@ -104,11 +109,25 @@ class _AccountSync implements _SyncLoop {
Future<void> _loop() async {
while (_running) {
final startedAt = DateTime.now();
try {
await _sync();
await _syncLog.log(
accountId: account.id,
success: true,
startedAt: startedAt,
finishedAt: DateTime.now(),
);
await _idle();
_backoffSeconds = 5;
} catch (e, st) {
await _syncLog.log(
accountId: account.id,
success: false,
errorMessage: e.toString(),
startedAt: startedAt,
finishedAt: DateTime.now(),
);
log(
'Sync failed for ${account.email}, retrying in ${_backoffSeconds}s',
error: e,
@@ -178,12 +197,14 @@ class _AccountSync implements _SyncLoop {
// ── JMAP ──────────────────────────────────────────────────────────────────────
class _JmapAccountSync implements _SyncLoop {
_JmapAccountSync(this.account, this._mailboxes, this._emails, this._accounts);
_JmapAccountSync(
this.account, this._mailboxes, this._emails, this._accounts, this._syncLog);
final Account account;
final MailboxRepository _mailboxes;
final EmailRepository _emails;
final AccountRepository _accounts;
final SyncLogRepository _syncLog;
bool _running = false;
int _backoffSeconds = 5;
@@ -207,11 +228,25 @@ class _JmapAccountSync implements _SyncLoop {
Future<void> _loop() async {
while (_running) {
final startedAt = DateTime.now();
try {
await _sync();
await _syncLog.log(
accountId: account.id,
success: true,
startedAt: startedAt,
finishedAt: DateTime.now(),
);
_backoffSeconds = 5;
await _wait();
} catch (e, st) {
await _syncLog.log(
accountId: account.id,
success: false,
errorMessage: e.toString(),
startedAt: startedAt,
finishedAt: DateTime.now(),
);
log(
'JMAP sync failed for ${account.email}, retrying in ${_backoffSeconds}s',
error: e,
+20 -2
View File
@@ -115,6 +115,21 @@ class SyncStates extends Table {
Set<Column> get primaryKey => {accountId, resourceType};
}
/// Lightweight audit trail for each sync cycle.
/// Useful for debugging and surfacing "last synced" timestamps in the UI.
@DataClassName('SyncLogRow')
class SyncLogs extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get accountId =>
text().references(Accounts, #id, onDelete: KeyAction.cascade)();
// "ok" | "error"
TextColumn get result => text()();
TextColumn get errorMessage => text().nullable()();
IntColumn get itemsSynced => integer().withDefault(const Constant(0))();
DateTimeColumn get startedAt => dateTime()();
DateTimeColumn get finishedAt => dateTime()();
}
/// Auto-saved compose drafts — persisted across app restarts.
class Drafts extends Table {
IntColumn get id => integer().autoIncrement()();
@@ -130,12 +145,12 @@ class Drafts extends Table {
// ── Database ──────────────────────────────────────────────────────────────────
@DriftDatabase(tables: [Accounts, Mailboxes, Emails, EmailBodies, Drafts, SyncStates, PendingChanges])
@DriftDatabase(tables: [Accounts, Mailboxes, Emails, EmailBodies, Drafts, SyncStates, PendingChanges, SyncLogs])
class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 6;
int get schemaVersion => 7;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -156,6 +171,9 @@ class AppDatabase extends _$AppDatabase {
if (from < 6) {
await m.createTable(pendingChanges);
}
if (from < 7) {
await m.createTable(syncLogs);
}
},
);
}
@@ -0,0 +1,27 @@
import 'package:drift/drift.dart';
import '../../core/repositories/sync_log_repository.dart';
import '../db/database.dart';
class SyncLogRepositoryImpl implements SyncLogRepository {
SyncLogRepositoryImpl(this._db);
final AppDatabase _db;
@override
Future<void> log({
required String accountId,
required bool success,
String? errorMessage,
required DateTime startedAt,
required DateTime finishedAt,
}) async {
await _db.into(_db.syncLogs).insert(SyncLogsCompanion.insert(
accountId: accountId,
result: success ? 'ok' : 'error',
errorMessage: Value(errorMessage),
startedAt: startedAt,
finishedAt: finishedAt,
));
}
}
+6
View File
@@ -14,6 +14,7 @@ import 'data/repositories/account_repository_impl.dart';
import 'data/repositories/draft_repository_impl.dart';
import 'data/repositories/email_repository_impl.dart';
import 'data/repositories/mailbox_repository_impl.dart';
import 'data/repositories/sync_log_repository_impl.dart';
import 'data/storage/flutter_secure_storage_impl.dart';
final dbProvider = Provider<AppDatabase>((ref) {
@@ -57,11 +58,16 @@ final emailRepositoryProvider = Provider<EmailRepository>((ref) {
);
});
final syncLogRepositoryProvider = Provider((ref) {
return SyncLogRepositoryImpl(ref.watch(dbProvider));
});
final syncManagerProvider = Provider<AccountSyncManager>((ref) {
final manager = AccountSyncManager(
ref.watch(accountRepositoryProvider),
ref.watch(mailboxRepositoryProvider),
ref.watch(emailRepositoryProvider),
syncLog: ref.watch(syncLogRepositoryProvider),
);
ref.onDispose(manager.dispose);
return manager;
@@ -0,0 +1,67 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart';
import 'db_test_helper.dart';
void main() {
configureSqliteForTests();
late final db = openTestDatabase();
setUpAll(() async {
await db.into(db.accounts).insert(AccountsCompanion.insert(
id: 'acc1',
displayName: 'Test',
email: 'test@example.com',
imapHost: 'imap.example.com',
imapPort: 993,
imapSsl: true,
smtpHost: 'smtp.example.com',
smtpPort: 587,
smtpSsl: true,
));
});
tearDownAll(() => db.close());
test('logs success entry', () async {
final repo = SyncLogRepositoryImpl(db);
final start = DateTime(2024, 1, 1, 10);
final end = DateTime(2024, 1, 1, 10, 0, 5);
await repo.log(
accountId: 'acc1',
success: true,
startedAt: start,
finishedAt: end,
);
final rows = await db.select(db.syncLogs).get();
expect(rows, hasLength(1));
expect(rows.first.result, 'ok');
expect(rows.first.errorMessage, null);
expect(rows.first.accountId, 'acc1');
});
test('logs error entry with message', () async {
final repo = SyncLogRepositoryImpl(db);
final start = DateTime(2024, 1, 1, 11);
final end = DateTime(2024, 1, 1, 11, 0, 2);
await repo.log(
accountId: 'acc1',
success: false,
errorMessage: 'Connection refused',
startedAt: start,
finishedAt: end,
);
final rows = await (db.select(db.syncLogs)
..where((r) => r.result.equals('error')))
.get();
expect(rows, hasLength(1));
expect(rows.first.result, 'error');
expect(rows.first.errorMessage, 'Connection refused');
});
}