trainUs/website/content/developers/understanding-the-architecture.en.md
David 352a3d5e88
Some checks are pending
CI / Lint & Format (push) Waiting to run
CI / Build (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (chromium) (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (firefox) (push) Waiting to run
Initial commit
2026-06-18 13:06:57 +02:00

7.2 KiB

title description slug weight date
Understanding the Architecture The why behind TrainUs's technical choices: local-first, the Worker thread, the migration system, and a few notable feature decisions. understanding-the-architecture 4 2026-06-16

This page explains why TrainUs is built the way it is. For the facts — schema, routes, exact fields — see [Reference]({{< relref "technical.en.md" >}}).

Why No Backend?

Many fitness apps run on an account and a central server. TrainUs makes the opposite choice: it's a Progressive Web Application that runs entirely in the browser, with all data stored locally via SQLite WASM and OPFS. There's no server to operate, no account system to build and secure, and the app keeps working — fully — with no network connection at all.

The trade-off is the same one described for users in the [user-facing explanation]({{< relref "comprendre.en.md" >}}): no built-in cross-device sync, no cloud backup. For a contributor, the practical consequence is that there's no API layer to design or maintain — every feature is, by construction, a local read/write against the repositories in src/db/repositories/.

Why SQLite Runs in a Web Worker

OPFS's synchronous file-access APIs (FileSystemSyncAccessHandle) are only available inside a Worker — they don't exist on the main thread. So worker.js owns the actual SQLite connection, and database.js on the main thread talks to it exclusively through postMessage(), wrapped in Promises keyed by a reply ID (see the diagram in [Reference §1]({{< relref "technical.en.md" >}}#main-thread--worker-architecture)).

This also means every repository call is inherently async, even for trivial reads — there's no synchronous "just query the DB" escape hatch from a Vue component. Composables exist specifically to absorb that asynchrony behind a reactive ref interface so components don't each reinvent loading/error state.

Why the opfs-sahpool VFS — and No Fallback

Of the OPFS VFS options SQLite WASM offers, opfs-sahpool was chosen because it gives synchronous I/O without requiring SharedArrayBuffer, which means it doesn't need COOP/COEP headers either (the alternative non-SAH OPFS VFS does). The dev server still sets them anyway, as a precaution — see [How-to Guides → Deploy TrainUs]({{< relref "how-to-guides.en.md" >}}#deploy-trainus) for the headers themselves.

There's deliberately no in-memory fallback when OPFS is unavailable (old browser, private browsing). A silent fallback would mean the app looks like it's saving the user's training data when it actually isn't — a fresh, empty database every reload, no warning. A clear, full-screen error was judged better than that kind of silent data loss.

Why the Migration System Is Built This Way

The startup migration flow (full state machine in [Reference §2]({{< relref "technical.en.md" >}}#migration-system)) has a few properties that aren't obvious from the flowchart alone:

  • The user must download a backup before the Update button is even enabled. Schema migrations run unattended SQL against real user data; if a migration script has a bug, the safety net needs to already be in the user's hands before the risky step runs, not offered as an afterthought.
  • Each migration runs in a transaction with its schema_version stamp. A failure rolls back to a fully consistent previous state — the database is never left half-migrated. Combined with the forced backup, a failed migration is recoverable two ways: the DB still works at the old version, and the user holds an export anyway.
  • A database newer than the running app (dbVersion > SCHEMA_VERSION) fails safe with a blocking error instead of attempting anything. This catches the case of someone reverting to an older app build against data already migrated forward — guessing how to handle that would be far riskier than just stopping.
  • The same RELEASES registry and migrationScript artifacts drive both the startup migration and the Restore Database adaptation path. Restore already needed to solve "bring an older backup up to the current schema"; reusing that mechanism for the routine update path avoided building (and maintaining) two separate migration engines.

This is also why SCHEMA_VERSION bumps are treated as a deliberate, ask-first decision (see [How-to Guides → Add a Database Migration]({{< relref "how-to-guides.en.md" >}}#add-a-database-migration)) rather than something to reach for casually: every bump obligates a tested migration script and a permanent RELEASES entry, forever.

A Few Notable Feature Design Decisions

Save dialog instead of the native file picker

Every disk save (JSON exports, the SQLite backup, the HTML export) goes through a custom SaveFileDialog.vue prompting for a filename, rather than the browser's native showSaveFilePicker(). The native API was rejected specifically because it has no Firefox support, and Firefox is this project's primary test target — see [Reference §3.12]({{< relref "technical.en.md" >}}#312-save-filename-prompt) for how the dialog is wired into the three existing download call sites.

Partial execution logs on exit

Exiting a session mid-way saves whatever steps were already completed, with finished_at = null marking it a partial run, rather than discarding the whole session or forcing the user to finish. Training sessions get interrupted in real life (phone call, equipment taken, time runs out) — losing a recorded warm-up because the rest didn't happen would make the statistics less trustworthy, not more.

Two different asymmetric side-flip strategies

Asymmetric exercises support flipping sides either after every rep (alternate: true) or after a full set (alternate: false) — see [Reference §3.7]({{< relref "technical.en.md" >}}#asymmetric-exercise-side-handling). Both are real training patterns (alternating lunges vs. one-arm-at-a-time rows), so the choice was left as a per-exercise setting rather than picking one behavior for everyone.

Why EMOM, AMRAP, and TABATA each compute rounds differently

The three timed combo types repurpose the same repetitions field on a session item for different meanings (full rounds for NONE/TABATA, total minutes for EMOM/AMRAP — see [Reference §3.7]({{< relref "technical.en.md" >}}#execution-queue-model)) instead of adding a separate field per type. This keeps the data model and the form UI uniform across combo types, at the cost of the field needing a type-dependent label ("Repetitions" vs. "Duration (min)") in the UI — a worthwhile trade since it avoids three parallel near-identical sets of fields.

Why the Content Width Cap Is Deliberate

List, detail, form, and settings views all cap their content width well short of the viewport on wide desktop screens (see [Reference Annex A]({{< relref "technical.en.md" >}}#annex-a--content-width-cap-on-listdetail-views)) — this is a readability choice, not an oversight. A workout card or form stretched across a 1920px display would force the eye to travel further than it needs to for what is, fundamentally, short list content. The cap is applied per-view rather than globally so a future multi-column grid layout (see the recipe in that same annex) can be introduced gradually, one view at a time, without touching the others.