feat(train): the person's workouts, sealed and synced (ADR 0030, D14)

WorkoutRepository keeps a workout with its series, sealed with the
person's key: a workout for its row, a series chunk for its row and its
workout, so the instance can neither read them nor move a chunk to
another workout. A readable or changed row shows locked, never as the
person's; a device without the account's keys saves nothing. On the
device's database a workout and its series are written in one
transaction, and a deleted one is blanked before it goes, so the sync
engine's tombstones hold nothing.

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 11:24:20 +01:00
commit d1daec9c0b
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
8 changed files with 801 additions and 0 deletions

View file

@ -0,0 +1,40 @@
import 'dart:typed_data';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' as protocol;
import 'package:halkyon_train_flutter/src/core/privacy/content_key_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// [ContentKeyGateway] on the person's local database, which the sync engine
/// keeps in step with their other devices.
class SyncContentKeyGateway(
final OfflineSyncDatabaseSession _db,
final SyncService _sync,
) implements ContentKeyGateway {
@override
Future<List<ContentKeyRow>> all() async => [
for (final key in await protocol.ContentKey.db.find(
_db,
orderBy: (t) => t.id,
))
(
dataKeyId: key.dataKeyId,
sealedKey: Uint8List.sublistView(key.sealedKey),
),
];
@override
Future<void> insert({
required String dataKeyId,
required Uint8List sealedKey,
}) async {
await protocol.ContentKey.db.insertRow(
_db,
protocol.ContentKey(
dataKeyId: dataKeyId,
sealedKey: ByteData.sublistView(sealedKey),
),
);
_sync.requestSync();
}
}

View file

@ -0,0 +1,164 @@
import 'dart:typed_data';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' as protocol;
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// [WorkoutGateway] on the person's local database, which the sync engine
/// keeps in step with their other devices.
class SyncWorkoutGateway(
final OfflineSyncDatabaseSession _db,
final SyncService _sync,
) implements WorkoutGateway {
@override
Future<List<WorkoutRow>> workouts() async => [
for (final workout in await protocol.Workout.db.find(
_db,
orderBy: (t) => t.id,
))
(
id: workout.id!.uuid,
envelope: (
keyId: workout.keyId,
nonce: workout.nonce,
payload: workout.payload,
),
),
];
@override
Future<List<SeriesRow>> seriesOf(String workoutId) async {
final protocol.UuidValue uuid;
try {
uuid = protocol.UuidValue.withValidation(workoutId);
} on FormatException {
// A route can name anything; what is no id names no workout.
return const [];
}
return [
for (final series in await protocol.WorkoutSeries.db.find(
_db,
where: (t) => t.workoutId.equals(uuid),
orderBy: (t) => t.id,
))
(
id: series.id!.uuid,
workoutId: workoutId,
envelope: (
keyId: series.keyId,
nonce: series.nonce,
payload: series.payload,
),
),
];
}
@override
Future<void> insert(WorkoutRow workout, List<SeriesRow> series) async {
await _db.db.transaction((transaction) async {
await protocol.Workout.db.insertRow(
_db,
protocol.Workout(
id: protocol.UuidValue.withValidation(workout.id),
keyId: workout.envelope.keyId,
nonce: workout.envelope.nonce,
payload: workout.envelope.payload,
),
transaction: transaction,
);
if (series.isNotEmpty) {
await protocol.WorkoutSeries.db.insert(_db, [
for (final row in series)
protocol.WorkoutSeries(
id: protocol.UuidValue.withValidation(row.id),
workoutId: protocol.UuidValue.withValidation(row.workoutId),
keyId: row.envelope.keyId,
nonce: row.envelope.nonce,
payload: row.envelope.payload,
),
], transaction: transaction);
}
});
_sync.requestSync();
}
@override
Future<void> update(String id, Envelope envelope) async {
if (await protocol.Workout.db.findById(
_db,
protocol.UuidValue.withValidation(id),
)
case final workout?) {
// Only the named columns change (ADR 0001, rule 1).
await protocol.Workout.db.updateRow(
_db,
workout.copyWith(
keyId: envelope.keyId,
nonce: envelope.nonce,
payload: envelope.payload,
),
columns: (t) => [t.keyId, t.nonce, t.payload],
);
_sync.requestSync();
}
}
@override
Future<void> delete(String id) async {
final uuid = protocol.UuidValue.withValidation(id);
await _db.db.transaction((transaction) async {
// Blanked first (ADR 0002, rule 2): the instance keeps deleted rows as
// they last were, until the space goes.
final series = await protocol.WorkoutSeries.db.find(
_db,
where: (t) => t.workoutId.equals(uuid),
transaction: transaction,
);
for (final row in series) {
await protocol.WorkoutSeries.db.updateRow(
_db,
protocol.WorkoutSeries(
id: row.id,
spaceId: row.spaceId,
workoutId: row.workoutId,
payload: ByteData(0),
),
columns: (t) => [t.keyId, t.nonce, t.payload],
transaction: transaction,
);
}
if (await protocol.Workout.db.findById(
_db,
uuid,
transaction: transaction,
)
case final workout?) {
await protocol.Workout.db.updateRow(
_db,
protocol.Workout(
id: workout.id,
spaceId: workout.spaceId,
payload: ByteData(0),
),
columns: (t) => [t.keyId, t.nonce, t.payload],
transaction: transaction,
);
}
await protocol.WorkoutSeries.db.deleteWhere(
_db,
where: (t) => t.workoutId.equals(uuid),
transaction: transaction,
);
await protocol.Workout.db.deleteWhere(
_db,
where: (t) => t.id.equals(uuid),
transaction: transaction,
);
});
_sync.requestSync();
}
@override
Stream<void> get remoteChanges => _sync.merges;
}

View file

@ -0,0 +1,31 @@
import 'package:halkyon_app/halkyon_app.dart';
/// A `workout` row as kept: its id and its envelope, sealed (ADR 0030, D7).
typedef WorkoutRow = ({String id, Envelope envelope});
/// A `workout_series` row as kept: its id, its workout's and its envelope.
typedef SeriesRow = ({String id, String workoutId, Envelope envelope});
/// Where the person's workouts are kept: their synced `workout` and
/// `workout_series` rows. The repository seals and opens; this only keeps.
abstract interface class WorkoutGateway() {
/// Every workout row.
Future<List<WorkoutRow>> workouts();
/// The series rows of the workout [workoutId].
Future<List<SeriesRow>> seriesOf(String workoutId);
/// Keeps a new workout with its series, together or not at all.
Future<void> insert(WorkoutRow workout, List<SeriesRow> series);
/// Replaces the workout [id]'s envelope (an edit); nothing for one gone.
Future<void> update(String id, Envelope envelope);
/// Deletes the workout [id] with its series, every payload blanked first
/// (ADR 0002, rule 2): the sync engine keeps deleted rows as they last
/// were.
Future<void> delete(String id);
/// Fires when a sync brought changes from the person's other devices.
Stream<void> get remoteChanges;
}

View file

@ -0,0 +1,159 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' show Uuid;
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/series_payload.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_payload.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/stored_workout.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// The person's workouts (ADR 0030, D14), sealed with their key on the way to
/// the instance and opened on the way back (D7): a workout for its row, a
/// series chunk for its row and its workout, so the instance can neither
/// read them nor move a chunk to another workout. Nothing readable is ever
/// taken as the person's: Train writes nothing unsealed, so a readable row
/// is one the instance made up, and shows locked.
class WorkoutRepository(
final WorkoutGateway _gateway,
final TrainKeys _keys,
) {
StreamSubscription<void>? _remote;
/// This device's changes, and those a sync brings while anyone listens.
late final StreamController<void> _changes = StreamController.broadcast(
onListen: () => _remote = _gateway.remoteChanges.listen(_changes.add),
onCancel: () => _remote?.cancel(),
);
late final _envelope = PayloadEnvelope(_keys.cipher);
/// Fires when the workouts changed, here or on the person's other devices.
Stream<void> get changes => _changes.stream;
/// Every workout, in the order they were made; one that doesn't open here
/// with its content null.
Future<List<StoredWorkout>> all() async {
// Keys may have come with a sync.
await _keys.refresh();
final rows = await _gateway.workouts()
..sort((a, b) => a.id.compareTo(b.id));
return [
for (final row in rows)
StoredWorkout(
row.id,
switch (_open(row.envelope, table: 'workout', id: row.id)) {
final payload? => decodeWorkout(payload),
null => null,
},
),
];
}
/// The series of the workout [workoutId] that open here, in time order.
Future<List<SeriesChunk>> series(String workoutId) async {
await _keys.refresh();
return [
for (final row in await _gateway.seriesOf(workoutId))
if (_open(
row.envelope,
table: 'workout_series',
id: row.id,
bound: 'workout:$workoutId',
)
case final payload?)
?decodeSeries(payload),
]..sort((a, b) => a.start.compareTo(b.start));
}
/// Keeps a new workout with its [series] and returns its id; throws
/// [KeysMissing] where this device can't seal.
Future<String> save(WorkoutContent content, List<SeriesChunk> series) async {
final keyId = await _keys.keyToWrite();
final id = const Uuid().v7();
await _gateway.insert(
(
id: id,
envelope: _envelope.seal(
encodeWorkout(content),
keyId: keyId,
table: 'workout',
id: id,
),
),
[
for (final chunk in series)
_sealedSeries(const Uuid().v7(), id, chunk, keyId),
],
);
_changes.add(null);
return id;
}
/// Replaces what the workout [id] is (its name, notes, totals); its series
/// stay as they are.
Future<void> update(String id, WorkoutContent content) async {
final keyId = await _keys.keyToWrite();
await _gateway.update(
id,
_envelope.seal(
encodeWorkout(content),
keyId: keyId,
table: 'workout',
id: id,
),
);
_changes.add(null);
}
/// Deletes the workout [id] with its series.
Future<void> delete(String id) async {
await _gateway.delete(id);
_changes.add(null);
}
/// Closes what it holds open.
Future<void> dispose() => _changes.close();
SeriesRow _sealedSeries(
String id,
String workoutId,
SeriesChunk chunk,
String keyId,
) => (
id: id,
workoutId: workoutId,
envelope: _envelope.seal(
encodeSeries(chunk),
keyId: keyId,
table: 'workout_series',
id: id,
bound: 'workout:$workoutId',
),
);
/// The payload of [envelope], opened where it is sealed and opens for
/// where it is kept; null otherwise, a readable one included.
ByteData? _open(
Envelope envelope, {
required String table,
required String id,
String bound = '',
}) {
if (envelope.keyId == null) {
return null;
}
return switch (_envelope.open(
envelope,
table: table,
id: id,
bound: bound,
)) {
Readable(:final payload) => payload,
Locked() => null,
};
}
}

View file

@ -0,0 +1,13 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// A workout as the person's devices keep it: its id and what it is, or
/// [content] null where it doesn't open here (sealed under a key this device
/// lacks, changed, or moved by the instance): shown as locked, never half.
final class const StoredWorkout(
final String id,
final WorkoutContent? content,
) with Equatable {
@override
List<Object?> get props => [id, content];
}

View file

@ -0,0 +1,140 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' as protocol;
import 'package:halkyon_train_flutter/src/core/privacy/sync_content_key_gateway.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/sync_workout_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/stored_workout.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
// The workouts on the device's own database, as the sync engine keeps them,
// with libsodium as its build hook compiles it.
final _person = protocol.UuidValue.withValidation(
'01a16000-0000-7000-8000-000000000001',
);
void main() {
final crypto = SodiumAccountCrypto();
late UnlockedAccountKeys alice;
late Directory folder;
late OfflineSyncDatabaseSession db;
late SyncService sync;
late TrainKeys keys;
late WorkoutRepository workouts;
final start = DateTime.utc(2026, 10, 28, 7, 30);
final ride = WorkoutContent(
activity: Activity.cycling,
startedAt: start,
endedAt: start.add(const Duration(minutes: 30)),
);
final positions = chunksOf(
SeriesKind.position,
start: start,
times: [for (var s = 0; s < 30 * 60; s++) s * 1000],
columns: {
'lat': [for (var s = 0; s < 30 * 60; s++) 525200000 + s * 40],
'lon': [for (var s = 0; s < 30 * 60; s++) 134050000 + s * 25],
},
);
setUpAll(() async {
alice = (await crypto.newAccount('alice password')).keys;
});
setUp(() async {
folder = await Directory.systemTemp.createTemp('workouts');
db = await protocol.Client(
'http://127.0.0.1:9/',
).createSyncSession('${folder.path}/db.sqlite', persistentUserId: _person);
sync = SyncService(
({required onMerge}) async {},
debounce: const Duration(days: 1),
);
keys = TrainKeys(
SyncContentKeyGateway(db, sync),
SodiumContentCipher(),
() async => alice,
);
await keys.refresh();
workouts = WorkoutRepository(SyncWorkoutGateway(db, sync), keys);
});
tearDown(() async {
await workouts.dispose();
keys.dispose();
await sync.dispose();
await db.close();
await folder.delete(recursive: true);
});
test('a workout and its series are kept, and read back opened', () async {
final id = await workouts.save(ride, positions);
expect(await workouts.all(), [StoredWorkout(id, ride)]);
expect(await workouts.series(id), positions);
expect(positions, hasLength(3));
});
test("its key is kept too, so the person's other devices open it", () async {
final id = await workouts.save(ride, positions);
final laptop = TrainKeys(
SyncContentKeyGateway(db, sync),
SodiumContentCipher(),
() async => alice,
);
addTearDown(laptop.dispose);
await laptop.refresh();
final elsewhere = WorkoutRepository(SyncWorkoutGateway(db, sync), laptop);
addTearDown(elsewhere.dispose);
expect(await elsewhere.all(), [StoredWorkout(id, ride)]);
});
test('an edit changes the workout alone', () async {
final id = await workouts.save(ride, positions);
final named = WorkoutContent(
activity: ride.activity,
startedAt: ride.startedAt,
endedAt: ride.endedAt,
name: 'To the lake',
);
await workouts.update(id, named);
expect(await workouts.all(), [StoredWorkout(id, named)]);
expect(await workouts.series(id), positions);
});
test('deleting a workout deletes it with its series, every payload '
'blanked first', () async {
final id = await workouts.save(ride, positions);
final kept = await workouts.save(ride, positions.take(1).toList());
await workouts.delete(id);
expect(await workouts.all(), [StoredWorkout(kept, ride)]);
expect(await workouts.series(id), isEmpty);
// The database keeps deleted rows as the sync engine's tombstones, as
// the instance does: past the engine, they hold nothing but blanks.
for (final (table, live) in [('workout', 1), ('workout_series', 1)]) {
final rows = await db.db.unsafeQuery(
'SELECT length("payload"), "keyId", "nonce" FROM "$table"',
);
final blank = [
for (final row in rows)
if (row[0] == 0) row,
];
expect(rows.length - blank.length, live, reason: table);
expect(blank, hasLength(table == 'workout' ? 1 : 3), reason: table);
expect(blank.every((row) => row[1] == null && row[2] == null), isTrue);
}
});
}

View file

@ -0,0 +1,196 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/stored_workout.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_workout_gateway.dart';
/// The person's workouts, sealed on the way to the instance and opened on
/// the way back (ADR 0030, D7 and D14), with libsodium as its build hook
/// compiles it.
void main() {
final crypto = SodiumAccountCrypto();
late UnlockedAccountKeys alice;
late MemoryContentKeyGateway keyRows;
late MemoryWorkoutGateway rows;
final opened = <TrainKeys>[];
final start = DateTime.utc(2026, 10, 28, 7, 30);
final run = WorkoutContent(
activity: Activity.running,
startedAt: start,
endedAt: start.add(const Duration(minutes: 25)),
name: 'Along the river',
distanceMetres: 5100,
);
final heartRate = chunksOf(
SeriesKind.heartRate,
start: start,
times: [for (var s = 0; s < 25 * 60; s += 10) s * 1000],
columns: {
'bpm': [for (var s = 0; s < 25 * 60; s += 10) 110 + s % 50],
},
);
/// A device of the account whose keys are [account] (none: signed in
/// without them), on the shared rows.
Future<WorkoutRepository> device(UnlockedAccountKeys? account) async {
final keys = TrainKeys(keyRows, SodiumContentCipher(), () async => account);
opened.add(keys);
await keys.refresh();
return WorkoutRepository(rows, keys);
}
setUpAll(() async {
alice = (await crypto.newAccount('alice password')).keys;
});
setUp(() {
keyRows = MemoryContentKeyGateway();
rows = MemoryWorkoutGateway();
});
tearDown(() {
for (final keys in opened) {
keys.dispose();
}
opened.clear();
});
test('a workout saved on one device reads on another of the account, its '
'series in time order', () async {
final phone = await device(alice);
final id = await phone.save(run, heartRate.reversed.toList());
final laptop = await device(alice);
expect(await laptop.all(), [StoredWorkout(id, run)]);
expect(await laptop.series(id), heartRate);
});
test('nothing the instance gets is readable: every row is sealed, no '
'name, no JSON', () async {
final phone = await device(alice);
await phone.save(run, heartRate);
expect(rows.written, hasLength(1 + heartRate.length));
for (final envelope in rows.written) {
expect(envelope.keyId, startsWith('train1.'));
expect(envelope.nonce, isNotNull);
final bytes = Uint8List.sublistView(envelope.payload);
expect(
utf8.decode(bytes, allowMalformed: true),
isNot(contains('river')),
);
expect(bytes.take(2), isNot([0x1f, 0x8b]));
}
});
test('an edit replaces the workout and keeps its series', () async {
final phone = await device(alice);
final id = await phone.save(run, heartRate);
final renamed = WorkoutContent(
activity: run.activity,
startedAt: run.startedAt,
endedAt: run.endedAt,
name: 'Morning run',
distanceMetres: run.distanceMetres,
);
await phone.update(id, renamed);
expect(await phone.all(), [StoredWorkout(id, renamed)]);
expect(await phone.series(id), heartRate);
});
test("a device without the account's keys sees workouts locked and saves "
'none', () async {
final phone = await device(alice);
final id = await phone.save(run, heartRate);
final writes = rows.written.length;
final elsewhere = await device(null);
expect(await elsewhere.all(), [StoredWorkout(id, null)]);
expect(await elsewhere.series(id), isEmpty);
await expectLater(
elsewhere.save(run, heartRate),
throwsA(isA<KeysMissing>()),
);
expect(rows.written, hasLength(writes));
});
test(
"a series the instance moves to another workout doesn't open there",
() async {
final phone = await device(alice);
final first = await phone.save(run, heartRate.take(1).toList());
final second = await phone.save(run, heartRate.skip(1).take(1).toList());
final moved = rows.seriesRows.firstWhere((row) => row.workoutId == first);
rows.seriesRows
..remove(moved)
..add((id: moved.id, workoutId: second, envelope: moved.envelope));
expect(await phone.series(first), isEmpty);
expect(await phone.series(second), [heartRate[1]]);
},
);
test('a workout the instance made up, readable, or one it changed, is '
"locked, never the person's", () async {
final phone = await device(alice);
final id = await phone.save(run, heartRate);
final sealed = rows.workoutRows[id]!;
final changed = Uint8List.fromList(Uint8List.sublistView(sealed.payload));
changed[changed.length - 1] ^= 1;
rows.workoutRows[id] = (
keyId: sealed.keyId,
nonce: sealed.nonce,
payload: ByteData.sublistView(changed),
);
rows.workoutRows['made-up'] = (
keyId: null,
nonce: null,
payload: ByteData.sublistView(
Uint8List.fromList(
utf8.encode(
'{"schemaVersion":1,"activity":"running","startedAt":0,'
'"endedAt":0}',
),
),
),
);
expect(await phone.all(), [
StoredWorkout(id, null),
const StoredWorkout('made-up', null),
]);
});
test('deleting a workout deletes its series', () async {
final phone = await device(alice);
final id = await phone.save(run, heartRate);
final kept = await phone.save(run, heartRate.take(1).toList());
await phone.delete(id);
expect(await phone.all(), [StoredWorkout(kept, run)]);
expect(rows.seriesRows.map((row) => row.workoutId), [kept]);
});
test('changes from other devices are announced', () async {
final phone = await device(alice);
final announced = expectLater(phone.changes, emits(null));
rows.bringRemoteChanges();
await announced;
});
}

View file

@ -0,0 +1,58 @@
import 'dart:async';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_gateway.dart';
/// [WorkoutGateway] in memory, whose rows a test changes as an instance
/// could; it records the envelopes written, so a test sees what reached it.
/// Blanking before a delete is the sync gateway's.
class MemoryWorkoutGateway() implements WorkoutGateway {
final workoutRows = <String, Envelope>{};
final seriesRows = <SeriesRow>[];
/// Every envelope ever written, in order.
final written = <Envelope>[];
final _remote = StreamController<void>.broadcast();
/// As a sync bringing others' changes.
void bringRemoteChanges() => _remote.add(null);
@override
Future<List<WorkoutRow>> workouts() async => [
for (final MapEntry(:key, :value) in workoutRows.entries)
(id: key, envelope: value),
];
@override
Future<List<SeriesRow>> seriesOf(String workoutId) async => [
for (final row in seriesRows)
if (row.workoutId == workoutId) row,
];
@override
Future<void> insert(WorkoutRow workout, List<SeriesRow> series) async {
workoutRows[workout.id] = workout.envelope;
seriesRows.addAll(series);
written
..add(workout.envelope)
..addAll(series.map((row) => row.envelope));
}
@override
Future<void> update(String id, Envelope envelope) async {
if (workoutRows.containsKey(id)) {
workoutRows[id] = envelope;
written.add(envelope);
}
}
@override
Future<void> delete(String id) async {
seriesRows.removeWhere((row) => row.workoutId == id);
workoutRows.remove(id);
}
@override
Stream<void> get remoteChanges => _remote.stream;
}