Echo / src /game /Store.js
mlmihjaz's picture
Per-user backend word sync, sea wavelets, and pond surface life
4e53d9c
Raw
History Blame Contribute Delete
17.3 kB
/**
* 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(); }
});
}