[V1] Query-Optimizations & Preparation for MacOS & Windows Compatibility

This commit is contained in:
Youwes09
2026-02-25 19:41:14 -06:00
parent 28e9e3bcf8
commit 9a0afed2b0
14 changed files with 1333 additions and 462 deletions
+126 -22
View File
@@ -1,37 +1,73 @@
/**
* Session-level request cache.
*
* Key design decisions:
* Key design decisions (v1, preserved):
* - Stores the Promise itself — concurrent callers await the same fetch (no thundering herd).
* - On real errors the entry is evicted so the next call retries.
* - AbortErrors do NOT evict — the request was cancelled by the user, not failed.
* This is critical: if we evicted on abort, rapid open/close would drain the browser's
* connection pool (Chromium allows only 6 concurrent connections to the same origin).
* - Subscribers are notified when a key is explicitly cleared (for reactive invalidation).
*
* v2 additions:
* - TTL-aware get(): stale entries are re-fetched automatically (default 5 min).
* Pass Infinity to pin an entry for the session (source list, extension list).
* - getPageSet(): lightweight page-number tracker for multi-page browse sessions.
* Mirrors Suwayomi's CACHE_PAGES_KEY pattern so GenreDrillPage / Search TagTab
* can resume a session without re-fetching pages already in memory.
* - Stable multi-tag cache keys: tag arrays are sorted before joining so
* ["Action","Romance"] and ["Romance","Action"] share the same bucket.
*/
const store = new Map<string, Promise<unknown>>();
interface Entry<T> {
promise: Promise<T>;
fetchedAt: number; // ms since epoch
}
const store = new Map<string, Entry<unknown>>();
const subs = new Map<string, Set<() => void>>();
/** Default revalidation window: 5 min (matches Suwayomi's browse-page TTL). */
export const DEFAULT_TTL_MS = 5 * 60 * 1_000;
export const cache = {
get<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
if (!store.has(key)) {
store.set(key, fetcher().catch((err) => {
// Only evict on real failures, not user cancellations
if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err);
}));
}
return store.get(key) as Promise<T>;
/**
* Return a cached promise.
* Re-fetches automatically once the entry is older than `ttl` ms.
* Pass `Infinity` to cache for the entire session (e.g. source/extension lists).
*/
get<T>(key: string, fetcher: () => Promise<T>, ttl: number = DEFAULT_TTL_MS): Promise<T> {
const existing = store.get(key) as Entry<T> | undefined;
if (existing && Date.now() - existing.fetchedAt < ttl) return existing.promise;
const promise = fetcher().catch((err) => {
// Only evict on real failures, not user cancellations
if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err);
}) as Promise<T>;
store.set(key, { promise, fetchedAt: Date.now() });
return promise;
},
has(key: string): boolean { return store.has(key); },
/** How old (ms) a cached entry is, or undefined if absent. */
ageOf(key: string): number | undefined {
const e = store.get(key);
return e ? Date.now() - e.fetchedAt : undefined;
},
clear(key: string) {
store.delete(key);
subs.get(key)?.forEach((cb) => cb());
},
clearAll() {
store.clear();
subs.forEach((set) => set.forEach((cb) => cb()));
},
/** Subscribe to cache invalidation for a key. Returns unsubscribe fn. */
subscribe(key: string, cb: () => void): () => void {
if (!subs.has(key)) subs.set(key, new Set());
@@ -40,7 +76,8 @@ export const cache = {
},
};
// ── Cache key constants — single source of truth, prevents mismatches ─────────
// ── Cache key constants ───────────────────────────────────────────────────────
export const CACHE_KEYS = {
LIBRARY: "library",
SOURCES: "sources",
@@ -48,15 +85,45 @@ export const CACHE_KEYS = {
GENRE: (genre: string) => `genre:${genre}`,
MANGA: (id: number) => `manga:${id}`,
CHAPTERS: (id: number) => `chapters:${id}`,
/**
* Stable key for a browse session's page-number set.
* Tag arrays are sorted so order never creates duplicate buckets —
* ["Action","Romance"] and ["Romance","Action"] share one key.
*
* Examples:
* CACHE_KEYS.sourceMangaPages("src123", "POPULAR")
* CACHE_KEYS.sourceMangaPages("src123", "SEARCH", "naruto")
* CACHE_KEYS.sourceMangaPages("src123", "SEARCH", ["Action","Romance"])
*/
sourceMangaPages(
sourceId: string,
type: "POPULAR" | "LATEST" | "SEARCH",
query?: string | string[],
): string {
const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? "");
return `pages:${sourceId}:${type}:${q}`;
},
/** Per-page result key. Always pair with sourceMangaPages(). */
sourceMangaPage(
sourceId: string,
type: "POPULAR" | "LATEST" | "SEARCH",
page: number,
query?: string | string[],
): string {
const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? "");
return `page:${sourceId}:${type}:${page}:${q}`;
},
} as const;
// ── In-flight request deduplication (for non-cached calls) ───────────────────
// ── In-flight request deduplication (for non-cached calls) ───────────────────
//
// Some requests (chapter lists, manga detail) are NOT stored in the long-lived
// cache but still get fired multiple times when a user rapidly opens/closes a
// manga. This map deduplicates them so only one network round-trip is active at
// a time per key — regardless of how many components request it simultaneously.
//
// a time per key.
const inflight = new Map<string, Promise<unknown>>();
export function deduped<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
@@ -66,18 +133,56 @@ export function deduped<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
return p;
}
// ── Source frecency helpers ────────────────────────────────────────────────────
// ── PageSet: per-session page-number tracker ──────────────────────────────────
//
// Tracks which page numbers have been fetched for a (source, type, query) bucket.
// Lives in a separate map from the TTL store so it never gets TTL-evicted while
// a browse session is actively paginating.
//
// Usage:
// const ps = getPageSet(sourceId, "SEARCH", ["Action", "Romance"]);
// ps.add(1); // after fetching page 1
// ps.next(); // → 2
// ps.pages(); // → Set {1}
// ps.clear(); // call when query/tags change
const FRECENCY_KEY = "moku-source-frecency";
const _pageSets = new Map<string, Set<number>>();
export interface PageSet {
add(page: number): void;
pages(): Set<number>;
/** Next page to fetch: max fetched + 1, or 1 if nothing fetched yet. */
next(): number;
clear(): void;
}
export function getPageSet(
sourceId: string,
type: "POPULAR" | "LATEST" | "SEARCH",
query?: string | string[],
): PageSet {
const key = CACHE_KEYS.sourceMangaPages(sourceId, type, query);
return {
add(page) {
if (!_pageSets.has(key)) _pageSets.set(key, new Set());
_pageSets.get(key)!.add(page);
},
pages() { return new Set(_pageSets.get(key) ?? []); },
next() { const s = _pageSets.get(key); return s?.size ? Math.max(...s) + 1 : 1; },
clear() { _pageSets.delete(key); },
};
}
// ── Source frecency helpers ───────────────────────────────────────────────────
const FRECENCY_KEY = "moku-source-frecency";
const MAX_FRECENCY_SOURCES = 4;
type FrecencyMap = Record<string, number>;
function loadFrecency(): FrecencyMap {
try {
const raw = localStorage.getItem(FRECENCY_KEY);
return raw ? JSON.parse(raw) : {};
} catch { return {}; }
try { const r = localStorage.getItem(FRECENCY_KEY); return r ? JSON.parse(r) : {}; }
catch { return {}; }
}
function saveFrecency(map: FrecencyMap) {
@@ -95,7 +200,6 @@ export function getTopSources<T extends { id: string }>(sources: T[]): T[] {
const map = loadFrecency();
const withScore = sources.map((s) => ({ s, score: map[s.id] ?? 0 }));
const hasFrecency = withScore.some((x) => x.score > 0);
if (hasFrecency) {
return withScore
.sort((a, b) => b.score - a.score)