feat(currencies): ship sterling, and migrate it into existing datasets
GBP joins the currencies the app comes with: £, two decimals, and a committed fallback rate of 8.704735 DKK to the pound. build.ts now fetches it too, so a build with a network bakes a live GBP rate and one without still covers it — the coverage check derives from the seed table, so it demanded GBP as soon as the table listed it. The seed table alone would not have delivered this. Baked currencies and rates seed a NEW dataset and nothing else, so an existing dataset would have been left typing the code, name, symbol and decimals by hand. A 4 -> 5 migration adds sterling to a dataset that lacks it, with its rate. Supplying a rate 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, or a GBP rate they already hold, is left exactly as it is — asserted, including the case where their definition is "wrong" (zero decimals), because it is theirs. This exposed a real latent bug. The 2 -> 3 step seeded the live seed-currency table BY REFERENCE, so adding sterling 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. Every step now carries its own frozen copy, and a source-level guard fails the build if a live default is imported again. Scoping that guard took two goes: matching the whole file flagged the comment explaining the rule, and then flagged the frozen local copy that is the fix. Two test fixtures used GBP as their example of an UNSUPPORTED currency and now use CHF, which no seed table lists. Verified in a browser by writing a schemaVersion 4 dataset into IndexedDB and reloading: the currency table gains "British pound / £ / 2" with "manual · 2026-09-02", the post currency selector offers GBP, and the income and post survive untouched. Note for the next person — a migration applies in memory on load and is written back on the next mutation, so reading IndexedDB straight after a reload still shows the OLD version. That is recorded in docs/TODO.md, because it made this verification look like a failure at first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
89f2f4fd19
commit
f4223573e4
14 files changed
+216
-25
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
+15
-1
@@ -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
|
||||
|
||||
+11
-4
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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([])],
|
||||
|
||||
+10
-3
@@ -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,
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
+55
-2
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user