Files
samplez/rippr-src/docs/v2/02-schema.md
uhryniuk 280fd7f988 Add Rippr source snapshot and full-history bundle
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>
2026-08-11 08:30:25 -05:00

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:

  • AppDatabase uses JournalMode.WRITE_AHEAD_LOGGING — deliberate, keep it. WAL with NORMAL sync survives app crashes without fsyncing every 2 Hz write.
  • exportSchema = true, and app/schemas/com.rippr.AppDatabase/1.json exists.
  • observeStats() returns a single-row aggregate with COALESCE guards so an empty table does not null-crash the non-null RideStats fields. That COALESCE lesson carries over — the same trap applies to per-trip aggregates.
  • The synced column 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

  1. Create data/ package; move and split the existing file.
  2. Add Trip, Segment, TripState (+ a Room TypeConverter for the enum, or store its name as String).
  3. Add tripId/segmentId to TrackPoint with FKs and indices.
  4. Split DAOs: TripDao, SegmentDao, TrackPointDao.
  5. Bump AppDatabase to version = 2, add all three entities, add fallbackToDestructiveMigration() with a comment pointing at T18.
  6. 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.
  7. Update TrackPointDaoTest.kt for the new shape; add trip/segment tests.

Acceptance criteria

  • app/schemas/com.rippr.data.AppDatabase/2.json generated (path follows the package, which moved to com.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 / TelemetryUploaderTest untouched and green

Tests

Instrumented (app/src/androidTest/…):

  • CASCADE delete removes segments and points
  • observeActiveTrip emits on insert, and emits null once endedAt is set
  • Points ordered by segmentId, id across 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 synced column 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).