/** * Store — IndexedDB-backed persistence with localStorage mirror and in-memory fallback. * * Three object stores: words (per-word stats), meta (settings), cache (dict blobs). * * Resilience layers (from preferred to fallback): * 1. IndexedDB — primary, fast, large. * 2. localStorage mirror — every word/meta write is mirrored here so progress * survives IDB failures (private mode, quota, browser policy, stale connection). * 3. In-memory Map — only used if BOTH IDB and localStorage are unavailable. * * Per-user namespacing: words and meta are scoped under a namespace string * (default 'guest'). Call setNamespace(email) after auth to switch to that * user's slice. Records are stored with their key prefixed `${ns}::${rawKey}` * inside IDB so multiple users on the same browser don't collide. * * Diagnostics: Store.onSave(fn) registers a callback invoked after every word * save with {word, idb, ls} flags so the UI can show a visible save indicator. * Store.diagnostics() returns a snapshot of the persistence state for the * Report Problem dialog. */ import { Auth } from '../auth/Auth.js'; const NAME = 'echo3d_db1'; const WORDS = 'words', META = 'meta', CACHE = 'cache'; const SEP = '::'; const VERSION = 'store-v4 (2026-06-13)'; let db = null; let useMemory = false; let idbWorking = false; let lsWorking = true; const mem = { words: new Map(), meta: new Map(), cache: new Map() }; let currentNs = 'guest'; // Counters for diagnostics const stats = { idbWriteAttempts: 0, idbWriteOk: 0, idbWriteErr: 0, lsWriteAttempts: 0, lsWriteOk: 0, lsWriteErr: 0, lastError: null, lastSaveAt: null, }; const saveListeners = new Set(); export function onSave(fn) { saveListeners.add(fn); return () => saveListeners.delete(fn); } function fireSave(info) { stats.lastSaveAt = Date.now(); for (const fn of saveListeners) { try { fn(info); } catch {} } } // ---------- backend sync (per-user words across browsers/devices) ---------- const pendingUpload = new Map(); // raw word → record (latest) let uploadTimer = null; let lastRemoteFor = null; // remembers which namespace we last pulled function queueUpload(rec) { // Only useful when there's a signed-in user. Guest progress lives only // locally — the backend has no anonymous record. if (!Auth.session()) return; pendingUpload.set(rec.w, { ...rec }); if (uploadTimer) return; uploadTimer = setTimeout(flushUpload, 1200); } async function flushUpload() { uploadTimer = null; if (!pendingUpload.size || !Auth.session()) return; const batch = [...pendingUpload.values()]; pendingUpload.clear(); try { await Auth.saveWords(batch); stats.remoteUploadOk = (stats.remoteUploadOk || 0) + batch.length; fireSave({ word: `↑ ${batch.length} synced`, idb: true, ls: true, remote: true }); } catch (e) { // Re-queue on failure so we try again next save. stats.remoteUploadErr = (stats.remoteUploadErr || 0) + 1; stats.lastError = `remote upload: ${e?.message || e}`; for (const r of batch) if (!pendingUpload.has(r.w)) pendingUpload.set(r.w, r); } } /** Pull words from the backend and merge into the local store. Latest * per-word stats win (compared by `seen` timestamp + `ok` count). Called * after login + namespace switch, and at boot when a session exists. */ export async function syncFromRemote() { if (!Auth.session()) return { fetched: 0, merged: 0 }; if (lastRemoteFor === currentNs) return { fetched: 0, merged: 0 }; let remote = []; try { remote = await Auth.fetchWords(); } catch (e) { stats.lastError = `remote fetch: ${e?.message || e}`; return { fetched: 0, merged: 0 }; } lastRemoteFor = currentNs; if (!remote.length) return { fetched: 0, merged: 0 }; // Build local index for merge decisions. const localByW = new Map(); for (const r of await allWords()) localByW.set(r.w, r); let merged = 0; for (const r of remote) { const local = localByW.get(r.w); // Pick the "fresher" record by (status==master) > seen > ok. const remoteScore = (r.status === 'master' ? 1e15 : 0) + (Number(r.seen) || 0) + (r.ok | 0); const localScore = local ? ((local.status === 'master' ? 1e15 : 0) + (Number(local.seen) || 0) + (local.ok | 0)) : -1; if (remoteScore > localScore) { await putWordNoRemote({ w: r.w, status: String(r.status || 'new'), streak: r.streak | 0, ok: r.ok | 0, bad: r.bad | 0, seen: Number(r.seen) || 0, stars: r.stars | 0, }); merged++; } } console.log(`[Store] remote sync · fetched=${remote.length} merged=${merged} ns=${currentNs}`); return { fetched: remote.length, merged }; } // ---------- namespace helpers ---------- function nsKey(rawKey) { return `${currentNs}${SEP}${rawKey}`; } function stripNs(key) { const i = String(key).indexOf(SEP); return i >= 0 ? String(key).slice(i + SEP.length) : String(key); } function inCurrentNs(key) { return String(key).startsWith(`${currentNs}${SEP}`); } // ---------- localStorage helpers ---------- const LS_PREFIX = 'echo3d'; const lsWordKey = (w) => `${LS_PREFIX}:${currentNs}:w:${w}`; const lsMetaKey = (k) => `${LS_PREFIX}:${currentNs}:m:${k}`; const lsWordPrefix = () => `${LS_PREFIX}:${currentNs}:w:`; function lsAvailable() { try { const k = `${LS_PREFIX}:_probe`; localStorage.setItem(k, '1'); localStorage.removeItem(k); return true; } catch { return false; } } function lsAllWords() { if (!lsWorking) return []; const out = []; const p = lsWordPrefix(); for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && k.startsWith(p)) { try { out.push(JSON.parse(localStorage.getItem(k))); } catch {} } } return out; } function lsPutWord(o) { if (!lsWorking) return false; stats.lsWriteAttempts++; try { localStorage.setItem(lsWordKey(o.w), JSON.stringify(o)); stats.lsWriteOk++; return true; } catch (e) { stats.lsWriteErr++; stats.lastError = `localStorage: ${e?.message || e}`; // Don't permanently disable — could be a transient quota hit. Only mark // unavailable if a probe fails. if (!lsAvailable()) lsWorking = false; return false; } } function lsClearWords() { if (!lsWorking) return; const p = lsWordPrefix(); const remove = []; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && k.startsWith(p)) remove.push(k); } remove.forEach(k => { try { localStorage.removeItem(k); } catch {} }); } function lsGetMeta(k, d) { if (!lsWorking) return d; try { const v = localStorage.getItem(lsMetaKey(k)); return v == null ? d : JSON.parse(v); } catch { return d; } } function lsSetMeta(k, v) { if (!lsWorking) return false; try { localStorage.setItem(lsMetaKey(k), JSON.stringify(v)); return true; } catch (e) { stats.lastError = `localStorage meta: ${e?.message || e}`; return false; } } // ---------- IDB ---------- export function open() { lsWorking = lsAvailable(); return new Promise((res) => { let r; try { r = indexedDB.open(NAME, 1); } catch (e) { stats.lastError = `indexedDB.open threw: ${e?.message || e}`; idbWorking = false; finalizeBackend(); return res(); } r.onupgradeneeded = (e) => { const d = e.target.result; if (!d.objectStoreNames.contains(WORDS)) d.createObjectStore(WORDS, { keyPath: 'w' }); if (!d.objectStoreNames.contains(META)) d.createObjectStore(META, { keyPath: 'k' }); if (!d.objectStoreNames.contains(CACHE)) d.createObjectStore(CACHE, { keyPath: 'k' }); }; r.onsuccess = (e) => { db = e.target.result; idbWorking = true; // The connection is only really dead on `onclose`. Transaction errors // bubble up to `onerror` but the connection itself stays usable. db.onclose = () => { idbWorking = false; stats.lastError = 'IDB connection closed'; finalizeBackend(); }; db.onerror = (ev) => { // Log only — don't take the connection offline. The localStorage // mirror will catch any specific transaction that fails. stats.lastError = `IDB error: ${ev?.target?.error?.message || 'unknown'}`; console.warn('[Store] IDB transaction error:', ev?.target?.error); }; console.log(`[Store] ${VERSION} ready · IDB=ok · LS=${lsWorking ? 'ok' : 'NO'}`); finalizeBackend(); migrateLegacyWords().finally(res); }; r.onerror = () => { stats.lastError = `indexedDB.open onerror: ${r.error?.message || 'unknown'}`; console.warn('[Store] IDB open failed; falling back to localStorage'); idbWorking = false; finalizeBackend(); res(); }; r.onblocked = () => { stats.lastError = 'IDB open blocked by another tab'; console.warn('[Store] IDB open blocked'); idbWorking = false; finalizeBackend(); res(); }; }); } function finalizeBackend() { useMemory = !idbWorking && !lsWorking; } function tx(store, mode) { return db.transaction(store, mode).objectStore(store); } async function migrateLegacyWords() { if (!idbWorking) return; return new Promise((res) => { let migrated = 0; try { const t = db.transaction(WORDS, 'readwrite'); const s = t.objectStore(WORDS); const c = s.openCursor(); c.onsuccess = (e) => { const cur = e.target.result; if (!cur) return; const rec = cur.value; if (!String(rec.w).includes(SEP)) { const newRec = { ...rec, w: `guest${SEP}${rec.w}` }; s.put(newRec); cur.delete(); migrated++; } cur.continue(); }; t.oncomplete = () => { if (migrated) console.log(`[Store] migrated ${migrated} legacy word(s) to 'guest' namespace`); res(migrated); }; t.onerror = () => res(0); c.onerror = () => res(0); } catch { res(0); } }); } // ---------- public API ---------- const MIGRATED_FLAG_PREFIX = `${LS_PREFIX}:_migrated:`; export async function setNamespace(ns) { const next = (ns && String(ns).trim()) || 'guest'; if (next === currentNs) return; const prev = currentNs; currentNs = next; lastRemoteFor = null; // force a remote pull on next syncFromRemote console.log(`[Store] namespace: ${prev} → ${next}`); // First-login migration: when switching from 'guest' into a real user // account, copy guest-namespace progress over if (a) we haven't done it // for this user before, and (b) the user namespace is empty. This keeps // progress that was earned as a guest before the player signed up. if (prev === 'guest' && next !== 'guest') { const flagKey = MIGRATED_FLAG_PREFIX + next; let alreadyMigrated = false; try { alreadyMigrated = localStorage.getItem(flagKey) === '1'; } catch {} if (!alreadyMigrated) { await copyGuestToCurrent(); try { localStorage.setItem(flagKey, '1'); } catch {} } } } async function copyGuestToCurrent() { const target = currentNs; // Words living under the 'guest' namespace, regardless of which storage // backing they came from. We temporarily flip the namespace to 'guest' // to use the existing read helpers, then flip back. const saved = currentNs; currentNs = 'guest'; const guestWords = await allWords(); // guest meta keys we care about: len, threshold (others are settings // that already live per-user via existing setMeta calls — they'll just // overwrite if the user already has them). const guestLen = await getMeta('len', undefined); const guestThr = await getMeta('threshold', undefined); currentNs = saved; if (!guestWords.length && guestLen === undefined && guestThr === undefined) return; // Only migrate if the destination has no per-word data — don't overwrite // an existing user's progress. const existing = await allWords(); if (existing.length) return; console.log(`[Store] migrating ${guestWords.length} guest word(s) → ${target}`); for (const w of guestWords) await putWord(w); if (guestLen !== undefined) await setMeta('len', guestLen); if (guestThr !== undefined) await setMeta('threshold', guestThr); } export function getNamespace() { return currentNs; } export function warning() { if (useMemory) return 'Your browser is blocking persistent storage. Progress will be lost when you close this tab.'; if (!idbWorking && lsWorking) return 'Using fallback storage. Progress is being saved to a smaller, slower store.'; return null; } export function isMemory() { return useMemory; } export function isIdbWorking() { return idbWorking; } export function isLsWorking() { return lsWorking; } /** For the Report Problem dialog. */ export function diagnostics() { return { version: VERSION, namespace: currentNs, idbWorking, lsWorking, useMemory, stats: { ...stats }, wordCountLs: lsAllWords().length, }; } export async function allWords() { const idbList = idbWorking ? await idbAllWords() : []; const lsList = lsAllWords(); const byW = new Map(); for (const r of lsList) byW.set(r.w, r); for (const r of idbList) { const prev = byW.get(r.w); if (!prev || (r.seen || 0) >= (prev.seen || 0)) byW.set(r.w, r); } if (useMemory) for (const r of mem.words.values()) byW.set(r.w, r); return [...byW.values()]; } function idbAllWords() { return new Promise((res) => { const out = []; try { const c = tx(WORDS, 'readonly').openCursor(); c.onsuccess = (e) => { const cur = e.target.result; if (!cur) return res(out); const rec = cur.value; if (inCurrentNs(rec.w)) out.push({ ...rec, w: stripNs(rec.w) }); cur.continue(); }; c.onerror = () => res(out); } catch { res(out); } }); } export async function putWord(o) { await putWordNoRemote(o); // Queue for backend upload (debounced). Guest namespace skips this since // anonymous progress isn't tied to any account. if (currentNs !== 'guest') queueUpload(o); } /** Internal — writes to LS + IDB + memory but does NOT queue backend upload. * Used by syncFromRemote to merge fetched records without echoing them back. */ async function putWordNoRemote(o) { const lsOk = lsPutWord(o); if (useMemory) { mem.words.set(o.w, o); fireSave({ word: o.w, idb: false, ls: lsOk, mem: true }); return; } let idbOk = false; if (idbWorking) { idbOk = await new Promise((res) => { stats.idbWriteAttempts++; try { const stored = { ...o, w: nsKey(o.w) }; const q = tx(WORDS, 'readwrite').put(stored); q.onsuccess = () => { stats.idbWriteOk++; res(true); }; q.onerror = () => { stats.idbWriteErr++; stats.lastError = `IDB put failed: ${q.error?.message || 'unknown'}`; res(false); }; } catch (e) { stats.idbWriteErr++; stats.lastError = `IDB put threw: ${e?.message || e}`; res(false); } }); } fireSave({ word: o.w, idb: idbOk, ls: lsOk, mem: false }); } export async function clearWords() { lsClearWords(); if (useMemory) { mem.words.clear(); return; } if (!idbWorking) return; return new Promise((res) => { try { const t = db.transaction(WORDS, 'readwrite'); const s = t.objectStore(WORDS); const c = s.openCursor(); c.onsuccess = (e) => { const cur = e.target.result; if (!cur) return; if (inCurrentNs(cur.value.w)) cur.delete(); cur.continue(); }; t.oncomplete = res; t.onerror = res; c.onerror = res; } catch { res(); } }); } export async function getMeta(k, d) { if (idbWorking) { const v = await new Promise((res) => { try { const q = tx(META, 'readonly').get(`${currentNs}${SEP}${k}`); q.onsuccess = () => res(q.result ? q.result.v : undefined); q.onerror = () => res(undefined); } catch { res(undefined); } }); if (v !== undefined) return v; } const lsv = lsGetMeta(k, undefined); if (lsv !== undefined) return lsv; if (useMemory && mem.meta.has(k)) return mem.meta.get(k); return d; } export async function setMeta(k, v) { lsSetMeta(k, v); if (useMemory) { mem.meta.set(k, v); return; } if (!idbWorking) return; return new Promise((res) => { try { const q = tx(META, 'readwrite').put({ k: `${currentNs}${SEP}${k}`, v }); q.onsuccess = res; q.onerror = res; // log only, don't disable } catch { res(); } }); } // Dictionary cache is intentionally NOT namespaced. export async function getCache(k) { if (useMemory) return mem.cache.get(k) || null; if (!idbWorking) return null; return new Promise((res) => { try { const q = tx(CACHE, 'readonly').get(k); q.onsuccess = () => res(q.result ? q.result.v : null); q.onerror = () => res(null); } catch { res(null); } }); } export async function setCache(k, v) { if (useMemory) { mem.cache.set(k, v); return; } if (!idbWorking) return; return new Promise((res) => { try { const q = tx(CACHE, 'readwrite').put({ k, v }); q.onsuccess = res; q.onerror = res; } catch { res(); } }); }