diff --git a/AGENTS.md b/AGENTS.md index e86876a..52ce6aa 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,13 @@ ship twice — re-introducing one is a regression, not a style choice. worker's precache list (`src/swPrecache.test.ts`) — which must contain no duplicate, because `cache.addAll` rejects on one, which is why the service worker had never once installed in any environment. +- **A migration step must not import a live default** — no seed table, no baked + rates. Each step carries its own frozen copy of whatever it writes, because a + step's output must not change when today's defaults do: it would silently + rewrite what an old dataset receives, and only for whoever had not migrated + yet. → `src/store/migrations.test.ts`, scoped to import bindings (matching the + whole file flagged both the comment explaining the rule and the frozen local + copy that is the fix). - **Every repository path named in a navigable document must be git-TRACKED** — this file, `README.md`, and `docs/*.md`. → `src/docsLinks.test.ts`, added when the docs moved into `docs/`: a restructure that quietly leaves thirty diff --git a/build.ts b/build.ts index c4a9ecd..196fe2c 100644 --- a/build.ts +++ b/build.ts @@ -13,7 +13,7 @@ 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_TARGETS: Currency[] = ["USD", "EUR", "GBP"]; const FX_TIMEOUT_MS = 8_000; /** diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 57a90e6..bcb33a6 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -18,7 +18,7 @@ record. no household mode. → [`PRODUCT.md`](PRODUCT.md) - **Schema migrations are mandatory, not optional.** `schemaVersion` plus ordered migration functions applied on load, because IndexedDB holds the - user's only copy of their financial data. Now at version 4. + user's only copy of their financial data. Now at version 5. → [`specs/2026-09-01-budget-app-design.md`](specs/2026-09-01-budget-app-design.md) - **A destructive write downloads a backup first.** Import and reset both export before replacing, and abort if the export fails. → commit `ac3d18a` @@ -136,6 +136,20 @@ record. now" died with only a CORS error to go on. The build-time fetch never noticed, because a server-side fetch follows redirects freely. → commit history, and `src/store/fxApi.ts` +- **A migration must never read today's defaults.** The 2 -> 3 step seeded the + live seed-currency table by reference, so adding sterling to that table would + have retroactively changed what an old dataset got from a step it had already + been through — silently, and only for whoever had not migrated yet. Each step + now carries its own frozen copy, and a source-level guard fails the build if a + live default is imported again. → `src/store/migrations.ts` +- **Sterling ships with the app, and is migrated into datasets that already + exist.** Baked currencies and rates seed a NEW dataset and nothing else, so + the seed table alone would have left every existing user typing the code, name + symbol and decimals by hand. The 4 -> 5 step supplies the rate as well, which + is the one thing the "rates never backstop a cleared rate" rule permits here: + it forbids re-supplying a number the owner deleted, not introducing one for a + currency they have never had. A GBP the owner already defined is left exactly + as it is. → `src/store/migrations.ts` - **The v3 -> v4 migration DROPS the stale URL rather than rewriting it** to the new endpoint, so a dataset that stored it follows whatever the current default is and the next move of the service needs no second migration for the same diff --git a/docs/TODO.md b/docs/TODO.md index a37e8f5..52b53d3 100755 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -8,14 +8,21 @@ decisions behind it are in `docs/specs/` — one spec per sub-project, each recording the alternatives that were rejected. Rules that outlived their task moved to `AGENTS.md`. -**Current state:** merged to `main`. 457 tests passing, +**Current state:** merged to `main`. 464 tests passing, `bunx tsc --noEmit` clean, `bun run build` succeeds on both the live-fetch and offline paths. -**Schema is at version 4.** v1 -> v2 made allocation rules a dated series; +**Schema is at version 5.** v1 -> v2 made allocation rules a dated series; v2 -> v3 moved currencies into the dataset; v3 -> v4 drops a stored rate-service -URL that can no longer work. All three are behaviour-preserving by construction -— not one changes a stored figure — and are tested as such. +URL that can no longer work; v4 -> v5 adds sterling to an existing dataset. All +four are behaviour-preserving by construction — not one changes a stored figure +— and are tested as such. + +A migration applies **on load, in memory**; it is written back on the next +mutation, not by `load()` itself. So a freshly migrated dataset shows the new +shape immediately while IndexedDB still holds the old version for a while. That +is not a bug, but it will mislead you if you verify a migration by reading +IndexedDB instead of the app. --- diff --git a/src/domain/seed.ts b/src/domain/seed.ts index e507a56..6eaed63 100644 --- a/src/domain/seed.ts +++ b/src/domain/seed.ts @@ -1,7 +1,7 @@ import { SEED_CURRENCIES } from "./types.ts"; import type { CurrencyDef, Dataset, FxRate, MonthId, Post } from "./types.ts"; -export const SCHEMA_VERSION = 4; +export const SCHEMA_VERSION = 5; const SEED_POST_NAMES = ["Video Games", "Food", "Events and Social"] as const; diff --git a/src/domain/types.test.ts b/src/domain/types.test.ts index e91db56..8adc3cb 100644 --- a/src/domain/types.test.ts +++ b/src/domain/types.test.ts @@ -7,8 +7,14 @@ import { DEFAULT_CURRENCY_DIGITS, SEED_CURRENCIES } from "./types.ts"; * currencies, so the dataset is the authority and there is nothing global to * assert. What remains true is what a NEW dataset starts with. */ -test("a new dataset starts with the three currencies the app shipped with", () => { - expect(SEED_CURRENCIES.map((c) => c.code)).toEqual(["DKK", "USD", "EUR"]); +test("a new dataset starts with the currencies the app shipped with", () => { + expect(SEED_CURRENCIES.map((c) => c.code)).toEqual(["DKK", "USD", "EUR", "GBP"]); +}); + +test("sterling carries the pound sign, so \"10£\" parses", () => { + const gbp = SEED_CURRENCIES.find((c) => c.code === "GBP")!; + expect(gbp.symbol).toBe("£"); + expect(gbp.digits).toBe(2); }); test("each seeded currency carries its decimals and a symbol to type", () => { diff --git a/src/domain/types.ts b/src/domain/types.ts index e4b203e..f3a4ff7 100755 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -28,6 +28,7 @@ export const SEED_CURRENCIES: readonly CurrencyDef[] = [ { code: "DKK", digits: 2, symbol: "kr", name: "Danish krone" }, { code: "USD", digits: 2, symbol: "$", name: "US dollar" }, { code: "EUR", digits: 2, symbol: "\u20ac", name: "Euro" }, + { code: "GBP", digits: 2, symbol: "\u00a3", name: "British pound" }, ]; /** Used when a currency has no definition. Two places is right almost always. */ diff --git a/src/export/json.test.ts b/src/export/json.test.ts index 0a9d0a6..e975d71 100644 --- a/src/export/json.test.ts +++ b/src/export/json.test.ts @@ -88,8 +88,9 @@ describe("validation", () => { test("rejects an unsupported currency", () => { const data = populated() as any; - data.settings.baseCurrency = "GBP"; - expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/GBP/); + // CHF, not GBP: GBP is one of the currencies the app ships with now. + data.settings.baseCurrency = "CHF"; + expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/CHF/); }); test("rejects a malformed MonthId", () => { diff --git a/src/store/bakedRates.test.ts b/src/store/bakedRates.test.ts index 7b19e21..6a973c4 100644 --- a/src/store/bakedRates.test.ts +++ b/src/store/bakedRates.test.ts @@ -6,7 +6,7 @@ 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"]); + expect(FALLBACK_FX_RATES.map((r) => r.currency).sort()).toEqual(["EUR", "GBP", "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); @@ -18,6 +18,7 @@ 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" }, + { currency: "GBP", baseUnitsPerOne: 8.712345, updatedAt: "2026-09-02", source: "api" }, ]; test("an unset variable falls back to the committed constants", () => { @@ -36,7 +37,7 @@ describe("reading what the build baked in", () => { 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" }])], + ["an unsupported currency", JSON.stringify([{ currency: "CHF", 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([])], diff --git a/src/store/bakedRates.ts b/src/store/bakedRates.ts index d4057bc..957d417 100644 --- a/src/store/bakedRates.ts +++ b/src/store/bakedRates.ts @@ -20,9 +20,10 @@ import { SEED_CURRENCIES, type Currency, type FxRate } from "../domain/types.ts" * * 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. + * values are real (frankfurter, DKK base, on the date each row carries); EUR + * barely moves because DKK is pegged to it, so it ages well. USD and GBP do + * move — a stale 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({ @@ -37,6 +38,12 @@ export const FALLBACK_FX_RATES: readonly FxRate[] = Object.freeze([ updatedAt: "2026-09-01", source: "manual" as const, }), + Object.freeze({ + currency: "GBP" as Currency, + baseUnitsPerOne: 8.704735, + updatedAt: "2026-09-02", + source: "manual" as const, + }), ]); /** diff --git a/src/store/currencyActions.test.ts b/src/store/currencyActions.test.ts index 1304be0..82ae150 100644 --- a/src/store/currencyActions.test.ts +++ b/src/store/currencyActions.test.ts @@ -63,7 +63,7 @@ describe("removing a currency", () => { const data = draft(); actions.addCurrency(data, { code: "JPY", digits: 0 }); actions.removeCurrency(data, "JPY"); - expect(data.currencies.map((c) => c.code)).toEqual(["DKK", "USD", "EUR"]); + expect(data.currencies.map((c) => c.code)).toEqual(["DKK", "USD", "EUR", "GBP"]); }); test("its exchange rate goes with it, so no orphan row is left behind", () => { diff --git a/src/store/migrations.test.ts b/src/store/migrations.test.ts index 1916d75..2954b8e 100644 --- a/src/store/migrations.test.ts +++ b/src/store/migrations.test.ts @@ -167,10 +167,13 @@ describe("v2 -> v3: currencies become data", () => { test("the three shipped currencies are added, with their decimals", () => { const migrated = migrate(v2()); expect(migrated.settings.schemaVersion).toBe(SCHEMA_VERSION); + // The first three come from this step, frozen at what it always produced. + // GBP is appended later, by 4 -> 5; see that block. expect(migrated.currencies).toEqual([ { code: "DKK", digits: 2, symbol: "kr", name: "Danish krone" }, { code: "USD", digits: 2, symbol: "$", name: "US dollar" }, { code: "EUR", digits: 2, symbol: "€", name: "Euro" }, + { code: "GBP", digits: 2, symbol: "£", name: "British pound" }, ]); }); @@ -191,7 +194,9 @@ describe("v2 -> v3: currencies become data", () => { const migrated = migrate(structuredClone(before)); expect(migrated.posts).toEqual(before.posts); expect(migrated.months).toEqual(before.months); - expect(migrated.fxRates).toEqual(before.fxRates); + // Sterling's rate arrives later, in 4 -> 5; nothing this step does touches + // the rate table. + expect(migrated.fxRates.filter((r) => r.currency !== "GBP")).toEqual(before.fxRates); expect(migrated.settings.baseCurrency).toBe("DKK"); expect(migrated.settings.foldStartMonth).toBe("2026-01"); }); @@ -218,7 +223,7 @@ describe("v2 -> v3: currencies become data", () => { expect(migrated.posts[0]!.rules).toEqual([ { from: "2026-01", rule: { kind: "fixed", amount: { amount: 500, currency: "DKK" } } }, ]); - expect(migrated.currencies.map((c) => c.code)).toEqual(["DKK", "USD", "EUR"]); + expect(migrated.currencies.map((c) => c.code)).toEqual(["DKK", "USD", "EUR", "GBP"]); }); /** @@ -230,7 +235,9 @@ describe("v2 -> v3: currencies become data", () => { const data = v2(); data.currencies = [{ code: "DKK", digits: 2 }, { code: "JPY", digits: 0 }]; const migrated = migrate(data); - expect(migrated.currencies.map((c) => c.code)).toEqual(["DKK", "JPY"]); + // JPY survives, which is the point: the step appends nothing and discards + // nothing. GBP is appended afterwards by 4 -> 5. + expect(migrated.currencies.map((c) => c.code)).toEqual(["DKK", "JPY", "GBP"]); }); }); @@ -253,7 +260,7 @@ describe("v3 -> v4: the stale rate-service URL is dropped", () => { test("a dataset holding the dead default loses the field entirely", () => { const out = migrate(v3(STALE)); - expect(out.settings.schemaVersion).toBe(4); + expect(out.settings.schemaVersion).toBe(SCHEMA_VERSION); // Removed rather than rewritten to the new URL, so the dataset follows // whatever the current default is and a future endpoint move needs no // second migration for the same user. @@ -268,6 +275,93 @@ describe("v3 -> v4: the stale rate-service URL is dropped", () => { test("a dataset that never stored a URL is untouched apart from the version", () => { const out = migrate(v3()); expect("fxApiUrl" in out.settings).toBe(false); - expect(out.settings.schemaVersion).toBe(4); + expect(out.settings.schemaVersion).toBe(SCHEMA_VERSION); }); }); + +describe("v4 -> v5: GBP joins the prebaked currencies", () => { + const v4 = (currencies: any[], fxRates: any[] = []) => ({ + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 4 }, + currencies, + fxRates, + posts: [], + months: [], + purchases: [], + }); + const DKK = { code: "DKK", digits: 2, symbol: "kr", name: "Danish krone" }; + const USD_RATE = { + currency: "USD", + baseUnitsPerOne: 6.449532, + updatedAt: "2026-09-01", + source: "manual" as const, + }; + + test("a dataset without GBP gains the definition and a rate", () => { + const out = migrate(v4([DKK], [USD_RATE])); + expect(out.settings.schemaVersion).toBe(SCHEMA_VERSION); + expect(out.currencies.map((c) => c.code)).toEqual(["DKK", "GBP"]); + const gbp = out.fxRates.find((r) => r.currency === "GBP")!; + expect(gbp.baseUnitsPerOne).toBe(8.704735); + // "manual", never "api": Settings shows this field, and a committed + // constant must not claim it came from a rate service. + expect(gbp.source).toBe("manual"); + }); + + test("a GBP the owner defined themselves is left exactly as it is", () => { + // Zero decimals is wrong for sterling, which is the point: it is THEIR + // definition, and a migration that corrected it would be editing data. + const mine = { code: "GBP", digits: 0, symbol: "quid" }; + const out = migrate(v4([DKK, mine])); + expect(out.currencies.find((c) => c.code === "GBP")).toEqual(mine); + }); + + test("a GBP rate the owner already has keeps their number", () => { + const mine = { + currency: "GBP", + baseUnitsPerOne: 9, + updatedAt: "2026-01-01", + source: "manual" as const, + }; + const out = migrate(v4([DKK], [mine])); + expect(out.fxRates.filter((r) => r.currency === "GBP")).toEqual([mine]); + }); + + test("everything else is left untouched", () => { + const out = migrate(v4([DKK], [USD_RATE])); + expect(out.fxRates.find((r) => r.currency === "USD")).toEqual(USD_RATE); + expect(out.currencies[0]).toEqual(DKK); + }); + + test("GBP is not added twice when the migration is somehow reapplied", () => { + const once = migrate(v4([DKK])); + const codes = once.currencies.filter((c) => c.code === "GBP"); + expect(codes).toHaveLength(1); + }); +}); + +/** + * A migration's output must not change when today's defaults change. The 2 -> 3 + * step originally seeded `SEED_CURRENCIES` by reference, so adding GBP to that + * table would have retroactively altered what an old dataset got from a step it + * had already been through — silently, and only for whoever had not migrated + * yet. Source-level, like eventCapture.test.ts, because the damage is invisible + * in review and in a green suite. + */ +test("no migration step imports a live default that a later commit could change", async () => { + const source = await Bun.file("src/store/migrations.ts").text(); + // Scoped to IMPORT BINDINGS, not to the whole file. Matching anywhere is too + // blunt: it flagged the comment explaining this rule, and then flagged + // V3_SEED_CURRENCIES, the frozen local copy that is the fix. What must never + // happen is the module reading a constant another commit can change. + const imports = [...source.matchAll(/import\s+([\s\S]*?)\s+from\s+["'][^"']+["']/g)].map( + (m) => m[1]!, + ); + // SCHEMA_VERSION is deliberately absent: migrate() reads it as the ceiling to + // run to, which must track the current version. It is not a default that a + // step writes into someone's data. + const live = ["SEED_CURRENCIES", "FALLBACK_FX_RATES", "BAKED_FX_RATES"]; + const offenders = imports.filter((binding) => + live.some((name) => new RegExp(`\\b${name}\\b`).test(binding)), + ); + expect(offenders).toEqual([]); +}); diff --git a/src/store/migrations.ts b/src/store/migrations.ts index f21a20a..24c46d5 100644 --- a/src/store/migrations.ts +++ b/src/store/migrations.ts @@ -1,6 +1,31 @@ import { SCHEMA_VERSION } from "../domain/seed.ts"; -import { SEED_CURRENCIES } from "../domain/types.ts"; import { STALE_FX_API_URL } from "./fxApi.ts"; + +/** + * What the 2 -> 3 step has always produced, frozen HERE rather than imported + * from the live seed table. + * + * This module must not read today's defaults. It originally seeded the live + * seed-currency table by reference, so adding a currency there would have + * retroactively changed what an old dataset received from a step it had already + * been through — silently, and only for whoever had not migrated yet. + * `src/store/migrations.test.ts` fails if a live default is referenced again; + * it matches source text, so do not name those constants even in a comment. + */ +const V3_SEED_CURRENCIES = [ + { code: "DKK", digits: 2, symbol: "kr", name: "Danish krone" }, + { code: "USD", digits: 2, symbol: "$", name: "US dollar" }, + { code: "EUR", digits: 2, symbol: "\u20ac", name: "Euro" }, +]; + +/** Sterling as 4 -> 5 adds it. Frozen for the same reason as the list above. */ +const V5_GBP = { code: "GBP", digits: 2, symbol: "\u00a3", name: "British pound" }; +const V5_GBP_RATE = { + currency: "GBP", + baseUnitsPerOne: 8.704735, + updatedAt: "2026-09-02", + source: "manual" as const, +}; import type { Dataset } from "../domain/types.ts"; export class UnsupportedSchemaError extends Error { @@ -55,7 +80,7 @@ const MIGRATIONS: Array<(data: any) => any> = [ settings: { ...data.settings, schemaVersion: 3 }, currencies: Array.isArray(data.currencies) ? data.currencies - : SEED_CURRENCIES.map((currency) => ({ ...currency })), + : V3_SEED_CURRENCIES.map((currency) => ({ ...currency })), }), // 3 -> 4: drop a stored rate-service URL that can no longer work. @@ -82,6 +107,34 @@ const MIGRATIONS: Array<(data: any) => any> = [ : { ...data.settings, schemaVersion: 4 }, }; }, + + // 4 -> 5: sterling joins the currencies the app ships with, and an existing + // dataset gets it too. Baked currencies and rates seed a NEW dataset and + // nothing else, so without this step the owner would have to type the code, + // name, symbol and decimals by hand to spend in a currency the app now + // claims to ship. + // + // The rate is supplied because there is no prior choice to overwrite: GBP has + // never existed in this dataset, so nothing is being silently replaced. That + // is the line the "rates never backstop a cleared rate" rule draws — it + // forbids re-supplying a number the owner removed, not introducing one for a + // currency they have never had. A GBP the owner already defined, or a GBP + // rate they already hold, is left exactly as it is. + // + // No stored figure changes: adding a currency definition and a rate for a + // currency nothing yet references cannot alter an amount already recorded. + (data: any) => { + const currencies = Array.isArray(data.currencies) ? data.currencies : []; + const fxRates = Array.isArray(data.fxRates) ? data.fxRates : []; + const hasGbp = currencies.some((c: any) => c?.code === "GBP"); + const hasGbpRate = fxRates.some((r: any) => r?.currency === "GBP"); + return { + ...data, + settings: { ...data.settings, schemaVersion: 5 }, + currencies: hasGbp ? currencies : [...currencies, { ...V5_GBP }], + fxRates: hasGbpRate || hasGbp ? fxRates : [...fxRates, { ...V5_GBP_RATE }], + }; + }, ]; export function migrate(raw: unknown): Dataset { diff --git a/src/store/snapshot.test.ts b/src/store/snapshot.test.ts index 598dd95..c977e21 100644 --- a/src/store/snapshot.test.ts +++ b/src/store/snapshot.test.ts @@ -208,7 +208,7 @@ describe("reset", () => { "Food", "Events and Social", ]); - expect(after.currencies.map((c) => c.code)).toEqual(["DKK", "USD", "EUR"]); + expect(after.currencies.map((c) => c.code)).toEqual(["DKK", "USD", "EUR", "GBP"]); expect(after.fxRates).toEqual(rates); expect(after.purchases).toEqual([]); expect(after.months).toHaveLength(1);