Implement the 029-on-demand-bestiary feature that replaces the bundled XMM bestiary JSON with a compact search index (~350KB) and on-demand source loading, where users explicitly provide a URL or upload a JSON file to fetch full stat block data per source, which is then normalized and cached in IndexedDB (with in-memory fallback) so creature stat blocks load instantly on subsequent visits while keeping the app bundle small and never auto-fetching copyrighted content
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
139
apps/web/src/adapters/bestiary-cache.ts
Normal file
139
apps/web/src/adapters/bestiary-cache.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { Creature, CreatureId } from "@initiative/domain";
|
||||
import { type IDBPDatabase, openDB } from "idb";
|
||||
|
||||
const DB_NAME = "initiative-bestiary";
|
||||
const STORE_NAME = "sources";
|
||||
const DB_VERSION = 1;
|
||||
|
||||
export interface CachedSourceInfo {
|
||||
readonly sourceCode: string;
|
||||
readonly displayName: string;
|
||||
readonly creatureCount: number;
|
||||
readonly cachedAt: number;
|
||||
}
|
||||
|
||||
interface CachedSourceRecord {
|
||||
sourceCode: string;
|
||||
displayName: string;
|
||||
creatures: Creature[];
|
||||
cachedAt: number;
|
||||
creatureCount: number;
|
||||
}
|
||||
|
||||
let db: IDBPDatabase | null = null;
|
||||
let dbFailed = false;
|
||||
|
||||
// In-memory fallback when IndexedDB is unavailable
|
||||
const memoryStore = new Map<string, CachedSourceRecord>();
|
||||
|
||||
async function getDb(): Promise<IDBPDatabase | null> {
|
||||
if (db) return db;
|
||||
if (dbFailed) return null;
|
||||
|
||||
try {
|
||||
db = await openDB(DB_NAME, DB_VERSION, {
|
||||
upgrade(database) {
|
||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||
database.createObjectStore(STORE_NAME, {
|
||||
keyPath: "sourceCode",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
return db;
|
||||
} catch {
|
||||
dbFailed = true;
|
||||
console.warn(
|
||||
"IndexedDB unavailable — bestiary cache will not persist across sessions.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cacheSource(
|
||||
sourceCode: string,
|
||||
displayName: string,
|
||||
creatures: Creature[],
|
||||
): Promise<void> {
|
||||
const record: CachedSourceRecord = {
|
||||
sourceCode,
|
||||
displayName,
|
||||
creatures,
|
||||
cachedAt: Date.now(),
|
||||
creatureCount: creatures.length,
|
||||
};
|
||||
|
||||
const database = await getDb();
|
||||
if (database) {
|
||||
await database.put(STORE_NAME, record);
|
||||
} else {
|
||||
memoryStore.set(sourceCode, record);
|
||||
}
|
||||
}
|
||||
|
||||
export async function isSourceCached(sourceCode: string): Promise<boolean> {
|
||||
const database = await getDb();
|
||||
if (database) {
|
||||
const record = await database.get(STORE_NAME, sourceCode);
|
||||
return record !== undefined;
|
||||
}
|
||||
return memoryStore.has(sourceCode);
|
||||
}
|
||||
|
||||
export async function getCachedSources(): Promise<CachedSourceInfo[]> {
|
||||
const database = await getDb();
|
||||
if (database) {
|
||||
const all: CachedSourceRecord[] = await database.getAll(STORE_NAME);
|
||||
return all.map((r) => ({
|
||||
sourceCode: r.sourceCode,
|
||||
displayName: r.displayName,
|
||||
creatureCount: r.creatureCount,
|
||||
cachedAt: r.cachedAt,
|
||||
}));
|
||||
}
|
||||
return [...memoryStore.values()].map((r) => ({
|
||||
sourceCode: r.sourceCode,
|
||||
displayName: r.displayName,
|
||||
creatureCount: r.creatureCount,
|
||||
cachedAt: r.cachedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function clearSource(sourceCode: string): Promise<void> {
|
||||
const database = await getDb();
|
||||
if (database) {
|
||||
await database.delete(STORE_NAME, sourceCode);
|
||||
} else {
|
||||
memoryStore.delete(sourceCode);
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearAll(): Promise<void> {
|
||||
const database = await getDb();
|
||||
if (database) {
|
||||
await database.clear(STORE_NAME);
|
||||
} else {
|
||||
memoryStore.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadAllCachedCreatures(): Promise<
|
||||
Map<CreatureId, Creature>
|
||||
> {
|
||||
const map = new Map<CreatureId, Creature>();
|
||||
const database = await getDb();
|
||||
|
||||
let records: CachedSourceRecord[];
|
||||
if (database) {
|
||||
records = await database.getAll(STORE_NAME);
|
||||
} else {
|
||||
records = [...memoryStore.values()];
|
||||
}
|
||||
|
||||
for (const record of records) {
|
||||
for (const creature of record.creatures) {
|
||||
map.set(creature.id, creature);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
Reference in New Issue
Block a user