record-player 0.1.0

Standalone Bitneedle record player, acoustic scratch engine, and browser command protocol
Documentation
const DB_NAME = "vin.yl.player";
const DB_VERSION = 2;
const STORE_NAME = "scratch-performances";

function requestPromise(request) {
  return new Promise((resolve, reject) => {
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error || new Error("IndexedDB request failed"));
  });
}

function openDatabase() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);
    request.onupgradeneeded = () => {
      const database = request.result;
      if (!database.objectStoreNames.contains(STORE_NAME)) {
        const store = database.createObjectStore(STORE_NAME, { keyPath: "id" });
        store.createIndex("recordHash", "recordHash", { unique: false });
        store.createIndex("createdAt", "createdAt", { unique: false });
        store.createIndex("recordHashCreatedAt", ["recordHash", "createdAt"], { unique: false });
      }
    };
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error || new Error("Unable to open scratch performance database"));
  });
}

async function withStore(mode, callback) {
  const database = await openDatabase();
  try {
    const transaction = database.transaction(STORE_NAME, mode);
    const result = await callback(transaction.objectStore(STORE_NAME));
    await new Promise((resolve, reject) => {
      transaction.oncomplete = resolve;
      transaction.onerror = () => reject(transaction.error || new Error("IndexedDB transaction failed"));
      transaction.onabort = () => reject(transaction.error || new Error("IndexedDB transaction aborted"));
    });
    return result;
  } finally {
    database.close();
  }
}

export async function saveScratchPerformance(performance) {
  await withStore("readwrite", store => requestPromise(store.put(structuredClone(performance))));
  return performance;
}

export async function getScratchPerformance(id) {
  return withStore("readonly", store => requestPromise(store.get(String(id))));
}

export async function listScratchPerformances({ recordHash = "", limit = 100 } = {}) {
  return withStore("readonly", async store => {
    const request = recordHash
      ? store.index("recordHash").getAll(String(recordHash))
      : store.getAll();
    const records = await requestPromise(request);
    return records
      .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
      .slice(0, Math.max(1, Number(limit) || 100));
  });
}

export async function deleteScratchPerformance(id) {
  await withStore("readwrite", store => requestPromise(store.delete(String(id))));
}

export async function clearScratchPerformances({ recordHash = "" } = {}) {
  if (!recordHash) {
    await withStore("readwrite", store => requestPromise(store.clear()));
    return;
  }
  const records = await listScratchPerformances({ recordHash, limit: Number.MAX_SAFE_INTEGER });
  await withStore("readwrite", store => Promise.all(records.map(record => requestPromise(store.delete(record.id)))));
}