1import type { SongMeta, SongRecord } from '../types.ts';
3// Song library in IndexedDB. One object store keyed by song id; the whole
4// song is small JSON, so records are read and written whole.
6const DB_NAME = 'midiboot';
7const STORE = 'songs';
8const INSTRUMENTS = 'instruments';
10let dbPromise: Promise<IDBDatabase> | null = null;
12function openDb(): Promise<IDBDatabase> {
13 if (dbPromise) return dbPromise;
14 const attempt: Promise<IDBDatabase> = new Promise((resolve, reject) => {
15 const req = indexedDB.open(DB_NAME, 2);
16 req.onupgradeneeded = () => {
17 const db = req.result;
18 if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE, { keyPath: 'id' });
19 if (!db.objectStoreNames.contains(INSTRUMENTS)) db.createObjectStore(INSTRUMENTS, { keyPath: 'id' });
20 };
21 // A tab holding an older version blocks the upgrade indefinitely;
22 // surface that instead of hanging behind the loading screen.
23 req.onblocked = () => reject(new Error('database upgrade blocked; close other midiboot tabs and reload'));
24 req.onsuccess = () => {
25 // Yield to a future upgrade (a newer tab) instead of blocking it.
26 req.result.onversionchange = () => req.result.close();
27 resolve(req.result);
28 };
29 req.onerror = () => reject(req.error);
30 });
31 // Do not cache a failure: once a blocking tab closes, the next call
32 // should get to try again.
33 dbPromise = attempt;
34 attempt.catch(() => {
35 if (dbPromise === attempt) dbPromise = null;
36 });
37 return attempt;
38}
40function txDone(tx: IDBTransaction): Promise<void> {
41 return new Promise((resolve, reject) => {
42 tx.oncomplete = () => resolve();
43 tx.onerror = () => reject(tx.error);
44 tx.onabort = () => reject(tx.error);
45 });
46}
48export async function putSong(rec: SongRecord): Promise<void> {
49 const db = await openDb();
50 const tx = db.transaction(STORE, 'readwrite');
51 tx.objectStore(STORE).put(rec);
52 await txDone(tx);
53}
55export async function getSong(id: string): Promise<SongRecord | null> {
56 const db = await openDb();
57 return new Promise((resolve, reject) => {
58 const req = db.transaction(STORE, 'readonly').objectStore(STORE).get(id);
59 req.onsuccess = () => resolve((req.result as SongRecord) ?? null);
60 req.onerror = () => reject(req.error);
61 });
62}
64export async function deleteSong(id: string): Promise<void> {
65 const db = await openDb();
66 const tx = db.transaction(STORE, 'readwrite');
67 tx.objectStore(STORE).delete(id);
68 await txDone(tx);
69}
71export async function listSongs(): Promise<SongMeta[]> {
72 const db = await openDb();
73 return new Promise((resolve, reject) => {
74 const req = db.transaction(STORE, 'readonly').objectStore(STORE).getAll();
75 req.onsuccess = () => {
76 const recs = (req.result as SongRecord[]) ?? [];
77 const metas = recs.map<SongMeta>((r) => ({
78 id: r.id,
79 name: r.name,
80 updatedAt: r.updatedAt,
81 trackCount: r.song.tracks.length,
82 noteCount: r.song.tracks.reduce((s, t) => s + t.notes.length, 0),
83 }));
84 metas.sort((a, b) => b.updatedAt - a.updatedAt);
85 resolve(metas);
86 };
87 req.onerror = () => reject(req.error);
88 });
89}
91// Imported instrument bundles (format numbl-instrument-v1), keyed by
92// bundle id. Whole records again: a bundle is tens of kilobytes of JSON.
94export interface ImportedInstrumentRecord {
95 id: string; // the bundle id
96 text: string; // the bundle JSON, kept verbatim as imported
97 importedAt: number;
98}
100export async function putImportedInstrument(rec: ImportedInstrumentRecord): Promise<void> {
101 const db = await openDb();
102 const tx = db.transaction(INSTRUMENTS, 'readwrite');
103 tx.objectStore(INSTRUMENTS).put(rec);
104 await txDone(tx);
105}
107export async function deleteImportedInstrument(id: string): Promise<void> {
108 const db = await openDb();
109 const tx = db.transaction(INSTRUMENTS, 'readwrite');
110 tx.objectStore(INSTRUMENTS).delete(id);
111 await txDone(tx);
112}
114export async function listImportedInstruments(): Promise<ImportedInstrumentRecord[]> {
115 const db = await openDb();
116 return new Promise((resolve, reject) => {
117 const req = db.transaction(INSTRUMENTS, 'readonly').objectStore(INSTRUMENTS).getAll();
118 req.onsuccess = () => resolve((req.result as ImportedInstrumentRecord[]) ?? []);
119 req.onerror = () => reject(req.error);
120 });
121}