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>
129 lines
5.4 KiB
Markdown
129 lines
5.4 KiB
Markdown
# 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<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
|
|
|
|
- [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).
|