Routes saved in the Fly App's mission library now sync to a new per-user routes collection on the API Server, in the WaypointMissionSpec JSON format. The library pulls on load and pushes on every save and delete, merges last-writer-wins by updatedAt, keeps unacknowledged deletes as tombstones, and keeps working offline. Each signed-in account has its own copy on the device; routes saved while signed out go to the first account that signs in. The API Server gets list / get / upsert / delete route endpoints with a PocketBase migration, and the Web App a read-only Routes view on the dashboard (rebuilt dist). Tested against a fake PocketBase and a fake server only; not yet tried with a real sign-in on the tablet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
212 lines
7.9 KiB
Dart
212 lines
7.9 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import '../../pb_auth.dart';
|
|
import 'waypoint_mission.dart';
|
|
|
|
/// One route as the API Server reports it: a [spec], or a tombstone (null
|
|
/// [spec]) left by a delete on some device. [updatedAt] is the
|
|
/// last-writer-wins clock either way.
|
|
class RemoteRoute {
|
|
const RemoteRoute(this.id, this.updatedAt, this.spec);
|
|
|
|
final String id;
|
|
final DateTime updatedAt;
|
|
final WaypointMissionSpec? spec;
|
|
|
|
bool get deleted => spec == null;
|
|
|
|
/// Reads one entry of the server's `/api/routes` view. Null when unusable.
|
|
static RemoteRoute? fromView(Map<String, dynamic> v) {
|
|
final String id = v['id'] as String? ?? '';
|
|
final DateTime? at = DateTime.tryParse(v['updatedAt'] as String? ?? '');
|
|
if (id.isEmpty || at == null) return null;
|
|
if (v['deleted'] == true) return RemoteRoute(id, at, null);
|
|
final Object? spec = v['spec'];
|
|
if (spec is! Map) return null;
|
|
try {
|
|
final WaypointMissionSpec m = WaypointMissionSpec.fromJson(Map<String, dynamic>.from(spec));
|
|
return RemoteRoute(id, at, m.copyWith(id: id, updatedAt: m.updatedAt ?? at));
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The server half of the mission library. [ApiMissionRemote] is the real one.
|
|
abstract class MissionRemote {
|
|
/// Who the routes belong to; empty while signed out (nothing syncs).
|
|
String get account;
|
|
|
|
/// Every route the account owns, tombstones included.
|
|
Future<List<RemoteRoute>> pull();
|
|
|
|
/// Stores [m]. Returns the server's copy when it holds a newer one (the
|
|
/// write was refused), else null.
|
|
Future<RemoteRoute?> push(WaypointMissionSpec m);
|
|
|
|
/// Tombstones route [id] as deleted at [at]. Returns the server's copy when
|
|
/// the route was changed after [at] (the delete was refused), else null.
|
|
Future<RemoteRoute?> pushDelete(String id, DateTime at);
|
|
}
|
|
|
|
/// What [mergeMissions] decided.
|
|
class MissionMerge {
|
|
const MissionMerge({required this.missions, required this.tombstones, required this.toPush, required this.toDelete});
|
|
|
|
/// The library after the merge, newest first.
|
|
final List<WaypointMissionSpec> missions;
|
|
|
|
/// Local deletes the server has not heard of yet.
|
|
final Map<String, DateTime> tombstones;
|
|
|
|
/// Local routes newer than the server's copy (or unknown to it).
|
|
final List<WaypointMissionSpec> toPush;
|
|
|
|
/// Local deletes newer than the server's live copy.
|
|
final Map<String, DateTime> toDelete;
|
|
}
|
|
|
|
final DateTime _epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
|
|
|
/// Last-writer-wins merge of the device's library with the server's, per
|
|
/// route id, by `updatedAt`. A delete counts as a write at the time it was
|
|
/// made, on either side. On a tie the server wins.
|
|
///
|
|
/// A route saved before it had an `updatedAt` is treated as the oldest
|
|
/// possible write (and stamped so it can be pushed).
|
|
MissionMerge mergeMissions({
|
|
required List<WaypointMissionSpec> local,
|
|
required Map<String, DateTime> tombstones,
|
|
required List<RemoteRoute> remote,
|
|
}) {
|
|
final Map<String, WaypointMissionSpec> mine = <String, WaypointMissionSpec>{
|
|
for (final WaypointMissionSpec m in local) m.id: m.updatedAt == null ? m.copyWith(updatedAt: _epoch) : m,
|
|
};
|
|
final Map<String, RemoteRoute> theirs = <String, RemoteRoute>{for (final RemoteRoute r in remote) r.id: r};
|
|
|
|
final Map<String, WaypointMissionSpec> out = <String, WaypointMissionSpec>{};
|
|
final Map<String, DateTime> keepTombstones = <String, DateTime>{};
|
|
final List<WaypointMissionSpec> toPush = <WaypointMissionSpec>[];
|
|
final Map<String, DateTime> toDelete = <String, DateTime>{};
|
|
|
|
final Set<String> ids = <String>{...mine.keys, ...tombstones.keys, ...theirs.keys};
|
|
for (final String id in ids) {
|
|
final WaypointMissionSpec? l = mine[id];
|
|
final DateTime? t = tombstones[id];
|
|
final RemoteRoute? r = theirs[id];
|
|
|
|
// The device's own latest word on this route: a save, or a delete.
|
|
final bool localDeleted = t != null && (l == null || !t.isBefore(l.updatedAt!));
|
|
final DateTime? localAt = localDeleted ? t : l?.updatedAt;
|
|
|
|
if (r == null) {
|
|
// The server never had it: push a save; a delete has nothing to undo.
|
|
if (!localDeleted && l != null) {
|
|
out[id] = l;
|
|
toPush.add(l);
|
|
}
|
|
continue;
|
|
}
|
|
if (localAt == null || !localAt.isAfter(r.updatedAt)) {
|
|
// The server's copy (or its delete) is the newest word.
|
|
if (!r.deleted) out[id] = r.spec!;
|
|
continue;
|
|
}
|
|
if (localDeleted) {
|
|
if (!r.deleted) {
|
|
keepTombstones[id] = t;
|
|
toDelete[id] = t;
|
|
}
|
|
} else {
|
|
out[id] = l!;
|
|
toPush.add(l);
|
|
}
|
|
}
|
|
|
|
final List<WaypointMissionSpec> missions = out.values.toList()
|
|
..sort((WaypointMissionSpec a, WaypointMissionSpec b) => b.updatedAt!.compareTo(a.updatedAt!));
|
|
return MissionMerge(missions: missions, tombstones: keepTombstones, toPush: toPush, toDelete: toDelete);
|
|
}
|
|
|
|
/// [MissionRemote] over the API Server's `/api/routes`, as the signed-in
|
|
/// pilot ([auth]).
|
|
class ApiMissionRemote implements MissionRemote {
|
|
ApiMissionRemote({PbAuth? auth}) : _auth = auth;
|
|
|
|
final PbAuth? _auth;
|
|
PbAuth get _a => _auth ?? auth;
|
|
|
|
static const Duration _timeout = Duration(seconds: 10);
|
|
|
|
@override
|
|
String get account => _a.isAuthed ? (_a.userEmail.isEmpty ? '_' : _a.userEmail) : '';
|
|
|
|
@override
|
|
Future<List<RemoteRoute>> pull() async {
|
|
final (int status, Map<String, dynamic> body) = await _send('GET', '/api/routes?mine=1&includeDeleted=1');
|
|
if (status != 200) throw MissionSyncException(status, body);
|
|
return <RemoteRoute>[
|
|
for (final Object? v in (body['routes'] as List<Object?>? ?? const <Object?>[]))
|
|
if (v is Map)
|
|
if (RemoteRoute.fromView(Map<String, dynamic>.from(v)) case final RemoteRoute r) r,
|
|
];
|
|
}
|
|
|
|
@override
|
|
Future<RemoteRoute?> push(WaypointMissionSpec m) async {
|
|
final (int status, Map<String, dynamic> body) =
|
|
await _send('PUT', '/api/routes/${Uri.encodeComponent(m.id)}', body: m.toJson());
|
|
if (status == 200 || status == 201) return null;
|
|
return _conflict(status, body);
|
|
}
|
|
|
|
@override
|
|
Future<RemoteRoute?> pushDelete(String id, DateTime at) async {
|
|
final String when = Uri.encodeQueryComponent(at.toUtc().toIso8601String());
|
|
final (int status, Map<String, dynamic> body) = await _send('DELETE', '/api/routes/${Uri.encodeComponent(id)}?at=$when');
|
|
// 404: the server never had it, which is as deleted as it gets.
|
|
if (status == 200 || status == 404) return null;
|
|
return _conflict(status, body);
|
|
}
|
|
|
|
RemoteRoute _conflict(int status, Map<String, dynamic> body) {
|
|
final Object? route = body['route'];
|
|
final RemoteRoute? r = status == 409 && route is Map ? RemoteRoute.fromView(Map<String, dynamic>.from(route)) : null;
|
|
if (r == null) throw MissionSyncException(status, body);
|
|
return r;
|
|
}
|
|
|
|
Future<(int, Map<String, dynamic>)> _send(String method, String path, {Object? body}) async {
|
|
final HttpClient http = HttpClient()..connectionTimeout = _timeout;
|
|
try {
|
|
final HttpClientRequest req = await http.openUrl(method, Uri.parse('${_a.serverUrl}$path'));
|
|
req.headers.set(HttpHeaders.authorizationHeader, _a.token);
|
|
if (body != null) {
|
|
req.headers.contentType = ContentType.json;
|
|
req.add(utf8.encode(jsonEncode(body)));
|
|
}
|
|
final HttpClientResponse resp = await req.close().timeout(_timeout);
|
|
final String text = await resp.transform(utf8.decoder).join().timeout(_timeout);
|
|
Map<String, dynamic> json = <String, dynamic>{};
|
|
try {
|
|
final Object? decoded = text.isEmpty ? null : jsonDecode(text);
|
|
if (decoded is Map) json = Map<String, dynamic>.from(decoded);
|
|
} catch (_) {}
|
|
return (resp.statusCode, json);
|
|
} finally {
|
|
http.close(force: true);
|
|
}
|
|
}
|
|
}
|
|
|
|
class MissionSyncException implements Exception {
|
|
const MissionSyncException(this.status, this.body);
|
|
final int status;
|
|
final Map<String, dynamic> body;
|
|
|
|
@override
|
|
String toString() => (body['error'] ?? 'Route sync failed ($status)').toString();
|
|
}
|