# 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 ```kotlin 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` — `WHERE endedAt IS NULL` | T03, T09 | | `observeCompletedTrips(): Flow>` — ordered `startedAt DESC` | T10 | | `observeTrip(id): Flow` | T11 | | `pointsForTrip(id): List` — ordered `segmentId, id` | T11, T14, T15 | | `segmentsForTrip(id): List` | 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 - [x] `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) - [x] Deleting a trip cascades to its segments and points - [x] `observeActiveTrip()` emits null on an empty database, not a crash - [x] Per-trip aggregate queries return zeros, not nulls, for a trip with no points - [x] 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).