P0: live provider chain — GitHub mirror primary, relay fallback, bundled dump last resort + app telemetry beacon
- MirrorFuelProvider: fetches CURRENT prices from raw.githubusercontent (latest.json pointer → history/<day>.json full dump), app-group day-cache so the ~2.8 MB dump is re-downloaded only when the mirror pushes a new day - LiveChainProvider: full path (app refresh) GitHub → relay; focused path (background alert checks) relay-first so alerts never pull the full dump over mobile data; records which leg served for About + telemetry - FuelBeacon: fire-and-forget X-Client app ping to the relay's existing widget-diag route (zero relay changes) — attribution + cadence on-LAN, silent skip off-LAN is the reachability datum; /stats app-hit spike = GitHub path failing - Bundled dump: FuelBoardDump dataset (real 2026-08-15 snapshot, 8,022 stations) + BundledDumpProvider + scripts/refresh_bundled_dump.sh — no-network last resort replaces the demo sample set - ContentView: About meta from the chain; catch falls back cached → bundled → sample - 89 tests (6 new: chain order x4, cache decision, beacon URL); Release build green; beacon route verified against the live relay (204 + logged)
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
// MirrorFuelProvider.swift — GitHub price-mirror provider (P0 live chain).
|
||||
//
|
||||
// The mirror (aptonline/fuelboard-data, pushed daily by the LAN relay) is the
|
||||
// PRODUCTION data source: its day files are the raw relay /api/v1/stations
|
||||
// envelope, so the shared decode works verbatim. Fetching from
|
||||
// raw.githubusercontent.com makes live prices work fully off-LAN — App
|
||||
// Review cannot reach the LAN relay — and the bundled dump stays as the
|
||||
// no-network last resort.
|
||||
//
|
||||
// Chain (LiveChainProvider):
|
||||
// full path (app refresh): GitHub mirror → LAN relay → bundled dump
|
||||
// focused path (alert fetch): LAN relay (small radius fetch) → GitHub
|
||||
// mirror (full decode, device filters) → dump
|
||||
// The focused path stays relay-first so a background alert check never pulls
|
||||
// the ~2.8 MB full dump over mobile data when the relay is reachable.
|
||||
//
|
||||
// Telemetry (FuelBeacon): raw GitHub fetches are invisible to repo analytics
|
||||
// (page views/clones only), so every chain fetch fires an opportunistic
|
||||
// fire-and-forget X-Client "app" ping at the relay's existing widget-diag
|
||||
// route (zero relay changes). On-LAN = attribution + cadence; off-LAN the
|
||||
// ping fails in ~2 s and the skip itself is the reachability datum. The
|
||||
// relay's /stats app-hit spike doubles as the fallback-outage signal.
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Mirror provider (GitHub raw)
|
||||
|
||||
/// Fetches CURRENT prices from the GitHub mirror. Same envelope as the
|
||||
/// relay, so `FuelPriceProvider.decodeStations` applies unchanged.
|
||||
struct MirrorFuelProvider: FuelPriceProviding {
|
||||
var baseURL = FuelHistoryStore.mirrorBase
|
||||
|
||||
/// Envelope metadata from the most recent successful mirror fetch —
|
||||
/// surfaced via `LiveChainProvider.latestMeta` in Settings → About.
|
||||
static var latestMeta: RelayMeta?
|
||||
|
||||
/// App-group cache of the last fetched FULL dump, keyed by snapshot day.
|
||||
/// The mirror pushes once/day, so between pushes the app reuses the
|
||||
/// cached dump instead of re-downloading ~2.8 MB at every 12 h gate.
|
||||
static let liveDumpCacheKey = "fuelboard.liveDumpCache"
|
||||
|
||||
struct DumpCache: Codable {
|
||||
let day: String
|
||||
let data: Data
|
||||
}
|
||||
|
||||
static func loadDumpCache() -> DumpCache? {
|
||||
guard let defaults = UserDefaults(suiteName: FuelStore.appGroupSuite),
|
||||
let raw = defaults.data(forKey: liveDumpCacheKey),
|
||||
let cache = try? JSONDecoder().decode(DumpCache.self, from: raw) else {
|
||||
return nil
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
static func saveDumpCache(day: String, data: Data) {
|
||||
guard let defaults = UserDefaults(suiteName: FuelStore.appGroupSuite),
|
||||
let raw = try? JSONEncoder().encode(DumpCache(day: day, data: data)) else { return }
|
||||
defaults.set(raw, forKey: liveDumpCacheKey)
|
||||
}
|
||||
|
||||
/// True when the cached dump is already the freshest the mirror has —
|
||||
/// avoids the ~2.8 MB re-download when the mirror hasn't pushed a new day.
|
||||
static func canReuseCache(cachedDay: String?, latestDay: String?) -> Bool {
|
||||
guard let cachedDay, let latestDay else { return false }
|
||||
return cachedDay == latestDay
|
||||
}
|
||||
|
||||
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
|
||||
// 1. Tiny pointer fetch — the freshest snapshot day.
|
||||
guard let latest = await FuelHistoryStore.fetchLatest(base: baseURL),
|
||||
let day = latest.availableTo ?? latest.date else {
|
||||
throw FuelProviderError.mirrorUnavailable
|
||||
}
|
||||
// 2. Reuse the cached full dump when the mirror hasn't pushed a new day.
|
||||
let cache = Self.loadDumpCache()
|
||||
let data: Data
|
||||
if Self.canReuseCache(cachedDay: cache?.day, latestDay: day), let cached = cache?.data {
|
||||
data = cached
|
||||
} else {
|
||||
let (fetched, response) = try await URLSession.shared.data(
|
||||
from: FuelHistoryStore.historyFileURL(day: day, base: baseURL)
|
||||
)
|
||||
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
||||
throw FuelProviderError.mirrorUnavailable
|
||||
}
|
||||
Self.saveDumpCache(day: day, data: fetched)
|
||||
data = fetched
|
||||
}
|
||||
let stations = try FuelPriceProvider.decodeStations(from: data)
|
||||
Self.latestMeta = FuelPriceProvider.decodeRelayMeta(from: data)
|
||||
return stations
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Live chain provider
|
||||
|
||||
/// The live price chain. Full path prefers the GitHub mirror; the focused
|
||||
/// path (alert checks) stays relay-first so a background alert never pulls
|
||||
/// the full dump over mobile data when the relay is up. Whichever leg serves
|
||||
/// is recorded for telemetry + the About section.
|
||||
struct LiveChainProvider: FuelPriceProviding {
|
||||
var mirror: FuelPriceProviding
|
||||
var relay: FuelPriceProviding
|
||||
|
||||
/// Which leg served the last fetch — Settings → About metadata + beacon.
|
||||
static var latestMeta: RelayMeta?
|
||||
static var lastSource: String?
|
||||
|
||||
init(mirror: FuelPriceProviding = MirrorFuelProvider(),
|
||||
relay: FuelPriceProviding = RelayFuelProvider()) {
|
||||
self.mirror = mirror
|
||||
self.relay = relay
|
||||
}
|
||||
|
||||
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
|
||||
if radiusKM != nil {
|
||||
// Focused alert fetch: light relay radius call first, mirror fallback.
|
||||
do {
|
||||
let stations = try await relay.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
|
||||
Self.latestMeta = RelayFuelProvider.latestMeta
|
||||
Self.lastSource = "relay"
|
||||
FuelBeacon.fire(source: "relay", n: stations.count)
|
||||
return stations
|
||||
} catch {
|
||||
let stations = try await mirror.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
|
||||
Self.latestMeta = MirrorFuelProvider.latestMeta
|
||||
Self.lastSource = "github"
|
||||
FuelBeacon.fire(source: "github", n: stations.count)
|
||||
return stations
|
||||
}
|
||||
}
|
||||
// Full dump: GitHub first (off-LAN + App Review), relay fallback.
|
||||
do {
|
||||
let stations = try await mirror.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
|
||||
Self.latestMeta = MirrorFuelProvider.latestMeta
|
||||
Self.lastSource = "github"
|
||||
FuelBeacon.fire(source: "github", n: stations.count)
|
||||
return stations
|
||||
} catch {
|
||||
let stations = try await relay.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
|
||||
Self.latestMeta = RelayFuelProvider.latestMeta
|
||||
Self.lastSource = "relay"
|
||||
FuelBeacon.fire(source: "relay", n: stations.count)
|
||||
return stations
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Telemetry beacon
|
||||
|
||||
/// Fire-and-forget attribution for the LIVE chain (see header note). Never
|
||||
/// awaited, never user-visible; a failed ping (off-LAN) is silent and IS the
|
||||
/// reachability datum. Disabled in unit tests via `isEnabled`.
|
||||
enum FuelBeacon {
|
||||
static var isEnabled = true
|
||||
|
||||
static func fire(source: String, n: Int, timeout: TimeInterval = 2) {
|
||||
guard isEnabled,
|
||||
let url = beaconURL(source: source, n: n) else { return }
|
||||
var request = RelayFuelProvider.relayRequest(url, client: "app", timeout: timeout)
|
||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
Task { _ = try? await URLSession.shared.data(for: request) }
|
||||
}
|
||||
|
||||
/// Testable URL construction — hits the relay's existing widget-diag
|
||||
/// beacon route with app attribution (zero relay changes).
|
||||
static func beaconURL(source: String, n: Int, base: URL = RelayFuelProvider().baseURL) -> URL? {
|
||||
guard var components = URLComponents(
|
||||
url: base.appendingPathComponent("api/v1/widget-diag"),
|
||||
resolvingAgainstBaseURL: false
|
||||
) else { return nil }
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "intent", value: "app-live"),
|
||||
URLQueryItem(name: "source", value: source),
|
||||
URLQueryItem(name: "n", value: String(n)),
|
||||
]
|
||||
return components.url
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user