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
bf15992a97
commit
d0dbed9bc2
10 changed files with 363 additions and 12 deletions
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue