mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-10-27 08:40:39 +00:00
Linked Designs P3c: localStorage disk cache
Mirror the in-memory resolver cache to localStorage so cold reloads
paint linked sources without a relay round-trip. 24-hour TTL keeps
return visits feeling instant while still propagating source edits
within a day; 4 MB LRU eviction stops a host with many linked
layers from blowing the quota.
Storage shape (artstr.linkedDesignCache.v1):
{ version: 1,
entries: {
[cacheKey]: { sourceEvent, fetchedAt }
}
}
The disk cache stores only the raw sourceEvent — premium / private
detection, vault lookup, and decrypt all re-run on every read via
the extracted _entryFromEvent helper. That means vault state,
sign-in changes, and unlocks since the last fetch are always
respected on disk-cache hits; we never serve stale "locked" state
once the user has unlocked, or stale "unlocked" state once they
clear the vault.
Refresh + Unlink continue to be the user's escape hatch:
clearCache(key) now wipes both the in-memory entry AND the disk
entry, so a manual refresh genuinely re-fetches from relays.
Eviction is LRU by fetchedAt when total content bytes exceed
LINKED_DESIGN_DISK_MAX_BYTES. Quota errors on save drop the whole
cache and continue — better to lose the optimization than to break
publishing for an unrelated localStorage write.
This commit is contained in:
1 parent
9152d41f4b
commit
5f2f057e52
1 file changed
+122
-31
+122
-31
@@ -6491,6 +6491,12 @@
|
||||
const LINKED_DESIGN_MAX_EVENT_BYTES = 1024 * 1024;
|
||||
const LINKED_DESIGN_MAX_TREE_BYTES = 1024 * 1024;
|
||||
const LINKED_DESIGN_MAX_DEPTH = 3;
|
||||
// Disk cache: 24-hour TTL (long enough to make return visits
|
||||
// feel instant; short enough that source edits propagate
|
||||
// within a day). 4 MB budget, LRU eviction.
|
||||
const LINKED_DESIGN_DISK_KEY = 'artstr.linkedDesignCache.v1';
|
||||
const LINKED_DESIGN_DISK_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const LINKED_DESIGN_DISK_MAX_BYTES = 4 * 1024 * 1024;
|
||||
const linkedDesigns = (() => {
|
||||
const cache = new Map(); // key (address or eventId) → { payload, fetchedAt, sourceEvent }
|
||||
const inflight = new Map(); // key → Promise (deduplicates parallel resolves)
|
||||
@@ -6525,38 +6531,72 @@
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function _fetchPayload({ ref, pinnedEventId }) {
|
||||
const relays = typeof _vaultRelayUnion === 'function' ? _vaultRelayUnion() : splitRelayList(state.community?.relays || '');
|
||||
let filter = null;
|
||||
if (pinnedEventId) {
|
||||
if (!/^[0-9a-f]{64}$/i.test(pinnedEventId)) return null;
|
||||
filter = { ids: [pinnedEventId], limit: 1 };
|
||||
} else {
|
||||
const coord = _parseAddressCoord(ref);
|
||||
if (!coord) return null;
|
||||
filter = {
|
||||
kinds: [coord.kind],
|
||||
authors: [coord.pubkey],
|
||||
'#d': [coord.dTag],
|
||||
limit: 5,
|
||||
};
|
||||
}
|
||||
let events;
|
||||
// Disk cache (LINKED_DESIGNS_FEATURE.md §7 polish). Mirror
|
||||
// the in-memory cache to localStorage so a cold reload paints
|
||||
// without waiting on relays. We store the raw sourceEvent
|
||||
// only — premium/private detection + vault lookup + decrypt
|
||||
// re-runs on every read so vault state, sign-in status, and
|
||||
// unlock changes are always current. LRU-bounded at 4 MB.
|
||||
function _diskLoad() {
|
||||
try {
|
||||
events = await queryRelays(relays, filter, { timeoutMs: 4500 });
|
||||
} catch (err) {
|
||||
console.warn('[linked-designs] relay query failed', err);
|
||||
return null;
|
||||
const raw = localStorage.getItem(LINKED_DESIGN_DISK_KEY);
|
||||
if (!raw) return { version: 1, entries: {} };
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed?.version !== 1 || !parsed?.entries) return { version: 1, entries: {} };
|
||||
return parsed;
|
||||
} catch { return { version: 1, entries: {} }; }
|
||||
}
|
||||
function _diskSave(cache) {
|
||||
try { localStorage.setItem(LINKED_DESIGN_DISK_KEY, JSON.stringify(cache)); }
|
||||
catch (err) {
|
||||
// Likely a quota error — drop the cache to recover.
|
||||
console.warn('[linked-designs] disk cache save failed (clearing)', err);
|
||||
try { localStorage.removeItem(LINKED_DESIGN_DISK_KEY); } catch {}
|
||||
}
|
||||
if (!events || !events.length) {
|
||||
console.warn('[linked-designs] no events returned for', ref || pinnedEventId);
|
||||
return null;
|
||||
}
|
||||
function _diskRead(key) {
|
||||
if (!key) return null;
|
||||
const disk = _diskLoad();
|
||||
const entry = disk.entries[key];
|
||||
if (!entry) return null;
|
||||
if (Date.now() - (entry.fetchedAt || 0) > LINKED_DESIGN_DISK_TTL_MS) return null;
|
||||
return entry.sourceEvent || null;
|
||||
}
|
||||
function _diskWrite(key, sourceEvent) {
|
||||
if (!key || !sourceEvent?.content) return;
|
||||
const disk = _diskLoad();
|
||||
disk.entries[key] = { sourceEvent, fetchedAt: Date.now() };
|
||||
_diskEvictIfNeeded(disk);
|
||||
_diskSave(disk);
|
||||
}
|
||||
function _diskDelete(key) {
|
||||
if (!key) return;
|
||||
const disk = _diskLoad();
|
||||
if (!disk.entries[key]) return;
|
||||
delete disk.entries[key];
|
||||
_diskSave(disk);
|
||||
}
|
||||
function _diskEvictIfNeeded(disk) {
|
||||
const arr = Object.entries(disk.entries);
|
||||
let totalBytes = 0;
|
||||
for (const [, e] of arr) totalBytes += (e?.sourceEvent?.content?.length || 0);
|
||||
if (totalBytes <= LINKED_DESIGN_DISK_MAX_BYTES) return;
|
||||
// LRU eviction by fetchedAt ascending.
|
||||
arr.sort((a, b) => (a[1].fetchedAt || 0) - (b[1].fetchedAt || 0));
|
||||
while (totalBytes > LINKED_DESIGN_DISK_MAX_BYTES && arr.length) {
|
||||
const [k, e] = arr.shift();
|
||||
delete disk.entries[k];
|
||||
totalBytes -= (e?.sourceEvent?.content?.length || 0);
|
||||
}
|
||||
// Pick the latest by created_at — relays should retain only
|
||||
// the latest addressable event per address, but defensively
|
||||
// sort in case more than one slipped through.
|
||||
events.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
|
||||
const ev = events[0];
|
||||
}
|
||||
|
||||
// Build a resolver entry from a single raw source event. The
|
||||
// input is whatever queryRelays / disk cache hands us; the
|
||||
// output is the same { payload, sourceEvent, contentBytes,
|
||||
// premium?, private? } shape resolveLinked stashes in the
|
||||
// in-memory cache. Pure given vault state + signing extension
|
||||
// — same event yields the same entry shape both times.
|
||||
async function _entryFromEvent(ev) {
|
||||
if (!ev?.content) {
|
||||
console.warn('[linked-designs] source event has empty content', ev?.id);
|
||||
return null;
|
||||
@@ -6668,6 +6708,52 @@
|
||||
return { payload: parsed, sourceEvent: ev, contentBytes: ev.content.length };
|
||||
}
|
||||
|
||||
// Try disk cache first; on miss query relays + write to disk.
|
||||
// Either way, the post-fetch entry build runs the same code
|
||||
// path through _entryFromEvent so vault/private/premium
|
||||
// detection always reflects current state.
|
||||
async function _fetchPayload({ ref, pinnedEventId }) {
|
||||
const key = _cacheKey({ ref, pinnedEventId });
|
||||
const cachedEv = _diskRead(key);
|
||||
if (cachedEv) {
|
||||
const entry = await _entryFromEvent(cachedEv);
|
||||
if (entry) return entry;
|
||||
// Cached event was malformed — drop it and fall through to a fresh fetch.
|
||||
_diskDelete(key);
|
||||
}
|
||||
const relays = typeof _vaultRelayUnion === 'function' ? _vaultRelayUnion() : splitRelayList(state.community?.relays || '');
|
||||
let filter = null;
|
||||
if (pinnedEventId) {
|
||||
if (!/^[0-9a-f]{64}$/i.test(pinnedEventId)) return null;
|
||||
filter = { ids: [pinnedEventId], limit: 1 };
|
||||
} else {
|
||||
const coord = _parseAddressCoord(ref);
|
||||
if (!coord) return null;
|
||||
filter = {
|
||||
kinds: [coord.kind],
|
||||
authors: [coord.pubkey],
|
||||
'#d': [coord.dTag],
|
||||
limit: 5,
|
||||
};
|
||||
}
|
||||
let events;
|
||||
try {
|
||||
events = await queryRelays(relays, filter, { timeoutMs: 4500 });
|
||||
} catch (err) {
|
||||
console.warn('[linked-designs] relay query failed', err);
|
||||
return null;
|
||||
}
|
||||
if (!events || !events.length) {
|
||||
console.warn('[linked-designs] no events returned for', ref || pinnedEventId);
|
||||
return null;
|
||||
}
|
||||
events.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
|
||||
const ev = events[0];
|
||||
const entry = await _entryFromEvent(ev);
|
||||
if (entry) _diskWrite(key, ev);
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Public: resolve a single linked layer's spec ({ ref, pinnedEventId }).
|
||||
// Returns { payload, sourceEvent, fetchedAt } or null when not
|
||||
// resolvable. Coalesces concurrent calls for the same key into
|
||||
@@ -6747,8 +6833,13 @@
|
||||
}
|
||||
|
||||
function clearCache(key) {
|
||||
if (key) cache.delete(key);
|
||||
else cache.clear();
|
||||
if (key) {
|
||||
cache.delete(key);
|
||||
_diskDelete(key);
|
||||
} else {
|
||||
cache.clear();
|
||||
try { localStorage.removeItem(LINKED_DESIGN_DISK_KEY); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronous cache peek for renderers that want to skip the
|
||||
|
||||
Reference in new issue
Block a user