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>
276 lines
12 KiB
TypeScript
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;
|
|
}
|