summaryrefslogtreecommitdiffhomepage
path: root/src/lib
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-04-09 23:40:59 +0900
committerAdam Malczewski <[email protected]>2026-04-09 23:40:59 +0900
commitb650a2ede779c5f2e84412bf30d9c182d92e83f6 (patch)
tree414b59c77bc56f894fde4c59fa0c3012fcf54233 /src/lib
parent1c64cfc8feb2aee5d622a50f2f4878281b9dcc05 (diff)
downloadflashair-speedsync-b650a2ede779c5f2e84412bf30d9c182d92e83f6.tar.gz
flashair-speedsync-b650a2ede779c5f2e84412bf30d9c182d92e83f6.zip
debug issue with IndexedDB
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/cache/imageCache.ts423
-rw-r--r--src/lib/components/CacheDebug.svelte85
-rw-r--r--src/lib/components/ImagePreview.svelte46
3 files changed, 388 insertions, 166 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;
diff --git a/src/lib/components/CacheDebug.svelte b/src/lib/components/CacheDebug.svelte
new file mode 100644
index 0000000..293ff62
--- /dev/null
+++ b/src/lib/components/CacheDebug.svelte
@@ -0,0 +1,85 @@
+<script lang="ts">
+ import { imageCache } from '../cache';
+
+ interface CacheStats {
+ entries: number;
+ fullCount: number;
+ thumbCount: number;
+ totalBytes: number;
+ idbEntries: number;
+ idbBytes: number;
+ idbError: string | undefined;
+ }
+
+ let stats = $state<CacheStats | undefined>(undefined);
+ let refreshing = $state(false);
+ let collapsed = $state(false);
+
+ function formatBytes(bytes: number): string {
+ if (bytes === 0) return '0 B';
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ const val = bytes / Math.pow(k, i);
+ return `${val.toFixed(1)} ${sizes[i]}`;
+ }
+
+ async function refresh() {
+ refreshing = true;
+ try {
+ stats = await imageCache.getStats();
+ } catch { /* ignore */ }
+ refreshing = false;
+ }
+
+ $effect(() => {
+ void refresh();
+ const id = setInterval(() => { void refresh(); }, 2000);
+ return () => clearInterval(id);
+ });
+</script>
+
+<div class="fixed top-2 left-2 z-50 bg-base-100/95 backdrop-blur-sm rounded-box shadow-lg border border-base-300 p-3 text-xs font-mono max-w-80 pointer-events-auto">
+ <button
+ class="flex items-center justify-between w-full mb-1"
+ onclick={() => collapsed = !collapsed}
+ >
+ <span class="font-bold text-sm">Cache Debug</span>
+ <span class="text-base-content/50">{collapsed ? '▶' : '▼'}</span>
+ </button>
+
+ {#if !collapsed && stats !== undefined}
+ <div class="space-y-2">
+ <!-- Memory Cache -->
+ <div>
+ <div class="font-semibold text-primary">Memory Cache</div>
+ <div>Entries: <span class="text-info">{stats.entries}</span> ({stats.fullCount} full, {stats.thumbCount} thumb)</div>
+ <div>Size: <span class="text-info">{formatBytes(stats.totalBytes)}</span></div>
+ </div>
+
+ <!-- IndexedDB -->
+ <div class="border-t border-base-300 pt-2">
+ <div class="font-semibold text-secondary">IndexedDB</div>
+ {#if stats.idbError !== undefined}
+ <div class="text-error">Error: {stats.idbError}</div>
+ {:else}
+ <div>Entries: <span class="text-info">{stats.idbEntries}</span></div>
+ <div>Size: <span class="text-info">{formatBytes(stats.idbBytes)}</span></div>
+ <div class="mt-1">
+ {#if stats.idbEntries === stats.entries}
+ <span class="text-success">✅ In sync with memory</span>
+ {:else if stats.idbEntries < stats.entries}
+ <span class="text-warning">⏳ IDB behind ({stats.entries - stats.idbEntries} pending)</span>
+ {:else}
+ <span class="text-info">IDB has {stats.idbEntries - stats.entries} extra (pre-loaded)</span>
+ {/if}
+ </div>
+ {/if}
+ </div>
+ </div>
+ {:else if !collapsed}
+ <div class="text-base-content/50">
+ {#if refreshing}Loading…{:else}No data{/if}
+ </div>
+ {/if}
+</div>
diff --git a/src/lib/components/ImagePreview.svelte b/src/lib/components/ImagePreview.svelte
index 20a9937..914f9a1 100644
--- a/src/lib/components/ImagePreview.svelte
+++ b/src/lib/components/ImagePreview.svelte
@@ -224,26 +224,36 @@
};
});
+ /**
+ * The path currently being loaded. Used to detect stale async results
+ * without relying on the AbortController (which the $effect cleanup
+ * may fire prematurely if Svelte re-schedules the effect).
+ */
+ let activePath: string | undefined;
+
$effect(() => {
const currentFile = file;
if (currentFile === undefined) {
+ activePath = undefined;
cleanup();
return;
}
+ // Track which path we are loading so async callbacks can detect staleness.
+ activePath = currentFile.path;
+
// Reset zoom on file change
resetZoom();
- // Clear stale state synchronously before async loads
- imgNaturalW = 0;
- imgNaturalH = 0;
+ // Revoke previous thumbnail blob URL (plain let, not $state)
if (rawThumbnailUrl !== undefined) {
URL.revokeObjectURL(rawThumbnailUrl);
rawThumbnailUrl = undefined;
}
- thumbnailBlobUrl = undefined;
- imageAspectRatio = '3 / 2';
+ // Kick off async loaders — they handle most $state writes internally.
+ // resetZoom() above writes to $state (zoomLevel, panX, panY) but those
+ // are never read in this $effect body, so they don't add dependencies.
loadThumbnail(currentFile);
loadFullImage(currentFile);
@@ -282,11 +292,18 @@
}
async function loadThumbnail(entry: FlashAirFileEntry) {
+ // Reset thumbnail state at the start of each load
+ thumbnailBlobUrl = undefined;
+ imageAspectRatio = '3 / 2';
+
const url = flashair.thumbnailUrl(entry.path);
if (url === undefined) return;
// Try cache first
const cached = await imageCache.get('thumbnail', entry.path);
+ // Check staleness after await
+ if (activePath !== entry.path) return;
+
if (cached !== undefined) {
const blobUrl = URL.createObjectURL(cached.blob);
rawThumbnailUrl = blobUrl;
@@ -300,6 +317,8 @@
try {
const { blob, meta } = await flashair.fetchThumbnail(entry.path);
+ // Check staleness after await
+ if (activePath !== entry.path) return;
// Store in cache (fire-and-forget)
void imageCache.put('thumbnail', entry.path, blob, meta);
const blobUrl = URL.createObjectURL(blob);
@@ -315,11 +334,14 @@
}
async function loadFullImage(entry: FlashAirFileEntry) {
+ // Reset state at the start of each load
if (rawObjectUrl !== undefined) {
URL.revokeObjectURL(rawObjectUrl);
rawObjectUrl = undefined;
}
fullObjectUrl = undefined;
+ imgNaturalW = 0;
+ imgNaturalH = 0;
progress = 0;
loadError = undefined;
@@ -331,7 +353,11 @@
// Try cache first — before setting downloading=true to avoid flicker
const cached = await imageCache.get('full', entry.path);
- if (cached !== undefined && !abort.signal.aborted) {
+ // Use activePath for staleness: the abort signal may have been tripped by
+ // a Svelte effect re-schedule even though the user didn't change images.
+ if (activePath !== entry.path) return;
+
+ if (cached !== undefined) {
const objectUrl = URL.createObjectURL(cached.blob);
rawObjectUrl = objectUrl;
fullObjectUrl = objectUrl;
@@ -358,7 +384,7 @@
const reader = res.body?.getReader();
if (reader === undefined) {
const blob = await res.blob();
- if (abort.signal.aborted) return;
+ if (abort.signal.aborted || activePath !== entry.path) return;
// Store in cache (fire-and-forget)
void imageCache.put('full', entry.path, blob);
autoCacheService.markCached(entry.path);
@@ -382,7 +408,7 @@
progress = totalBytes > 0 ? received / totalBytes : 0;
}
- if (abort.signal.aborted) return;
+ if (abort.signal.aborted || activePath !== entry.path) return;
const blob = new Blob(chunks);
// Store in cache (fire-and-forget)
@@ -393,10 +419,10 @@
fullObjectUrl = objectUrl;
progress = 1;
} catch (e) {
- if (abort.signal.aborted) return;
+ if (abort.signal.aborted || activePath !== entry.path) return;
loadError = e instanceof Error ? e.message : String(e);
} finally {
- if (!abort.signal.aborted) {
+ if (!abort.signal.aborted && activePath === entry.path) {
downloading = false;
autoCacheService.resumeAfterUserDownload();
}