Two copies for two jobs. rippr-src/ is a browsable git archive export of the tracked tree at 2f76983 - no build outputs, no local.properties, no nested .git - which is convenient to read in gitea but carries no history and will drift. rippr-full-history.bundle is the real backup: all 18 commits, verified as "records a complete history" and test-cloned before committing. This matters because ~/dojo/rippr has no git remote and otherwise exists only on one machine. rippr-src/SNAPSHOT.md explains the difference and how to restore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5.4 KiB
T02 — Schema v2 entities + DAOs
Phase 1 · Depends on T01 · Status Done
Goal
Replace the flat single-table schema with Trip → Segment → TrackPoint, split the
monolithic TrackPoint.kt into a data/ package, and add the queries the v2 screens
need. Done when the new schema builds, exports to app/schemas/com.rippr.data.AppDatabase/2.json, and the
instrumented DAO tests pass.
Context
app/src/main/java/com/rippr/TrackPoint.kt (113 lines) currently holds the entity, the
RideStats projection, the DAO, and the AppDatabase all in one file. That was fine
for one table and is not fine for three.
Existing details that matter:
AppDatabaseusesJournalMode.WRITE_AHEAD_LOGGING— deliberate, keep it. WAL with NORMAL sync survives app crashes without fsyncing every 2 Hz write.exportSchema = true, andapp/schemas/com.rippr.AppDatabase/1.jsonexists.observeStats()returns a single-row aggregate withCOALESCEguards so an empty table does not null-crash the non-nullRideStatsfields. That COALESCE lesson carries over — the same trap applies to per-trip aggregates.- The
syncedcolumn drives the upload backlog; it stays.
Design
enum class TripState { RECORDING, PAUSED, COMPLETED }
@Entity(tableName = "trips")
data class Trip(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val startedAt: Long,
val endedAt: Long? = null, // null ⇒ active
val name: String? = null, // null ⇒ UI derives a name from startedAt
val state: TripState = TripState.RECORDING,
val distanceM: Double = 0.0,
val movingMillis: Long = 0,
val maxSpeedKmh: Float = 0f,
val elevationGainM: Double = 0.0,
val pointCount: Int = 0,
)
@Entity(
tableName = "segments",
foreignKeys = [ForeignKey(Trip::class, ["id"], ["tripId"], onDelete = CASCADE)],
indices = [Index("tripId")],
)
data class Segment(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val tripId: Long,
val startedAt: Long,
val endedAt: Long? = null, // null ⇒ open
)
TrackPoint gains tripId and segmentId, both with CASCADE foreign keys and indices.
Deleting a trip therefore removes its segments and points in one statement — which is
exactly what Discard (T06) and Delete (T12) need.
Why state as well as endedAt: endedAt == null distinguishes active from
finished, but cannot distinguish RECORDING from PAUSED. Both are needed — the service
resumes differently depending on which it finds after a process restart.
Destructive migration. v1 data is intentionally discarded, so bump to version 2 with
fallbackToDestructiveMigration(). No Migration object, no MigrationTestHelper.
⚠ This is correct only while there is no ride data worth keeping. Once v2 is in daily use, a future schema change would silently wipe real rides. T18 removes it.
New queries
| Query | Used by |
|---|---|
observeActiveTrip(): Flow<Trip?> — WHERE endedAt IS NULL |
T03, T09 |
observeCompletedTrips(): Flow<List<Trip>> — ordered startedAt DESC |
T10 |
observeTrip(id): Flow<Trip?> |
T11 |
pointsForTrip(id): List<TrackPoint> — ordered segmentId, id |
T11, T14, T15 |
segmentsForTrip(id): List<Segment> |
T14, T15 |
openSegment(tripId): Segment? |
T06 |
deleteTrip(id) — CASCADE handles the rest |
T06, T12 |
Ordering by segmentId, id rather than timestamp matters: timestamp is GPS time and
can jump, whereas the autoincrement id is genuinely monotonic in write order.
Implementation
- Create
data/package; move and split the existing file. - Add
Trip,Segment,TripState(+ a RoomTypeConverterfor the enum, or store itsnameasString). - Add
tripId/segmentIdtoTrackPointwith FKs and indices. - Split DAOs:
TripDao,SegmentDao,TrackPointDao. - Bump
AppDatabasetoversion = 2, add all three entities, addfallbackToDestructiveMigration()with a comment pointing at T18. - Verify CASCADE actually fires in a test rather than assuming — Room does enable foreign keys for its generated code, but that is worth proving, not trusting.
- Update
TrackPointDaoTest.ktfor the new shape; add trip/segment tests.
Acceptance criteria
app/schemas/com.rippr.data.AppDatabase/2.jsongenerated (path follows the package, which moved tocom.rippr.data; the orphaned v1 dir was removed)- Deleting a trip cascades to its segments and points
observeActiveTrip()emits null on an empty database, not a crash- Per-trip aggregate queries return zeros, not nulls, for a trip with no points
- Existing
TelemetryTest/TelemetryUploaderTestuntouched and green
Tests
Instrumented (app/src/androidTest/…):
- CASCADE delete removes segments and points
observeActiveTripemits on insert, and emits null onceendedAtis set- Points ordered by
segmentId, idacross multiple segments - Empty-trip aggregates are zero, not null (the COALESCE trap from v1)
Risks / gotchas
fallbackToDestructiveMigration()silently wipes. Correct here, dangerous later.- Foreign keys. Room enables them for its generated code; the cascade test confirms this empirically rather than relying on it.
- The
syncedcolumn must survive the refactor or the upload backlog logic breaks.
Out of scope
The repository layer (T03), any service changes (T06), any aggregate computation (T07).