/** * In-memory caches for the Studio furni listings. * * The Studio GET route recomputes three heavy database snapshots plus * per-item filesystem checks on every single request: * 1. `items_base` → local import index (full table scan) * 2. `FurnitureData.json` → normalized classname set (full file parse) * 3. `catalog_items.item_ids` → referenced id set (full table scan) * 4. one `existsSync` per imported item for .nitro presence * * All of those are cached here with a short TTL. Mutations (import/delete/ * regen/sync) clear everything via `clearFurniImportCache()`. */ import { existsSync } from "node:fs"; import path from "node:path"; import { sql } from "drizzle-orm"; import { db, ItemsBase } from "@/lib/db"; import { normalizeClassname } from "@/lib/furni/classname"; import { indexLocalFurniture } from "@/lib/furni/local-presence"; import { readFurniData } from "./furni-data"; const CACHE_TTL = 30_000; // 30 seconds type LocalSnapshot = { localIndex: Map>; importedSet: Set; timestamp: number; }; let localSnapshot: LocalSnapshot | null = null; /** Items already present in `items_base`, indexed + flattened to a set. */ export async function getLocalCatalogSnapshot(): Promise { const now = Date.now(); if (localSnapshot && now - localSnapshot.timestamp < CACHE_TTL) { return localSnapshot; } const rows = await db .select({ id: ItemsBase.id, itemName: ItemsBase.itemName }) .from(ItemsBase); const localIndex = indexLocalFurniture(rows); const snapshot: LocalSnapshot = { localIndex, importedSet: new Set(localIndex.keys()), timestamp: now, }; localSnapshot = snapshot; return snapshot; } let furniDataSetSnapshot: { set: Set; timestamp: number } | null = null; /** Normalized classnames present in the local FurnitureData.json. */ export async function getFurniDataClassnames(): Promise> { const now = Date.now(); if ( furniDataSetSnapshot && now - furniDataSetSnapshot.timestamp < CACHE_TTL ) { return furniDataSetSnapshot.set; } const set = new Set(); try { const fd = (await readFurniData()) as { roomitemtypes?: { furnitype?: Array<{ classname?: string }> }; wallitemtypes?: { furnitype?: Array<{ classname?: string }> }; }; for (const e of [ ...(fd.roomitemtypes?.furnitype ?? []), ...(fd.wallitemtypes?.furnitype ?? []), ]) { if (e.classname) set.add(normalizeClassname(e.classname)); } } catch { /* best effort — empty set means every item shows "no furnidata" */ } furniDataSetSnapshot = { set, timestamp: now }; return set; } let catalogIdsSnapshot: { set: Set; timestamp: number } | null = null; /** IDs referenced by any `catalog_items.item_ids`. */ export async function getCatalogIdSet(): Promise> { const now = Date.now(); if (catalogIdsSnapshot && now - catalogIdsSnapshot.timestamp < CACHE_TTL) { return catalogIdsSnapshot.set; } const set = new Set(); try { const rows = await db.execute(sql`SELECT item_ids FROM catalog_items`); for (const row of ( rows as unknown as [Array<{ item_ids: string | null }>, unknown] )[0]) { for (const part of String(row.item_ids ?? "").split(/[;,]/)) { const n = Number(part.trim()); if (Number.isFinite(n) && n > 0) set.add(n); } } } catch { /* best effort — empty set means every item shows "no catalog entry" */ } catalogIdsSnapshot = { set, timestamp: now }; return set; } const nitroPresenceCache = new Map(); /** `existsSync` memoized per (dir + base) — nitro files change rarely. */ export function nitroExistsCached(dir: string, base: string): boolean { const key = `${dir}/${base}`; const cached = nitroPresenceCache.get(key); if (cached !== undefined) return cached; const present = existsSync(path.join(dir, `${base}.nitro`)); nitroPresenceCache.set(key, present); return present; } type FurniStats = { totalInDb: number; inCatalog: number; notInCatalog: number; missingNitro: number; }; let statsSnapshot: { stats: FurniStats; timestamp: number } | null = null; /** * Studio header stats. Falls back to a previously cached result if the * expensive scan (all of items_base + a 10k filesystem probe) fails. */ export async function getFurniImportStats( nitroDir: string, ): Promise { const now = Date.now(); if (statsSnapshot && now - statsSnapshot.timestamp < CACHE_TTL) { return statsSnapshot.stats; } try { const [totalResult, catalogResult, allIdsResult, referencedResult] = await Promise.all([ db.execute(sql`SELECT COUNT(*) as cnt FROM items_base`), db.execute( sql`SELECT COUNT(DISTINCT item_ids) as cnt FROM catalog_items`, ), db.execute(sql`SELECT id FROM items_base`), db.execute(sql`SELECT item_ids FROM catalog_items`), ]); const totalInDb = Number( (totalResult as unknown as [[{ cnt: bigint }], unknown])[0][0].cnt, ); const inCatalog = Number( (catalogResult as unknown as [[{ cnt: bigint }], unknown])[0][0].cnt, ); const allIds = ( allIdsResult as unknown as [Array<{ id: number }>, unknown] )[0].map((r) => Number(r.id)); const referenced = new Set(); for (const row of ( referencedResult as unknown as [ Array<{ item_ids: string | null }>, unknown, ] )[0]) { for (const part of String(row.item_ids ?? "").split(/[;,]/)) { const n = Number(part.trim()); if (Number.isFinite(n) && n > 0) referenced.add(n); } } const notInCatalog = allIds.filter((id) => !referenced.has(id)).length; const [importedItems] = (await db.execute(sql` SELECT ib.item_name FROM items_base ib WHERE EXISTS ( SELECT 1 FROM catalog_items ci WHERE FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ',')) > 0 ) LIMIT 10000 `)) as unknown as [Array<{ item_name: string }>, unknown]; let missingNitro = 0; for (const item of importedItems) { const starIdx = item.item_name.indexOf("*"); const base = starIdx !== -1 ? item.item_name.substring(0, starIdx) : item.item_name; if (!nitroExistsCached(nitroDir, base)) missingNitro++; } const stats: FurniStats = { totalInDb, inCatalog, notInCatalog, missingNitro, }; statsSnapshot = { stats, timestamp: now }; return stats; } catch (err) { console.warn( "[furni-import-cache] Stats scan failed:", (err as Error).message, ); return ( statsSnapshot?.stats ?? { totalInDb: 0, inCatalog: 0, notInCatalog: 0, missingNitro: 0, } ); } } /** Drop every cache after a mutation so the next Studio request is fresh. */ export function clearFurniImportCache(): void { localSnapshot = null; furniDataSetSnapshot = null; catalogIdsSnapshot = null; nitroPresenceCache.clear(); statsSnapshot = null; }