feat(store): migrate allocation rules to a dated series (v1 -> v2)
A pure shape change: standingRule becomes one version effective from the fold start, which is where it already applied from. Asserted behaviour-preserving — every post in every month resolves to the same allocation before and after, so no balance moves.
This commit is contained in:
@@ -22,3 +22,95 @@ describe("migrate", () => {
|
||||
expect(() => migrate({ posts: [] })).toThrow(/schemaVersion/);
|
||||
});
|
||||
});
|
||||
|
||||
import { allocationFor } from "../domain/allocation.ts";
|
||||
|
||||
/** A v1 dataset, in the shape that shipped before rules were versioned. */
|
||||
function v1Dataset() {
|
||||
return {
|
||||
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 },
|
||||
fxRates: [],
|
||||
posts: [
|
||||
{
|
||||
id: "food",
|
||||
name: "Food",
|
||||
order: 0,
|
||||
archived: false,
|
||||
currency: "DKK",
|
||||
standingRule: { kind: "percentOfIncome", percent: 20 },
|
||||
},
|
||||
{
|
||||
id: "games",
|
||||
name: "Video Games",
|
||||
order: 1,
|
||||
archived: false,
|
||||
currency: "DKK",
|
||||
standingRule: { kind: "fixed", amount: { amount: 400, currency: "DKK" } },
|
||||
},
|
||||
],
|
||||
months: [
|
||||
{ id: "2026-01", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} },
|
||||
{
|
||||
id: "2026-02",
|
||||
income: { amount: 12000, currency: "DKK" },
|
||||
ruleOverrides: { games: { kind: "fixed", amount: { amount: 900, currency: "DKK" } } },
|
||||
},
|
||||
],
|
||||
purchases: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("v1 -> v2: rules become a dated series", () => {
|
||||
test("each standingRule becomes one version starting at the fold start", () => {
|
||||
const migrated = migrate(v1Dataset());
|
||||
expect(migrated.settings.schemaVersion).toBe(2);
|
||||
expect(migrated.posts[0]!.rules).toEqual([
|
||||
{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 20 } },
|
||||
]);
|
||||
expect(migrated.posts[1]!.rules).toEqual([
|
||||
{ from: "2026-01", rule: { kind: "fixed", amount: { amount: 400, currency: "DKK" } } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("the old field is gone afterwards", () => {
|
||||
const migrated = migrate(v1Dataset()) as unknown as Record<string, any>;
|
||||
expect("standingRule" in (migrated.posts as any[])[0]).toBe(false);
|
||||
});
|
||||
|
||||
test("per-month overrides are left untouched", () => {
|
||||
const migrated = migrate(v1Dataset());
|
||||
expect(migrated.months[1]!.ruleOverrides.games).toEqual({
|
||||
kind: "fixed",
|
||||
amount: { amount: 900, currency: "DKK" },
|
||||
});
|
||||
});
|
||||
|
||||
// The property that matters: this is a shape change, not a recalculation.
|
||||
// A shape-only test would pass while every balance quietly moved.
|
||||
test("NO allocation changes: every post in every month resolves identically", () => {
|
||||
const before = v1Dataset();
|
||||
const migrated = migrate(v1Dataset());
|
||||
|
||||
// Resolve the v1 way by hand, since allocationFor now speaks v2 only.
|
||||
const v1Allocation = (postId: string, monthId: string) => {
|
||||
const post = before.posts.find((p) => p.id === postId)!;
|
||||
const month = before.months.find((m) => m.id === monthId);
|
||||
const rule = (month?.ruleOverrides as any)?.[postId] ?? post.standingRule;
|
||||
const income = before.months.find((m) => m.id === monthId)?.income.amount ?? 0;
|
||||
return rule.kind === "fixed"
|
||||
? rule.amount.amount
|
||||
: Number(((income * rule.percent) / 100).toFixed(2));
|
||||
};
|
||||
|
||||
for (const postId of ["food", "games"]) {
|
||||
for (const monthId of ["2026-01", "2026-02"]) {
|
||||
expect(allocationFor(migrated, postId, monthId)).toBe(v1Allocation(postId, monthId));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("a v1 post with no posts at all migrates without throwing", () => {
|
||||
const empty = { ...v1Dataset(), posts: [] };
|
||||
expect(migrate(empty).posts).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
+21
-1
@@ -16,7 +16,27 @@ export class UnsupportedSchemaError extends Error {
|
||||
* 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 yet.
|
||||
// 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 }],
|
||||
};
|
||||
}),
|
||||
}),
|
||||
];
|
||||
|
||||
export function migrate(raw: unknown): Dataset {
|
||||
|
||||
Reference in New Issue
Block a user