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 d0dbed9bc2
commit b15f912ad8
4 changed files with 83 additions and 11 deletions

View file

@ -33,6 +33,7 @@ class InventoryJsonCodec {
updatedAt: v.updatedAt,
lastAuthor: v.lastAuthor,
schemaRowVersion: v.schemaRowVersion,
isDeleted: v.isDeleted,
),
'label': v.label,
'speciesId': v.speciesId,
@ -61,6 +62,7 @@ class InventoryJsonCodec {
updatedAt: l.updatedAt,
lastAuthor: l.lastAuthor,
schemaRowVersion: l.schemaRowVersion,
isDeleted: l.isDeleted,
),
'varietyId': l.varietyId,
'type': l.type.name,
@ -88,6 +90,7 @@ class InventoryJsonCodec {
updatedAt: n.updatedAt,
lastAuthor: n.lastAuthor,
schemaRowVersion: n.schemaRowVersion,
isDeleted: n.isDeleted,
),
'varietyId': n.varietyId,
'name': n.name,
@ -104,6 +107,7 @@ class InventoryJsonCodec {
updatedAt: e.updatedAt,
lastAuthor: e.lastAuthor,
schemaRowVersion: e.schemaRowVersion,
isDeleted: e.isDeleted,
),
'parentType': e.parentType.name,
'parentId': e.parentId,
@ -120,6 +124,7 @@ class InventoryJsonCodec {
updatedAt: g.updatedAt,
lastAuthor: g.lastAuthor,
schemaRowVersion: g.schemaRowVersion,
isDeleted: g.isDeleted,
),
'lotId': g.lotId,
'testedOn': g.testedOn,
@ -137,6 +142,7 @@ class InventoryJsonCodec {
updatedAt: c.updatedAt,
lastAuthor: c.lastAuthor,
schemaRowVersion: c.schemaRowVersion,
isDeleted: c.isDeleted,
),
'lotId': c.lotId,
'checkedOn': c.checkedOn,
@ -174,6 +180,7 @@ class InventoryJsonCodec {
updatedAt: p.updatedAt,
lastAuthor: p.lastAuthor,
schemaRowVersion: p.schemaRowVersion,
isDeleted: p.isDeleted,
),
'displayName': p.displayName,
'publicKey': p.publicKey,
@ -190,6 +197,7 @@ class InventoryJsonCodec {
updatedAt: a.updatedAt,
lastAuthor: a.lastAuthor,
schemaRowVersion: a.schemaRowVersion,
isDeleted: a.isDeleted,
),
'parentType': a.parentType.name,
'parentId': a.parentId,
@ -236,7 +244,7 @@ class InventoryJsonCodec {
createdAt: _int(m, 'createdAt'),
updatedAt: _string(m, 'updatedAt'),
lastAuthor: _string(m, 'lastAuthor'),
isDeleted: false,
isDeleted: _bool(m, 'isDeleted'),
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
label: _string(m, 'label'),
speciesId: speciesId,
@ -263,7 +271,7 @@ class InventoryJsonCodec {
createdAt: _int(m, 'createdAt'),
updatedAt: _string(m, 'updatedAt'),
lastAuthor: _string(m, 'lastAuthor'),
isDeleted: false,
isDeleted: _bool(m, 'isDeleted'),
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
varietyId: _string(m, 'varietyId'),
// Unknown enum values safe defaults (data-model §5.2).
@ -296,7 +304,7 @@ class InventoryJsonCodec {
createdAt: _int(m, 'createdAt'),
updatedAt: _string(m, 'updatedAt'),
lastAuthor: _string(m, 'lastAuthor'),
isDeleted: false,
isDeleted: _bool(m, 'isDeleted'),
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
varietyId: _string(m, 'varietyId'),
name: _string(m, 'name'),
@ -312,7 +320,7 @@ class InventoryJsonCodec {
createdAt: _int(m, 'createdAt'),
updatedAt: _string(m, 'updatedAt'),
lastAuthor: _string(m, 'lastAuthor'),
isDeleted: false,
isDeleted: _bool(m, 'isDeleted'),
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
parentType: parentType,
parentId: _string(m, 'parentId'),
@ -326,7 +334,7 @@ class InventoryJsonCodec {
createdAt: _int(m, 'createdAt'),
updatedAt: _string(m, 'updatedAt'),
lastAuthor: _string(m, 'lastAuthor'),
isDeleted: false,
isDeleted: _bool(m, 'isDeleted'),
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
lotId: _string(m, 'lotId'),
testedOn: m['testedOn'] as int?,
@ -341,7 +349,7 @@ class InventoryJsonCodec {
createdAt: _int(m, 'createdAt'),
updatedAt: _string(m, 'updatedAt'),
lastAuthor: _string(m, 'lastAuthor'),
isDeleted: false,
isDeleted: _bool(m, 'isDeleted'),
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
lotId: _string(m, 'lotId'),
checkedOn: m['checkedOn'] as int?,
@ -379,7 +387,7 @@ class InventoryJsonCodec {
createdAt: _int(m, 'createdAt'),
updatedAt: _string(m, 'updatedAt'),
lastAuthor: _string(m, 'lastAuthor'),
isDeleted: false,
isDeleted: _bool(m, 'isDeleted'),
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
displayName: _string(m, 'displayName'),
publicKey: m['publicKey'] as String?,
@ -397,7 +405,7 @@ class InventoryJsonCodec {
createdAt: _int(m, 'createdAt'),
updatedAt: _string(m, 'updatedAt'),
lastAuthor: _string(m, 'lastAuthor'),
isDeleted: false,
isDeleted: _bool(m, 'isDeleted'),
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
parentType: parentType,
parentId: _string(m, 'parentId'),
@ -419,11 +427,15 @@ class InventoryJsonCodec {
required String updatedAt,
required String lastAuthor,
required int schemaRowVersion,
bool isDeleted = false,
}) => {
'id': id,
'createdAt': createdAt,
'updatedAt': updatedAt,
'lastAuthor': lastAuthor,
// Only emitted for tombstones user backups stay tombstone-free, so their
// bytes are unchanged; the sync snapshot carries deletions so they replicate.
if (isDeleted) 'isDeleted': true,
'schemaRowVersion': schemaRowVersion,
};
@ -456,6 +468,8 @@ class InventoryJsonCodec {
return value;
}
bool _bool(Map<String, dynamic> m, String key) => m[key] == true;
int _int(Map<String, dynamic> m, String key, {int? fallback}) {
final value = m[key];
if (value is int) return value;

View file

@ -1745,6 +1745,29 @@ class VarietyRepository {
);
}
/// Snapshots the inventory for device-to-device SYNC unlike [exportInventory]
/// this INCLUDES tombstones (so deletions replicate) and EXCLUDES attachment
/// bytes (photos are large and stay device-local for now; syncing media is a
/// separate concern). Everything else the CRDT rows with their sync metadata
/// replicates and merges LWW-by-HLC on the other device.
Future<InventorySnapshot> exportForSync() async {
final varieties = await _db.select(_db.varieties).get(); // incl. tombstones
return InventorySnapshot(
varieties: varieties,
speciesNamesById: await _scientificNamesFor(
varieties.map((v) => v.speciesId).whereType<String>().toSet(),
),
lots: await _db.select(_db.lots).get(),
vernacularNames: await _db.select(_db.varietyVernacularNames).get(),
externalLinks: await _db.select(_db.externalLinks).get(),
germinationTests: await _db.select(_db.germinationTests).get(),
conditionChecks: await _db.select(_db.conditionChecks).get(),
movements: await _db.select(_db.movements).get(),
parties: await _db.select(_db.parties).get(),
// attachments intentionally omitted photos don't ride the sync wire.
);
}
/// Imports a snapshot, reconciling by row id (UUIDv7) so nothing duplicates:
/// unknown id insert preserving the original sync metadata; known mutable
/// id last-writer-wins on the packed HLC `updatedAt`; movements are

View file

@ -50,11 +50,12 @@ class ExportImportService implements InventorySnapshotIO {
/// The backup file extension. The content is the sealed interchange JSON.
static const backupExtension = 'tanemaki';
/// Raw (unsealed) interchange snapshot for device-to-device sync the same
/// JSON as a backup, but the sync transport (not this) does the encryption.
/// Raw (unsealed) interchange snapshot for device-to-device sync: includes
/// tombstones (so deletions replicate) and omits photo bytes (kept device-
/// local). The sync transport (not this) does the encryption.
@override
Future<List<int>> buildSnapshot() async =>
utf8.encode(_jsonCodec.encode(await _repository.exportInventory()));
utf8.encode(_jsonCodec.encode(await _repository.exportForSync()));
/// Merges a raw interchange snapshot from another device (LWW, idempotent).
@override

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');