| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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'; |
|
|
| |
| 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 {} } |
| } |
|
|
| |
| const pendingUpload = new Map(); |
| let uploadTimer = null; |
| let lastRemoteFor = null; |
|
|
| function queueUpload(rec) { |
| |
| |
| 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) { |
| |
| 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); |
| } |
| } |
|
|
| |
| |
| |
| 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 }; |
|
|
| |
| 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); |
| |
| 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 }; |
| } |
|
|
| |
| 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}`); |
| } |
|
|
| |
| 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}`; |
| |
| |
| 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; |
| } |
| } |
|
|
| |
| 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; |
| |
| |
| db.onclose = () => { |
| idbWorking = false; |
| stats.lastError = 'IDB connection closed'; |
| finalizeBackend(); |
| }; |
| db.onerror = (ev) => { |
| |
| |
| 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); } |
| }); |
| } |
|
|
| |
| 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; |
| console.log(`[Store] namespace: ${prev} → ${next}`); |
|
|
| |
| |
| |
| |
| 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; |
|
|
| |
| |
| |
| const saved = currentNs; |
| currentNs = 'guest'; |
| const guestWords = await allWords(); |
| |
| |
| |
| const guestLen = await getMeta('len', undefined); |
| const guestThr = await getMeta('threshold', undefined); |
| currentNs = saved; |
|
|
| if (!guestWords.length && guestLen === undefined && guestThr === undefined) return; |
|
|
| |
| |
| 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; } |
|
|
| |
| 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); |
| |
| |
| if (currentNs !== 'guest') queueUpload(o); |
| } |
|
|
| |
| |
| 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; |
| } catch { res(); } |
| }); |
| } |
|
|
| |
| 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(); } |
| }); |
| } |
|
|