feat(sync): device-to-device inventory sync, end to end
Wires the encrypted sync transport into a working replication between one identity's own devices, reusing the CRDT-ready model (HLC + tombstones + LWW) the data model was built for. - SocialSession gains a 5th interface, sync, over the shared connection, built with a per-install deviceId + the app's inventory namespace. - DeviceIdStore: a stable random per-install id in the keystore — distinct from the seed-derived CRDT node id, which is IDENTICAL across a user's devices (so it couldn't tell them apart / they'd clobber each other's replaceable record). - InventorySnapshotIO (implemented by ExportImportService): build/apply the inventory as the SAME interchange JSON as backups but UNSEALED — the sync transport does the encryption, so it must not double-seal. Apply = the existing idempotent LWW importInventory. - SyncService: on (re)connect publishes this device's snapshot + subscribes to the others'; a local edit (debounced, off tableUpdates) republishes; incoming snapshots merge idempotently; skips its own echo; a byte check skips re-publishing an unchanged snapshot so the ping-pong converges. Foreground only, like the inbox. - DI/Bootstrap/switchSocialAccount wire and lifecycle it per identity. Tests: DeviceIdStore, ExportImportService snapshot round-trip (unsealed + idempotent), SyncService.handleRemote (applies others', skips own). Follow-ups: deltas vs full-snapshot pushes; background sync; conflict UI.
This commit is contained in:
parent
182c334883
commit
225880fc64
10 changed files with 363 additions and 12 deletions
31
apps/app_seeds/test/services/device_id_store_test.dart
Normal file
31
apps/app_seeds/test/services/device_id_store_test.dart
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tane/services/device_id_store.dart';
|
||||
|
||||
import '../support/test_support.dart';
|
||||
|
||||
void main() {
|
||||
test('generates a 64-bit hex id and persists it', () async {
|
||||
final secret = InMemorySecretStore();
|
||||
final id = await DeviceIdStore(secret).deviceId();
|
||||
expect(id, matches(RegExp(r'^[0-9a-f]{16}$')));
|
||||
|
||||
// A fresh store over the same keystore reads the SAME id (stable install).
|
||||
final again = await DeviceIdStore(secret).deviceId();
|
||||
expect(again, id);
|
||||
});
|
||||
|
||||
test('is stable across calls on one instance', () async {
|
||||
final store = DeviceIdStore(InMemorySecretStore());
|
||||
expect(await store.deviceId(), await store.deviceId());
|
||||
});
|
||||
|
||||
test('two fresh installs get different ids', () async {
|
||||
final a = await DeviceIdStore(InMemorySecretStore(), random: Random(1))
|
||||
.deviceId();
|
||||
final b = await DeviceIdStore(InMemorySecretStore(), random: Random(2))
|
||||
.deviceId();
|
||||
expect(a, isNot(b));
|
||||
});
|
||||
}
|
||||
|
|
@ -141,6 +141,27 @@ void main() {
|
|||
expect(summary.inserted, 1);
|
||||
});
|
||||
|
||||
test('sync snapshot round-trips UNSEALED between devices, idempotently',
|
||||
() async {
|
||||
final (serviceA, _, _) = newService(dbA);
|
||||
final repoA = newTestRepository(dbA);
|
||||
await repoA.addQuickVariety(label: 'Sync tomato');
|
||||
|
||||
final snapshot = await serviceA.buildSnapshot();
|
||||
// Unsealed: plain interchange JSON — the sync transport does the encryption,
|
||||
// so this must NOT be double-sealed.
|
||||
expect(BackupBox.looksSealed(Uint8List.fromList(snapshot)), isFalse);
|
||||
expect(utf8.decode(snapshot).contains('Sync tomato'), isTrue);
|
||||
|
||||
final (serviceB, _, _) = newService(dbB);
|
||||
await serviceB.applySnapshot(snapshot);
|
||||
expect((await dbB.select(dbB.varieties).get()).single.label, 'Sync tomato');
|
||||
|
||||
// Idempotent: re-applying the same snapshot changes nothing.
|
||||
await serviceB.applySnapshot(snapshot);
|
||||
expect(await dbB.select(dbB.varieties).get(), hasLength(1));
|
||||
});
|
||||
|
||||
test('legacy plain exports still restore (no seal, direct parse)', () async {
|
||||
final repoA = newTestRepository(dbA);
|
||||
await repoA.addQuickVariety(label: 'Old export');
|
||||
|
|
|
|||
59
apps/app_seeds/test/services/sync_service_test.dart
Normal file
59
apps/app_seeds/test/services/sync_service_test.dart
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tane/services/inventory_snapshot_io.dart';
|
||||
import 'package:tane/services/social_connection.dart';
|
||||
import 'package:tane/services/social_service.dart';
|
||||
import 'package:tane/services/social_settings.dart';
|
||||
import 'package:tane/services/sync_service.dart';
|
||||
|
||||
import '../support/test_support.dart';
|
||||
|
||||
/// Records what it's asked to build/apply — no DB, no relay.
|
||||
class _FakeIO implements InventorySnapshotIO {
|
||||
final applied = <List<int>>[];
|
||||
List<int> snapshot = const [1, 2, 3];
|
||||
|
||||
@override
|
||||
Future<List<int>> buildSnapshot() async => snapshot;
|
||||
|
||||
@override
|
||||
Future<void> applySnapshot(List<int> bytes) async => applied.add(bytes);
|
||||
}
|
||||
|
||||
void main() {
|
||||
const seedHex =
|
||||
'000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f';
|
||||
|
||||
Future<SyncService> make(_FakeIO io) async => SyncService(
|
||||
connection: SocialConnection(
|
||||
social: await SocialService.fromRootSeedHex(seedHex),
|
||||
settings: SocialSettings(InMemorySecretStore()),
|
||||
open: (_) async => throw StateError('unused'),
|
||||
online: const Stream.empty(),
|
||||
),
|
||||
io: io,
|
||||
localChanges: const Stream.empty(),
|
||||
selfDeviceId: 'me',
|
||||
);
|
||||
|
||||
SyncSnapshot snap(String device, List<int> data) =>
|
||||
SyncSnapshot(deviceId: device, data: data, at: DateTime(2026));
|
||||
|
||||
test('merges another device\'s snapshot into the local inventory', () async {
|
||||
final io = _FakeIO();
|
||||
final sync = await make(io);
|
||||
await sync.handleRemote(snap('other', const [9, 9, 9]));
|
||||
expect(io.applied, [
|
||||
[9, 9, 9]
|
||||
]);
|
||||
await sync.stop();
|
||||
});
|
||||
|
||||
test('ignores our own device\'s snapshot (no self-echo)', () async {
|
||||
final io = _FakeIO();
|
||||
final sync = await make(io);
|
||||
await sync.handleRemote(snap('me', const [1]));
|
||||
expect(io.applied, isEmpty);
|
||||
await sync.stop();
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue