Recurrence is two orthogonal fields, not a list of special cases: a kind (everyNMonths / everyNDays / everyNWeeks) and an anchoring (calendar / lastCharge). The phone bill that recharges early when its data cap is hit is everyNDays(30) anchored to lastCharge — the domain never learns what a data cap is, because confirming the occurrence early already records the date. Purchase.source pairs occurrenceDate (identity: which slot this claims) with Purchase.date (truth: when the money moved). They differ whenever a bill is paid off schedule, and lastCharge measures from the latter. RecurringCost.amount is a single mutable Money with no RuleVersion-style series. Safe only because confirmed purchases hold what was actually paid; under projection-only it would destroy history. The 6 -> 7 step writes an empty array and reads nothing. Decimals took 6.
217 lines
9.2 KiB
TypeScript
217 lines
9.2 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 : [],
|
|
}),
|
|
];
|
|
|
|
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;
|
|
}
|