318 lines
8.8 KiB
JavaScript
318 lines
8.8 KiB
JavaScript
// @ts-check
|
|
/**
|
|
* IndexedDB-Adapter mit localStorage-Fallback (#81, #84).
|
|
* Sync-Boot bleibt über localStorage; IDB speichert parallel + Snapshots.
|
|
*/
|
|
(function (root) {
|
|
const KEY = 'code-kumpel-v1';
|
|
const DB_NAME = 'code-kumpel';
|
|
const DB_VER = 1;
|
|
const STORE = 'state';
|
|
const BACKUPS = 'backups';
|
|
const MAX_BACKUPS = 8;
|
|
const errors = [];
|
|
|
|
function logErr(ctx, e) {
|
|
const msg = (e && e.message) || String(e);
|
|
errors.push({ at: Date.now(), ctx, msg });
|
|
if (errors.length > 40) errors.shift();
|
|
console.warn('[CK.storage]', ctx, e);
|
|
}
|
|
|
|
/** @returns {Promise<IDBDatabase|null>} */
|
|
function openDB() {
|
|
return new Promise((resolve) => {
|
|
if (typeof indexedDB === 'undefined') return resolve(null);
|
|
try {
|
|
const req = indexedDB.open(DB_NAME, DB_VER);
|
|
req.onupgradeneeded = () => {
|
|
const db = req.result;
|
|
if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
|
|
if (!db.objectStoreNames.contains(BACKUPS)) {
|
|
const os = db.createObjectStore(BACKUPS, { keyPath: 'id', autoIncrement: true });
|
|
os.createIndex('at', 'at');
|
|
}
|
|
};
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => { logErr('open', req.error); resolve(null); };
|
|
} catch (e) {
|
|
logErr('open-throw', e);
|
|
resolve(null);
|
|
}
|
|
});
|
|
}
|
|
|
|
function lsGet() {
|
|
try {
|
|
const raw = localStorage.getItem(KEY);
|
|
if (!raw) return null;
|
|
return JSON.parse(raw);
|
|
} catch (e) {
|
|
logErr('lsGet', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function lsSet(state) {
|
|
try {
|
|
localStorage.setItem(KEY, JSON.stringify(state));
|
|
return true;
|
|
} catch (e) {
|
|
logErr('lsSet', e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function lsClear() {
|
|
try { localStorage.removeItem(KEY); } catch (e) { logErr('lsClear', e); }
|
|
}
|
|
|
|
/** @param {IDBDatabase} db */
|
|
function idbGet(db) {
|
|
return new Promise((resolve) => {
|
|
try {
|
|
const tx = db.transaction(STORE, 'readonly');
|
|
const req = tx.objectStore(STORE).get(KEY);
|
|
req.onsuccess = () => resolve(req.result || null);
|
|
req.onerror = () => { logErr('idbGet', req.error); resolve(null); };
|
|
} catch (e) {
|
|
logErr('idbGet-throw', e);
|
|
resolve(null);
|
|
}
|
|
});
|
|
}
|
|
|
|
/** @param {IDBDatabase} db @param {any} state */
|
|
function idbPut(db, state) {
|
|
return new Promise((resolve) => {
|
|
try {
|
|
const tx = db.transaction(STORE, 'readwrite');
|
|
tx.objectStore(STORE).put(state, KEY);
|
|
tx.oncomplete = () => resolve(true);
|
|
tx.onerror = () => { logErr('idbPut', tx.error); resolve(false); };
|
|
} catch (e) {
|
|
logErr('idbPut-throw', e);
|
|
resolve(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
/** @param {IDBDatabase} db */
|
|
function idbClear(db) {
|
|
return new Promise((resolve) => {
|
|
try {
|
|
const tx = db.transaction([STORE, BACKUPS], 'readwrite');
|
|
tx.objectStore(STORE).clear();
|
|
tx.objectStore(BACKUPS).clear();
|
|
tx.oncomplete = () => resolve(true);
|
|
tx.onerror = () => { logErr('idbClear', tx.error); resolve(false); };
|
|
} catch (e) {
|
|
logErr('idbClear-throw', e);
|
|
resolve(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Snapshot speichern (nur kompakte Meta + memories-count optional full).
|
|
* @param {IDBDatabase} db
|
|
* @param {any} state
|
|
*/
|
|
function pushBackup(db, state) {
|
|
return new Promise((resolve) => {
|
|
try {
|
|
const snap = {
|
|
at: Date.now(),
|
|
xp: state && state.xp,
|
|
name: state && state.name,
|
|
memories: state && state.memories ? state.memories.length : 0,
|
|
data: state
|
|
};
|
|
const tx = db.transaction(BACKUPS, 'readwrite');
|
|
const os = tx.objectStore(BACKUPS);
|
|
os.add(snap);
|
|
tx.oncomplete = async () => {
|
|
try {
|
|
const tx2 = db.transaction(BACKUPS, 'readwrite');
|
|
const os2 = tx2.objectStore(BACKUPS);
|
|
const all = await new Promise((res) => {
|
|
const r = os2.getAll();
|
|
r.onsuccess = () => res(r.result || []);
|
|
r.onerror = () => res([]);
|
|
});
|
|
all.sort((a, b) => (a.at || 0) - (b.at || 0));
|
|
const excess = all.length - MAX_BACKUPS;
|
|
for (let i = 0; i < excess; i++) {
|
|
if (all[i] && all[i].id != null) os2.delete(all[i].id);
|
|
}
|
|
} catch (e) { logErr('backup-trim', e); }
|
|
resolve(true);
|
|
};
|
|
tx.onerror = () => { logErr('backup', tx.error); resolve(false); };
|
|
} catch (e) {
|
|
logErr('backup-throw', e);
|
|
resolve(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function listBackups() {
|
|
const db = await openDB();
|
|
if (!db) return [];
|
|
return new Promise((resolve) => {
|
|
try {
|
|
const tx = db.transaction(BACKUPS, 'readonly');
|
|
const r = tx.objectStore(BACKUPS).getAll();
|
|
r.onsuccess = () => {
|
|
const list = (r.result || []).map((b) => ({
|
|
id: b.id,
|
|
at: b.at,
|
|
xp: b.xp,
|
|
name: b.name,
|
|
memories: b.memories
|
|
}));
|
|
list.sort((a, b) => (b.at || 0) - (a.at || 0));
|
|
resolve(list);
|
|
};
|
|
r.onerror = () => resolve([]);
|
|
} catch (e) {
|
|
logErr('listBackups', e);
|
|
resolve([]);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function restoreBackup(id) {
|
|
const db = await openDB();
|
|
if (!db) return null;
|
|
return new Promise((resolve) => {
|
|
try {
|
|
const tx = db.transaction(BACKUPS, 'readonly');
|
|
const r = tx.objectStore(BACKUPS).get(id);
|
|
r.onsuccess = () => resolve((r.result && r.result.data) || null);
|
|
r.onerror = () => resolve(null);
|
|
} catch (e) {
|
|
logErr('restoreBackup', e);
|
|
resolve(null);
|
|
}
|
|
});
|
|
}
|
|
|
|
let _lastBackupAt = 0;
|
|
const BACKUP_MIN_MS = 5 * 60 * 1000;
|
|
|
|
/**
|
|
* Speichert sync in localStorage und async in IDB (+ gelegentlich Backup).
|
|
* @param {any} state
|
|
* @param {{forceBackup?: boolean}} [opts]
|
|
*/
|
|
async function save(state, opts) {
|
|
lsSet(state);
|
|
const db = await openDB();
|
|
if (!db) return { ls: true, idb: false };
|
|
const ok = await idbPut(db, state);
|
|
const now = Date.now();
|
|
if (opts && opts.forceBackup || now - _lastBackupAt > BACKUP_MIN_MS) {
|
|
await pushBackup(db, state);
|
|
_lastBackupAt = now;
|
|
}
|
|
return { ls: true, idb: ok };
|
|
}
|
|
|
|
/** Sync-Load für Boot (localStorage first). */
|
|
function loadSync() {
|
|
return lsGet();
|
|
}
|
|
|
|
/**
|
|
* Hydrate: wenn IDB neuer/größer, bevorzuge IDB und migriere.
|
|
* @returns {Promise<any|null>}
|
|
*/
|
|
async function hydrate() {
|
|
const ls = lsGet();
|
|
const db = await openDB();
|
|
if (!db) return ls;
|
|
const idb = await idbGet(db);
|
|
if (!idb && ls) {
|
|
await idbPut(db, ls);
|
|
return ls;
|
|
}
|
|
if (idb && !ls) {
|
|
lsSet(idb);
|
|
return idb;
|
|
}
|
|
if (idb && ls) {
|
|
const idbMem = (idb.memories && idb.memories.length) || 0;
|
|
const lsMem = (ls.memories && ls.memories.length) || 0;
|
|
const idbXp = idb.xp || 0;
|
|
const lsXp = ls.xp || 0;
|
|
// Prefer richer state
|
|
if (idbMem > lsMem || (idbMem === lsMem && idbXp > lsXp)) {
|
|
lsSet(idb);
|
|
return idb;
|
|
}
|
|
await idbPut(db, ls);
|
|
return ls;
|
|
}
|
|
return ls;
|
|
}
|
|
|
|
async function clearAll() {
|
|
lsClear();
|
|
const db = await openDB();
|
|
if (db) await idbClear(db);
|
|
}
|
|
|
|
function storageSizeEstimate() {
|
|
let lsBytes = 0;
|
|
try {
|
|
const raw = localStorage.getItem(KEY);
|
|
lsBytes = raw ? raw.length * 2 : 0;
|
|
} catch (e) {}
|
|
return { localStorageBytes: lsBytes, key: KEY, backend: typeof indexedDB !== 'undefined' ? 'idb+ls' : 'ls' };
|
|
}
|
|
|
|
function getErrors() { return errors.slice(); }
|
|
|
|
/**
|
|
* Manueller Sync-Token (#83): Base64url JSON (kein Cloud).
|
|
* @param {any} state
|
|
* @param {'full'|'friends'|'knowledge'} kind
|
|
*/
|
|
function encodeSyncToken(payload) {
|
|
const json = JSON.stringify(payload);
|
|
const b64 = btoa(unescape(encodeURIComponent(json)));
|
|
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
}
|
|
|
|
function decodeSyncToken(token) {
|
|
try {
|
|
let b64 = String(token).replace(/-/g, '+').replace(/_/g, '/');
|
|
while (b64.length % 4) b64 += '=';
|
|
const json = decodeURIComponent(escape(atob(b64)));
|
|
return JSON.parse(json);
|
|
} catch (e) {
|
|
logErr('decodeSync', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const api = {
|
|
KEY,
|
|
loadSync,
|
|
hydrate,
|
|
save,
|
|
clearAll,
|
|
listBackups,
|
|
restoreBackup,
|
|
storageSizeEstimate,
|
|
getErrors,
|
|
encodeSyncToken,
|
|
decodeSyncToken,
|
|
logErr,
|
|
MAX_BACKUPS
|
|
};
|
|
|
|
root.CodeKumpelStorage = api;
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
})(typeof window !== 'undefined' ? window : globalThis);
|