Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions assets/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,7 @@
"synchronization": "Synchronisation",
"syncHistoryDesc": "Synchronisationsstatus und ausstehende Änderungen anzeigen",
"lastSyncStatus": "Letzter Synchronisationsstatus",
"lastAttempted": "Letzter Versuch",
"noSyncHistory": "Kein Synchronisationsverlauf verfügbar",
"pendingChanges": "Ausstehende Änderungen",
"noPendingChanges": "Keine ausstehenden Änderungen",
Expand Down
1 change: 1 addition & 0 deletions assets/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,7 @@
"synchronization": "Synchronization",
"syncHistoryDesc": "View sync status and pending changes",
"lastSyncStatus": "Last sync status",
"lastAttempted": "Last attempted",
"noSyncHistory": "No sync history available",
"pendingChanges": "Pending changes",
"noPendingChanges": "No pending changes",
Expand Down
1 change: 1 addition & 0 deletions assets/translations/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,7 @@
"synchronization": "Sincronización",
"syncHistoryDesc": "Ver estado de sincronización y cambios pendientes",
"lastSyncStatus": "Último estado de sincronización",
"lastAttempted": "Último intento",
"noSyncHistory": "No hay historial de sincronización disponible",
"pendingChanges": "Cambios pendientes",
"noPendingChanges": "No hay cambios pendientes",
Expand Down
1 change: 1 addition & 0 deletions assets/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,7 @@
"synchronization": "Synchronisation",
"syncHistoryDesc": "Voir l'état de synchronisation et les modifications en attente",
"lastSyncStatus": "Dernier état de synchronisation",
"lastAttempted": "Dernière tentative",
"noSyncHistory": "Aucun historique de synchronisation disponible",
"pendingChanges": "Modifications en attente",
"noPendingChanges": "Aucune modification en attente",
Expand Down
1 change: 1 addition & 0 deletions assets/translations/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,7 @@
"synchronization": "Sincronizzazione",
"syncHistoryDesc": "Visualizza lo stato della sincronizzazione e le modifiche in sospeso",
"lastSyncStatus": "Ultimo stato di sincronizzazione",
"lastAttempted": "Ultimo tentativo",
"noSyncHistory": "Nessuno storico di sincronizzazione disponibile",
"pendingChanges": "Modifiche in sospeso",
"noPendingChanges": "Nessuna modifica in sospeso",
Expand Down
1 change: 1 addition & 0 deletions assets/translations/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,7 @@
"synchronization": "Синхронизация",
"syncHistoryDesc": "Просмотр статуса синхронизации и ожидающих изменений",
"lastSyncStatus": "Последний статус синхронизации",
"lastAttempted": "Последняя попытка",
"noSyncHistory": "История синхронизации недоступна",
"pendingChanges": "Ожидающие изменения",
"noPendingChanges": "Нет ожидающих изменений",
Expand Down
1 change: 1 addition & 0 deletions drift_schemas/default/drift_schema_v9.json

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions lib/core/module/http_module.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ abstract class InjectHttpClientModule {
BaseOptions(
baseUrl: url,
headers: {'Accept': 'application/json'},
// Without these, a stalled connection falls through to the OS socket
// timeout, which can hang a request for minutes.
connectTimeout: const Duration(seconds: 15),
sendTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
),
);

Expand Down
80 changes: 73 additions & 7 deletions lib/core/sync/sync_database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,17 @@ import 'package:drift_sync_core/drift_sync_core.dart';
import 'package:injectable/injectable.dart';
import 'package:trakli/core/utils/services/logger.dart' as app_logger;
import 'package:trakli/data/database/app_database.dart';
import 'package:trakli/data/sync/budget_sync_handler.dart';
import 'package:trakli/data/sync/category_sync_handler.dart';
import 'package:trakli/data/sync/config_sync_handler.dart';
import 'package:trakli/data/sync/group_sync_handler.dart';
import 'package:trakli/data/sync/media_sync_handler.dart';
import 'package:trakli/data/sync/notification_sync_handler.dart';
import 'package:trakli/data/sync/party_sync_handler.dart';
import 'package:trakli/data/sync/reminder_sync_handler.dart';
import 'package:trakli/data/sync/transaction_sync_handler.dart';
import 'package:trakli/data/sync/transfer_sync_handler.dart';
import 'package:trakli/data/sync/wallet_sync_handler.dart';

@lazySingleton
class SynchAppDatabase extends DriftSynchronizer<AppDatabase> {
Expand All @@ -22,10 +31,62 @@ class SynchAppDatabase extends DriftSynchronizer<AppDatabase> {
classifyFailure: restFailureClassifier,
);

/// Entity types swept by [reconcileOrphanedLocalChanges] → backing tables.
static const Map<String, String> reconciledEntityTables = {
TransactionSyncHandler.entity: 'transactions',
TransferSyncHandler.entity: 'transfers',
static const reconciledEntities = {
CategorySyncHandler.entity: (
table: 'categories',
clientId: 'client_id',
deletedAt: 'deleted_at'
),
ConfigSyncHandler.entity: (
table: 'configs',
clientId: 'client_id',
deletedAt: 'deleted_at'
),
WalletSyncHandler.entity: (
table: 'wallets',
clientId: 'client_id',
deletedAt: 'deleted_at'
),
PartySyncHandler.entity: (
table: 'parties',
clientId: 'client_id',
deletedAt: 'deleted_at'
),
GroupSyncHandler.entity: (
table: 'groups',
clientId: 'client_id',
deletedAt: 'deleted_at'
),
NotificationSyncHandler.entity: (
table: 'notifications',
clientId: 'client_id',
deletedAt: 'deleted_at'
),
TransactionSyncHandler.entity: (
table: 'transactions',
clientId: 'client_id',
deletedAt: 'deleted_at'
),
TransferSyncHandler.entity: (
table: 'transfers',
clientId: 'client_id',
deletedAt: 'deleted_at'
),
BudgetSyncHandler.entity: (
table: 'budgets',
clientId: 'client_id',
deletedAt: 'deleted_at'
),
ReminderSyncHandler.entity: (
table: 'reminders',
clientId: 'client_id',
deletedAt: 'deleted_at'
),
MediaSyncHandler.entity: (
table: 'media_files',
clientId: 'path',
deletedAt: null
),
};

final _syncStateController = StreamController<SyncState>.broadcast();
Expand All @@ -45,13 +106,18 @@ class SynchAppDatabase extends DriftSynchronizer<AppDatabase> {
/// Re-enqueues rows that have no server id and no local_changes entry.
Future<int> reconcileOrphanedLocalChanges() async {
var enqueued = 0;
for (final entry in reconciledEntityTables.entries) {
for (final entry in reconciledEntities.entries) {
final handler =
typeHandlers.where((h) => h.entityType == entry.key).firstOrNull;
if (handler == null) continue;

final orphanIds =
await appDatabase.getOrphanedClientIds(entry.value, entry.key);
final source = entry.value;
final orphanIds = await appDatabase.getOrphanedClientIds(
source.table,
entry.key,
clientIdColumn: source.clientId,
deletedAtColumn: source.deletedAt,
);
for (final clientId in orphanIds) {
try {
final entity = await handler.getLocalByClientId(clientId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The result of getLocalByClientId should be null-checked. If an entity was deleted or changed between the time orphanIds were identified and this iteration, the handler might return null, leading to a crash in the subsequent sync engine logic.

Suggested change
final entity = await handler.getLocalByClientId(clientId);
final entity = await handler.getLocalByClientId(clientId);
if (entity == null) continue;

Expand Down
54 changes: 45 additions & 9 deletions lib/data/database/app_database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ class AppDatabase extends _$AppDatabase with SynchronizerDb {
super(executor ?? _openConnection());

@override
int get schemaVersion => 8;
int get schemaVersion => 9;

@override
MigrationStrategy get migration {
Expand Down Expand Up @@ -132,8 +132,8 @@ class AppDatabase extends _$AppDatabase with SynchronizerDb {
// in SQL. The remaining rows are filtered by their per-row exponential
// backoff below (not expressible in SQL).
final rows = await (select(localChanges)
..where((lc) =>
lc.quarantinedAt.isNull() & lc.dismissed.equals(false)))
..where(
(lc) => lc.quarantinedAt.isNull() & lc.dismissed.equals(false)))
.get();

return rows
Expand Down Expand Up @@ -167,12 +167,20 @@ class AppDatabase extends _$AppDatabase with SynchronizerDb {

/// Client ids with no server id and no local_changes row in any state.
Future<List<String>> getOrphanedClientIds(
String tableName, String entityType) async {
String tableName,
String entityType, {
String clientIdColumn = 'client_id',
String? deletedAtColumn = 'deleted_at',
}) async {
final activeRow =
deletedAtColumn == null ? '' : 'AND t.$deletedAtColumn IS NULL ';
final rows = await customSelect(
'SELECT t.client_id AS client_id FROM $tableName t '
'WHERE t.id IS NULL AND t.deleted_at IS NULL AND NOT EXISTS ('
'SELECT t.$clientIdColumn AS client_id FROM $tableName t '
'WHERE t.id IS NULL $activeRow'
'AND NOT EXISTS ('
'SELECT 1 FROM local_changes lc '
'WHERE lc.entity_type = ?1 AND lc.entity_id = t.client_id)',
'WHERE lc.entity_type = ?1 '
'AND lc.entity_id = t.$clientIdColumn)',
variables: [Variable.withString(entityType)],
).get();
return rows.map((r) => r.read<String>('client_id')).toList();
Expand Down Expand Up @@ -329,6 +337,8 @@ class AppDatabase extends _$AppDatabase with SynchronizerDb {
return LocalSyncMetadata(
entityType: row.entityType,
lastSyncedAt: row.lastSyncedAt,
lastAttemptedAt: row.lastAttemptedAt,
lastError: row.lastError,
);
}

Expand All @@ -339,6 +349,8 @@ class AppDatabase extends _$AppDatabase with SynchronizerDb {
.map((row) => LocalSyncMetadata(
entityType: row.entityType,
lastSyncedAt: row.lastSyncedAt,
lastAttemptedAt: row.lastAttemptedAt,
lastError: row.lastError,
))
.toList();
}
Expand All @@ -354,6 +366,21 @@ class AppDatabase extends _$AppDatabase with SynchronizerDb {
);
}

@override
Future<void> recordEntitySyncAttempt(
String entityType, {
required DateTime attemptedAt,
Object? error,
}) async {
await into(syncMetadata).insertOnConflictUpdate(
SyncMetadataCompanion(
entityType: Value(entityType),
lastAttemptedAt: Value(attemptedAt),
lastError: Value(error?.toString()),
),
);
}

@override
Future<void> parkRemoteItem(ParkedRemoteItem item) async {
await deferredRemoteItems.insertOne(
Expand All @@ -368,8 +395,7 @@ class AppDatabase extends _$AppDatabase with SynchronizerDb {
}

@override
Future<List<ParkedRemoteItem>> getParkedRemoteItems(
String entityType) async {
Future<List<ParkedRemoteItem>> getParkedRemoteItems(String entityType) async {
final rows = await (select(deferredRemoteItems)
..where((t) => t.entityType.equals(entityType)))
.get();
Expand Down Expand Up @@ -473,5 +499,15 @@ extension Migrations on GeneratedDatabase {
await m.addColumn(
schema.localChanges, schema.localChanges.quarantinedAt);
},
from8To9: (Migrator m, Schema9 schema) async {
await m.addColumn(
schema.syncMetadata,
schema.syncMetadata.lastAttemptedAt,
);
await m.addColumn(
schema.syncMetadata,
schema.syncMetadata.lastError,
);
},
);
}
Loading
Loading