summaryrefslogtreecommitdiffhomepage
path: root/src/lib/cache/imageCache.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/cache/imageCache.ts')
-rw-r--r--src/lib/cache/imageCache.ts423
1 files changed, 267 insertions, 156 deletions
diff --git a/src/lib/cache/imageCache.ts b/src/lib/cache/imageCache.ts
index d06143f..2326974 100644
--- a/src/lib/cache/imageCache.ts
+++ b/src/lib/cache/imageCache.ts
@@ -4,9 +4,6 @@ const DB_NAME = 'speedsync-cache';
const DB_VERSION = 1;
const STORE_NAME = 'images';
-/** 30 days in milliseconds. */
-const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
-
export interface CachedImage {
/** The file path on the SD card, used as the primary key. */
readonly path: string;
@@ -26,25 +23,60 @@ function makeCacheKey(kind: 'thumbnail' | 'full', path: string): CacheKey {
return `${kind}:${path}`;
}
-function openDb(): Promise<IDBDatabase> {
- return new Promise((resolve, reject) => {
- const request = indexedDB.open(DB_NAME, DB_VERSION);
+// ---------------------------------------------------------------------------
+// In-memory layer — guarantees that a put() is immediately visible to get()
+// without waiting for the IndexedDB transaction to commit. This is critical
+// because the auto-cache service calls `void imageCache.put(...)` (fire-and-
+// forget) and a subsequent get() in ImagePreview must find the data.
+// ---------------------------------------------------------------------------
+const memoryCache = new Map<CacheKey, CachedImage>();
- request.onupgradeneeded = () => {
- const db = request.result;
- if (!db.objectStoreNames.contains(STORE_NAME)) {
- db.createObjectStore(STORE_NAME, { keyPath: 'key' });
- }
- };
+// ---------------------------------------------------------------------------
+// IndexedDB persistence layer — survives page refreshes.
+// ---------------------------------------------------------------------------
- request.onsuccess = () => {
- resolve(request.result);
- };
+/** Shared DB connection (lazy, created once). */
+let dbInstance: IDBDatabase | undefined;
+let dbPromise: Promise<IDBDatabase | undefined> | undefined;
- request.onerror = () => {
- reject(new Error(`Failed to open IndexedDB: ${request.error?.message ?? 'unknown error'}`));
- };
+function openDb(): Promise<IDBDatabase | undefined> {
+ if (dbInstance !== undefined) return Promise.resolve(dbInstance);
+ if (dbPromise !== undefined) return dbPromise;
+
+ dbPromise = new Promise<IDBDatabase | undefined>((resolve) => {
+ try {
+ const request = indexedDB.open(DB_NAME, DB_VERSION);
+
+ request.onupgradeneeded = () => {
+ const db = request.result;
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
+ db.createObjectStore(STORE_NAME, { keyPath: 'key' });
+ }
+ };
+
+ request.onsuccess = () => {
+ dbInstance = request.result;
+
+ // If the connection is unexpectedly closed, reset so we can reopen.
+ dbInstance.onclose = () => {
+ dbInstance = undefined;
+ dbPromise = undefined;
+ };
+
+ resolve(dbInstance);
+ };
+
+ request.onerror = () => {
+ dbPromise = undefined;
+ resolve(undefined);
+ };
+ } catch {
+ dbPromise = undefined;
+ resolve(undefined);
+ }
});
+
+ return dbPromise;
}
interface StoredRecord {
@@ -56,193 +88,272 @@ interface StoredRecord {
readonly storedAt: number;
}
+/** Read a single record from IndexedDB. Returns undefined on any failure. */
+async function idbGet(key: CacheKey): Promise<StoredRecord | undefined> {
+ const db = await openDb();
+ if (db === undefined) return undefined;
+
+ return new Promise((resolve) => {
+ try {
+ const tx = db.transaction(STORE_NAME, 'readonly');
+ const store = tx.objectStore(STORE_NAME);
+ const request = store.get(key);
+
+ request.onsuccess = () => {
+ resolve((request.result as StoredRecord | undefined) ?? undefined);
+ };
+ request.onerror = () => resolve(undefined);
+ } catch {
+ resolve(undefined);
+ }
+ });
+}
+
+/** Write a record to IndexedDB (best-effort). */
+async function idbPut(record: StoredRecord): Promise<void> {
+ const db = await openDb();
+ if (db === undefined) return;
+
+ return new Promise((resolve) => {
+ try {
+ const tx = db.transaction(STORE_NAME, 'readwrite');
+ const store = tx.objectStore(STORE_NAME);
+ store.put(record);
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => resolve();
+ } catch {
+ resolve();
+ }
+ });
+}
+
+/** Delete a record from IndexedDB by key. */
+async function idbDelete(key: CacheKey): Promise<void> {
+ const db = await openDb();
+ if (db === undefined) return;
+
+ return new Promise((resolve) => {
+ try {
+ const tx = db.transaction(STORE_NAME, 'readwrite');
+ const store = tx.objectStore(STORE_NAME);
+ store.delete(key);
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => resolve();
+ } catch {
+ resolve();
+ }
+ });
+}
+
+/** 30 days in milliseconds. */
+const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
+
function isExpired(storedAt: number): boolean {
return Date.now() - storedAt > CACHE_TTL_MS;
}
export const imageCache = {
/**
- * Retrieve a cached image. Returns undefined if not found or expired.
+ * Retrieve a cached image.
+ *
+ * Checks the in-memory Map first (instant), then falls back to IndexedDB.
+ * Returns undefined if not found or expired.
*/
async get(kind: 'thumbnail' | 'full', path: string): Promise<CachedImage | undefined> {
- let db: IDBDatabase;
- try {
- db = await openDb();
- } catch {
- return undefined;
+ const key = makeCacheKey(kind, path);
+
+ // Fast path: in-memory
+ const mem = memoryCache.get(key);
+ if (mem !== undefined) {
+ if (isExpired(mem.storedAt)) {
+ memoryCache.delete(key);
+ void idbDelete(key);
+ return undefined;
+ }
+ return mem;
}
- return new Promise((resolve) => {
- const tx = db.transaction(STORE_NAME, 'readonly');
- const store = tx.objectStore(STORE_NAME);
- const key = makeCacheKey(kind, path);
- const request = store.get(key);
-
- request.onsuccess = () => {
- const record = request.result as StoredRecord | undefined;
- if (record === undefined || record === null) {
- resolve(undefined);
- return;
- }
- if (isExpired(record.storedAt)) {
- // Expired — remove in background, return undefined
- void imageCache.delete(kind, path);
- resolve(undefined);
- return;
- }
- resolve({
- path: record.path,
- kind: record.kind,
- blob: record.blob,
- meta: record.meta,
- storedAt: record.storedAt,
- });
- };
-
- request.onerror = () => {
- resolve(undefined);
- };
+ // Slow path: IndexedDB
+ const record = await idbGet(key);
+ if (record === undefined) return undefined;
+ if (isExpired(record.storedAt)) {
+ void idbDelete(key);
+ return undefined;
+ }
- tx.oncomplete = () => {
- db.close();
- };
- });
+ const cached: CachedImage = {
+ path: record.path,
+ kind: record.kind,
+ blob: record.blob,
+ meta: record.meta,
+ storedAt: record.storedAt,
+ };
+ // Promote to memory for future fast lookups
+ memoryCache.set(key, cached);
+ return cached;
},
/**
* Store an image in the cache.
+ *
+ * Writes to the in-memory Map synchronously (before the first await),
+ * so fire-and-forget callers (`void imageCache.put(...)`) make data
+ * instantly visible to subsequent get() calls.
+ * IndexedDB persistence happens in the background.
*/
async put(kind: 'thumbnail' | 'full', path: string, blob: Blob, meta?: ThumbnailMeta): Promise<void> {
- let db: IDBDatabase;
- try {
- db = await openDb();
- } catch {
- return;
- }
+ const key = makeCacheKey(kind, path);
+ const storedAt = Date.now();
- const record: StoredRecord = {
- key: makeCacheKey(kind, path),
- path,
- kind,
- blob,
- meta,
- storedAt: Date.now(),
- };
+ const cached: CachedImage = { path, kind, blob, meta, storedAt };
- return new Promise((resolve) => {
- const tx = db.transaction(STORE_NAME, 'readwrite');
- const store = tx.objectStore(STORE_NAME);
- store.put(record);
-
- tx.oncomplete = () => {
- db.close();
- resolve();
- };
+ // Instant: write to memory
+ memoryCache.set(key, cached);
- tx.onerror = () => {
- db.close();
- resolve();
- };
- });
+ // Background: persist to IndexedDB
+ const record: StoredRecord = { key, path, kind, blob, meta, storedAt };
+ await idbPut(record);
},
/**
* Delete a single entry from the cache.
*/
async delete(kind: 'thumbnail' | 'full', path: string): Promise<void> {
- let db: IDBDatabase;
- try {
- db = await openDb();
- } catch {
- return;
+ const key = makeCacheKey(kind, path);
+ memoryCache.delete(key);
+ await idbDelete(key);
+ },
+
+ /**
+ * Remove all expired entries from the cache.
+ */
+ async pruneExpired(): Promise<void> {
+ // Prune in-memory
+ for (const [key, entry] of memoryCache) {
+ if (isExpired(entry.storedAt)) {
+ memoryCache.delete(key);
+ }
}
- return new Promise((resolve) => {
- const tx = db.transaction(STORE_NAME, 'readwrite');
- const store = tx.objectStore(STORE_NAME);
- store.delete(makeCacheKey(kind, path));
+ // Prune IndexedDB
+ const db = await openDb();
+ if (db === undefined) return;
- tx.oncomplete = () => {
- db.close();
- resolve();
- };
+ return new Promise((resolve) => {
+ try {
+ const tx = db.transaction(STORE_NAME, 'readwrite');
+ const store = tx.objectStore(STORE_NAME);
+ const request = store.openCursor();
+ const keysToDelete: IDBValidKey[] = [];
+
+ request.onsuccess = () => {
+ const cursor = request.result;
+ if (cursor !== null) {
+ const record = cursor.value as StoredRecord;
+ if (isExpired(record.storedAt)) {
+ keysToDelete.push(cursor.key);
+ }
+ cursor.continue();
+ } else {
+ for (const k of keysToDelete) {
+ store.delete(k);
+ }
+ }
+ };
- tx.onerror = () => {
- db.close();
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => resolve();
+ } catch {
resolve();
- };
+ }
});
},
/**
- * Remove all expired entries from the cache. Call periodically or on startup.
+ * Get diagnostic information about the cache.
*/
- async pruneExpired(): Promise<void> {
- let db: IDBDatabase;
- try {
- db = await openDb();
- } catch {
- return;
+ async getStats(): Promise<{
+ entries: number;
+ fullCount: number;
+ thumbCount: number;
+ totalBytes: number;
+ idbEntries: number;
+ idbBytes: number;
+ idbError: string | undefined;
+ }> {
+ let fullCount = 0;
+ let thumbCount = 0;
+ let totalBytes = 0;
+ for (const entry of memoryCache.values()) {
+ if (entry.kind === 'full') fullCount++;
+ else thumbCount++;
+ totalBytes += entry.blob.size;
}
- return new Promise((resolve) => {
- const tx = db.transaction(STORE_NAME, 'readwrite');
- const store = tx.objectStore(STORE_NAME);
- const request = store.openCursor();
- const keysToDelete: IDBValidKey[] = [];
-
- request.onsuccess = () => {
- const cursor = request.result;
- if (cursor !== null) {
- const record = cursor.value as StoredRecord;
- if (isExpired(record.storedAt)) {
- keysToDelete.push(cursor.key);
- }
- cursor.continue();
- } else {
- for (const key of keysToDelete) {
- store.delete(key);
- }
- }
- };
+ let idbEntries = 0;
+ let idbBytes = 0;
+ let idbError: string | undefined;
- tx.oncomplete = () => {
- db.close();
- resolve();
- };
+ try {
+ const db = await openDb();
+ if (db !== undefined) {
+ const result = await new Promise<{ entries: number; bytes: number }>((resolve, reject) => {
+ const tx = db.transaction(STORE_NAME, 'readonly');
+ const store = tx.objectStore(STORE_NAME);
+ const req = store.openCursor();
+ let entries = 0;
+ let bytes = 0;
+
+ req.onsuccess = () => {
+ const cursor = req.result;
+ if (cursor !== null) {
+ const rec = cursor.value as StoredRecord;
+ entries++;
+ if (rec.blob) bytes += rec.blob.size;
+ cursor.continue();
+ }
+ };
+
+ tx.oncomplete = () => resolve({ entries, bytes });
+ tx.onerror = () => reject(new Error(tx.error?.message ?? 'unknown'));
+ });
+ idbEntries = result.entries;
+ idbBytes = result.bytes;
+ }
+ } catch (e) {
+ idbError = e instanceof Error ? e.message : String(e);
+ }
- tx.onerror = () => {
- db.close();
- resolve();
- };
- });
+ return {
+ entries: memoryCache.size,
+ fullCount,
+ thumbCount,
+ totalBytes,
+ idbEntries,
+ idbBytes,
+ idbError,
+ };
},
/**
* Clear the entire cache.
*/
async clear(): Promise<void> {
- let db: IDBDatabase;
- try {
- db = await openDb();
- } catch {
- return;
- }
+ memoryCache.clear();
- return new Promise((resolve) => {
- const tx = db.transaction(STORE_NAME, 'readwrite');
- const store = tx.objectStore(STORE_NAME);
- store.clear();
+ const db = await openDb();
+ if (db === undefined) return;
- tx.oncomplete = () => {
- db.close();
- resolve();
- };
-
- tx.onerror = () => {
- db.close();
+ return new Promise((resolve) => {
+ try {
+ const tx = db.transaction(STORE_NAME, 'readwrite');
+ const store = tx.objectStore(STORE_NAME);
+ store.clear();
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => resolve();
+ } catch {
resolve();
- };
+ }
});
},
} as const;