feat(train): the sync engine and Train's first models (ADR 0030, D14)

Train's server syncs with serverpod_offline_sync as Learn's does, and
holds three synced tables, every payload sealed on the devices:
content_key (the person's data key, sealed to the account), workout (its
type, times, name and totals, changed when edited) and workout_series
(chunks of a workout's samples, written once). A person's space goes
with them, with everything in it.

The migration only adds: the three tables, each indexed on spaceId and
cascading with its space (a series also with its workout), and the sync
engine's own tables.

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:13:04 +01:00
commit 7b20851a0a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -0,0 +1,125 @@
import 'dart:typed_data';
import 'package:halkyon_train_server/src/generated/protocol.dart';
import 'package:halkyon_train_server/src/train_base.dart';
import 'package:halkyon_train_server/src/train_data.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
import 'package:test/test.dart';
import '../test_tools/serverpod_test_tools.dart';
void main() {
withServerpod(
"A person's workouts",
(sessionBuilder, endpoints) {
late Session session;
late UuidValue alice;
late UuidValue bob;
setUp(() {
// The test pod is built without the generated Serverpod class, which
// sets up sync for the running server.
Serverpod.instance.initializeOfflineSync(syncTables: syncTables);
session = sessionBuilder.build();
alice = const Uuid().v7obj();
bob = const Uuid().v7obj();
});
tearDown(() async {
for (final space in [alice, bob]) {
await session.db.unsafeExecute(
'DELETE FROM "offline_sync_spaces" WHERE "uuidSpaceId" = @space',
parameters: QueryParameters.named({'space': space.uuid}),
);
}
});
/// How many rows of [table] are in [person]'s personal space, counted
/// past the sync engine, as the operator would.
Future<int> rowsOf(UuidValue person, String table) async {
final result = await session.db.unsafeQuery(
'SELECT count(*) FROM "$table" t '
'JOIN "offline_sync_spaces" s ON s."id" = t."spaceId" '
'WHERE s."uuidSpaceId" = @space',
parameters: QueryParameters.named({'space': person.uuid}),
);
return result.first.first! as int;
}
test("Train deletes a person's data with them (ADR 0014's hook)", () {
expect(trainBase().deletePersonData, contains(deleteTrainData));
});
test('deleting a person deletes their space with every key, workout and '
"series in it, and nobody else's", () async {
for (final person in [alice, bob]) {
await _workoutOf(session, person);
}
for (final table in ['content_key', 'workout', 'workout_series']) {
expect(await rowsOf(alice, table), 1, reason: table);
}
await session.db.transaction(
(transaction) => deleteTrainData(session, alice, transaction),
);
// SELECT count(*) FROM "offline_sync_spaces" WHERE "uuidSpaceId" = $1
expect(
await OfflineSyncSpace.db.count(
session,
where: (t) => t.uuidSpaceId.equals(alice),
),
0,
);
for (final table in ['content_key', 'workout', 'workout_series']) {
expect(await rowsOf(alice, table), 0, reason: table);
expect(await rowsOf(bob, table), 1, reason: table);
}
});
},
// The sync engine's database, as the running server has it. Writing
// through it takes transactions of its own, outside the one a test would
// be rolled back in, so the tests delete what they made instead.
databaseInterceptor: (session, inner) {
final database = offlineSyncDatabaseInterceptor(session, inner);
if (database is OfflineSyncDatabase) {
_syncDatabases[session] = database;
}
return database;
},
rollbackDatabase: RollbackDatabase.disabled,
);
}
/// The sync engine's database of each session, as the test pod's own
/// database wraps it.
final _syncDatabases = Expando<OfflineSyncDatabase>();
/// A key, a workout and a series of it in [person]'s personal space, written
/// as their device would through the sync engine (payloads stand in for
/// sealed ones: the server never reads them).
Future<void> _workoutOf(Session session, UuidValue person) =>
_syncDatabases[session]!.transactionForUser(person, (transaction) async {
final sealed = ByteData.sublistView(Uint8List.fromList([1, 2, 3]));
await ContentKey.db.insertRow(
session,
ContentKey(dataKeyId: 'p1.0.key', sealedKey: sealed),
transaction: transaction,
);
final workout = await Workout.db.insertRow(
session,
Workout(keyId: 'p1.0.key', payload: sealed, nonce: sealed),
transaction: transaction,
);
await WorkoutSeries.db.insertRow(
session,
WorkoutSeries(
workoutId: workout.id!,
keyId: 'p1.0.key',
payload: sealed,
nonce: sealed,
),
transaction: transaction,
);
});