Files
budget2.0/src/store/migrations.ts
T
Lucas WintherandClaude Opus 5 bdb2b9d80f fix(store): 7 -> 8 expands a month-only charge date on an anchored cost
The step stripped `occurrenceDate` from an anchored cost's charges and left
`Purchase.date` alone. Under schema 7 those dates are legitimately month-only:
a monthly cost's grid was month-granular, and `confirmOccurrence` wrote
`date: overrides.date ?? occurrenceDate`, so every one-click confirmation of a
month-granular anchored bill was stored without a day. An anchored cost has no
slots, so after the upgrade that date IS the anchor the series steps from — a
realistic schema-7 dataset therefore migrated cleanly and then threw out of
every money route on first load. The owner has a phone bill; this is their
data, not a hand-edited file.

The day comes from the `occurrenceDate` about to be stripped when that slot
falls in the same month, because it is the day the owner actually settled;
otherwise the 1st, the same convention the `startDate` expansion already uses.
A slot in a DIFFERENT month is not borrowed from — that would move the
purchase, and moving money is what this step must not do.

Extending an unshipped step rather than appending a new one: 7 -> 8 exists only
on this branch, so no stored dataset has been through it.

The regression test builds a schema-7 dataset the app itself would have
written and folds the result, rather than only inspecting the shape. A second
test pins the agreement the two halves of this fix have to keep:
`parseDatasetJson` migrates before it validates, so everything this step
produces must satisfy the import boundary the next commit tightens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 18:31:52 +02:00

276 lines
12 KiB
TypeScript

import { SCHEMA_VERSION } from "../domain/seed.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" },
];
/**
* What 5 -> 6 falls back to when a dataset carries no usable per-currency
* digits. Frozen for the same reason as the tables above: a step's output must
* not move when today's defaults do.
*/
const V6_FALLBACK_DIGITS = 2;
/** 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 {
constructor(public readonly found: number) {
super(
`Data was written by a newer version of the app (schema ${found}, this build understands ${SCHEMA_VERSION}). Update the app before opening it.`,
);
this.name = "UnsupportedSchemaError";
}
}
/**
* Ordered migration steps. Each entry upgrades from its index version to the
* next. Add one whenever SCHEMA_VERSION increases; never edit an existing one,
* because users' stored data has already been through it.
*/
const MIGRATIONS: Array<(data: any) => any> = [
// index 0: there is no version 0 in the wild.
undefined as unknown as (data: any) => any,
// 1 -> 2: a post's single `standingRule` becomes a one-entry dated series,
// effective from the fold start. Behaviour-preserving by construction: the
// standing rule already applied from the fold start onward, so every month
// resolves to exactly the allocation it did before.
(data: any) => ({
...data,
settings: { ...data.settings, schemaVersion: 2 },
posts: (data.posts ?? []).map((post: any) => {
const { standingRule, ...rest } = post;
return {
...rest,
rules:
standingRule === undefined
? []
: [{ from: data.settings.foldStartMonth, rule: standingRule }],
};
}),
}),
// 2 -> 3: currencies become data. Until now the three supported currencies
// and their decimal places were compile-time constants, so adding one meant
// changing code; they now live in the dataset, which is also what lets the
// owner define their own.
//
// Behaviour-preserving by construction: every currency that existed had two
// decimal places, and the seeded table records two, so not one stored amount
// can round differently than it did before. An existing `currencies` field
// is left alone rather than overwritten — a hand-edited file could already
// carry currencies the owner added, and discarding them would be data loss.
(data: any) => ({
...data,
settings: { ...data.settings, schemaVersion: 3 },
currencies: Array.isArray(data.currencies)
? data.currencies
: V3_SEED_CURRENCIES.map((currency) => ({ ...currency })),
}),
// 3 -> 4: drop a stored rate-service URL that can no longer work.
// api.frankfurter.app now answers every request with a 301 to
// api.frankfurter.dev/v1, and because that redirect carries no CORS headers
// a browser refuses to follow it — so "Fetch rates now" failed with nothing
// but a CORS error to go on. Changing the default fixes a dataset that never
// stored the URL; this fixes one that did.
//
// The field is REMOVED rather than rewritten to the new endpoint, so the
// dataset follows whatever the current default is and a future move of the
// service needs no second migration for the same user. A URL the owner chose
// themselves is left strictly alone: only the exact dead default is dropped.
//
// No stored figure changes, so this cannot alter a single number in the
// budget.
(data: any) => {
const { fxApiUrl, ...settings } = data.settings ?? {};
return {
...data,
settings:
fxApiUrl === STALE_FX_API_URL
? { ...settings, schemaVersion: 4 }
: { ...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 }],
};
},
// 5 -> 6: decimal places stop being per-currency and become one setting for
// the whole dataset. See docs/specs/2026-09-02-global-decimals-design.md,
// which records what that gives up: a dataset mixing minor units — yen at 0
// beside kroner at 2 — can no longer be described.
//
// The setting takes the MAXIMUM of what the currencies declared, not the base
// currency's value. A dataset holding a three-decimal currency already has
// three-decimal amounts stored in it, and adopting the base's 2 would mean
// the next edit of one silently truncated it. The maximum can only ever keep
// more precision than before, never less.
//
// Behaviour-preserving on every dataset this app has written: all four seeded
// currencies record 2, so the maximum is 2 and not one stored amount rounds
// differently than it did before.
(data: any) => {
const currencies = Array.isArray(data.currencies) ? data.currencies : [];
const declared = currencies
.map((currency: any) => currency?.digits)
.filter((digits: unknown): digits is number => Number.isInteger(digits));
return {
...data,
settings: {
...data.settings,
schemaVersion: 6,
digits: declared.length > 0 ? Math.max(...declared) : V6_FALLBACK_DIGITS,
},
// `digits` dropped from each entry; everything else about a definition
// the owner wrote is theirs and survives untouched.
currencies: currencies.map(({ digits, ...rest }: any) => rest),
};
},
// 6 -> 7: recurring costs join the dataset.
//
// Behaviour-preserving by construction, and more strongly than most: an
// empty array projects no occurrences, so `expected` is zero everywhere and
// `projected` equals `remaining` in every month of every existing dataset.
// Not one stored figure is read, let alone written.
//
// An existing `recurring` field is left alone rather than overwritten, for
// the same reason the 2 -> 3 step leaves `currencies` alone: a hand-edited
// file could already carry them, and discarding them would be data loss.
//
// Nothing is frozen here because nothing is copied — the step writes a
// literal empty array and reads no default, live or otherwise.
(data: any) => ({
...data,
settings: { ...data.settings, schemaVersion: 7 },
recurring: Array.isArray(data.recurring) ? data.recurring : [],
}),
// 7 -> 8: recurring costs split into two projection models.
//
// A `lastCharge` cost no longer has a grid of slots — its next due date is
// one cycle after the latest actual charge — so `occurrenceDate` on its
// charges is meaningless, and leaving it would invite a future reader to
// trust it. A `calendar` cost keeps its slots and is not touched.
//
// Day precision also now requires a real day, and on BOTH sides of an
// anchored cost. Its `startDate`, if it was month-only, is expanded to the
// 1st. So is the `date` of every purchase charged to it — an anchored cost
// has no slots, so `anchoredOccurrences` steps forward from the latest
// charge's own date, and a month-only one there throws out of the first
// fold after the upgrade. Those dates are REAL in schema 7: the old
// `confirmOccurrence` wrote `date: overrides.date ?? occurrenceDate`, so a
// month-granular monthly cost's one-click confirmations were stored
// month-only. That is a phone bill confirmed from the Expected band — the
// ordinary case, not a hand-edited file.
//
// The day comes from the `occurrenceDate` about to be stripped when that
// slot falls in the same month, because it is the day the owner actually
// settled; otherwise the 1st, the same convention the startDate expansion
// uses. Requiring the same month matters: taking a day from another month
// would MOVE the purchase, and moving money is what this step must not do.
//
// NOT behaviour-preserving, and it must not pretend to be: an anchored
// cost's projected dates deliberately change, which is the point of the
// change. What IS preserved is every recorded figure — no Money is touched,
// no purchase is added or removed.
//
// The two literals below are inline strings, not imported constants: this
// step must not read a live default.
(data: any) => {
const recurring = Array.isArray(data.recurring) ? data.recurring : [];
const anchoredIds = new Set(
recurring.filter((c: any) => c?.anchoring === "lastCharge").map((c: any) => c?.id),
);
return {
...data,
settings: { ...data.settings, schemaVersion: 8 },
recurring: recurring.map((cost: any) => {
if (cost?.anchoring !== "lastCharge") return cost;
if (cost?.recurrence?.kind !== "everyNMonths") return cost;
if (typeof cost.startDate !== "string" || cost.startDate.length !== 7) return cost;
return { ...cost, startDate: `${cost.startDate}-01` };
}),
purchases: (Array.isArray(data.purchases) ? data.purchases : []).map((p: any) => {
if (!p?.source || !anchoredIds.has(p.source.recurringId)) return p;
const slot = p.source.occurrenceDate;
const monthOnly = typeof p.date === "string" && p.date.length === 7;
const date = !monthOnly
? p.date
: typeof slot === "string" && slot.length === 10 && slot.slice(0, 7) === p.date
? slot
: `${p.date}-01`;
return { ...p, date, source: { recurringId: p.source.recurringId } };
}),
};
},
];
export function migrate(raw: unknown): Dataset {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
throw new Error("Stored value is not a dataset");
}
const data = raw as Record<string, any>;
const version = data.settings?.schemaVersion;
if (typeof version !== "number") {
throw new Error("Stored dataset has no settings.schemaVersion");
}
if (version > SCHEMA_VERSION) throw new UnsupportedSchemaError(version);
let current = data;
for (let v = version; v < SCHEMA_VERSION; v++) {
const step = MIGRATIONS[v];
if (!step) throw new Error(`No migration from schema ${v} to ${v + 1}`);
current = step(current);
}
return current as Dataset;
}