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,
|
updatedAt: v.updatedAt,
|
||||||
lastAuthor: v.lastAuthor,
|
lastAuthor: v.lastAuthor,
|
||||||
schemaRowVersion: v.schemaRowVersion,
|
schemaRowVersion: v.schemaRowVersion,
|
||||||
|
isDeleted: v.isDeleted,
|
||||||
),
|
),
|
||||||
'label': v.label,
|
'label': v.label,
|
||||||
'speciesId': v.speciesId,
|
'speciesId': v.speciesId,
|
||||||
|
|
@ -61,6 +62,7 @@ class InventoryJsonCodec {
|
||||||
updatedAt: l.updatedAt,
|
updatedAt: l.updatedAt,
|
||||||
lastAuthor: l.lastAuthor,
|
lastAuthor: l.lastAuthor,
|
||||||
schemaRowVersion: l.schemaRowVersion,
|
schemaRowVersion: l.schemaRowVersion,
|
||||||
|
isDeleted: l.isDeleted,
|
||||||
),
|
),
|
||||||
'varietyId': l.varietyId,
|
'varietyId': l.varietyId,
|
||||||
'type': l.type.name,
|
'type': l.type.name,
|
||||||
|
|
@ -88,6 +90,7 @@ class InventoryJsonCodec {
|
||||||
updatedAt: n.updatedAt,
|
updatedAt: n.updatedAt,
|
||||||
lastAuthor: n.lastAuthor,
|
lastAuthor: n.lastAuthor,
|
||||||
schemaRowVersion: n.schemaRowVersion,
|
schemaRowVersion: n.schemaRowVersion,
|
||||||
|
isDeleted: n.isDeleted,
|
||||||
),
|
),
|
||||||
'varietyId': n.varietyId,
|
'varietyId': n.varietyId,
|
||||||
'name': n.name,
|
'name': n.name,
|
||||||
|
|
@ -104,6 +107,7 @@ class InventoryJsonCodec {
|
||||||
updatedAt: e.updatedAt,
|
updatedAt: e.updatedAt,
|
||||||
lastAuthor: e.lastAuthor,
|
lastAuthor: e.lastAuthor,
|
||||||
schemaRowVersion: e.schemaRowVersion,
|
schemaRowVersion: e.schemaRowVersion,
|
||||||
|
isDeleted: e.isDeleted,
|
||||||
),
|
),
|
||||||
'parentType': e.parentType.name,
|
'parentType': e.parentType.name,
|
||||||
'parentId': e.parentId,
|
'parentId': e.parentId,
|
||||||
|
|
@ -120,6 +124,7 @@ class InventoryJsonCodec {
|
||||||
updatedAt: g.updatedAt,
|
updatedAt: g.updatedAt,
|
||||||
lastAuthor: g.lastAuthor,
|
lastAuthor: g.lastAuthor,
|
||||||
schemaRowVersion: g.schemaRowVersion,
|
schemaRowVersion: g.schemaRowVersion,
|
||||||
|
isDeleted: g.isDeleted,
|
||||||
),
|
),
|
||||||
'lotId': g.lotId,
|
'lotId': g.lotId,
|
||||||
'testedOn': g.testedOn,
|
'testedOn': g.testedOn,
|
||||||
|
|
@ -137,6 +142,7 @@ class InventoryJsonCodec {
|
||||||
updatedAt: c.updatedAt,
|
updatedAt: c.updatedAt,
|
||||||
lastAuthor: c.lastAuthor,
|
lastAuthor: c.lastAuthor,
|
||||||
schemaRowVersion: c.schemaRowVersion,
|
schemaRowVersion: c.schemaRowVersion,
|
||||||
|
isDeleted: c.isDeleted,
|
||||||
),
|
),
|
||||||
'lotId': c.lotId,
|
'lotId': c.lotId,
|
||||||
'checkedOn': c.checkedOn,
|
'checkedOn': c.checkedOn,
|
||||||
|
|
@ -174,6 +180,7 @@ class InventoryJsonCodec {
|
||||||
updatedAt: p.updatedAt,
|
updatedAt: p.updatedAt,
|
||||||
lastAuthor: p.lastAuthor,
|
lastAuthor: p.lastAuthor,
|
||||||
schemaRowVersion: p.schemaRowVersion,
|
schemaRowVersion: p.schemaRowVersion,
|
||||||
|
isDeleted: p.isDeleted,
|
||||||
),
|
),
|
||||||
'displayName': p.displayName,
|
'displayName': p.displayName,
|
||||||
'publicKey': p.publicKey,
|
'publicKey': p.publicKey,
|
||||||
|
|
@ -190,6 +197,7 @@ class InventoryJsonCodec {
|
||||||
updatedAt: a.updatedAt,
|
updatedAt: a.updatedAt,
|
||||||
lastAuthor: a.lastAuthor,
|
lastAuthor: a.lastAuthor,
|
||||||
schemaRowVersion: a.schemaRowVersion,
|
schemaRowVersion: a.schemaRowVersion,
|
||||||
|
isDeleted: a.isDeleted,
|
||||||
),
|
),
|
||||||
'parentType': a.parentType.name,
|
'parentType': a.parentType.name,
|
||||||
'parentId': a.parentId,
|
'parentId': a.parentId,
|
||||||
|
|
@ -236,7 +244,7 @@ class InventoryJsonCodec {
|
||||||
createdAt: _int(m, 'createdAt'),
|
createdAt: _int(m, 'createdAt'),
|
||||||
updatedAt: _string(m, 'updatedAt'),
|
updatedAt: _string(m, 'updatedAt'),
|
||||||
lastAuthor: _string(m, 'lastAuthor'),
|
lastAuthor: _string(m, 'lastAuthor'),
|
||||||
isDeleted: false,
|
isDeleted: _bool(m, 'isDeleted'),
|
||||||
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
||||||
label: _string(m, 'label'),
|
label: _string(m, 'label'),
|
||||||
speciesId: speciesId,
|
speciesId: speciesId,
|
||||||
|
|
@ -263,7 +271,7 @@ class InventoryJsonCodec {
|
||||||
createdAt: _int(m, 'createdAt'),
|
createdAt: _int(m, 'createdAt'),
|
||||||
updatedAt: _string(m, 'updatedAt'),
|
updatedAt: _string(m, 'updatedAt'),
|
||||||
lastAuthor: _string(m, 'lastAuthor'),
|
lastAuthor: _string(m, 'lastAuthor'),
|
||||||
isDeleted: false,
|
isDeleted: _bool(m, 'isDeleted'),
|
||||||
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
||||||
varietyId: _string(m, 'varietyId'),
|
varietyId: _string(m, 'varietyId'),
|
||||||
// Unknown enum values → safe defaults (data-model §5.2).
|
// Unknown enum values → safe defaults (data-model §5.2).
|
||||||
|
|
@ -296,7 +304,7 @@ class InventoryJsonCodec {
|
||||||
createdAt: _int(m, 'createdAt'),
|
createdAt: _int(m, 'createdAt'),
|
||||||
updatedAt: _string(m, 'updatedAt'),
|
updatedAt: _string(m, 'updatedAt'),
|
||||||
lastAuthor: _string(m, 'lastAuthor'),
|
lastAuthor: _string(m, 'lastAuthor'),
|
||||||
isDeleted: false,
|
isDeleted: _bool(m, 'isDeleted'),
|
||||||
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
||||||
varietyId: _string(m, 'varietyId'),
|
varietyId: _string(m, 'varietyId'),
|
||||||
name: _string(m, 'name'),
|
name: _string(m, 'name'),
|
||||||
|
|
@ -312,7 +320,7 @@ class InventoryJsonCodec {
|
||||||
createdAt: _int(m, 'createdAt'),
|
createdAt: _int(m, 'createdAt'),
|
||||||
updatedAt: _string(m, 'updatedAt'),
|
updatedAt: _string(m, 'updatedAt'),
|
||||||
lastAuthor: _string(m, 'lastAuthor'),
|
lastAuthor: _string(m, 'lastAuthor'),
|
||||||
isDeleted: false,
|
isDeleted: _bool(m, 'isDeleted'),
|
||||||
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
||||||
parentType: parentType,
|
parentType: parentType,
|
||||||
parentId: _string(m, 'parentId'),
|
parentId: _string(m, 'parentId'),
|
||||||
|
|
@ -326,7 +334,7 @@ class InventoryJsonCodec {
|
||||||
createdAt: _int(m, 'createdAt'),
|
createdAt: _int(m, 'createdAt'),
|
||||||
updatedAt: _string(m, 'updatedAt'),
|
updatedAt: _string(m, 'updatedAt'),
|
||||||
lastAuthor: _string(m, 'lastAuthor'),
|
lastAuthor: _string(m, 'lastAuthor'),
|
||||||
isDeleted: false,
|
isDeleted: _bool(m, 'isDeleted'),
|
||||||
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
||||||
lotId: _string(m, 'lotId'),
|
lotId: _string(m, 'lotId'),
|
||||||
testedOn: m['testedOn'] as int?,
|
testedOn: m['testedOn'] as int?,
|
||||||
|
|
@ -341,7 +349,7 @@ class InventoryJsonCodec {
|
||||||
createdAt: _int(m, 'createdAt'),
|
createdAt: _int(m, 'createdAt'),
|
||||||
updatedAt: _string(m, 'updatedAt'),
|
updatedAt: _string(m, 'updatedAt'),
|
||||||
lastAuthor: _string(m, 'lastAuthor'),
|
lastAuthor: _string(m, 'lastAuthor'),
|
||||||
isDeleted: false,
|
isDeleted: _bool(m, 'isDeleted'),
|
||||||
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
||||||
lotId: _string(m, 'lotId'),
|
lotId: _string(m, 'lotId'),
|
||||||
checkedOn: m['checkedOn'] as int?,
|
checkedOn: m['checkedOn'] as int?,
|
||||||
|
|
@ -379,7 +387,7 @@ class InventoryJsonCodec {
|
||||||
createdAt: _int(m, 'createdAt'),
|
createdAt: _int(m, 'createdAt'),
|
||||||
updatedAt: _string(m, 'updatedAt'),
|
updatedAt: _string(m, 'updatedAt'),
|
||||||
lastAuthor: _string(m, 'lastAuthor'),
|
lastAuthor: _string(m, 'lastAuthor'),
|
||||||
isDeleted: false,
|
isDeleted: _bool(m, 'isDeleted'),
|
||||||
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
||||||
displayName: _string(m, 'displayName'),
|
displayName: _string(m, 'displayName'),
|
||||||
publicKey: m['publicKey'] as String?,
|
publicKey: m['publicKey'] as String?,
|
||||||
|
|
@ -397,7 +405,7 @@ class InventoryJsonCodec {
|
||||||
createdAt: _int(m, 'createdAt'),
|
createdAt: _int(m, 'createdAt'),
|
||||||
updatedAt: _string(m, 'updatedAt'),
|
updatedAt: _string(m, 'updatedAt'),
|
||||||
lastAuthor: _string(m, 'lastAuthor'),
|
lastAuthor: _string(m, 'lastAuthor'),
|
||||||
isDeleted: false,
|
isDeleted: _bool(m, 'isDeleted'),
|
||||||
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
|
||||||
parentType: parentType,
|
parentType: parentType,
|
||||||
parentId: _string(m, 'parentId'),
|
parentId: _string(m, 'parentId'),
|
||||||
|
|
@ -419,11 +427,15 @@ class InventoryJsonCodec {
|
||||||
required String updatedAt,
|
required String updatedAt,
|
||||||
required String lastAuthor,
|
required String lastAuthor,
|
||||||
required int schemaRowVersion,
|
required int schemaRowVersion,
|
||||||
|
bool isDeleted = false,
|
||||||
}) => {
|
}) => {
|
||||||
'id': id,
|
'id': id,
|
||||||
'createdAt': createdAt,
|
'createdAt': createdAt,
|
||||||
'updatedAt': updatedAt,
|
'updatedAt': updatedAt,
|
||||||
'lastAuthor': lastAuthor,
|
'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,
|
'schemaRowVersion': schemaRowVersion,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -456,6 +468,8 @@ class InventoryJsonCodec {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _bool(Map<String, dynamic> m, String key) => m[key] == true;
|
||||||
|
|
||||||
int _int(Map<String, dynamic> m, String key, {int? fallback}) {
|
int _int(Map<String, dynamic> m, String key, {int? fallback}) {
|
||||||
final value = m[key];
|
final value = m[key];
|
||||||
if (value is int) return value;
|
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:
|
/// Imports a snapshot, reconciling by row id (UUIDv7) so nothing duplicates:
|
||||||
/// unknown id → insert preserving the original sync metadata; known mutable
|
/// unknown id → insert preserving the original sync metadata; known mutable
|
||||||
/// id → last-writer-wins on the packed HLC `updatedAt`; movements are
|
/// 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.
|
/// The backup file extension. The content is the sealed interchange JSON.
|
||||||
static const backupExtension = 'tanemaki';
|
static const backupExtension = 'tanemaki';
|
||||||
|
|
||||||
/// Raw (unsealed) interchange snapshot for device-to-device sync — the same
|
/// Raw (unsealed) interchange snapshot for device-to-device sync: includes
|
||||||
/// JSON as a backup, but the sync transport (not this) does the encryption.
|
/// tombstones (so deletions replicate) and omits photo bytes (kept device-
|
||||||
|
/// local). The sync transport (not this) does the encryption.
|
||||||
@override
|
@override
|
||||||
Future<List<int>> buildSnapshot() async =>
|
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).
|
/// Merges a raw interchange snapshot from another device (LWW, idempotent).
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,40 @@ void main() {
|
||||||
expect(await dbB.select(dbB.varieties).get(), hasLength(1));
|
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 {
|
test('legacy plain exports still restore (no seal, direct parse)', () async {
|
||||||
final repoA = newTestRepository(dbA);
|
final repoA = newTestRepository(dbA);
|
||||||
await repoA.addQuickVariety(label: 'Old export');
|
await repoA.addQuickVariety(label: 'Old export');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue