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:
parent
61fc5c5f7c
commit
d1daec9c0b
5 changed files with 801 additions and 0 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
Loading…
Reference in a new issue