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) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-01 19:55:08 +02:00
1 parent 438d53d016
commit 15ae45a804
3 files changed
+121

No files matched your search

+57
View File
@@ -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<T>(req: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export function openDb(): Promise<IDBDatabase> {
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<Dataset | null> {
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<void> {
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<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
} finally {
db.close();
}
}
+24
View File
@@ -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/);
});
});
+40
View File
@@ -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<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;
}