feat: bake FX rates in at build time so a fresh install can convert

TODO section 2, designed and approved, now built. A brand-new dataset arrives
with DKK/USD/EUR rates instead of erroring on the first EUR purchase.

build.ts fetches at build time and embeds the result via `define`. A network
failure does NOT fail the build — it bakes committed constants instead, and an
incomplete response is treated as a failure too, since one unconvertible
currency is the problem this solves. Fetched rates carry source "api" and the
BUILD date, which is why parseFxResponse now takes an explicit updatedAt; the
constants carry source "manual" and their own date, so Settings never claims a
hardcoded number came from a rate service. SKIP_FX_FETCH=1 and FX_API_URL are
there for offline, CI and reproducible builds.

They seed a NEW dataset and nothing else. They are NOT a fallback for a missing
rate: clearing a rate removes its row so MissingRateError surfaces, rather than
silently converting money at a number the user never chose. A test asserts a
cleared rate still throws, and loading an existing dataset never touches its
rates.

Domain purity forced the shape. createSeedDataset takes the rates as an
argument (and copies the rows, so shared constants cannot be edited out from
under the caller); the env read lives in the new src/store/bakedRates.ts behind
the try/catch accessor, because an unset BUN_PUBLIC_* leaves a bare process.env
in the browser bundle; and createSnapshotStore takes seed rates as a parameter
so it stays free of the ambient environment, with src/store/index.ts wiring it.

Verified all three build paths: live fetch bakes api-sourced rates with today's
date, an unreachable endpoint and SKIP_FX_FETCH both bake manual constants and
still build, and the payload is present in the bundle in each case. Verified at
runtime that a fresh install converts EUR 100 -> 747.50 DKK and USD 100 ->
644.95 DKK with the variable both set and unset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-02 14:37:39 +02:00
1 parent f50c65bd77
commit b4e1933761
9 files changed
+301 -9

No files matched your search

+12 -5
View File
@@ -1,4 +1,4 @@
import type { Dataset, MonthId, Post } from "./types.ts";
import type { Dataset, FxRate, MonthId, Post } from "./types.ts";
export const SCHEMA_VERSION = 2;
@@ -9,10 +9,15 @@ export function newId(): string {
}
/**
* The first-run dataset. Takes the start month as an argument because the
* domain layer must not read the ambient clock.
* The first-run dataset. Takes the start month AND the seed exchange rates as
* arguments because the domain layer must read neither the ambient clock nor
* the ambient environment. The store passes `BAKED_FX_RATES`; callers that
* want a bare dataset pass nothing.
*/
export function createSeedDataset(startMonth: MonthId): Dataset {
export function createSeedDataset(
startMonth: MonthId,
fxRates: readonly FxRate[] = [],
): Dataset {
const posts: Post[] = SEED_POST_NAMES.map((name, order) => ({
id: newId(),
name,
@@ -30,7 +35,9 @@ export function createSeedDataset(startMonth: MonthId): Dataset {
foldStartMonth: startMonth,
schemaVersion: SCHEMA_VERSION,
},
fxRates: [],
// Copied, so a caller passing shared constants cannot have them edited
// out from under it the first time the user changes a rate.
fxRates: fxRates.map((rate) => ({ ...rate })),
posts,
months: [
{ id: startMonth, income: { amount: 0, currency: "DKK" }, ruleOverrides: {} },