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) <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-02 14:37:39 +02:00
co-authored by Claude Opus 5
parent f50c65bd77
commit b4e1933761
9 changed files with 301 additions and 9 deletions
+62
View File
@@ -3,11 +3,70 @@ import { rm } from "node:fs/promises";
import path from "node:path";
import { normalizeBase } from "./src/ui/basePath.ts";
import { buildManifest } from "./src/manifest.ts";
import { buildFxUrl, parseFxResponse, DEFAULT_FX_API_URL } from "./src/store/fxApi.ts";
import { FALLBACK_FX_RATES } from "./src/store/bakedRates.ts";
import type { Currency, FxRate } from "./src/domain/types.ts";
const basePath = normalizeBase(process.env.BUN_PUBLIC_BASE_PATH);
const outdir = path.join(process.cwd(), "dist");
const version = process.env.BUILD_VERSION ?? String(Date.now());
const FX_BASE: Currency = "DKK";
const FX_TARGETS: Currency[] = ["USD", "EUR"];
const FX_TIMEOUT_MS = 8_000;
/**
* Exchange rates embedded at build time so a fresh install can convert a EUR
* purchase on day one instead of erroring. They seed a new dataset only — see
* src/store/bakedRates.ts for why they must never backstop a missing rate.
*
* A network failure must NOT fail the build: it falls back to the committed
* constants, which are labelled "manual" so Settings never claims a hardcoded
* number came from a rate service. Fetched rates carry the build date rather
* than the date the browser happens to run, which is why parseFxResponse
* takes an explicit `updatedAt`.
*
* SKIP_FX_FETCH=1 forces the constants, for offline or reproducible builds.
* FX_API_URL overrides the endpoint.
*/
async function bakeFxRates(): Promise<readonly FxRate[]> {
const buildDate = new Date().toISOString().slice(0, 10);
const describe = (rates: readonly FxRate[]) =>
rates.map((r) => `${r.currency} ${r.baseUnitsPerOne}`).join(", ");
if (process.env.SKIP_FX_FETCH === "1") {
console.log(`Baking the committed FX constants (SKIP_FX_FETCH=1): ${describe(FALLBACK_FX_RATES)}`);
return FALLBACK_FX_RATES;
}
const template = process.env.FX_API_URL ?? DEFAULT_FX_API_URL;
try {
const response = await fetch(buildFxUrl(template, FX_BASE, FX_TARGETS), {
signal: AbortSignal.timeout(FX_TIMEOUT_MS),
});
if (!response.ok) throw new Error(`rate service returned ${response.status}`);
const rates = parseFxResponse(await response.json(), FX_BASE, buildDate);
// A partial response would leave one currency unconvertible on a fresh
// install, which is the whole problem this solves. Prefer the complete
// constants over an incomplete live answer.
const covered = new Set(rates.map((r) => r.currency));
const missing = FX_TARGETS.filter((c) => !covered.has(c));
if (missing.length > 0) throw new Error(`response omitted ${missing.join(", ")}`);
console.log(`Baked live FX rates for ${buildDate}: ${describe(rates)}`);
return rates;
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
console.warn(
`Could not fetch FX rates (${message}); baking the committed constants instead: ${describe(FALLBACK_FX_RATES)}`,
);
return FALLBACK_FX_RATES;
}
}
const bakedFxRates = await bakeFxRates();
await rm(outdir, { recursive: true, force: true });
const result = await Bun.build({
@@ -44,6 +103,9 @@ const result = await Bun.build({
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
"process.env.BUN_PUBLIC_BASE_PATH": JSON.stringify(basePath),
// Double-encoded on purpose: the inlined literal must be a JSON *string*
// for parseBakedRates to parse at runtime.
"process.env.BUN_PUBLIC_BAKED_FX_RATES": JSON.stringify(JSON.stringify(bakedFxRates)),
},
});
+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: {} },
+76
View File
@@ -0,0 +1,76 @@
import { test, expect, describe } from "bun:test";
import { FALLBACK_FX_RATES, parseBakedRates } from "./bakedRates.ts";
import { createSeedDataset } from "../domain/seed.ts";
import type { FxRate } from "../domain/types.ts";
import { MissingRateError, toBase } from "../domain/fx.ts";
describe("the committed fallback rates", () => {
test("cover every non-base currency and are honestly labelled manual", () => {
expect(FALLBACK_FX_RATES.map((r) => r.currency).sort()).toEqual(["EUR", "USD"]);
// "api" would claim a hardcoded number came from a rate service. Settings
// shows this field to the user.
expect(FALLBACK_FX_RATES.every((r) => r.source === "manual")).toBe(true);
expect(FALLBACK_FX_RATES.every((r) => r.baseUnitsPerOne > 0)).toBe(true);
});
});
describe("reading what the build baked in", () => {
const baked: FxRate[] = [
{ currency: "USD", baseUnitsPerOne: 6.812345, updatedAt: "2026-09-02", source: "api" },
{ currency: "EUR", baseUnitsPerOne: 7.456789, updatedAt: "2026-09-02", source: "api" },
];
test("an unset variable falls back to the committed constants", () => {
expect(parseBakedRates(undefined)).toEqual(FALLBACK_FX_RATES);
});
test("a well-formed payload is used as-is", () => {
expect(parseBakedRates(JSON.stringify(baked))).toEqual(baked);
});
test("six decimal places survive — a rate is not money", () => {
const [usd] = parseBakedRates(JSON.stringify(baked));
expect(usd!.baseUnitsPerOne).toBe(6.812345);
});
test.each([
["not JSON at all", "{oh dear"],
["a non-array", JSON.stringify({ USD: 6.8 })],
["an unsupported currency", JSON.stringify([{ currency: "GBP", baseUnitsPerOne: 8.6, updatedAt: "2026-09-02", source: "api" }])],
["a non-positive rate", JSON.stringify([{ currency: "USD", baseUnitsPerOne: 0, updatedAt: "2026-09-02", source: "api" }])],
["a missing rate value", JSON.stringify([{ currency: "USD", updatedAt: "2026-09-02", source: "api" }])],
["an empty array", JSON.stringify([])],
])("%s falls back rather than booting with junk", (_label, payload) => {
expect(parseBakedRates(payload)).toEqual(FALLBACK_FX_RATES);
});
});
describe("what baked rates are allowed to do", () => {
test("they seed a new dataset", () => {
const data = createSeedDataset("2026-09", FALLBACK_FX_RATES);
expect(data.fxRates).toEqual([...FALLBACK_FX_RATES]);
});
test("seeding without them still yields no rates at all", () => {
expect(createSeedDataset("2026-09").fxRates).toEqual([]);
});
test("the seeded rows are copies, so editing one cannot rewrite the constants", () => {
const data = createSeedDataset("2026-09", FALLBACK_FX_RATES);
data.fxRates[0]!.baseUnitsPerOne = 999;
expect(FALLBACK_FX_RATES[0]!.baseUnitsPerOne).not.toBe(999);
});
/**
* The decision this guards: clearing a rate REMOVES the row so
* MissingRateError surfaces, instead of silently converting money at a
* number the user never chose. Baked rates seed; they never backstop.
*/
test("they do NOT backstop a rate the user has cleared", () => {
const data = createSeedDataset("2026-09", FALLBACK_FX_RATES);
data.fxRates = data.fxRates.filter((r) => r.currency !== "EUR");
expect(() =>
toBase({ amount: 100, currency: "EUR" }, data.settings.baseCurrency, data.fxRates),
).toThrow(MissingRateError);
});
});
+99
View File
@@ -0,0 +1,99 @@
import { CURRENCIES, type Currency, type FxRate } from "../domain/types.ts";
/**
* Exchange rates baked in at build time so a fresh install can convert a EUR
* purchase on day one instead of erroring.
*
* They seed a NEW dataset and nothing else. They deliberately do NOT act as a
* fallback whenever a rate is missing: clearing a rate removes its row so
* `MissingRateError` surfaces, rather than silently converting money at a
* number the user never chose. `src/store/bakedRates.test.ts` pins that.
*
* This lives in the store layer, not the domain, because reading the ambient
* environment is exactly what `src/domain/` may not do. `createSeedDataset`
* takes the rates as an argument instead.
*/
/**
* The last-resort rates, committed so a build with no network still produces a
* working app. DKK per one unit, six decimals, never rounded to money.
*
* Labelled "manual" rather than "api" on purpose: Settings shows this field,
* and a hardcoded constant must not claim it came from a rate service. The
* values are real (frankfurter.app, DKK base, 2026-09-01); EUR barely moves
* because DKK is pegged to it, so it ages well. USD does move — a stale USD
* number here is the reason the user can always edit or re-fetch a rate.
*/
export const FALLBACK_FX_RATES: readonly FxRate[] = Object.freeze([
Object.freeze({
currency: "USD" as Currency,
baseUnitsPerOne: 6.449532,
updatedAt: "2026-09-01",
source: "manual" as const,
}),
Object.freeze({
currency: "EUR" as Currency,
baseUnitsPerOne: 7.474959,
updatedAt: "2026-09-01",
source: "manual" as const,
}),
]);
/** The currencies a baked payload must cover: everything but the base. */
const REQUIRED = CURRENCIES.filter((c) => c !== "DKK");
function isUsableRate(value: unknown): value is FxRate {
if (typeof value !== "object" || value === null) return false;
const rate = value as Record<string, unknown>;
return (
CURRENCIES.includes(rate.currency as Currency) &&
typeof rate.baseUnitsPerOne === "number" &&
Number.isFinite(rate.baseUnitsPerOne) &&
rate.baseUnitsPerOne > 0 &&
typeof rate.updatedAt === "string" &&
(rate.source === "api" || rate.source === "manual")
);
}
/**
* Parses what `build.ts` embedded, falling back to the committed constants on
* anything unusable — an unset variable (a plain `bun --hot`), a build whose
* fetch failed, or a malformed payload. Falling back beats throwing: this runs
* during first-run seeding, and an app that will not boot is worse than an app
* with a slightly stale USD rate the user can edit.
*/
export function parseBakedRates(raw: string | undefined): readonly FxRate[] {
if (!raw) return FALLBACK_FX_RATES;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return FALLBACK_FX_RATES;
}
if (!Array.isArray(parsed) || !parsed.every(isUsableRate)) return FALLBACK_FX_RATES;
// A partial payload would leave one currency unconvertible on a fresh
// install, which is the whole problem this feature exists to solve.
const covered = new Set(parsed.map((rate) => rate.currency));
if (!REQUIRED.every((currency) => covered.has(currency))) return FALLBACK_FX_RATES;
return parsed;
}
/**
* try/catch, not a `typeof process` guard. Bun inlines the literal only when
* the variable is set; unset, the bare reference reaches the browser and
* throws before the app boots. A `typeof` test survives inlining and discards
* the inlined value. See `readBasePathEnv` in src/ui/basePath.ts.
*/
export function readBakedRatesEnv(): string | undefined {
try {
return process.env.BUN_PUBLIC_BAKED_FX_RATES;
} catch {
return undefined;
}
}
export const BAKED_FX_RATES = parseBakedRates(readBakedRatesEnv());
+8
View File
@@ -38,3 +38,11 @@ describe("parseFxResponse", () => {
expect(() => parseFxResponse({ rates: { EUR: 0 } }, "DKK")).toThrow(/EUR/);
});
});
test("an explicit updatedAt is used instead of the clock", () => {
// The build bakes rates in with the BUILD date, not the date the browser
// happens to run parseFxResponse.
const rates = parseFxResponse({ rates: { EUR: 0.13378, USD: 0.15505 } }, "DKK", "2026-09-01");
expect(rates.map((r) => r.updatedAt)).toEqual(["2026-09-01", "2026-09-01"]);
expect(rates.every((r) => r.source === "api")).toBe(true);
});
+6 -2
View File
@@ -13,7 +13,12 @@ export function buildFxUrl(
.replace("{targets}", targets.join(","));
}
export function parseFxResponse(body: unknown, base: Currency): FxRate[] {
export function parseFxResponse(
body: unknown,
base: Currency,
/** Defaults to now. The build passes its own date when baking rates in. */
updatedAt: string = new Date().toISOString(),
): FxRate[] {
const rates =
typeof body === "object" && body !== null
? (body as Record<string, unknown>).rates
@@ -22,7 +27,6 @@ export function parseFxResponse(body: unknown, base: Currency): FxRate[] {
throw new Error("Exchange rate response has no `rates` object");
}
const updatedAt = new Date().toISOString();
const out: FxRate[] = [];
for (const [currency, value] of Object.entries(rates as Record<string, unknown>)) {
+4
View File
@@ -1,6 +1,7 @@
import { monthOf } from "../domain/months.ts";
import { readDataset, writeDataset } from "./db.ts";
import { createSnapshotStore } from "./snapshot.ts";
import { BAKED_FX_RATES } from "./bakedRates.ts";
/** The current month, read at module load. The UI owns the clock; the domain does not. */
export const currentMonth = monthOf(new Date().toISOString().slice(0, 10));
@@ -8,6 +9,9 @@ export const currentMonth = monthOf(new Date().toISOString().slice(0, 10));
export const store = createSnapshotStore(
{ read: readDataset, write: writeDataset },
currentMonth,
// First run only, so a fresh install can convert a EUR purchase on day one.
// Never a backstop for a rate the user has cleared.
BAKED_FX_RATES,
);
export * from "./snapshot.ts";
+25
View File
@@ -180,3 +180,28 @@ test("get before load throws rather than returning empty data", () => {
const store = createSnapshotStore(fake.persistence, "2026-09");
expect(() => store.get()).toThrow(/not loaded/i);
});
describe("seed rates", () => {
test("a first run seeds the rates it was given", async () => {
const fake = fakePersistence(null);
const rates = [
{ currency: "EUR" as const, baseUnitsPerOne: 7.474959, updatedAt: "2026-09-01", source: "manual" as const },
];
const store = createSnapshotStore(fake.persistence, "2026-09", rates);
await store.load();
expect(store.get().fxRates).toEqual(rates);
});
test("an existing dataset is never given seed rates", async () => {
// Loading stored data must not touch fxRates — the user may have
// deliberately cleared one, and re-adding it would convert money at a
// number they never chose.
const stored = createSeedDataset("2026-01");
const fake = fakePersistence(stored);
const store = createSnapshotStore(fake.persistence, "2026-09", [
{ currency: "EUR", baseUnitsPerOne: 7.474959, updatedAt: "2026-09-01", source: "manual" },
]);
await store.load();
expect(store.get().fxRates).toEqual([]);
});
});
+9 -2
View File
@@ -1,5 +1,5 @@
import { createSeedDataset } from "../domain/seed.ts";
import type { Dataset, MonthId } from "../domain/types.ts";
import type { Dataset, FxRate, MonthId } from "../domain/types.ts";
export interface Persistence {
read(): Promise<Dataset | null>;
@@ -19,6 +19,13 @@ export interface SnapshotStore {
export function createSnapshotStore(
persistence: Persistence,
currentMonth: MonthId,
/**
* Rates for a FIRST RUN only. Passed in rather than read here so this module
* stays free of the ambient environment; src/store/index.ts supplies
* BAKED_FX_RATES. Loading an existing dataset never touches its rates — the
* user may have cleared one deliberately.
*/
seedRates: readonly FxRate[] = [],
): SnapshotStore {
let snapshot: Dataset | null = null;
const listeners = new Set<Listener>();
@@ -64,7 +71,7 @@ export function createSnapshotStore(
notify();
return;
}
await commit(createSeedDataset(currentMonth));
await commit(createSeedDataset(currentMonth, seedRates));
},
get,