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.
122 lines
4 KiB
Dart
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;
|
|
}
|
|
}
|