/** * IndexedDB wrapper — store-layout verification and self-healing (the * interrupted-upgrade recovery documented on `openDB`). Runs against * fake-indexeddb; each test gets a pristine database. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IDBFactory } from 'fake-indexeddb'; import { DATASETS_STORE, FONTS_STORE, SNIPPETS_STORE, THEMES_STORE, _resetDbForTests, getAll, openDB, put, } from './db'; const ALL_STORES = [SNIPPETS_STORE, DATASETS_STORE, THEMES_STORE, FONTS_STORE]; /** Open the raw database at `version` with a custom (or absent) upgrade body. */ function rawOpen(version: number, upgrade?: (db: IDBDatabase) => void): Promise { return new Promise((resolve, reject) => { const req = indexedDB.open('astrolabe', version); req.onupgradeneeded = () => upgrade?.(req.result); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error ?? new Error('open failed')); }); } beforeEach(() => { // A fresh factory per test — no databases survive between tests. globalThis.indexedDB = new IDBFactory(); _resetDbForTests(); }); afterEach(() => { _resetDbForTests(); }); describe('openDB', () => { it('creates every expected store on first run', async () => { const db = await openDB(); for (const store of ALL_STORES) expect(db.objectStoreNames.contains(store)).toBe(true); }); it('upgrades a v1 database (snippets + datasets) to include the themes store', async () => { const v1 = await rawOpen(1, (db) => { db.createObjectStore(SNIPPETS_STORE, { keyPath: 'id' }); db.createObjectStore(DATASETS_STORE, { keyPath: 'id' }); }); v1.close(); const db = await openDB(); expect(db.objectStoreNames.contains(THEMES_STORE)).toBe(true); }); it('self-heals a database stamped at the current version with stores missing', async () => { // The interrupted-upgrade state: version already 2, but the themes store // was never created (e.g. a hot reload opened v2 before the create-store // code existed). onupgradeneeded will never fire again for v2. const broken = await rawOpen(2, (db) => { db.createObjectStore(SNIPPETS_STORE, { keyPath: 'id' }); db.createObjectStore(DATASETS_STORE, { keyPath: 'id' }); }); broken.close(); const db = await openDB(); expect(db.objectStoreNames.contains(THEMES_STORE)).toBe(true); expect(db.version).toBe(3); // healed by a forced extra upgrade pass // Transactions on every store now work. await put(THEMES_STORE, { id: 1, name: 'ok' }); await expect(getAll(THEMES_STORE)).resolves.toEqual([{ id: 1, name: 'ok' }]); }); it('opens a database whose version is already past DB_VERSION', async () => { // A prior self-heal bump leaves the version above the code's constant; a // versioned open would throw VersionError. All stores already exist here. const ahead = await rawOpen(7, (db) => { for (const store of ALL_STORES) db.createObjectStore(store, { keyPath: 'id' }); }); ahead.close(); const db = await openDB(); expect(db.version).toBe(7); for (const store of ALL_STORES) expect(db.objectStoreNames.contains(store)).toBe(true); }); it('self-heals an ahead-of-code database with stores missing', async () => { const ahead = await rawOpen(5, (db) => { db.createObjectStore(SNIPPETS_STORE, { keyPath: 'id' }); }); ahead.close(); const db = await openDB(); expect(db.version).toBe(6); for (const store of ALL_STORES) expect(db.objectStoreNames.contains(store)).toBe(true); }); it('preserves existing records across the self-heal upgrade', async () => { const broken = await rawOpen(2, (db) => { db.createObjectStore(SNIPPETS_STORE, { keyPath: 'id' }); db.createObjectStore(DATASETS_STORE, { keyPath: 'id' }); }); await new Promise((resolve, reject) => { const t = broken.transaction(SNIPPETS_STORE, 'readwrite'); t.objectStore(SNIPPETS_STORE).put({ id: 'a', name: 'kept' }); t.oncomplete = () => resolve(); t.onerror = () => reject(t.error ?? new Error('tx failed')); }); broken.close(); await openDB(); await expect(getAll(SNIPPETS_STORE)).resolves.toEqual([{ id: 'a', name: 'kept' }]); }); });