diff --git a/lib/providers/bank_provider.dart b/lib/providers/bank_provider.dart index 40ba339..f056276 100644 --- a/lib/providers/bank_provider.dart +++ b/lib/providers/bank_provider.dart @@ -91,25 +91,6 @@ class BankController extends ChangeNotifier { return false; } - /// Drop tombstones that match any of [txs] (fingerprint-aware). - void clearRemoteDeletesMatching(Iterable txs) { - final list = txs.toList(growable: false); - if (list.isEmpty) return; - final next = [ - for (final tomb in _pendingRemoteDeletes) - if (!list.any(tomb.matchesForDelete)) tomb, - ]; - if (next.length == _pendingRemoteDeletes.length) return; - _pendingRemoteDeletes = next; - unawaited(_persistPendingDeletes()); - } - - void clearAllPendingRemoteDeletes() { - if (_pendingRemoteDeletes.isEmpty) return; - _pendingRemoteDeletes = const []; - unawaited(_persistPendingDeletes()); - } - void requeueRemoteDeletes(Iterable txs) { for (final tx in txs) { _queueRemoteDelete(tx, notify: false); @@ -120,11 +101,54 @@ class BankController extends ChangeNotifier { void _queueRemoteDelete(BankTransaction tx, {bool notify = true}) { if (tx.id.isEmpty) return; if (_isTombstoned(tx)) return; - _pendingRemoteDeletes = [..._pendingRemoteDeletes, tx]; + _pendingRemoteDeletes = [ + ..._pendingRemoteDeletes, + tx.copyWith(isManual: true), + ]; unawaited(_persistPendingDeletes()); if (notify) onPendingRemoteDeletesChanged?.call(); } + /// Merge delete tombstones from another device and strip matching local rows. + Future applyRemoteDeletes( + List remote, { + bool notify = true, + }) async { + var tombsChanged = false; + for (final tomb in remote) { + if (_isTombstoned(tomb)) continue; + _pendingRemoteDeletes = [ + ..._pendingRemoteDeletes, + tomb.copyWith(isManual: true), + ]; + tombsChanged = true; + } + if (_pendingRemoteDeletes.isEmpty) return; + + final beforeRemote = _remoteTransactions.length; + final beforeImported = _importedTransactions.length; + _remoteTransactions = [ + for (final tx in _remoteTransactions) + if (!_isTombstoned(tx)) tx, + ]; + _importedTransactions = [ + for (final tx in _importedTransactions) + if (!_isTombstoned(tx)) tx, + ]; + final stripped = beforeRemote != _remoteTransactions.length || + beforeImported != _importedTransactions.length; + + if (tombsChanged || stripped) { + _invalidateMerged(); + if (tombsChanged) await _persistPendingDeletes(); + if (stripped) { + await _persistCache(); + await _persistImported(); + } + if (notify) notifyListeners(); + } + } + Future _persistPendingDeletes() async { final storage = _storage; if (storage == null) return; diff --git a/lib/providers/sync_provider.dart b/lib/providers/sync_provider.dart index 7669f2c..667362b 100644 --- a/lib/providers/sync_provider.dart +++ b/lib/providers/sync_provider.dart @@ -274,23 +274,42 @@ class SyncController extends ChangeNotifier { _armPushDebounce(); } - /// Removes tombstoned bank txs from PocketBase before pull/push can resurrect them. + /// Pull shared delete tombstones, then remove matching local bank rows. + Future _pullAndApplyTxDeletes(PbSyncService service) async { + try { + final remote = await service.pullTxDeletes(onStatus: _setStatus); + await _bank.applyRemoteDeletes(remote, notify: false); + } catch (error) { + debugPrint('SyncController: tx_deletes pull skipped ($error)'); + } + } + + /// Removes tombstoned bank txs from the transactions collection. + /// + /// Tombstones themselves stay local + synced via `tx_deletes` so other + /// devices learn about the delete and do not push the row back. Future _flushPendingTxDeletes(PbSyncService service) async { final pending = List.of(_bank.pendingRemoteDeletes); if (pending.isEmpty) return; _setStatus('Removing deleted bank txs…'); try { - final matched = await service.deleteTransactionsMatching(pending); - if (matched.isNotEmpty) { - _bank.clearRemoteDeletesMatching(matched); - } - // Do NOT clear unmatched tombstones — they still block pull resurrection - // until pushTransactions GC removes the remote rows. + await service.deleteTransactionsMatching(pending); + // Keep tombstones — they must remain until every device has seen them. } catch (error) { debugPrint('SyncController: tx deletes failed ($error)'); } } + Future _pushTxDeletes(PbSyncService service) async { + final tombs = List.of(_bank.pendingRemoteDeletes); + if (tombs.isEmpty) return; + try { + await service.pushTxDeletes(tombs, onStatus: _setStatus); + } catch (error) { + debugPrint('SyncController: tx_deletes push skipped ($error)'); + } + } + void _armPushDebounce() { if (!_auth.isLoggedIn) return; if (_syncing) { @@ -323,7 +342,8 @@ class SyncController extends ChangeNotifier { try { final service = PbSyncService(_auth.client); - // Delete first — otherwise pull re-merges the row and push recreates it. + // Learn deletes from other devices before merging bank txs / pushing. + await _pullAndApplyTxDeletes(service); await _flushPendingTxDeletes(service); final remoteMeta = await service.pull( onProgress: (p) { @@ -438,6 +458,7 @@ class SyncController extends ChangeNotifier { } // Retry deletes in case any were queued during the pull/merge window. await _flushPendingTxDeletes(service); + await _pushTxDeletes(service); _pushBankPending = false; final tombs = List.of(_bank.pendingRemoteDeletes); final bankSummary = await service.pushTransactions( @@ -448,8 +469,6 @@ class SyncController extends ChangeNotifier { onProgress: _setProgress, onStatus: _setStatus, ); - // Push GC removes orphan admin test rows; tombstones are done. - _bank.clearAllPendingRemoteDeletes(); _lastSyncedAt = DateTime.now(); _lastError = null; _lastSyncSummary = '$metaSummary\n$bankSummary'; @@ -601,7 +620,10 @@ class SyncController extends ChangeNotifier { } if (pushBank) { _setStatus('Updating bank txs…'); + // Other devices may have deleted test txs — learn that before push. + await _pullAndApplyTxDeletes(service); await _flushPendingTxDeletes(service); + await _pushTxDeletes(service); try { final tombs = List.of(_bank.pendingRemoteDeletes); final summary = await service.pushTransactions( @@ -612,7 +634,6 @@ class SyncController extends ChangeNotifier { onProgress: _setProgress, onStatus: _setStatus, ); - _bank.clearAllPendingRemoteDeletes(); parts.add(summary); } catch (error) { debugPrint('SyncController: bank tx push skipped ($error)'); diff --git a/lib/services/pb_sync_service.dart b/lib/services/pb_sync_service.dart index 64c14a5..e9d7156 100644 --- a/lib/services/pb_sync_service.dart +++ b/lib/services/pb_sync_service.dart @@ -804,6 +804,70 @@ class PbSyncService { return matchedTombs; } + /// Pull synced delete tombstones for admin test transactions. + Future> pullTxDeletes({ + void Function(String message)? onStatus, + }) async { + final userId = _userId; + final filter = 'user="$userId"'; + onStatus?.call('Pulling deleted test txs…'); + final records = await _fullList('tx_deletes', filter); + final out = []; + for (final r in records) { + final tx = _bankTxFromRecord(r); + if (tx == null) continue; + out.add(tx.copyWith(isManual: true)); + } + debugPrint('PbSyncService pull: tx_deletes=${out.length}'); + return out; + } + + /// Upsert local delete tombstones. Never removes remote-only rows (union). + Future pushTxDeletes( + List tombs, { + void Function(String message)? onStatus, + }) async { + if (tombs.isEmpty) return 'Tx deletes: nothing to push'; + final userId = _userId; + final filter = 'user="$userId"'; + onStatus?.call('Syncing deleted test txs…'); + final existing = { + for (final r in await _fullList('tx_deletes', filter)) + r.getStringValue('tx_id'): r, + }; + + final ops = <_BatchOp>[]; + var skipped = 0; + final seen = {}; + for (final tomb in tombs) { + final syncId = _clip(tomb.id, 2000); + if (syncId.isEmpty || !seen.add(syncId)) continue; + final body = { + 'user': userId, + 'tx_id': syncId, + 'date': _clip(tomb.date, 32), + 'description': _clip( + tomb.description.isEmpty ? 'Unknown' : tomb.description, + 4000, + ), + 'amount': tomb.amount, + 'currency': _clip( + tomb.currency.isEmpty ? 'EUR' : tomb.currency.toUpperCase(), + 8, + ), + }; + final remote = existing[syncId]; + if (remote == null) { + ops.add(_BatchOp.create(body)); + } else { + skipped++; + } + } + + await _runWriteOps('tx_deletes', ops, onStatus: onStatus); + return 'Tx deletes: local ${tombs.length} (+${ops.length} · $skipped exist)'; + } + Future _runOne(String collection, _BatchOp op) { final col = _pb.collection(collection); final Future future; @@ -968,6 +1032,12 @@ class PbSyncService { filter = 'user="$userId" && tx_id="${key.replaceAll('"', '\\"')}"'; } + case 'tx_deletes': + final key = body['tx_id']; + if (userId != null && key is String) { + filter = + 'user="$userId" && tx_id="${key.replaceAll('"', '\\"')}"'; + } } if (filter == null) { throw StateError('Cannot upsert $collection without filter');