Define final MotorCloud data model and offline-first sync contract
This commit is contained in:
parent
2146a718fb
commit
f828253695
|
|
@ -0,0 +1,323 @@
|
||||||
|
# MotorSignal — Final MotorCloud Data Model & Offline-First Sync Contract
|
||||||
|
|
||||||
|
**Status:** Phase 3 implementation baseline
|
||||||
|
**Date:** 2026-09-01
|
||||||
|
**Backend:** Firebase Authentication + Firestore + authenticated Cloudflare R2 Worker
|
||||||
|
**Local source of truth:** Drift SQLite
|
||||||
|
|
||||||
|
## 1. Ownership boundary
|
||||||
|
|
||||||
|
Every cloud-owned resource belongs to exactly one Firebase UID unless it is explicitly introduced as a collaborative resource later.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Firebase Auth UID
|
||||||
|
├── users/{uid}
|
||||||
|
├── users/{uid}/motorcycles/{motorcycleId}
|
||||||
|
├── users/{uid}/rides/{rideId}
|
||||||
|
├── users/{uid}/trips/{tripId}
|
||||||
|
├── users/{uid}/events/{eventId}
|
||||||
|
└── users/{uid}/friends/{friendId}
|
||||||
|
```
|
||||||
|
|
||||||
|
The Firebase UID is never supplied by the UI as an ownership claim. Server authorization derives ownership from `request.auth.uid`.
|
||||||
|
|
||||||
|
## 2. Ride storage split
|
||||||
|
|
||||||
|
Firestore stores queryable ride metadata. R2 stores the compressed forensic telemetry bundle.
|
||||||
|
|
||||||
|
```text
|
||||||
|
users/{uid}/rides/{rideId}
|
||||||
|
metadata / summary / references
|
||||||
|
|
||||||
|
users/{uid}/rides/{rideId}/telemetry.zip (Cloudflare R2)
|
||||||
|
```
|
||||||
|
|
||||||
|
The R2 object key must remain UID-scoped:
|
||||||
|
|
||||||
|
```text
|
||||||
|
users/{uid}/rides/{rideId}/telemetry.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
The client must never be able to select another user's UID as the destination.
|
||||||
|
|
||||||
|
## 3. Ride document contract
|
||||||
|
|
||||||
|
The canonical cloud ride document should contain:
|
||||||
|
|
||||||
|
```text
|
||||||
|
id
|
||||||
|
ownerId
|
||||||
|
motorcycleId
|
||||||
|
tripId?
|
||||||
|
startedAt
|
||||||
|
endedAt
|
||||||
|
durationSeconds
|
||||||
|
distanceMiles
|
||||||
|
topSpeedMph
|
||||||
|
averageSpeedMph
|
||||||
|
maxLeanLeft
|
||||||
|
maxLeanRight
|
||||||
|
maxAccelG
|
||||||
|
maxBrakingG
|
||||||
|
elevationGain
|
||||||
|
elevationLoss
|
||||||
|
qualityScore
|
||||||
|
qualityRating
|
||||||
|
eventCounts
|
||||||
|
performanceRuns
|
||||||
|
telemetryBundle.provider
|
||||||
|
telemetryBundle.objectKey
|
||||||
|
telemetryBundle.bundleSizeBytes
|
||||||
|
telemetryBundle.sha256
|
||||||
|
telemetryBundle.status
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
uploadedAt
|
||||||
|
syncVersion
|
||||||
|
software
|
||||||
|
visibility
|
||||||
|
```
|
||||||
|
|
||||||
|
Telemetry samples and raw forensic records do not belong directly in the Firestore ride document.
|
||||||
|
|
||||||
|
## 4. Stable identity
|
||||||
|
|
||||||
|
The long-term canonical `rideId` must be an application-generated stable cloud identity, not a Firebase auto-ID and not a device-local database integer.
|
||||||
|
|
||||||
|
The current implementation uses `ride_<localRideId>_<UTC start timestamp>` as a deterministic compatibility identity. This is safe for the current local database but is a migration target because a reinstall/new device can assign a different local integer ID.
|
||||||
|
|
||||||
|
Before multi-device restore is released, add a persisted stable ride cloud ID to the local ride model and migrate existing rides deterministically. All telemetry, metadata updates, retries, restore operations, and deletes must use that stable ID.
|
||||||
|
|
||||||
|
## 5. Local-first lifecycle
|
||||||
|
|
||||||
|
The riding path remains completely independent of cloud availability.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Sensors
|
||||||
|
↓
|
||||||
|
Local recording
|
||||||
|
↓
|
||||||
|
Drift transaction
|
||||||
|
↓
|
||||||
|
Ride completed
|
||||||
|
↓
|
||||||
|
Sync outbox entry
|
||||||
|
↓
|
||||||
|
Network/auth available
|
||||||
|
↓
|
||||||
|
Firebase metadata + R2 telemetry upload
|
||||||
|
↓
|
||||||
|
Server acknowledgement
|
||||||
|
↓
|
||||||
|
Local outbox = SYNCED
|
||||||
|
```
|
||||||
|
|
||||||
|
A ride is never marked synced before the provider returns success.
|
||||||
|
|
||||||
|
## 6. Sync state contract
|
||||||
|
|
||||||
|
Current implementation states:
|
||||||
|
|
||||||
|
```text
|
||||||
|
localOnly
|
||||||
|
queued
|
||||||
|
waitingForNetwork
|
||||||
|
uploading
|
||||||
|
retryWait
|
||||||
|
authRequired
|
||||||
|
synced
|
||||||
|
failed
|
||||||
|
cancelled
|
||||||
|
```
|
||||||
|
|
||||||
|
Terminal states are `synced`, `failed`, and `cancelled`.
|
||||||
|
|
||||||
|
`failed` is intentionally not equivalent to `synced`. Retrying a failed item must explicitly rearm it.
|
||||||
|
|
||||||
|
## 7. Outbox persistence
|
||||||
|
|
||||||
|
The outbox is persisted in SharedPreferences as a compact manifest containing:
|
||||||
|
|
||||||
|
```text
|
||||||
|
localRideId
|
||||||
|
providerId
|
||||||
|
status
|
||||||
|
createdAt
|
||||||
|
attemptCount
|
||||||
|
lastAttemptAt
|
||||||
|
nextRetryAt
|
||||||
|
uploadedAt
|
||||||
|
remoteId
|
||||||
|
lastError
|
||||||
|
bundleLocalPath
|
||||||
|
```
|
||||||
|
|
||||||
|
The persisted outbox is now namespaced by account UID:
|
||||||
|
|
||||||
|
```text
|
||||||
|
motorsignal_sync_outbox_manifest_v2_<firebaseUid>
|
||||||
|
```
|
||||||
|
|
||||||
|
Signed-out/local operation uses a separate `local` namespace.
|
||||||
|
|
||||||
|
This is mandatory for account isolation on a shared device. User A's synced state must never cause User B's same-numbered local ride to appear synced.
|
||||||
|
|
||||||
|
## 8. Idempotency
|
||||||
|
|
||||||
|
All cloud writes must be repeat-safe.
|
||||||
|
|
||||||
|
```text
|
||||||
|
same stable rideId + same owner UID + repeated upload
|
||||||
|
= one logical cloud ride
|
||||||
|
```
|
||||||
|
|
||||||
|
Firestore writes use `set(..., merge: true)` against the deterministic ride document. R2 upload uses the deterministic object key and authenticated Worker validation.
|
||||||
|
|
||||||
|
## 9. Authentication rules
|
||||||
|
|
||||||
|
Normal MotorCloud backup requires a non-anonymous authenticated Firebase user.
|
||||||
|
|
||||||
|
Signed-out or anonymous users can continue recording locally but cloud upload must resolve to `authRequired` and leave the local ride untouched.
|
||||||
|
|
||||||
|
A missing/expired Firebase ID token must not result in a successful local sync flag.
|
||||||
|
|
||||||
|
## 10. Retry policy
|
||||||
|
|
||||||
|
Temporary failures use exponential backoff:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1st retry: 5 seconds
|
||||||
|
2nd: 30 seconds
|
||||||
|
3rd: 2 minutes
|
||||||
|
4th: 5 minutes
|
||||||
|
5th: 15 minutes
|
||||||
|
later: 30 minutes
|
||||||
|
```
|
||||||
|
|
||||||
|
Authentication failures are parked as `authRequired` and rearmed after successful authentication.
|
||||||
|
|
||||||
|
Permanent failures remain `failed` until an explicit retry is requested.
|
||||||
|
|
||||||
|
## 11. Backup All
|
||||||
|
|
||||||
|
`Back Up All` operates on local rides and delegates every cloud operation to `SyncManager` using the Firebase provider.
|
||||||
|
|
||||||
|
The operation is intentionally batch/enqueue based:
|
||||||
|
|
||||||
|
```text
|
||||||
|
local rides
|
||||||
|
↓
|
||||||
|
SyncManager.backupAllRideIds()
|
||||||
|
↓
|
||||||
|
Firebase provider queue
|
||||||
|
↓
|
||||||
|
processQueue()
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing synced or queued rides are not duplicated.
|
||||||
|
|
||||||
|
## 12. Cloud status UX
|
||||||
|
|
||||||
|
The account/cloud screen exposes:
|
||||||
|
|
||||||
|
- signed-in MotorCloud identity
|
||||||
|
- synced count
|
||||||
|
- pending count
|
||||||
|
- failed count
|
||||||
|
- current processing state
|
||||||
|
- last successful sync
|
||||||
|
- last sync error/notice
|
||||||
|
- Sync Now
|
||||||
|
- Back Up All
|
||||||
|
- Retry
|
||||||
|
|
||||||
|
The status is diagnostic information, not the source of truth for local ride availability.
|
||||||
|
|
||||||
|
## 13. GitHub separation
|
||||||
|
|
||||||
|
GitHub is not a MotorCloud backup destination.
|
||||||
|
|
||||||
|
The normal ride upload path must never use a GitHub PAT or write to the developer's `ride_logs` repository.
|
||||||
|
|
||||||
|
The legacy GitHub exporter is retained only as an explicit developer/export provider. It must call the dedicated `uploadRideToGitHub()` method and must never be selected as the default sync provider.
|
||||||
|
|
||||||
|
## 14. Security
|
||||||
|
|
||||||
|
Firestore rules must enforce:
|
||||||
|
|
||||||
|
```text
|
||||||
|
request.auth.uid == userId
|
||||||
|
```
|
||||||
|
|
||||||
|
for user-owned resources.
|
||||||
|
|
||||||
|
R2 Worker authorization must validate the Firebase token and derive the UID from verified authentication rather than trusting a client-provided owner ID.
|
||||||
|
|
||||||
|
The application must never log Firebase tokens, GitHub PATs, or raw private telemetry unnecessarily.
|
||||||
|
|
||||||
|
## 15. Offline and restart guarantees
|
||||||
|
|
||||||
|
Required invariants:
|
||||||
|
|
||||||
|
1. Local ride data survives cloud outages.
|
||||||
|
2. A failed upload remains unsynced.
|
||||||
|
3. An upload in progress is requeued after process restart.
|
||||||
|
4. Successful sync state survives process restart.
|
||||||
|
5. User A and User B have separate outbox persistence namespaces.
|
||||||
|
6. Duplicate enqueue/upload is idempotent.
|
||||||
|
7. Sign-out blocks cloud upload.
|
||||||
|
8. Re-authentication resumes only that account's pending work.
|
||||||
|
|
||||||
|
## 16. Multi-device restore contract
|
||||||
|
|
||||||
|
Restore should be additive and resumable:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Authenticate
|
||||||
|
↓
|
||||||
|
Read user metadata
|
||||||
|
↓
|
||||||
|
Discover cloud rides
|
||||||
|
↓
|
||||||
|
Compare stable ride IDs
|
||||||
|
↓
|
||||||
|
Download missing metadata
|
||||||
|
↓
|
||||||
|
Download required telemetry bundles
|
||||||
|
↓
|
||||||
|
Materialize local records
|
||||||
|
↓
|
||||||
|
Mark local/cloud reconciliation complete
|
||||||
|
```
|
||||||
|
|
||||||
|
Restore must never silently delete local rides. Conflicts must be surfaced or resolved using an explicit policy.
|
||||||
|
|
||||||
|
## 17. Next implementation gates
|
||||||
|
|
||||||
|
Before Free/Pro external testing:
|
||||||
|
|
||||||
|
- [x] MotorCloud normal upload path separated from GitHub
|
||||||
|
- [x] Account-scoped sync outbox
|
||||||
|
- [x] Persistent sync state/counts
|
||||||
|
- [x] Back Up All UI and manager path
|
||||||
|
- [x] Duplicate enqueue protection
|
||||||
|
- [x] Explicit failed retry behavior
|
||||||
|
- [ ] Stable persisted cloud ride ID migration
|
||||||
|
- [ ] Automated Firebase Emulator ownership tests
|
||||||
|
- [ ] Automated R2 Worker authorization tests
|
||||||
|
- [ ] Multi-device restore implementation
|
||||||
|
- [ ] Conflict/reconciliation implementation
|
||||||
|
- [ ] Account deletion cascade
|
||||||
|
- [ ] Account-level data export
|
||||||
|
- [ ] Free/Pro cloud entitlement enforcement
|
||||||
|
|
||||||
|
## 18. Product rule
|
||||||
|
|
||||||
|
MotorCloud is downstream of the ride recorder.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cloud failure ≠ ride failure
|
||||||
|
|
||||||
|
Local ride data is authoritative for recording.
|
||||||
|
Cloud data is the durable synchronized copy.
|
||||||
|
```
|
||||||
Loading…
Reference in New Issue