code-kumpel/storage.js
2026-10-24 04:51:23 +02:00

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);