uto_manager/lib/domain/services/command_event_store.dart

277 lines
8.9 KiB
Dart

import 'dart:convert';
/// The type of a structural command event.
enum CommandEventType {
/// A unit node was attached (added or re-parented) under a new parent.
attachNode,
/// A unit node was detached from its parent.
detachNode,
/// A soldier was assigned to a position slot.
assignPersonnel,
/// A soldier was unassigned from a position slot.
unassignPersonnel,
/// A position slot was modified (title, MOS, rank, or key-leader flag).
modifySlot,
}
/// A single atomic structural change event in the UTO Manager.
///
/// [CommandEvent]s are the unit of work for the offline-first architecture.
/// Every structural mutation (re-parent, assign, unassign, slot edit) is
/// recorded as a [CommandEvent] before being applied to the local database.
/// When connectivity is restored, queued events are transmitted to any
/// remote store via [DeltaSynchronizer].
class CommandEvent {
CommandEvent({
required this.eventId,
required this.type,
required this.payload,
required this.occurredAt,
this.isSynced = false,
});
/// Deserializes a [CommandEvent] from a JSON-compatible map.
factory CommandEvent.fromJson(Map<String, dynamic> map) {
return CommandEvent(
eventId: map['eventId'] as String,
type: CommandEventType.values.firstWhere((t) => t.name == map['type']),
payload: map['payload'] as Map<String, dynamic>,
occurredAt: DateTime.parse(map['occurredAt'] as String),
isSynced: map['isSynced'] as bool? ?? false,
);
}
/// Unique identifier for this event (UUID v4).
final String eventId;
/// The type of structural change this event represents.
final CommandEventType type;
/// Type-specific payload data (serialized as a JSON-compatible map).
final Map<String, dynamic> payload;
/// When this event was created (UTC).
final DateTime occurredAt;
/// Whether this event has been transmitted to the remote store.
bool isSynced;
/// Serializes this event to a JSON-compatible map.
Map<String, dynamic> toJson() => {
'eventId': eventId,
'type': type.name,
'payload': payload,
'occurredAt': occurredAt.toUtc().toIso8601String(),
'isSynced': isSynced,
};
@override
String toString() => 'CommandEvent(id: $eventId, type: ${type.name}, synced: $isSynced)';
}
// ---------------------------------------------------------------------------
// Payload factory helpers
// ---------------------------------------------------------------------------
/// Payload factories for each [CommandEventType].
///
/// These helpers produce the canonical payload maps consumed by
/// [CommandEventStore] and [DeltaSynchronizer].
abstract final class CommandPayloads {
/// Payload for [CommandEventType.attachNode].
static Map<String, dynamic> attachNode({
required String unitId,
required String newParentId,
String? previousParentId,
}) =>
{
'unitId': unitId,
'newParentId': newParentId,
if (previousParentId != null) 'previousParentId': previousParentId,
};
/// Payload for [CommandEventType.detachNode].
static Map<String, dynamic> detachNode({
required String unitId,
required String previousParentId,
}) =>
{
'unitId': unitId,
'previousParentId': previousParentId,
};
/// Payload for [CommandEventType.assignPersonnel].
static Map<String, dynamic> assignPersonnel({
required String soldierId,
required String slotId,
required String unitId,
}) =>
{
'soldierId': soldierId,
'slotId': slotId,
'unitId': unitId,
};
/// Payload for [CommandEventType.unassignPersonnel].
static Map<String, dynamic> unassignPersonnel({
required String soldierId,
required String slotId,
required String unitId,
}) =>
{
'soldierId': soldierId,
'slotId': slotId,
'unitId': unitId,
};
/// Payload for [CommandEventType.modifySlot].
static Map<String, dynamic> modifySlot({
required String slotId,
required String unitId,
required Map<String, dynamic> changes,
}) =>
{
'slotId': slotId,
'unitId': unitId,
'changes': changes,
};
}
// ---------------------------------------------------------------------------
// CommandEventStore
// ---------------------------------------------------------------------------
/// In-memory command event store for offline-first operation.
///
/// All structural mutations are appended here before being applied to the
/// local Drift database. Unsynced events are queued for transmission by
/// [DeltaSynchronizer] when connectivity is restored.
///
/// This implementation uses an in-memory list as the backing store.
/// In a production deployment the queue would be persisted to a dedicated
/// Drift table so events survive app restarts.
class CommandEventStore {
CommandEventStore();
final List<CommandEvent> _events = [];
/// All events recorded since the store was created.
List<CommandEvent> get allEvents => List.unmodifiable(_events);
/// Events that have not yet been transmitted to the remote store.
List<CommandEvent> get pendingEvents => _events.where((e) => !e.isSynced).toList();
/// The total number of events in the store.
int get eventCount => _events.length;
/// The number of unsynced events awaiting transmission.
int get pendingCount => pendingEvents.length;
// ---------------------------------------------------------------------------
// Append
// ---------------------------------------------------------------------------
/// Appends a new [event] to the store.
void append(CommandEvent event) {
_events.add(event);
}
// ---------------------------------------------------------------------------
// Mark synced
// ---------------------------------------------------------------------------
/// Marks the event with [eventId] as synced.
///
/// Returns true if the event was found and updated, false otherwise.
bool markSynced(String eventId) {
final index = _events.indexWhere((e) => e.eventId == eventId);
if (index == -1) return false;
_events[index].isSynced = true;
return true;
}
/// Marks all pending events as synced (bulk operation after successful
/// remote transmission).
void markAllSynced() {
for (final event in _events) {
event.isSynced = true;
}
}
// ---------------------------------------------------------------------------
// Serialization
// ---------------------------------------------------------------------------
/// Serializes all pending events to a JSON string for transmission.
String exportPendingToJson() {
final list = pendingEvents.map((e) => e.toJson()).toList();
return const JsonEncoder.withIndent(' ').convert(list);
}
/// Clears all events from the store (e.g., after a full sync).
void clear() => _events.clear();
}
// ---------------------------------------------------------------------------
// DeltaSynchronizer
// ---------------------------------------------------------------------------
/// Transmits pending [CommandEvent]s from a [CommandEventStore] to a remote
/// endpoint when connectivity is available.
///
/// This implementation provides the interface and a stub transmit method.
/// In a production deployment the transmit method would POST the JSON payload
/// to a REST or gRPC endpoint and handle retry/backoff logic.
class DeltaSynchronizer {
DeltaSynchronizer({required this.store});
final CommandEventStore store;
/// Whether the synchronizer believes it has network connectivity.
bool _isOnline = true;
/// Sets the connectivity state. When set to true, [syncIfOnline] will
/// attempt to transmit pending events.
set isOnline(bool value) => _isOnline = value;
/// Returns true if the synchronizer believes it has network connectivity.
bool get isOnline => _isOnline;
/// Transmits all pending events if online.
///
/// Returns the number of events successfully transmitted, or 0 if offline
/// or if there are no pending events.
///
/// In this implementation the transmission is simulated (no real network
/// call). Replace [_transmit] with a real HTTP/gRPC call in production.
Future<int> syncIfOnline() async {
if (!_isOnline) return 0;
final pending = store.pendingEvents;
if (pending.isEmpty) return 0;
final transmitted = await _transmit(pending);
if (transmitted) {
store.markAllSynced();
return pending.length;
}
return 0;
}
/// Stub transmission method. Replace with real network call in production.
///
/// Returns true on success, false on failure.
Future<bool> _transmit(List<CommandEvent> events) async {
// Simulate async network latency.
await Future<void>.delayed(const Duration(milliseconds: 10));
// In production: POST events as JSON to remote endpoint.
// final json = store.exportPendingToJson();
// final response = await http.post(Uri.parse(remoteUrl), body: json);
// return response.statusCode == 200;
return true;
}
}