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>
This commit is contained in:
128
rippr-src/docs/v2/02-schema.md
Normal file
128
rippr-src/docs/v2/02-schema.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user