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:
1 parent
438d53d016
commit
15ae45a804
3 files changed
+121
No files matched your search
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user