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

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).