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:
co-authored by
Claude Sonnet 4.6
parent
8a0e09301b
commit
db548a7d8b
@@ -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 {}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user