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:
vjrj 2026-07-10 23:45:40 +02:00
parent bf15992a97
commit d0dbed9bc2
10 changed files with 363 additions and 12 deletions

View file

@ -9,14 +9,23 @@ import 'package:commons_core/commons_core.dart';
/// social sessions on demand. Local-first: constructing this is cheap and
/// offline; nothing connects to a relay until [openSession] is called, so the
/// app runs fully without network and the social layer only enriches.
/// The app-data namespace this identity's devices sync their inventory under.
/// Lives here (app layer), not in `commons_core`, which stays seed-agnostic.
const kInventorySyncNamespace = 'org.comunes.tane/inventory';
class SocialService {
SocialService({
required this.identity,
this.account = 0,
this.rootSeedHex = '',
this.deviceId = '',
List<String> relays = const [],
}) : relays = List.unmodifiable(relays);
/// This install's device id (for device-to-device sync). Empty in tests /
/// when sync isn't wired.
final String deviceId;
/// The derived Nostr identity (secp256k1). Same key across reinstalls that
/// restore the same seed (for a given [account]).
final NostrIdentity identity;
@ -43,6 +52,7 @@ class SocialService {
static Future<SocialService> fromRootSeedHex(
String rootSeedHex, {
int account = 0,
String deviceId = '',
List<String> relays = const [],
}) async {
final identity = await NostrKeyDerivation.deriveFromSeed(
@ -53,6 +63,7 @@ class SocialService {
identity: identity,
account: account,
rootSeedHex: rootSeedHex,
deviceId: deviceId,
relays: relays,
);
}
@ -74,18 +85,23 @@ class SocialService {
/// skipped; throws only when none are reachable. Caller closes it.
Future<SocialSession> openSession(List<String> relayUrls) async {
final pool = await RelayPool.connect(relayUrls, identity: identity);
return SocialSession(pool);
return SocialSession(pool, deviceId: deviceId);
}
}
/// One relay channel with the three transports on top the
/// "one channel, three interfaces" shape, at the app layer.
class SocialSession {
SocialSession(this._channel)
SocialSession(this._channel, {String deviceId = ''})
: offers = NostrOfferTransport(_channel),
messages = NostrMessageTransport(_channel),
trust = NostrTrustTransport(_channel),
profile = NostrProfileTransport(_channel);
profile = NostrProfileTransport(_channel),
sync = NostrSyncTransport(
_channel,
namespace: kInventorySyncNamespace,
deviceId: deviceId,
);
final NostrChannel _channel;
@ -94,6 +110,9 @@ class SocialSession {
final TrustTransport trust;
final ProfileTransport profile;
/// Device-to-device inventory sync for THIS identity's own devices.
final SyncTransport sync;
Future<void> close() => _channel.close();
}