tane/apps/app_seeds/lib/services/sync_service.dart
vjrj d0dbed9bc2 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.
2026-07-10 23:45:40 +02:00

122 lines
4 KiB
Dart

import 'dart:async';
import 'package:commons_core/commons_core.dart';
import 'package:flutter/foundation.dart';
import 'inventory_snapshot_io.dart';
import 'social_connection.dart';
import 'social_service.dart';
/// Keeps this identity's inventory replicated across its OWN devices, over the
/// shared [SocialConnection]'s [SyncTransport].
///
/// On (re)connect it publishes this device's snapshot and subscribes to the
/// others'; a local edit (debounced) republishes. Incoming snapshots are merged
/// with the existing idempotent LWW importer — so applying one you already have
/// writes nothing and can't echo back into a push loop. A content check skips
/// re-publishing an unchanged snapshot, bounding the ping-pong to convergence.
///
/// Foreground only, like the inbox — a background sync daemon is a later concern.
class SyncService {
SyncService({
required SocialConnection connection,
required InventorySnapshotIO io,
required Stream<void> localChanges,
required String selfDeviceId,
Duration debounce = const Duration(seconds: 2),
}) : _connection = connection,
_io = io,
_localChanges = localChanges,
_selfDeviceId = selfDeviceId,
_debounce = debounce;
final SocialConnection _connection;
final InventorySnapshotIO _io;
final Stream<void> _localChanges;
final String _selfDeviceId;
final Duration _debounce;
StreamSubscription<SocialSession?>? _sessionsSub;
StreamSubscription<SyncSnapshot>? _snapSub;
StreamSubscription<void>? _changesSub;
Timer? _debounceTimer;
SocialSession? _session;
List<int>? _lastPushed; // the snapshot we last published (for change dedup)
bool _started = false;
/// Begins syncing. Subscribe to the connection's sessions BEFORE it starts
/// connecting, so the first session is caught too.
void start() {
if (_started) return;
_started = true;
_sessionsSub = _connection.sessions.listen(_onSession);
_changesSub = _localChanges.listen((_) => _schedulePush());
final current = _connection.current;
if (current != null) _onSession(current);
}
void _onSession(SocialSession? session) {
if (identical(session, _session) && _snapSub != null) return;
unawaited(_snapSub?.cancel());
_snapSub = null;
_session = session;
if (session != null) {
_snapSub = session.sync.snapshots().listen(handleRemote, onError: (_) {});
_lastPushed = null; // a fresh session should re-publish
unawaited(pushLocal()); // publish our latest on (re)connect
}
}
/// Merges one incoming [snapshot], skipping our own device's echo. A testable
/// seam (no relay).
@visibleForTesting
Future<void> handleRemote(SyncSnapshot snapshot) async {
if (snapshot.deviceId == _selfDeviceId) return; // our own — nothing to do
try {
await _io.applySnapshot(snapshot.data);
} catch (_) {
// A malformed/old snapshot merges to nothing; ignore.
}
}
void _schedulePush() {
_debounceTimer?.cancel();
_debounceTimer = Timer(_debounce, () => unawaited(pushLocal()));
}
/// Publishes this device's current inventory, unless it's byte-identical to
/// what we last published (so a merge that changed nothing doesn't loop).
@visibleForTesting
Future<void> pushLocal() async {
final session = _session;
if (session == null) return;
try {
final bytes = await _io.buildSnapshot();
if (_sameBytes(bytes, _lastPushed)) return;
await session.sync.pushSnapshot(bytes);
_lastPushed = bytes;
} catch (_) {
// offline / relay refused — the next connect or change retries.
}
}
static bool _sameBytes(List<int> a, List<int>? b) {
if (b == null || a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
Future<void> stop() async {
_started = false;
_debounceTimer?.cancel();
await _changesSub?.cancel();
_changesSub = null;
await _sessionsSub?.cancel();
_sessionsSub = null;
await _snapSub?.cancel();
_snapSub = null;
_session = null;
}
}