ADR-084 — Decompose the data.ts mega-client into $lib/data/ submodules
Status · Accepted Date · 2026-08-05 Gates · architectural-review R6 (data.ts accreting business logic vs. TA.md §constraints "no hidden business logic in the data client") Builds on · ADR-006 (data client), ADR-017 (locale-overlay merge)
Gating sentence: the 2,554-line src/lib/data.ts mega-client is decomposed incrementally into $lib/data/ submodules over a shared $lib/data/core.ts fetch+cache+i18n-overlay keystone; data.ts stays as a re-export barrel so all 65 consumers (from '$lib/data') keep working with ZERO import changes. Phase 1 (this ADR) extracts the keystone (core.ts) and the flagged business logic — the hero-override gallery builders (galleries.ts). Phases 2+ peel domain loaders (universe, science, stations, provenance) as separate, independently-shippable passes.
Context
data.ts is 2,554 lines / 147 exports — the single data client every route imports. The architectural review (R6, MED) flagged it as eroding the TA.md §constraints rule "no hidden business logic in the data client": it mixes pure fetch+cache+overlay loaders with business logic — the gallery-URL builders that orchestrate hero-override reordering (getMissionGallery/getFleetGallery/getCategoryGallery + 11 more, each doing loadHeroOverrides + applyHeroOverride + URL synthesis). It has also become an implicit god-module: a change anywhere risks the whole client.
Two facts make a safe split tractable (verified in code):
- Consumers import barrel-style — 65 files do
from '$lib/data', none reach into internal symbols. A re-export barrel is transparent to them. - The gallery builders depend only on
get()+assetOrigin+$lib/image-hero(and each other) — NOT on the domain loaders. So they extract without a circular dependency.
Decision
A shared keystone, not a barrel of copies.
$lib/data/core.tsowns the fetch+cache+i18n-overlay primitives —get<T>(), thecache+i18nBundlesmaps,loadI18nBundle, theFetchLiketype, andresetCoreCache(). Every current and future submodule importsgetfrom here. This is the single source of the caching contract (ADR-006/017); it must not be duplicated.Extract the flagged business logic first.
$lib/data/galleries.tsowns the hero-override gallery builders + their helpers (GALLERY_ID_ALIASES,gallerySiteIdVariants, the internalgetCategoryGallery). This is the concrete R6 fix — the "business logic in the client" moves out, importinggetfromcoreandloadHeroOverrides/applyHeroOverridefrom$lib/image-hero.data.tsbecomes a thin re-export barrel + the remaining domain loaders. It importsget/FetchLikefromcore, re-exports everything fromgalleries, and keeps__resetCache(which callsresetCoreCache()then clears its own provenance vars). Consumers see no change.Phases 2+ are separate PRs, one domain at a time. Universe (
getNamedStars,getExoplanetSystems,getDeepSkyObjects,getMilkyWaySchematic,getLocalGroup,getBlackHoles,getCultureDoors), science (getScienceSection/getScienceTab/getProgram/getEssay), stations (ISS/Tiangong), and provenance (image/audio/logos/text/badges) each peel into$lib/data/<domain>.tsimportingcore, re-exported bydata.ts. Each phase is independently green-testable and low-risk because the barrel keeps imports stable. The full end-state isdata.ts= a barrel; the loaders live in domain files.
Consequences
- Positive: the caching contract has one home; the constraint-violating gallery logic is out of the client; the god-module shrinks per phase without a big-bang rewrite; each phase is a small, reviewable diff with zero consumer churn.
- Cost: a re-export barrel adds one indirection hop (negligible; tree-shaking handles it). During Phase 1 the barrel and submodules must agree on the
get()signature — enforced by the type system + the existingdata.test.tssuite. - Risk: circular imports if a submodule imported
data.ts— avoided by the rule "submodules importcore, neverdata.ts;data.tsimports submodules." Galleries reference each other withingalleries.ts(fine).
Alternatives considered
- Big-bang full decomposition — split all ~10 domains at once. Rejected: a 2.5k-line file touched by 65 consumers in one PR is high-risk and unreviewable; the phased barrel gives the same end-state safely.
- Leave it, just extract galleries — addresses R6 literally but leaves the keystone entangled, so Phase 2 would have to extract
coreanyway. Doingcorein Phase 1 unblocks all later phases for the same cost. - A
$lib/data/index.tsbarrel replacingdata.ts— would require rewriting 65 import paths ($lib/dataalready resolves todata.ts; making it a dir needs an index). Keepingdata.tsas the barrel is zero-churn.