3. Cache-first boot
3. Cache-first boot
Section titled “3. Cache-first boot”The fastest request is the one you do not make. This doc covers the disk cache and the repository pattern built on it.
What is cached, and what deliberately is not
Section titled “What is cached, and what deliberately is not” CACHED (disk, survives process death) ├── home feed, first page, per nav tab GET /v2/{tab} TTL 15 min └── content / detail pages GET /v1/{route} TTL 6 hours
ALREADY PERSISTED (SharedPreferences, pre-existing — now actually used) ├── /v2/settings payload → app config + nav items + min app version ├── current_user → the signed-in session ├── user_profiles → profile list └── geo_country → NEW: the resolved country
NEVER CACHED (correctness beats speed) ├── continue-watching changes every time you watch anything ├── device / stream counts an entitlement gate ├── popup notifications campaign-timed └── feed page 2+ only page 1 is needed to paint a cold start, and paginated pages would evict itSettings and nav items are not a new cache. SettingsDataSource.saveSettings
has always written the whole /v2/settings payload to SharedPreferences.
It was simply never read back in a way that could skip the network — the
guard was analytics_matomo_site_id == null. That guard is now “have we
loaded settings at all this session”, and getCacheSettings() returns the
nav items the payload already contained.
The store
Section titled “The store”lib/data/datasources/local/api_cache_store.dart — a plain Hive
Box<String> holding a JSON envelope.
Hive box "api_cache" ┌──────────────────────────────────────────────────────────────────┐ │ key value │ ├──────────────────────────────────────────────────────────────────┤ │ "u42:3|https://…/v2/?country=BD&…" {"v":1, │ │ "savedAtMs":1756…, │ │ "payload":{…raw body…}} │ │ "u42:3|https://…/v1/some-film?…" {…} │ │ "anon|https://…/v2/?country=BD&…" {…} │ │ "__meta" {"key":savedAtMs, …} │ └──────────────────────────────────────────────────────────────────┘Why Box<String> and not a @HiveType? No build_runner step, and
no new typeId to collide with the taken ones (1 = CacheContentEntity,
4 = CacheSubtitleEntity).
Why the __meta index? Eviction needs each entry’s age. Decoding 150
stored feed payloads to find the oldest would be absurd, and Hive’s string
keys iterate lexicographically rather than by insertion, so key order tells
you nothing. One small {key: savedAtMs} map, rewritten on each write,
makes trimming cheap.
The key is scoped, and that is a correctness requirement
Section titled “The key is scoped, and that is a correctness requirement” key = "<scope>|<resolved url>"
scope = "anon" logged out = "<userId>:<profileId>" logged inProfiles carry a maturity level. A kids profile must never be served
the parent profile’s cached feed, and scoping the key is what guarantees
that — not a check somewhere that could be forgotten. There is a test for
exactly this (entries are scoped per profile within one account).
On top of that, clearAll() runs whenever the signed-in identity goes
away — UserAuthDataSource.removeUser() (logout) and
ProfileManagementDataSource.removeUser() (called by profile switch). The
scoping already makes cross-user reads impossible; the wipe stops a shared
device from keeping the previous account’s content on disk.
It never throws
Section titled “It never throws”Every operation degrades to a no-op if the box is unavailable, and a corrupt entry is deleted and read as a miss:
Box<String>? get _box => Hive.isBoxOpen(boxName) ? Hive.box<String>(boxName) : null;A cache is an optimisation. It must never be the reason a request fails.
Local datasources
Section titled “Local datasources”HomeLocalDataSource and ContentLocalDataSource are read-only twins of
their remote siblings. They build the same URL via the shared builders
from doc 2, decode the envelope, and hand back the same model type — so
the repository can treat both sides interchangeably.
maxAge: null means “any age”, used once the network has already failed.
The write-through lives in the remote datasource, not the repository:
final _response = await getApiV2(category);final model = HomePageModel.fromJson(_response.data['result']);unawaited(cache.write(apiV2Url(category), _response.data));return model;That is the only layer that knows the resolved URL the cache is keyed on,
and unawaited because the caller already has its data and must not wait
on a disk write.
The repository decides the strategy
Section titled “The repository decides the strategy”This is the part that matters architecturally. The datasources are
transport; the repository owns when to trust what. It mirrors the
getProfilesFromLocal() precedent that already existed in
ProfileManagementRepositoryImpl, rather than inventing a new shape.
┌──────────────────────── HomeRepositoryImpl ────────────────────────┐ │ │ │ getHomePageFromCache(cat, {maxAge}) ← disk only, never network │ │ ├─ hit → Right(HomePage) │ │ └─ miss → Left(CacheFailure) │ │ │ │ getHomePage(cat) ← network, with a net │ │ ├─ ok → Right(fresh) … and the datasource wrote it back │ │ └─ threw → readStale(cat) │ │ ├─ hit → Right(stale) ← yesterday's feed │ │ └─ miss → Left(ServerFailure) │ └─────────────────────────────────────────────────────────────────────┘CacheFailure is new, and it exists to keep two very different situations
apart:
| Failure | Means | Caller should |
|---|---|---|
CacheFailure |
nothing has ever been stored | go to the network and wait |
ServerFailure |
the server said no, and no stale copy exists | show the error screen |
Before this change, the whole body was catch(e) { return Left(ServerFailure()); } —
which is why a single flaky request produced an empty app rather than the
feed the user saw ten minutes ago.
The read path on a cold start
Section titled “The read path on a cold start” restoreLocal() refresh() (behind the user) │ │ ▼ ▼ getHomePageFromCache('/') getHomePage('/') │ │ ▼ ▼ HomeLocalDataSource HomeDataSource │ │ ── network ──► ▼ ▼ ApiCacheStore.readStale() write-through ──► ApiCacheStore │ │ ▼ ▼ homePageListGlobal = [page] homePageListGlobal = [fresh] │ │ ▼ ▼ HOME PAINTS (t ≈ 0.4 s) home_feed_revision.value++ │ ▼ HomeScreenBloc adds HomeFeedRefreshed → re-emits → fresh sections swap inhome_feed_revision is a ValueNotifier<int> in const.dart, matching
the app’s existing global-notifier style. It is the wire that turns
“cache-first” into “cache-first and current” rather than “cache-first
and stale until you kill the app”.
Next: The bootstrap and the splash — who calls all of this, in what order, and with what deadline.