diff --git a/apps/app_seeds/lib/data/export_import/inventory_json_codec.dart b/apps/app_seeds/lib/data/export_import/inventory_json_codec.dart index 9d8c3ee..f4f51d8 100644 --- a/apps/app_seeds/lib/data/export_import/inventory_json_codec.dart +++ b/apps/app_seeds/lib/data/export_import/inventory_json_codec.dart @@ -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 m, String key) => m[key] == true; + int _int(Map m, String key, {int? fallback}) { final value = m[key]; if (value is int) return value; diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index 21955d0..b760b8e 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -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 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().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 diff --git a/apps/app_seeds/lib/services/export_import_service.dart b/apps/app_seeds/lib/services/export_import_service.dart index ef5a450..7e0ae8c 100644 --- a/apps/app_seeds/lib/services/export_import_service.dart +++ b/apps/app_seeds/lib/services/export_import_service.dart @@ -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> 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 diff --git a/apps/app_seeds/test/services/export_import_service_test.dart b/apps/app_seeds/test/services/export_import_service_test.dart index c427c88..073ba2e 100644 --- a/apps/app_seeds/test/services/export_import_service_test.dart +++ b/apps/app_seeds/test/services/export_import_service_test.dart @@ -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');