From 15ae45a804a4571402be005bbe8838f6335f3327 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 1 Sep 2026 19:55:08 +0200 Subject: [PATCH] feat(store): add IndexedDB persistence and schema migrations The dataset is one atomic record, so a write can never leave posts and purchases inconsistent. Co-Authored-By: Claude Opus 5 (1M context) --- src/store/db.ts | 57 ++++++++++++++++++++++++++++++++++++ src/store/migrations.test.ts | 24 +++++++++++++++ src/store/migrations.ts | 40 +++++++++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 src/store/db.ts create mode 100644 src/store/migrations.test.ts create mode 100644 src/store/migrations.ts diff --git a/src/store/db.ts b/src/store/db.ts new file mode 100644 index 0000000..1dcff23 --- /dev/null +++ b/src/store/db.ts @@ -0,0 +1,57 @@ +import type { Dataset } from "../domain/types.ts"; +import { migrate } from "./migrations.ts"; + +export const DB_NAME = "budget2"; +export const STORE_NAME = "state"; +export const RECORD_KEY = "dataset"; + +/** IndexedDB's own version. Bumped only when the object stores change. */ +const IDB_VERSION = 1; + +function request(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +export function openDb(): Promise { + return new Promise((resolve, reject) => { + const open = indexedDB.open(DB_NAME, IDB_VERSION); + open.onupgradeneeded = () => { + const db = open.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + }; + open.onsuccess = () => resolve(open.result); + open.onerror = () => reject(open.error); + }); +} + +export async function readDataset(): Promise { + const db = await openDb(); + try { + const tx = db.transaction(STORE_NAME, "readonly"); + const raw = await request(tx.objectStore(STORE_NAME).get(RECORD_KEY)); + return raw === undefined ? null : migrate(raw); + } finally { + db.close(); + } +} + +/** Writes the whole dataset in one transaction, so a write can never tear. */ +export async function writeDataset(dataset: Dataset): Promise { + const db = await openDb(); + try { + const tx = db.transaction(STORE_NAME, "readwrite"); + await request(tx.objectStore(STORE_NAME).put(dataset, RECORD_KEY)); + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + } finally { + db.close(); + } +} diff --git a/src/store/migrations.test.ts b/src/store/migrations.test.ts new file mode 100644 index 0000000..0201f44 --- /dev/null +++ b/src/store/migrations.test.ts @@ -0,0 +1,24 @@ +import { test, expect, describe } from "bun:test"; +import { migrate, UnsupportedSchemaError } from "./migrations.ts"; +import { createSeedDataset, SCHEMA_VERSION } from "../domain/seed.ts"; + +describe("migrate", () => { + test("passes a current-version dataset through unchanged", () => { + const data = createSeedDataset("2026-09"); + expect(migrate(data)).toEqual(data); + }); + + test("rejects a version newer than this build understands", () => { + const future = { ...createSeedDataset("2026-09"), settings: { baseCurrency: "DKK", foldStartMonth: "2026-09", schemaVersion: SCHEMA_VERSION + 1 } }; + expect(() => migrate(future)).toThrow(UnsupportedSchemaError); + }); + + test("rejects a value that is not an object", () => { + expect(() => migrate("nope")).toThrow(/not a dataset/i); + expect(() => migrate(null)).toThrow(/not a dataset/i); + }); + + test("rejects a dataset with no schemaVersion", () => { + expect(() => migrate({ posts: [] })).toThrow(/schemaVersion/); + }); +}); diff --git a/src/store/migrations.ts b/src/store/migrations.ts new file mode 100644 index 0000000..4791e1c --- /dev/null +++ b/src/store/migrations.ts @@ -0,0 +1,40 @@ +import { SCHEMA_VERSION } from "../domain/seed.ts"; +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 yet. +]; + +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; + 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; +}