feat(train): the instance names its map source (ADR 0030, D11, D22)

Train's first endpoint of its own, map.source, names the map workouts
are drawn on, for people signed in only, as a provider's key may be in
its style's address: OpenFreeMap's Liberty style where the operator sets
nothing, else MAP_STYLE_URL (HTTPS only) with MAP_SOURCE_NAME,
MAP_ATTRIBUTION and MAP_ABOUT_URL. A model without a table: no
migration. D22 is recorded in ADR 0030.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-28 13:42:28 +01:00
commit dd778cb2cd
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
14 changed files with 574 additions and 4 deletions

View file

@ -13,6 +13,8 @@
import 'dart:async' as _ida;
import 'package:halkyon_base_client/halkyon_base_client.dart' as _ihk4p5t7;
import 'package:halkyon_oidc_client/halkyon_oidc_client.dart' as _ibkpwzgl;
import 'package:halkyon_train_client/src/protocol/map/map_source.dart'
as _iebj1x2g;
import 'package:http/http.dart' as _i85jenna;
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
as _iacc;
@ -26,6 +28,24 @@ import 'protocol.dart' as _il2as5qe;
import 'sync_tables.dart' as _ii3f3u05;
import 'package:halkyon_train_client/migrations/migration_registry.dart';
/// The map workouts are drawn on (ADR 0030, D11, D22), for the people signed
/// in: an operator's provider may have its key in the style's address.
/// {@category Endpoint}
class EndpointMap extends _isc.EndpointRef {
EndpointMap(_isc.EndpointCaller caller) : super(caller);
@override
String get name => 'map';
/// The instance's map source, as its operator sets it.
_ida.Future<_iebj1x2g.MapSource> source() =>
caller.callServerEndpoint<_iebj1x2g.MapSource>(
'map',
'source',
{},
);
}
class Modules {
Modules(Client client) {
base = _ihk4p5t7.Caller(client);
@ -73,13 +93,16 @@ class Client extends _isc.ServerpodClientShared {
disconnectStreamsOnLostInternetConnection,
httpClientOverride: httpClientOverride,
) {
map = EndpointMap(this);
modules = Modules(this);
}
late final EndpointMap map;
late final Modules modules;
@override
Map<String, _isc.EndpointRef> get endpointRefLookup => {};
Map<String, _isc.EndpointRef> get endpointRefLookup => {'map': map};
@override
Map<String, _isc.ModuleEndpointCaller> get moduleLookup => {

View file

@ -0,0 +1,127 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// The map this instance's workouts are drawn on (ADR 0030, D11, D22), as
/// its operator sets it: a MapLibre style, its source's name and the credit
/// it asks for, and where the source says what it sees. OpenFreeMap where
/// nothing is set.
abstract class MapSource
implements _isc.SerializableModel, _isc.ProtocolSerialization {
MapSource._({
required this.name,
required this.styleUrl,
required this.attribution,
this.aboutUrl,
});
factory MapSource({
required String name,
required String styleUrl,
required String attribution,
String? aboutUrl,
}) = _MapSourceImpl;
factory MapSource.fromJson(Map<String, dynamic> jsonSerialization) {
return MapSource(
name: jsonSerialization['name'] as String,
styleUrl: jsonSerialization['styleUrl'] as String,
attribution: jsonSerialization['attribution'] as String,
aboutUrl: jsonSerialization['aboutUrl'] as String?,
);
}
/// What the app names as drawing the map, as `OpenFreeMap`.
String name;
/// The MapLibre (Mapbox GL) style the vector tiles are drawn by; a
/// provider's key may be in it, so only people signed in get it.
String styleUrl;
/// The credit the map shows, as the source's terms ask.
String attribution;
/// Where the source says what it keeps of those viewing its maps; null
/// where the operator names nothing.
String? aboutUrl;
/// Returns a shallow copy of this [MapSource]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
MapSource copyWith({
String? name,
String? styleUrl,
String? attribution,
String? aboutUrl,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'MapSource',
'name': name,
'styleUrl': styleUrl,
'attribution': attribution,
if (aboutUrl != null) 'aboutUrl': aboutUrl,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'MapSource',
'name': name,
'styleUrl': styleUrl,
'attribution': attribution,
if (aboutUrl != null) 'aboutUrl': aboutUrl,
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _Undefined {}
class _MapSourceImpl extends MapSource {
_MapSourceImpl({
required String name,
required String styleUrl,
required String attribution,
String? aboutUrl,
}) : super._(
name: name,
styleUrl: styleUrl,
attribution: attribution,
aboutUrl: aboutUrl,
);
/// Returns a shallow copy of this [MapSource]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
MapSource copyWith({
String? name,
String? styleUrl,
String? attribution,
Object? aboutUrl = _Undefined,
}) {
return MapSource(
name: name ?? this.name,
styleUrl: styleUrl ?? this.styleUrl,
attribution: attribution ?? this.attribution,
aboutUrl: aboutUrl is String? ? aboutUrl : this.aboutUrl,
);
}
}

View file

@ -21,9 +21,11 @@ import 'package:serverpod_client/serverpod_client.dart' as _isc;
import 'package:serverpod_database/serverpod_database.dart' as _isd;
import 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.dart'
as _ipulbpi2;
import 'map/map_source.dart' as _ikw0qg8x;
import 'privacy/content_key.dart' as _imed3x5d;
import 'workout/workout.dart' as _i5b9b17z;
import 'workout/workout_series.dart' as _i92mefto;
export 'map/map_source.dart';
export 'privacy/content_key.dart';
export 'workout/workout.dart';
export 'workout/workout_series.dart';
@ -312,6 +314,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
}
}
if (t == _ikw0qg8x.MapSource) {
return _ikw0qg8x.MapSource.fromJson(data) as T;
}
if (t == _imed3x5d.ContentKey) {
return _imed3x5d.ContentKey.fromJson(data) as T;
}
@ -321,6 +326,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _i92mefto.WorkoutSeries) {
return _i92mefto.WorkoutSeries.fromJson(data) as T;
}
if (t == _isc.getType<_ikw0qg8x.MapSource?>()) {
return (data != null ? _ikw0qg8x.MapSource.fromJson(data) : null) as T;
}
if (t == _isc.getType<_imed3x5d.ContentKey?>()) {
return (data != null ? _imed3x5d.ContentKey.fromJson(data) : null) as T;
}
@ -351,6 +359,7 @@ class Protocol extends _isd.DatabaseSerializationManager {
static String? getClassNameForType(Type type) {
return switch (type) {
_ikw0qg8x.MapSource => 'MapSource',
_imed3x5d.ContentKey => 'ContentKey',
_i5b9b17z.Workout => 'Workout',
_i92mefto.WorkoutSeries => 'WorkoutSeries',
@ -371,6 +380,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
}
switch (data) {
case _ikw0qg8x.MapSource():
return 'MapSource';
case _imed3x5d.ContentKey():
return 'ContentKey';
case _i5b9b17z.Workout():
@ -413,6 +424,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (dataClassName is! String) {
return super.deserializeByClassName(data);
}
if (dataClassName == 'MapSource') {
return deserialize<_ikw0qg8x.MapSource>(data['data']);
}
if (dataClassName == 'ContentKey') {
return deserialize<_imed3x5d.ContentKey>(data['data']);
}

View file

@ -21,11 +21,36 @@ import 'package:serverpod_auth_idp_server/serverpod_auth_idp_server.dart'
as _iais;
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart'
as _izehhkf5;
import '../map/map_endpoint.dart' as _i35zr6r3;
export 'future_calls.dart' show ServerpodFutureCallsGetter;
class Endpoints extends _is.EndpointDispatch {
@override
void initializeEndpoints(_is.Server server) {
var endpoints = <String, _is.Endpoint>{
'map': _i35zr6r3.MapEndpoint()
..initialize(
server,
'map',
null,
),
};
connectors['map'] = _is.EndpointConnector(
name: 'map',
endpoint: endpoints['map']!,
methodConnectors: {
'source': _is.MethodConnector(
name: 'source',
params: {},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['map'] as _i35zr6r3.MapEndpoint).source(session),
),
},
);
modules['halkyon_base'] = _ik8molhd.Endpoints()
..initializeEndpoints(server);
modules['serverpod_auth_idp'] = _iais.Endpoints()

View file

@ -0,0 +1,127 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod/serverpod.dart' as _is;
/// The map this instance's workouts are drawn on (ADR 0030, D11, D22), as
/// its operator sets it: a MapLibre style, its source's name and the credit
/// it asks for, and where the source says what it sees. OpenFreeMap where
/// nothing is set.
abstract class MapSource
implements _is.SerializableModel, _is.ProtocolSerialization {
MapSource._({
required this.name,
required this.styleUrl,
required this.attribution,
this.aboutUrl,
});
factory MapSource({
required String name,
required String styleUrl,
required String attribution,
String? aboutUrl,
}) = _MapSourceImpl;
factory MapSource.fromJson(Map<String, dynamic> jsonSerialization) {
return MapSource(
name: jsonSerialization['name'] as String,
styleUrl: jsonSerialization['styleUrl'] as String,
attribution: jsonSerialization['attribution'] as String,
aboutUrl: jsonSerialization['aboutUrl'] as String?,
);
}
/// What the app names as drawing the map, as `OpenFreeMap`.
String name;
/// The MapLibre (Mapbox GL) style the vector tiles are drawn by; a
/// provider's key may be in it, so only people signed in get it.
String styleUrl;
/// The credit the map shows, as the source's terms ask.
String attribution;
/// Where the source says what it keeps of those viewing its maps; null
/// where the operator names nothing.
String? aboutUrl;
/// Returns a shallow copy of this [MapSource]
/// with some or all fields replaced by the given arguments.
@_is.useResult
MapSource copyWith({
String? name,
String? styleUrl,
String? attribution,
String? aboutUrl,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'MapSource',
'name': name,
'styleUrl': styleUrl,
'attribution': attribution,
if (aboutUrl != null) 'aboutUrl': aboutUrl,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'MapSource',
'name': name,
'styleUrl': styleUrl,
'attribution': attribution,
if (aboutUrl != null) 'aboutUrl': aboutUrl,
};
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _Undefined {}
class _MapSourceImpl extends MapSource {
_MapSourceImpl({
required String name,
required String styleUrl,
required String attribution,
String? aboutUrl,
}) : super._(
name: name,
styleUrl: styleUrl,
attribution: attribution,
aboutUrl: aboutUrl,
);
/// Returns a shallow copy of this [MapSource]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
MapSource copyWith({
String? name,
String? styleUrl,
String? attribution,
Object? aboutUrl = _Undefined,
}) {
return MapSource(
name: name ?? this.name,
styleUrl: styleUrl ?? this.styleUrl,
attribution: attribution ?? this.attribution,
aboutUrl: aboutUrl is String? ? aboutUrl : this.aboutUrl,
);
}
}

View file

@ -21,9 +21,11 @@ import 'package:serverpod_auth_idp_server/serverpod_auth_idp_server.dart'
as _iais;
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart'
as _izehhkf5;
import 'map/map_source.dart' as _ikw0qg8x;
import 'privacy/content_key.dart' as _imed3x5d;
import 'workout/workout.dart' as _i5b9b17z;
import 'workout/workout_series.dart' as _i92mefto;
export 'map/map_source.dart';
export 'privacy/content_key.dart';
export 'workout/workout.dart';
export 'workout/workout_series.dart';
@ -297,6 +299,9 @@ class Protocol extends _is.DatabaseSerializationManager {
}
}
if (t == _ikw0qg8x.MapSource) {
return _ikw0qg8x.MapSource.fromJson(data) as T;
}
if (t == _imed3x5d.ContentKey) {
return _imed3x5d.ContentKey.fromJson(data) as T;
}
@ -306,6 +311,9 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _i92mefto.WorkoutSeries) {
return _i92mefto.WorkoutSeries.fromJson(data) as T;
}
if (t == _is.getType<_ikw0qg8x.MapSource?>()) {
return (data != null ? _ikw0qg8x.MapSource.fromJson(data) : null) as T;
}
if (t == _is.getType<_imed3x5d.ContentKey?>()) {
return (data != null ? _imed3x5d.ContentKey.fromJson(data) : null) as T;
}
@ -339,6 +347,7 @@ class Protocol extends _is.DatabaseSerializationManager {
static String? getClassNameForType(Type type) {
return switch (type) {
_ikw0qg8x.MapSource => 'MapSource',
_imed3x5d.ContentKey => 'ContentKey',
_i5b9b17z.Workout => 'Workout',
_i92mefto.WorkoutSeries => 'WorkoutSeries',
@ -359,6 +368,8 @@ class Protocol extends _is.DatabaseSerializationManager {
}
switch (data) {
case _ikw0qg8x.MapSource():
return 'MapSource';
case _imed3x5d.ContentKey():
return 'ContentKey';
case _i5b9b17z.Workout():
@ -405,6 +416,9 @@ class Protocol extends _is.DatabaseSerializationManager {
if (dataClassName is! String) {
return super.deserializeByClassName(data);
}
if (dataClassName == 'MapSource') {
return deserialize<_ikw0qg8x.MapSource>(data['data']);
}
if (dataClassName == 'ContentKey') {
return deserialize<_imed3x5d.ContentKey>(data['data']);
}

View file

@ -0,0 +1,2 @@
map:
- source:

View file

@ -0,0 +1,13 @@
import 'package:halkyon_train_server/src/generated/protocol.dart';
import 'package:halkyon_train_server/src/map/map_source_config.dart';
import 'package:serverpod/serverpod.dart';
/// The map workouts are drawn on (ADR 0030, D11, D22), for the people signed
/// in: an operator's provider may have its key in the style's address.
class MapEndpoint extends Endpoint {
@override
bool get requireLogin => true;
/// The instance's map source, as its operator sets it.
Future<MapSource> source(Session session) async => mapSource();
}

View file

@ -0,0 +1,16 @@
### The map this instance's workouts are drawn on (ADR 0030, D11, D22), as
### its operator sets it: a MapLibre style, its source's name and the credit
### it asks for, and where the source says what it sees. OpenFreeMap where
### nothing is set.
class: MapSource
fields:
### What the app names as drawing the map, as `OpenFreeMap`.
name: String
### The MapLibre (Mapbox GL) style the vector tiles are drawn by; a
### provider's key may be in it, so only people signed in get it.
styleUrl: String
### The credit the map shows, as the source's terms ask.
attribution: String
### Where the source says what it keeps of those viewing its maps; null
### where the operator names nothing.
aboutUrl: String?

View file

@ -0,0 +1,44 @@
import 'dart:io';
import 'package:halkyon_train_server/src/generated/protocol.dart';
/// The map source where the operator sets none (ADR 0030, D11): OpenFreeMap,
/// which takes no key and makes no contract, its Liberty style.
final openFreeMap = MapSource(
name: 'OpenFreeMap',
styleUrl: 'https://tiles.openfreemap.org/styles/liberty',
attribution: 'OpenFreeMap © OpenMapTiles Data from OpenStreetMap',
aboutUrl: 'https://openfreemap.org',
);
/// The instance's map source, as its operator's environment sets it.
MapSource mapSource() => mapSourceFrom(Platform.environment);
/// The map source [environment] sets: `MAP_STYLE_URL`, a MapLibre style over
/// HTTPS, with `MAP_SOURCE_NAME` (else the style's host), `MAP_ATTRIBUTION`
/// (else OpenStreetMap's credit) and `MAP_ABOUT_URL` (HTTPS, else none);
/// [openFreeMap] without a style, or with one that isn't an HTTPS address.
MapSource mapSourceFrom(Map<String, String> environment) {
final style = _https(environment['MAP_STYLE_URL']);
if (style == null) {
return openFreeMap;
}
String? set(String name) => switch (environment[name]?.trim()) {
final value? when value.isNotEmpty => value,
_ => null,
};
return MapSource(
name: set('MAP_SOURCE_NAME') ?? style.host,
styleUrl: style.toString(),
attribution: set('MAP_ATTRIBUTION') ?? '© OpenStreetMap contributors',
aboutUrl: _https(environment['MAP_ABOUT_URL'])?.toString(),
);
}
/// [value] as an HTTPS address with a host; null for anything else.
Uri? _https(String? value) {
final uri = Uri.tryParse(value?.trim() ?? '');
return uri != null && uri.isScheme('https') && uri.host.isNotEmpty
? uri
: null;
}

View file

@ -0,0 +1,36 @@
import 'package:serverpod/serverpod.dart';
import 'package:test/test.dart';
import '../test_tools/serverpod_test_tools.dart';
/// The map the app draws workouts on, as the instance names it (ADR 0030,
/// D11, D22): for the people signed in, as the provider's key may be in it.
void main() {
withServerpod('The map source', (sessionBuilder, endpoints) {
test('names OpenFreeMap to someone signed in, where the operator sets '
'nothing', () async {
final source = await endpoints.map.source(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
const Uuid().v7(),
{},
),
),
);
expect(source.name, 'OpenFreeMap');
expect(source.styleUrl, startsWith('https://tiles.openfreemap.org/'));
});
test('names nothing to nobody signed in', () async {
await expectLater(
endpoints.map.source(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
),
),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
});
});
}

View file

@ -11,9 +11,12 @@
// ignore_for_file: no_leading_underscores_for_local_identifiers
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _ida;
import 'dart:io' as _idi;
import 'package:halkyon_train_server/src/generated/future_calls.dart'
as _i59xasdy;
import 'package:halkyon_train_server/src/generated/map/map_source.dart'
as _ip8bmyd2;
import 'package:serverpod/serverpod.dart' as _is;
import 'package:serverpod_test/serverpod_test.dart' as _ist;
import 'package:halkyon_train_server/src/generated/protocol.dart';
@ -150,6 +153,8 @@ void withServerpod(
class TestEndpoints {
late final futureCalls = _FutureCalls();
late final _MapEndpoint map;
}
class _InternalTestEndpoints extends TestEndpoints
@ -158,13 +163,59 @@ class _InternalTestEndpoints extends TestEndpoints
void initialize(
_is.SerializationManager serializationManager,
_is.EndpointDispatch endpoints,
) {}
) {
map = _MapEndpoint(
endpoints,
serializationManager,
);
}
}
class _FutureCalls {
late final pruning = _PruningFutureCall();
}
class _MapEndpoint {
_MapEndpoint(
this._endpointDispatch,
this._serializationManager,
);
final _is.EndpointDispatch _endpointDispatch;
final _is.SerializationManager _serializationManager;
_ida.Future<_ip8bmyd2.MapSource> source(
_ist.TestSessionBuilder sessionBuilder,
) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'map',
method: 'source',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'map',
methodName: 'source',
parameters: _ist.testObjectToJson({}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_ip8bmyd2.MapSource>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
}
class _PruningFutureCall {
Future<void> pruneAccounts(_ist.TestSessionBuilder sessionBuilder) async {
var _localUniqueSession =

View file

@ -0,0 +1,68 @@
import 'package:halkyon_train_server/src/map/map_source_config.dart';
import 'package:test/test.dart';
/// The instance's map source as its operator sets it (ADR 0030, D11, D22).
void main() {
test('without a setting it is OpenFreeMap, named, with its attribution', () {
final source = mapSourceFrom(const {});
expect(source.name, 'OpenFreeMap');
expect(source.styleUrl, 'https://tiles.openfreemap.org/styles/liberty');
expect(source.attribution, contains('OpenStreetMap'));
expect(source.aboutUrl, 'https://openfreemap.org');
});
test("an operator's provider is named as they set it", () {
final source = mapSourceFrom(const {
'MAP_STYLE_URL':
'https://api.maptiler.com/maps/outdoor-v2/style.json?key=k',
'MAP_SOURCE_NAME': 'MapTiler',
'MAP_ATTRIBUTION': '© MapTiler © OpenStreetMap contributors',
'MAP_ABOUT_URL': 'https://www.maptiler.com/privacy-policy/',
});
expect(source.name, 'MapTiler');
expect(
source.styleUrl,
'https://api.maptiler.com/maps/outdoor-v2/style.json?key=k',
);
expect(source.attribution, '© MapTiler © OpenStreetMap contributors');
expect(source.aboutUrl, 'https://www.maptiler.com/privacy-policy/');
});
test('a provider without a name is named by its host, and credits '
'OpenStreetMap', () {
final source = mapSourceFrom(const {
'MAP_STYLE_URL': 'https://maps.example.org/style.json',
});
expect(source.name, 'maps.example.org');
expect(source.attribution, '© OpenStreetMap contributors');
expect(source.aboutUrl, isNull);
});
test("a style that isn't HTTPS, or isn't an address, is never used: the "
'map stays OpenFreeMap', () {
for (final url in [
'http://maps.example.org/style.json',
'maps.example.org/style.json',
'https://',
' ',
]) {
expect(
mapSourceFrom({'MAP_STYLE_URL': url}).name,
'OpenFreeMap',
reason: url,
);
}
});
test("an about page that isn't HTTPS is left out", () {
final source = mapSourceFrom(const {
'MAP_STYLE_URL': 'https://maps.example.org/style.json',
'MAP_ABOUT_URL': 'javascript:alert(1)',
});
expect(source.aboutUrl, isNull);
});
}

View file

@ -1,7 +1,7 @@
# ADR 0030: Halkyon Train, what it does and where its data comes from
- **Status:** decided 27–28 Oct 2026, by the developer (D1–D21 below). The shell (D12)
is built; recording (D13–D21) is being built.
- **Status:** decided 27–28 Oct 2026, by the developer (D1–D22 below). The shell (D12)
is built; recording (D13–D22) is being built.
- **Numbering:** 0025 is held by the federation draft (stashed), 0026–0029 by the .NET
rewrite on the local branch `rewrite-dotnet`, abandoned on 27 Oct; Train takes the next
free number so that neither is ever read as this one.
@ -337,6 +337,16 @@ Strava. What was found, 27 Oct:
than in a snack bar, which would cover the button it speaks of. The service holds a
partial wake lock while it records (at most 12 hours), so that its clock and file run
with the screen off where no GPS fix wakes the processor.
- **D22 OpenFreeMap first, the PMTiles sidecar with route planning** (recommended, over
the sidecar now, which no one else ever sees the areas viewed through but needs Train's
server and the sidecar deployed on learn.halkyon.eu before anything else of Train is
ready, and a region chosen, gigabytes on the server's disk; and over a source fixed in
the app, which skips D11's operator's setting and would be undone): Train's server names
the instance's map source, as its operator sets it, OpenFreeMap where nothing is set
(D11), so 5c needs no deploy; a workout's page names the source drawing it and what it
sees (the device's address and the area viewed, not the track). The sidecar comes when
route planning gives Train's server a reason to run on the instance: the region's
PMTiles file served by Caddy with range requests, no server code.
## Consequences