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
3 changes: 3 additions & 0 deletions assets/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,9 @@
"deleteGroupConfirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
"duplicate": "Duplicate",
"categoryNameAlreadyExists": "Kategoriename existiert bereits",
"walletNameAlreadyExists": "Eine Brieftasche mit diesem Namen und dieser Währung existiert bereits",
"groupNameAlreadyExists": "Gruppenname existiert bereits",
"partyNameAlreadyExists": "Name des Geschäftspartners existiert bereits",
"deleteWallet": "Wallet löschen",
"deleteWalletConfirm": "Are you sure you want to delete {name}?",
"edit": "Edit",
Expand Down
3 changes: 3 additions & 0 deletions assets/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,9 @@
"deleteGroupConfirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
"duplicate": "Duplicate",
"categoryNameAlreadyExists": "Category name already exists",
"walletNameAlreadyExists": "A wallet with this name and currency already exists",
"groupNameAlreadyExists": "Group name already exists",
"partyNameAlreadyExists": "Party name already exists",
"deleteWallet": "Delete wallet",
"deleteWalletConfirm": "Are you sure you want to delete {name}?",
"edit": "Edit",
Expand Down
3 changes: 3 additions & 0 deletions assets/translations/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,9 @@
"deleteGroupConfirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
"duplicate": "Duplicate",
"categoryNameAlreadyExists": "El nombre de la categoría ya existe",
"walletNameAlreadyExists": "Ya existe una billetera con este nombre y esta moneda",
"groupNameAlreadyExists": "El nombre del grupo ya existe",
"partyNameAlreadyExists": "El nombre de la parte ya existe",
"deleteWallet": "Eliminar billetera",
"deleteWalletConfirm": "Are you sure you want to delete {name}?",
"defaultName": "Default",
Expand Down
3 changes: 3 additions & 0 deletions assets/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,9 @@
"deleteGroupConfirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
"duplicate": "Duplicate",
"categoryNameAlreadyExists": "Le nom de la catégorie existe déjà",
"walletNameAlreadyExists": "Un portefeuille avec ce nom et cette devise existe déjà",
"groupNameAlreadyExists": "Le nom du groupe existe déjà",
"partyNameAlreadyExists": "Le nom du tiers existe déjà",
"deleteWallet": "Supprimer le portefeuille",
"deleteWalletConfirm": "Are you sure you want to delete {name}?",
"defaultName": "Default",
Expand Down
3 changes: 3 additions & 0 deletions assets/translations/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,9 @@
"deleteGroupConfirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
"duplicate": "Duplicate",
"categoryNameAlreadyExists": "Il nome della categoria esiste già",
"walletNameAlreadyExists": "Esiste già un portafoglio con questo nome e questa valuta",
"groupNameAlreadyExists": "Il nome del gruppo esiste già",
"partyNameAlreadyExists": "Il nome della parte esiste già",
"deleteWallet": "Elimina portafoglio",
"deleteWalletConfirm": "Are you sure you want to delete {name}?",
"edit": "Edit",
Expand Down
3 changes: 3 additions & 0 deletions assets/translations/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,9 @@
"deleteGroupConfirm": "Вы уверены, что хотите удалить \"{name}\"? Это действие невозможно отменить.",
"duplicate": "Дублировать",
"categoryNameAlreadyExists": "Имя категории уже существует",
"walletNameAlreadyExists": "Кошелек с таким названием и валютой уже существует",
"groupNameAlreadyExists": "Название группы уже существует",
"partyNameAlreadyExists": "Имя стороны уже существует",
"deleteWallet": "Удалить кошелек",
"deleteWalletConfirm": "Вы уверены, что хотите удалить {name}?",
"edit": "Edit",
Expand Down
29 changes: 29 additions & 0 deletions lib/core/sync/sync_entity.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/// The entity kinds the sync layer names.
///
/// The wire and storage boundaries speak plain strings — `drift_sync_core`
/// types `SyncTypeHandler.entityType` as `String`, and `local_changes.entityType`
/// is a text column holding values already written to users' databases — so
/// [key] is what crosses them, and it must keep matching the `entity` constant
/// on each handler. `sync_entity_test` asserts that it does.
enum SyncEntity {
wallet('wallet'),
category('category'),
group('group'),
party('party'),
transaction('transaction'),
transfer('transfer'),
budget('budget'),
budgetPeriodState('budget_period_state'),
reminder('reminder'),
notification('notification'),
config('config'),
media('media'),

/// Read-through cache rather than a synced type: it has no handler, and
/// appears here only so its paged reads can name themselves.
holding('holding');

const SyncEntity(this.key);

final String key;
}
284 changes: 284 additions & 0 deletions lib/data/database/app_database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import 'package:trakli/presentation/utils/enums.dart';

import 'app_database.steps.dart';
import 'tables/sync_meta_data.dart';
import 'package:trakli/core/sync/sync_entity.dart';

part 'app_database.g.dart';

Expand Down Expand Up @@ -217,6 +218,289 @@ class AppDatabase extends _$AppDatabase with SynchronizerDb {
return results.map((row) => row.readTable(categories)).toList();
}

/// Folds [loserClientId] into [winnerClientId]: re-tags everything the
/// duplicate categorised, moves budgets that targeted it, deletes it, and
/// clears its stuck outbox entry. Returns the number of re-tagged rows.
///
/// Repairs a create the API *rejected* (400, sent without a client id): the
/// duplicate never received a server id, so it is the loser. A create that
/// did send one gets 200 and the existing record instead, which
/// [adoptCategoryServerId] reconciles the other way round.
Future<int> mergeDuplicateCategory({
required String loserClientId,
required String winnerClientId,
}) =>
_mergeCategory(loserClientId, winnerClientId, requireWinner: true);

/// Hands [serverId] to [clientId], folding away whichever local category
/// holds it. See [_adoptServerId].
Future<void> adoptCategoryServerId({
required int? serverId,
required String clientId,
}) =>
_adoptServerId('categories', serverId, clientId, _mergeCategory);

/// Hands [serverId] to [clientId], folding away whichever local wallet
/// holds it. See [_adoptServerId].
Future<void> adoptWalletServerId({
required int? serverId,
required String clientId,
}) =>
_adoptServerId('wallets', serverId, clientId, _mergeWallet);

/// Hands [serverId] to [clientId], folding away whichever local party
/// holds it. See [_adoptServerId].
Future<void> adoptPartyServerId({
required int? serverId,
required String clientId,
}) =>
_adoptServerId('parties', serverId, clientId, _mergeParty);

/// Hands [serverId] to [clientId], folding away whichever local group
/// holds it. See [_adoptServerId].
Future<void> adoptGroupServerId({
required int? serverId,
required String clientId,
}) =>
_adoptServerId('groups', serverId, clientId, _mergeGroup);

/// Makes [serverId] available to [clientId] by merging the row that owns it
/// into [clientId]. A no-op unless some *other* local row owns it. Call it
/// immediately before writing the row, inside the same transaction.
///
/// The create endpoints answer a name the user already has with 200 and the
/// existing record, after moving the posted client id onto it — so the server
/// id that comes back is one this device filed under a different client id,
/// and `SyncTable.id` is unique locally. Downloads reach the same state from
/// the other side. Either way the server now points at the incoming copy, so
/// the older one gives up its references.
Future<void> _adoptServerId(
String table,
int? serverId,
String clientId,
Future<int> Function(String loser, String winner,
{required bool requireWinner})
merge,
) async {
if (serverId == null || clientId.isEmpty) return;

final holder = await customSelect(
'SELECT client_id FROM $table WHERE id = ? LIMIT 1',
variables: [Variable<int>(serverId)],
).getSingleOrNull();
if (holder == null) return;

final holderClientId = holder.read<String>('client_id');
if (holderClientId.isEmpty || holderClientId == clientId) return;

// The caller writes the survivor next; on a download its row does not
// exist yet.
await merge(holderClientId, clientId, requireWinner: false);
}

Future<int> _mergeCategory(
String loser,
String winner, {
required bool requireWinner,
}) {
return _mergeDuplicate(
entityType: SyncEntity.category,
table: 'categories',
loserClientId: loser,
winnerClientId: winner,
targetType: BudgetTargetType.category,
requireWinner: requireWinner,
repoint: () async {
// Re-tag by insert-then-delete rather than by update: a transaction
// already tagged with both categories would collide on the
// (source, type, category) primary key.
await customStatement(
'INSERT OR IGNORE INTO categorizables '
'(categorizable_id, categorizable_type, category_client_id) '
'SELECT categorizable_id, categorizable_type, ? FROM categorizables '
'WHERE category_client_id = ?',
[winner, loser],
);
return (delete(categorizables)
..where((c) => c.categoryClientId.equals(loser)))
.go();
},
);
}

Future<int> _mergeWallet(
String loser,
String winner, {
required bool requireWinner,
}) {
return _mergeDuplicate(
entityType: SyncEntity.wallet,
table: 'wallets',
loserClientId: loser,
winnerClientId: winner,
targetType: BudgetTargetType.wallet,
requireWinner: requireWinner,
repoint: () async {
var moved = await (update(transactions)
..where((t) => t.walletClientId.equals(loser)))
.write(TransactionsCompanion(walletClientId: Value(winner)));
moved += await (update(transfers)
..where((t) => t.fromWalletClientId.equals(loser)))
.write(TransfersCompanion(fromWalletClientId: Value(winner)));
moved += await (update(transfers)
..where((t) => t.toWalletClientId.equals(loser)))
.write(TransfersCompanion(toWalletClientId: Value(winner)));
return moved;
},
);
}

Future<int> _mergeParty(
String loser,
String winner, {
required bool requireWinner,
}) {
return _mergeDuplicate(
entityType: SyncEntity.party,
table: 'parties',
loserClientId: loser,
winnerClientId: winner,
// Parties cannot be budget targets.
targetType: null,
requireWinner: requireWinner,
repoint: () => (update(transactions)
..where((t) => t.partyClientId.equals(loser)))
.write(TransactionsCompanion(partyClientId: Value(winner))),
);
}

Future<int> _mergeGroup(
String loser,
String winner, {
required bool requireWinner,
}) {
return _mergeDuplicate(
entityType: SyncEntity.group,
table: 'groups',
loserClientId: loser,
winnerClientId: winner,
targetType: BudgetTargetType.group,
requireWinner: requireWinner,
repoint: () => (update(transactions)
..where((t) => t.groupClientId.equals(loser)))
.write(TransactionsCompanion(groupClientId: Value(winner))),
);
}

/// Shared body of the merges. [repoint] moves the rows that referenced the
/// duplicate and reports how many; everything around it is the same for
/// every entity.
Future<int> _mergeDuplicate({
required SyncEntity entityType,
required String table,
required String loserClientId,
required String winnerClientId,
required BudgetTargetType? targetType,
required bool requireWinner,
required Future<int> Function() repoint,
}) {
if (loserClientId == winnerClientId) {
throw ArgumentError.value(
loserClientId,
'loserClientId',
'A ${entityType.key} cannot be merged into itself',
);
}

return transaction(() async {
final required = [
loserClientId,
if (requireWinner) winnerClientId,
];
for (final clientId in required) {
final exists = await customSelect(
'SELECT 1 FROM $table WHERE client_id = ? LIMIT 1',
variables: [Variable<String>(clientId)],
).getSingleOrNull();
if (exists == null) {
throw StateError('No ${entityType.key} with client id $clientId');
}
}

final repointed = await repoint();
if (targetType != null) {
await _repointBudgetTargets(targetType, loserClientId, winnerClientId);
}

// Its queued write cannot stand alone, and re-pointing it would collide
// with the survivor's entry on (entity_id, entity_type).
await (delete(localChanges)
..where((lc) =>
lc.entityType.equals(entityType.key) &
lc.entityId.equals(loserClientId)))
.go();
await (delete(deferredRemoteItems)
..where((d) =>
d.entityType.equals(entityType.key) &
d.clientId.equals(loserClientId)))
.go();

await _repointPayloads(loserClientId, winnerClientId);

await customStatement(
'DELETE FROM $table WHERE client_id = ?',
[loserClientId],
);

return repointed;
});
}

/// Budget targets are keyed by (budget, type, target), so a budget that
/// targeted both copies would collide on the primary key — add what is
/// missing, then drop the duplicate's rows.
Future<void> _repointBudgetTargets(
BudgetTargetType targetType,
String loserClientId,
String winnerClientId,
) async {
await customStatement(
'INSERT OR IGNORE INTO budget_targets '
'(budget_client_id, target_type, target_client_id) '
'SELECT budget_client_id, target_type, ? FROM budget_targets '
'WHERE target_type = ? AND target_client_id = ?',
[winnerClientId, targetType.name, loserClientId],
);
await (delete(budgetTargets)
..where((t) =>
t.targetType.equalsValue(targetType) &
t.targetClientId.equals(loserClientId)))
.go();
}

/// Rewrites references to [loserClientId] inside the queued outbox payloads
/// and the parked download payloads.
///
/// They are immutable JSON snapshots of whole DTO graphs — a queued
/// transaction embeds its wallet object, which is written back verbatim and
/// would resurrect the row this merge just deleted. References sit under many
/// keys, in both camelCase and snake_case, at any depth; client ids are
/// unique enough that swapping the text hits exactly them.
Future<void> _repointPayloads(
String loserClientId,
String winnerClientId,
) async {
for (final table in const ['local_changes', 'deferred_remote_items']) {
await customStatement(
// instr() is an exact, case-sensitive substring test. LIKE would treat
// % and _ in the id as wildcards and match case-insensitively, so it
// can select rows replace() then leaves untouched.
'UPDATE $table SET data = replace(data, ?, ?) WHERE instr(data, ?) > 0',
[loserClientId, winnerClientId, loserClientId],
);
}
}

@override
Future<void> concludeEntityLocalChanges(
String entityType,
Expand Down
Loading
Loading