fix(sync): replicate deletions and keep photos off the wire

Reusing the backup snapshot for sync had two bugs that would bite real use:

1. Deletions didn't propagate. exportInventory() filters isDeleted=false
   (user backups are tombstone-free by design) and the JSON codec dropped
   isDeleted (omitted on encode, hardcoded false on decode) — so deleting a
   variety on one device never reached the others. Now the codec round-trips
   isDeleted (emitted only when true, so backups stay byte-identical and it's
   backward compatible), and a new exportForSync() includes tombstones. The
   importer already merges them LWW-correctly.

2. Photos bloated events. The snapshot embeds photo bytes as base64, so a
   real inventory would blow past relay event-size limits and sync would
   silently fail. exportForSync() omits attachments entirely (photos stay
   device-local; media sync is a separate, deferred concern).

buildSnapshot() now uses exportForSync(); backups keep using exportInventory
(photos in, tombstones out) unchanged.

Tests: a deletion replicates as a tombstone; the sync snapshot carries no
photo bytes while a sealed backup still does.
This commit is contained in:
vjrj 2026-07-11 00:17:08 +02:00
parent 225880fc64
commit 21e4357970
4 changed files with 83 additions and 11 deletions

View file

@ -162,6 +162,40 @@ void main() {
expect(await dbB.select(dbB.varieties).get(), hasLength(1));
});
test('sync replicates a DELETION (tombstone), not just additions', () async {
final (serviceA, _, _) = newService(dbA);
final repoA = newTestRepository(dbA);
final id = await repoA.addQuickVariety(label: 'To delete');
// First sync: device B receives the variety, alive.
final (serviceB, _, _) = newService(dbB);
await serviceB.applySnapshot(await serviceA.buildSnapshot());
var onB = (await dbB.select(dbB.varieties).get()).firstWhere((v) => v.id == id);
expect(onB.isDeleted, isFalse);
// A deletes it; the next sync carries the tombstone.
await repoA.softDeleteVariety(id);
await serviceB.applySnapshot(await serviceA.buildSnapshot());
onB = (await dbB.select(dbB.varieties).get()).firstWhere((v) => v.id == id);
expect(onB.isDeleted, isTrue); // deletion replicated, not resurrected
});
test('the sync snapshot carries NO photo bytes (photos stay device-local)',
() async {
final (serviceA, _, _) = newService(dbA);
final repoA = newTestRepository(dbA);
final id = await repoA.addQuickVariety(label: 'With photo');
await repoA.addPhoto(id, Uint8List.fromList(List.filled(5000, 42)));
final syncBytes = await serviceA.buildSnapshot();
expect(utf8.decode(syncBytes).contains('bytesBase64'), isFalse);
expect(syncBytes.length, lessThan(5000)); // the 5 KB photo isn't in it
// A sealed BACKUP, by contrast, still embeds the photo.
final backup = await serviceA.buildSealedBackup();
expect(backup.length, greaterThan(5000));
});
test('legacy plain exports still restore (no seal, direct parse)', () async {
final repoA = newTestRepository(dbA);
await repoA.addQuickVariety(label: 'Old export');