Sync the Fly App's waypoint routes through the API Server
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>
This commit is contained in:
1 parent
e49f318a0e
commit
93f4a580e9
18 files changed
+1525
-43
No files matched your search
@@ -6,6 +6,7 @@ import 'package:pilotvault_fly/fly/action_rail_state.dart';
|
||||
import 'package:pilotvault_fly/fly/fly_prefs.dart';
|
||||
import 'package:pilotvault_fly/fly/modes/intelligent_modes.dart';
|
||||
import 'package:pilotvault_fly/fly/modes/mission_library.dart';
|
||||
import 'package:pilotvault_fly/fly/modes/mission_sync.dart';
|
||||
import 'package:pilotvault_fly/fly/modes/waypoint_editor.dart';
|
||||
import 'package:pilotvault_fly/fly/modes/waypoint_mission.dart';
|
||||
import 'package:pilotvault_fly/fly/product_profile.dart';
|
||||
@@ -290,6 +291,156 @@ void main() {
|
||||
final List<WaypointMissionSpec> out = MissionLibrary.decode('[{"name":"ok","points":[]}, {"points": 7}, 3]');
|
||||
expect(out.map((WaypointMissionSpec m) => m.name), <String>['ok']);
|
||||
});
|
||||
|
||||
group('sync', () {
|
||||
WaypointMissionSpec named(String id, String name, int day) =>
|
||||
route(<LatLng>[base, at(0, 100)]).copyWith(id: id, name: name, updatedAt: DateTime.utc(2026, 1, day));
|
||||
RemoteRoute live(WaypointMissionSpec m) => RemoteRoute(m.id, m.updatedAt!, m);
|
||||
RemoteRoute gone(String id, int day) => RemoteRoute(id, DateTime.utc(2026, 1, day), null);
|
||||
List<String> names(Iterable<WaypointMissionSpec> ms) => <String>[for (final WaypointMissionSpec m in ms) m.name];
|
||||
|
||||
test('merge: newest write wins per route, either side', () {
|
||||
final MissionMerge m = mergeMissions(
|
||||
local: <WaypointMissionSpec>[named('a', 'A local', 5), named('b', 'B local', 2), named('c', 'C only here', 3)],
|
||||
tombstones: const <String, DateTime>{},
|
||||
remote: <RemoteRoute>[live(named('a', 'A server', 4)), live(named('b', 'B server', 6)), live(named('d', 'D only there', 1))],
|
||||
);
|
||||
expect(names(m.missions), <String>['B server', 'A local', 'C only here', 'D only there'], reason: 'newest first');
|
||||
expect(names(m.toPush), unorderedEquals(<String>['A local', 'C only here']));
|
||||
expect(m.toDelete, isEmpty);
|
||||
});
|
||||
|
||||
test('merge: a tie goes to the server and pushes nothing', () {
|
||||
final MissionMerge m = mergeMissions(
|
||||
local: <WaypointMissionSpec>[named('a', 'mine', 3)],
|
||||
tombstones: const <String, DateTime>{},
|
||||
remote: <RemoteRoute>[live(named('a', 'theirs', 3))],
|
||||
);
|
||||
expect(names(m.missions), <String>['theirs']);
|
||||
expect(m.toPush, isEmpty);
|
||||
});
|
||||
|
||||
test('merge: deletes are writes too', () {
|
||||
final MissionMerge m = mergeMissions(
|
||||
local: <WaypointMissionSpec>[named('kept', 'edited after their delete', 5), named('dropped', 'stale', 2)],
|
||||
tombstones: <String, DateTime>{
|
||||
'del': DateTime.utc(2026, 1, 5), // deleted here after their edit
|
||||
'lost': DateTime.utc(2026, 1, 1), // deleted here, but edited there since
|
||||
'never': DateTime.utc(2026, 1, 1), // deleted before it ever reached the server
|
||||
'both': DateTime.utc(2026, 1, 4), // deleted on both sides
|
||||
},
|
||||
remote: <RemoteRoute>[
|
||||
gone('kept', 4),
|
||||
gone('dropped', 3),
|
||||
live(named('del', 'old', 4)),
|
||||
live(named('lost', 'edited there', 2)),
|
||||
gone('both', 2),
|
||||
],
|
||||
);
|
||||
expect(names(m.missions), <String>['edited after their delete', 'edited there']);
|
||||
expect(names(m.toPush), <String>['edited after their delete'], reason: 'resurrects it on the server');
|
||||
expect(m.toDelete, <String, DateTime>{'del': DateTime.utc(2026, 1, 5)});
|
||||
expect(m.tombstones.keys, <String>['del'], reason: 'only unacknowledged deletes stay');
|
||||
});
|
||||
|
||||
test('merge: a route with no updatedAt is the oldest write, and gets one to push', () {
|
||||
final MissionMerge m = mergeMissions(
|
||||
local: <WaypointMissionSpec>[route(<LatLng>[base]).copyWith(id: 'legacy', name: 'legacy')],
|
||||
tombstones: const <String, DateTime>{},
|
||||
remote: const <RemoteRoute>[],
|
||||
);
|
||||
expect(m.toPush.single.updatedAt, isNotNull);
|
||||
});
|
||||
|
||||
test('the server view parses, tombstones included', () {
|
||||
final WaypointMissionSpec spec = named('wp-1', 'R', 3);
|
||||
final RemoteRoute? r = RemoteRoute.fromView(<String, dynamic>{
|
||||
'id': 'wp-1',
|
||||
'updatedAt': '2026-01-03T00:00:00.000000Z',
|
||||
'deleted': false,
|
||||
'spec': spec.toJson(),
|
||||
});
|
||||
expect(r!.spec!.toJson(), spec.toJson());
|
||||
final RemoteRoute? t = RemoteRoute.fromView(<String, dynamic>{'id': 'wp-1', 'updatedAt': '2026-01-04T00:00:00Z', 'deleted': true});
|
||||
expect(t!.deleted, isTrue);
|
||||
expect(RemoteRoute.fromView(<String, dynamic>{'id': 'wp-1', 'deleted': false, 'spec': spec.toJson()}), isNull);
|
||||
});
|
||||
|
||||
test('a route saved on one tablet is there after a reinstall + sign-in', () async {
|
||||
final _FakeRemote server = _FakeRemote('pilot@x');
|
||||
final MissionLibrary tablet = MissionLibrary(remote: server);
|
||||
await tablet.load();
|
||||
final WaypointMissionSpec saved = await tablet.save(route(<LatLng>[base, at(0, 100)]).copyWith(name: 'Survey'));
|
||||
await tablet.idle;
|
||||
expect(server.routes.keys, <String>[saved.id]);
|
||||
expect(tablet.syncStatus, MissionSyncStatus.synced);
|
||||
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{}); // reinstall
|
||||
final MissionLibrary fresh = MissionLibrary(remote: server);
|
||||
await fresh.load();
|
||||
expect(fresh.missions.single.toJson(), saved.toJson());
|
||||
|
||||
await fresh.delete(saved.id);
|
||||
await fresh.idle;
|
||||
expect(server.routes[saved.id]!.deleted, isTrue);
|
||||
await tablet.sync();
|
||||
expect(tablet.missions, isEmpty, reason: 'the other tablet learns of the delete');
|
||||
});
|
||||
|
||||
test('offline: saves and deletes stay local and go up on the next sync', () async {
|
||||
final _FakeRemote server = _FakeRemote('pilot@x')..offline = true;
|
||||
final MissionLibrary lib = MissionLibrary(remote: server);
|
||||
await lib.load();
|
||||
expect(lib.syncStatus, MissionSyncStatus.failed);
|
||||
final WaypointMissionSpec a = await lib.save(route(<LatLng>[base]).copyWith(name: 'A'), now: DateTime.utc(2026, 1, 1));
|
||||
final WaypointMissionSpec b = await lib.save(route(<LatLng>[base]).copyWith(name: 'B'), now: DateTime.utc(2026, 1, 2));
|
||||
server.routes[b.id] = RemoteRoute(b.id, DateTime.utc(2026, 1, 2), b); // reached the server earlier
|
||||
await lib.delete(b.id, now: DateTime.utc(2026, 1, 3));
|
||||
await lib.idle;
|
||||
expect(names(lib.missions), <String>['A'], reason: 'works offline');
|
||||
|
||||
final MissionLibrary reopened = MissionLibrary(remote: server);
|
||||
await reopened.load();
|
||||
expect(names(reopened.missions), <String>['A'], reason: 'the pending delete survives a restart');
|
||||
|
||||
server.offline = false;
|
||||
await reopened.sync();
|
||||
expect(server.routes[a.id]!.spec!.name, 'A');
|
||||
expect(server.routes[b.id]!.deleted, isTrue);
|
||||
expect(reopened.syncStatus, MissionSyncStatus.synced);
|
||||
});
|
||||
|
||||
test('a refused push adopts the newer server copy', () async {
|
||||
final _FakeRemote server = _FakeRemote('pilot@x');
|
||||
final MissionLibrary lib = MissionLibrary(remote: server);
|
||||
await lib.load();
|
||||
final WaypointMissionSpec a = await lib.save(route(<LatLng>[base]).copyWith(name: 'mine'), now: DateTime.utc(2026, 1, 1));
|
||||
// Someone else's newer save lands between our pull and our push.
|
||||
server.beforePush = () => server.routes[a.id] = live(a.copyWith(name: 'theirs', updatedAt: DateTime.utc(2026, 1, 9)));
|
||||
await lib.idle;
|
||||
expect(names(lib.missions), <String>['theirs']);
|
||||
});
|
||||
|
||||
test('signed-out routes are claimed by the first account; accounts stay apart', () async {
|
||||
final _FakeRemote server = _FakeRemote('');
|
||||
final MissionLibrary lib = MissionLibrary(remote: server);
|
||||
await lib.load();
|
||||
await lib.save(route(<LatLng>[base]).copyWith(name: 'before sign-in'));
|
||||
await lib.idle;
|
||||
expect(lib.syncStatus, MissionSyncStatus.local);
|
||||
expect(server.routes, isEmpty);
|
||||
|
||||
server.who = 'a@x';
|
||||
await lib.sync();
|
||||
expect(names(lib.missions), <String>['before sign-in']);
|
||||
expect(server.routes.values.single.spec!.name, 'before sign-in');
|
||||
|
||||
server.who = 'b@x';
|
||||
server.routes.clear(); // b's own (empty) server library
|
||||
await lib.sync();
|
||||
expect(lib.missions, isEmpty, reason: "b never sees a's routes");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('WaypointEditor', () {
|
||||
@@ -647,3 +798,46 @@ void main() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// An in-memory API Server route store, with the server's last-writer-wins rule.
|
||||
class _FakeRemote implements MissionRemote {
|
||||
_FakeRemote(this.who);
|
||||
|
||||
String who;
|
||||
bool offline = false;
|
||||
void Function()? beforePush;
|
||||
final Map<String, RemoteRoute> routes = <String, RemoteRoute>{};
|
||||
|
||||
@override
|
||||
String get account => who;
|
||||
|
||||
void _check() {
|
||||
if (offline) throw const MissionSyncException(0, <String, dynamic>{'error': 'offline'});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<RemoteRoute>> pull() async {
|
||||
_check();
|
||||
return routes.values.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RemoteRoute?> push(WaypointMissionSpec m) async {
|
||||
_check();
|
||||
beforePush?.call();
|
||||
final RemoteRoute? have = routes[m.id];
|
||||
if (have != null && have.updatedAt.isAfter(m.updatedAt!)) return have;
|
||||
routes[m.id] = RemoteRoute(m.id, m.updatedAt!, m);
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RemoteRoute?> pushDelete(String id, DateTime at) async {
|
||||
_check();
|
||||
final RemoteRoute? have = routes[id];
|
||||
if (have == null) return null;
|
||||
if (have.updatedAt.isAfter(at)) return have;
|
||||
routes[id] = RemoteRoute(id, at, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user