Caching & local storage
Five kinds of store, each chosen for a reason: secrets in hardware-backed storage, relational civic data in SQLite, large blobs and state files on disk, and everything ephemeral in memory.
Placement rules
Tokens go in the Keychain/Keystore and nowhere else — the token store's contract states they must never be written to SQLite, preferences, or logs. Relational data that gets queried (representatives, terms, offices, committees, bills) lives in SQLite. Large payloads that are fetched whole and parsed whole (dataset JSON, district GeoJSON) are cached as files with ETag or SHA validators, because storing megabyte blobs in SQLite buys nothing and file rename gives atomic replacement for free. Small state machines (attestation state, pending crash report, session marker) are individual JSON files written atomically — temp file, then rename — so a crash mid-write can never leave a half-file. Anything that can be cheaply recomputed stays in memory.
The SQLite schema
One database, tth_congress_members.db, schema version 6, foreign keys on, every write inside a transaction. The doc comment at the top of DatabaseService is the schema's source of truth; this is the shape and intent:
| Table | Holds | Notes on design |
|---|---|---|
app_metadata | Key/value strings | Dataset SHA markers; cached user profile. The keystone of SHA-gated sync. |
representatives | One row per rep, all levels | Partial unique indexes per chamber. State reps keep district NULL so multi-member state districts can't replace each other; the district lives on the term. |
representative_terms | Current term per rep | Unique on (rep, start, end, type); type strings match the upstream feed spellings. |
representative_offices | Office addresses | lat/lng start NULL and are filled by on-device geocoding, persisted so future sessions skip the geocoder. |
social_media_profiles | Social handles | Synced with the socials dataset. |
jurisdictions | Canonical hierarchy | Present for future local-level data; unused by current flows. |
committees / sub_committees | Committee catalog | Case-insensitive unique identifiers from the feed. |
committee_memberships / sub_committee_memberships | Rep ↔ committee joins | Unique per pair; batch-inserted with a single uuid→id map instead of N+1 lookups. |
images / representative_images | Image records + joins | Deduped by content hash; one image per (rep, kind). |
bills | Bill cache | cached_at/expires_at carry the status-based TTL; keyed locally because server bill ids repeat across Congresses. |
representative_bills | Rep ↔ bill links | Replaced per rep on refresh; orphaned bills reclaimed only when no rep links them. |
representative_bill_checks | Negative cache | "Checked and found nothing" marker, 24 h TTL — an empty result that costs no network. |
Migrations (versions 2→6) tell the schema's history: deduplicating terms, adding the bills tables, dropping ambiguous unique indexes that could collide national and state reps, and twice normalizing position/term vocabulary to canonical spellings.
File caches and their validators
The API client caches GET responses as files named by path (slashes become underscores) in the application-support directory. Two validator schemes coexist. Dataset files (/representatives/congress/*, state legislators) are validated by SHA comparison against the status endpoints — the file plus its app_metadata marker together mean "seeded", which is why a failed fetch must never write a file (a spurious empty file would read as a seeded dataset). District GeoJSON uses HTTP ETags with a sidecar .etag file and If-None-Match, giving 304-for-free freshness on payloads up to 1.2 MB. The ETag write ordering is deliberate — stage body, delete old ETag, rename body, write new ETag — so the cache can never hold a new ETag with an old body; the worst crash outcome is an unvalidated body forcing one full refetch.
These same file caches are the entire data source in offline mode: GETs read the file, fall back to bundled seed assets under assets/offline/, and POSTs throw.
In-memory caches
Each in-memory cache exists to absorb a specific hot path: the representatives query LRU (50) absorbs location/filter churn; the bill LRU (50) makes reopening a rep's legislation sheet instant; the ImageProvider LRU (100) exists because providers are resolved during paint and each resolution costs a synchronous file stat; community feeds and cursors make hub-switching free; the profile photo bytes render the title-bar avatar synchronously; the geocode cache (with negative entries) protects the OS geocoder's aggressive rate limits. None survive a restart, and all of the service-level ones are invalidated by the dataset version token or explicit clears when underlying data moves.
Concurrency at the storage boundary
The API client serializes by key through two independent pools of 256 striped locks — one for requests (path + query), one for cache files (path only). They are separate on purpose: an auth-recovery request fired from inside a cache operation must not deadlock against that operation's own lock. The reporting subsystem serializes its file mutations with a single reentrant lock, and every JSON state file in the app is written via temp-then-rename with recovery logic that promotes or discards leftover temp files on the next read.
| Store | Lifetime | Invalidation |
|---|---|---|
| Keychain/Keystore tokens | Until sign-out / revocation | Cleared on terminal refresh failure |
| SQLite civic data | Indefinite | SHA-gated upsert; bills by TTL sweep |
| Dataset file caches | Indefinite | SHA mismatch → refetch |
| District GeoJSON | Indefinite | ETag / 304 |
| Rep photos (cache dir) | OS may reclaim | Re-resolved through the image pipeline |
| State/crash JSON files | Purpose-scoped | Explicit lifecycle (attest, prompt, sweep) |
| In-memory LRUs | Process | Version token, explicit clears, eviction |