import type { Visit } from "../model/types"; import { sameDocument } from "../model/url"; import { collectSubtreeIds } from "../query/subtree"; /** * IndexedDB persistence for visits. * * We use IndexedDB rather than chrome.storage.local because we expect many * thousands of nodes and want range queries by time. The service worker can be * killed at any moment, so every helper opens/uses the db defensively. */ const DB_NAME = "history-graph"; const DB_VERSION = 1; const STORE = "visits"; let dbPromise: Promise | null = null; function openDb(): Promise { if (dbPromise) return dbPromise; dbPromise = new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, DB_VERSION); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(STORE)) { const store = db.createObjectStore(STORE, { keyPath: "id" }); store.createIndex("ts", "ts"); store.createIndex("tabId", "tabId"); store.createIndex("parentId", "parentId"); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); return dbPromise; } function tx(db: IDBDatabase, mode: IDBTransactionMode): IDBObjectStore { return db.transaction(STORE, mode).objectStore(STORE); } export async function putVisit(visit: Visit): Promise { const db = await openDb(); await new Promise((resolve, reject) => { const req = tx(db, "readwrite").put(visit); req.onsuccess = () => resolve(); req.onerror = () => reject(req.error); }); } export async function getVisit(id: string): Promise { const db = await openDb(); return new Promise((resolve, reject) => { const req = tx(db, "readonly").get(id); req.onsuccess = () => resolve(req.result as Visit | undefined); req.onerror = () => reject(req.error); }); } /** Patch an existing visit in place (e.g. to fill in title/favicon later). */ export async function patchVisit(id: string, patch: Partial): Promise { const existing = await getVisit(id); if (!existing) return; await putVisit({ ...existing, ...patch }); } /** The direct children of a visit (the visits whose parentId is `id`). */ export async function getChildren(id: string): Promise { const db = await openDb(); return new Promise((resolve, reject) => { const req = tx(db, "readonly").index("parentId").getAll(IDBKeyRange.only(id)); req.onsuccess = () => resolve((req.result as Visit[] | undefined) ?? []); req.onerror = () => reject(req.error); }); } /** * Delete a single visit, reparenting its children onto its parent (the deleted * node's grandparent; null → they become roots). Removes one mis-recorded stop * without losing the branch underneath it. Done in one readwrite transaction so * a child is never left pointing at a parent that's already gone. */ export async function deleteVisitReparenting(id: string): Promise { const db = await openDb(); const node = await getVisit(id); if (!node) return; const children = await getChildren(id); await new Promise((resolve, reject) => { const store = tx(db, "readwrite"); for (const child of children) store.put({ ...child, parentId: node.parentId }); store.delete(id); store.transaction.oncomplete = () => resolve(); store.transaction.onerror = () => reject(store.transaction.error); }); } /** * Delete a visit and its entire subtree. Collects the node + all descendants by * walking the parentId index, then deletes them in one transaction. The * visited-set in collectSubtreeIds guards against a corrupt cycle. */ export async function deleteSubtree(id: string): Promise { const db = await openDb(); const all = await new Promise((resolve, reject) => { const req = tx(db, "readonly").getAll(); req.onsuccess = () => resolve((req.result as Visit[] | undefined) ?? []); req.onerror = () => reject(req.error); }); const ids = collectSubtreeIds(all, id); await new Promise((resolve, reject) => { const store = tx(db, "readwrite"); for (const victim of ids) store.delete(victim); store.transaction.oncomplete = () => resolve(); store.transaction.onerror = () => reject(store.transaction.error); }); } /** Wipe every stored visit. Used by the viewer's "Clear" button. */ export async function clearAllVisits(): Promise { const db = await openDb(); await new Promise((resolve, reject) => { const req = tx(db, "readwrite").clear(); req.onsuccess = () => resolve(); req.onerror = () => reject(req.error); }); } /** The most recent visit recorded in a tab (any URL). */ export async function getLatestVisitInTab(tabId: number): Promise { const db = await openDb(); return new Promise((resolve, reject) => { let best: Visit | undefined; const req = tx(db, "readonly").index("tabId").openCursor(IDBKeyRange.only(tabId)); req.onsuccess = () => { const cursor = req.result; if (cursor) { const v = cursor.value as Visit; if (!best || v.ts > best.ts) best = v; cursor.continue(); } else { resolve(best); } }; req.onerror = () => reject(req.error); }); } /** * The most recent visit in a tab whose URL matches. Used by the title handler * to attach a title to the right visit even if the "current" pointer has since * moved on. Returns undefined if no visit for that tab+url exists yet. */ export async function getLatestVisitForTabUrl( tabId: number, url: string, ): Promise { const db = await openDb(); return new Promise((resolve, reject) => { let best: Visit | undefined; const req = tx(db, "readonly").index("tabId").openCursor(IDBKeyRange.only(tabId)); req.onsuccess = () => { const cursor = req.result; if (cursor) { const v = cursor.value as Visit; if (sameDocument(v.url, url) && (!best || v.ts > best.ts)) best = v; cursor.continue(); } else { resolve(best); } }; req.onerror = () => reject(req.error); }); } /** * Walk up the parent chain from `fromId` and return the first ANCESTOR visit * (not `fromId` itself) whose URL is the same document as `url`. Used to detect * an in-app "back": when a page pushes you to a URL you came from, we move HEAD * onto that existing node rather than forking a duplicate. The visited-set guard * keeps a corrupt cycle from spinning forever. */ export async function findAncestorVisitByUrl( fromId: string, url: string, ): Promise { let cur = await getVisit(fromId); const seen = new Set([fromId]); while (cur?.parentId && !seen.has(cur.parentId)) { seen.add(cur.parentId); const parent = await getVisit(cur.parentId); if (!parent) break; if (sameDocument(parent.url, url)) return parent; cur = parent; } return undefined; } /** All visits in a time range [from, to], ascending by timestamp. */ export async function getVisitsInRange(from: number, to: number): Promise { const db = await openDb(); return new Promise((resolve, reject) => { const out: Visit[] = []; const range = IDBKeyRange.bound(from, to); const req = tx(db, "readonly").index("ts").openCursor(range); req.onsuccess = () => { const cursor = req.result; if (cursor) { out.push(cursor.value as Visit); cursor.continue(); } else { resolve(out); } }; req.onerror = () => reject(req.error); }); }