feat(train): the Loop's days read on the phone (ADR 0030, D37, D38)

The server gains Train's synced `day`, `day_series` (a day's heart
rate, deleted with its day) and `night`, each a sealed payload alone;
the migration `days` only adds their tables, indexes and foreign keys
(to their sync space, and a chunk's to its day, deferred), and the
person's deletion takes them with their spaces.

On Android, `LoopDays.kt` reads a band's days from a date to today,
framed by the SDK's sync start and stop, into lines of JSON, then
deletes on the band what the app kept up to the date it names, never
today; `LoopBridge` answers `readDays` with them, one band call at a
time, and `background` schedules `LoopWorker`, a WorkManager job every
four hours that appends the same lines to the person's
`loop_days.txt` while the app doesn't hold the band.

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 19:53:04 +01:00
commit 88e8693025
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -51,42 +51,51 @@ void main() {
expect(trainBase().deletePersonData, contains(deleteTrainData));
});
test('deleting a person deletes their space with every key, workout, '
"series and profile 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',
'profile',
]) {
expect(await rowsOf(alice, table), 1, reason: table);
}
test(
'deleting a person deletes their space with every key, workout, '
"series, profile, day, day's series and night 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',
'profile',
'day',
'day_series',
'night',
]) {
expect(await rowsOf(alice, table), 1, reason: table);
}
await session.db.transaction(
(transaction) => deleteTrainData(session, alice, transaction),
);
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',
'profile',
]) {
expect(await rowsOf(alice, table), 0, reason: table);
expect(await rowsOf(bob, table), 1, reason: table);
}
});
// 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',
'profile',
'day',
'day_series',
'night',
]) {
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
@ -106,9 +115,10 @@ void main() {
/// database wraps it.
final _syncDatabases = Expando<OfflineSyncDatabase>();
/// A key, a workout, a series of it and a profile 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).
/// A key, a workout, a series of it, a profile, a day, a series of it and a
/// night 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]));
@ -137,4 +147,24 @@ Future<void> _workoutOf(Session session, UuidValue person) =>
Profile(keyId: 'p1.0.key', payload: sealed, nonce: sealed),
transaction: transaction,
);
final day = await Day.db.insertRow(
session,
Day(keyId: 'p1.0.key', payload: sealed, nonce: sealed),
transaction: transaction,
);
await DaySeries.db.insertRow(
session,
DaySeries(
dayId: day.id!,
keyId: 'p1.0.key',
payload: sealed,
nonce: sealed,
),
transaction: transaction,
);
await Night.db.insertRow(
session,
Night(keyId: 'p1.0.key', payload: sealed, nonce: sealed),
transaction: transaction,
);
});