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:
parent
225880fc64
commit
21e4357970
4 changed files with 83 additions and 11 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue