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
5 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];
}