From d02aaa0d87b1d68e8857042f015f0efeec0109a0 Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 13 Sep 2026 11:40:55 +0200 Subject: [PATCH] perf(studio): cache furni imports and virtualize the furniture grid - Add TTL-based caching for local index lookup, furnidata classnames, catalog id set, nitro file presence and import stats - Invalidate caches after furnace single/batch/clone imports - Rewrite batch progress with elapsed time, rate and verification chips - Virtualize the grid with @tanstack/react-virtual and replace the Load more button with infinite scroll via an IntersectionObserver --- src/app/api/admin/import/clone/batch/route.ts | 4 + src/app/api/admin/import/furni/batch/route.ts | 6 + src/app/api/admin/import/furni/route.ts | 193 ++------- .../admin/studio/batch-progress.tsx | 214 +++++++--- src/components/admin/studio/studio-client.tsx | 377 ++++++++++++------ src/lib/services/furni-import-cache.ts | 223 +++++++++++ 6 files changed, 672 insertions(+), 345 deletions(-) create mode 100644 src/lib/services/furni-import-cache.ts diff --git a/src/app/api/admin/import/clone/batch/route.ts b/src/app/api/admin/import/clone/batch/route.ts index d81a0d98..9d284d3d 100644 --- a/src/app/api/admin/import/clone/batch/route.ts +++ b/src/app/api/admin/import/clone/batch/route.ts @@ -21,6 +21,7 @@ import { syncAssetsToGamedataBundle, verifyAndFixInteractionModesCount, } from "@/lib/services/furni-import"; +import { clearFurniImportCache } from "@/lib/services/furni-import-cache"; import { runSseBatch } from "@/lib/services/import/core/sse-batch"; import { rcon } from "@/lib/services/rcon"; @@ -179,6 +180,9 @@ export const POST = withAdmin( } catch { /* best effort — post-import consolidation is non-fatal */ } + + // New items + files landed — drop caches so the Studio refresh is fresh. + clearFurniImportCache(); }, }); }, diff --git a/src/app/api/admin/import/furni/batch/route.ts b/src/app/api/admin/import/furni/batch/route.ts index 1fc0b197..d74b8461 100644 --- a/src/app/api/admin/import/furni/batch/route.ts +++ b/src/app/api/admin/import/furni/batch/route.ts @@ -19,6 +19,7 @@ import { syncAssetsToGamedataBundle, verifyAndFixInteractionModesCount, } from "@/lib/services/furni-import"; +import { clearFurniImportCache } from "@/lib/services/furni-import-cache"; import { rcon } from "@/lib/services/rcon"; import type { ImportSingleResult } from "@/types/furni"; @@ -195,6 +196,11 @@ export const POST = withAdmin( await new Promise((r) => setImmediate(r)); } + if (succeeded > 0) { + // New items + files landed — drop caches so the Studio refresh is fresh. + clearFurniImportCache(); + } + if (aborted) { send({ type: "batch_complete", diff --git a/src/app/api/admin/import/furni/route.ts b/src/app/api/admin/import/furni/route.ts index 355732eb..bea7a874 100644 --- a/src/app/api/admin/import/furni/route.ts +++ b/src/app/api/admin/import/furni/route.ts @@ -5,10 +5,7 @@ import { apiError, apiOk } from "@/lib/api"; import { withAdmin } from "@/lib/api-handler"; import { db, ItemsBase } from "@/lib/db"; import { normalizeClassname } from "@/lib/furni/classname"; -import { - indexLocalFurniture, - localFurnitureStatus, -} from "@/lib/furni/local-presence"; +import { localFurnitureStatus } from "@/lib/furni/local-presence"; import { PERMS } from "@/lib/permissions"; import { logAudit } from "@/lib/services/audit"; import { getCloneList } from "@/lib/services/clone-import"; @@ -16,7 +13,6 @@ import { getSource } from "@/lib/services/clone-sources"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; import { appendFurniEntriesBulk, - readFurniData, rebuildCatalogOfferIds, reconcileFurniDataWithItemsBase, verifyAndFixSpriteIds, @@ -37,6 +33,14 @@ import { syncAssetsToGamedataBundle, verifyAndFixInteractionModesCount, } from "@/lib/services/furni-import"; +import { + clearFurniImportCache, + getCatalogIdSet, + getFurniDataClassnames, + getFurniImportStats, + getLocalCatalogSnapshot, + nitroExistsCached, +} from "@/lib/services/furni-import-cache"; import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache"; import { listFurniture } from "@/lib/services/habbofurni"; import { streamImportResponse } from "@/lib/services/import/core/single-response"; @@ -111,6 +115,8 @@ export const DELETE = withAdmin( }, }); + clearFurniImportCache(); + return apiOk({ classname, deletedFiles, @@ -145,68 +151,7 @@ export const GET = withAdmin( // Stats endpoint const action = request.nextUrl.searchParams.get("action"); if (action === "stats") { - 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, - ); - - // An item is "in the catalog" when its items_base id is referenced by - // any catalog_items.item_ids (the canonical link — catalog_name can be - // a translated display name, so matching on it is unreliable). - 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; - - // Count missing .nitro files by checking filesystem - 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; - const nitroPath = path.join( - /*turbopackIgnore: true*/ nitroDir, - `${base}.nitro`, - ); - if (!existsSync(/*turbopackIgnore: true*/ nitroPath)) missingNitro++; - } - - return apiOk({ totalInDb, inCatalog, notInCatalog, missingNitro }); + return apiOk(await getFurniImportStats(nitroDir)); } // Live furnidata reconcile (without hotel RCON reload) @@ -255,18 +200,14 @@ export const GET = withAdmin( unknown, ]; - // Filter to only those missing .nitro on filesystem + // Filter to only those missing .nitro on filesystem (memoized — fast) const missing = importedItems.filter((item) => { const starIdx = item.item_name.indexOf("*"); const base = starIdx !== -1 ? item.item_name.substring(0, starIdx) : item.item_name; - const p = path.join( - /*turbopackIgnore: true*/ nitroDir, - `${base}.nitro`, - ); - return !existsSync(/*turbopackIgnore: true*/ p); + return !nitroExistsCached(nitroDir, base); }); const total = missing.length; @@ -304,11 +245,9 @@ export const GET = withAdmin( const page = parseInt(request.nextUrl.searchParams.get("page") || "1", 10); // ── Browse a custom clone source (retro hotel furnidata) ──────── - const localRows = await db - .select({ id: ItemsBase.id, itemName: ItemsBase.itemName }) - .from(ItemsBase); - const localIndex = indexLocalFurniture(localRows); - const importedSet = new Set(localIndex.keys()); + const { localIndex, importedSet } = await getLocalCatalogSnapshot(); + const furniDataSet = await getFurniDataClassnames(); + const catalogIdSet = await getCatalogIdSet(); const sourceId = request.nextUrl.searchParams.get("source"); if (sourceId) { const source = await getSource(sourceId); @@ -324,37 +263,6 @@ export const GET = withAdmin( const sourceIconBase = source.iconBaseUrl?.trim().replace(/\/+$/, ""); - // Bulk-load FurnitureData and catalog references for status checks - const furniDataSet = new Set(); - const catalogIdSet = 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) furniDataSet.add(normalizeClassname(e.classname)); - } - } catch { - /* best effort */ - } - 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) catalogIdSet.add(n); - } - } - } catch { - /* best effort */ - } - const enriched = items.map((item) => { const presence = localFurnitureStatus( item.classname, @@ -370,20 +278,10 @@ export const GET = withAdmin( starIdx !== -1 ? item.classname.substring(0, starIdx) : item.classname; - const p = path.join( - /*turbopackIgnore: true*/ nitroDir, - `${base}.nitro`, - ); nitroExists = - existsSync(/*turbopackIgnore: true*/ p) || + nitroExistsCached(nitroDir, base) || (localIndex.get(normalizeClassname(item.classname)) ?? []).some( - (row) => - existsSync( - path.join( - /*turbopackIgnore: true*/ nitroDir, - `${row.itemName.split("*")[0]}.nitro`, - ), - ), + (row) => nitroExistsCached(nitroDir, row.itemName.split("*")[0]), ); } return { @@ -415,37 +313,6 @@ export const GET = withAdmin( }); } - // Bulk-load FurnitureData and catalog references for status checks - const furniDataSet = new Set(); - const catalogIdSet = 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) furniDataSet.add(normalizeClassname(e.classname)); - } - } catch { - /* best effort */ - } - 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) catalogIdSet.add(n); - } - } - } catch { - /* best effort */ - } - const result = await listFurniture({ search: search || undefined, page, @@ -468,20 +335,10 @@ export const GET = withAdmin( starIdx !== -1 ? item.classname.substring(0, starIdx) : item.classname; - const p = path.join( - /*turbopackIgnore: true*/ nitroDir, - `${base}.nitro`, - ); nitroExists = - existsSync(/*turbopackIgnore: true*/ p) || + nitroExistsCached(nitroDir, base) || (localIndex.get(normalizeClassname(item.classname)) ?? []).some( - (row) => - existsSync( - path.join( - /*turbopackIgnore: true*/ nitroDir, - `${row.itemName.split("*")[0]}.nitro`, - ), - ), + (row) => nitroExistsCached(nitroDir, row.itemName.split("*")[0]), ); } return { @@ -658,6 +515,9 @@ export const POST = withAdmin( const succeeded = results.filter((r) => r.ok).length; const failed = results.filter((r) => !r.ok).length; + // New items + files landed — drop caches so the next Studio load is fresh. + clearFurniImportCache(); + return apiOk({ batch: true, succeeded, @@ -799,6 +659,8 @@ export const POST = withAdmin( }, }); + clearFurniImportCache(); + return apiOk({ id: result.itemId, catalogItemId: result.catalogItemId, @@ -990,6 +852,9 @@ export const PATCH = withAdmin( const succeeded = results.filter((r) => r.ok).length; const failed = results.filter((r) => !r.ok).length; + // New .nitro files were written — drop the presence cache. + clearFurniImportCache(); + return apiOk({ succeeded, failed, results }); }, ); diff --git a/src/components/admin/studio/batch-progress.tsx b/src/components/admin/studio/batch-progress.tsx index 38f2284b..e7e7677c 100644 --- a/src/components/admin/studio/batch-progress.tsx +++ b/src/components/admin/studio/batch-progress.tsx @@ -1,8 +1,17 @@ "use client"; -import { Ban, Check, Circle, Loader2, RefreshCw, X } from "lucide-react"; -import { memo } from "react"; +import { + Ban, + Check, + Circle, + CircleAlert, + Loader2, + RefreshCw, + X, +} from "lucide-react"; +import { memo, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; import type { BatchItemStatus, VerificationSummary } from "./studio-types"; const statusLabels: Record = { @@ -15,6 +24,22 @@ const statusLabels: Record = { failed: "Failed", }; +function formatDuration(ms: number): string { + if (!Number.isFinite(ms) || ms < 0) return "0s"; + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rest = s % 60; + return rest > 0 ? `${m}m ${rest}s` : `${m}m`; +} + +function formatRate(completed: number, ms: number): string { + if (ms < 1500) return ""; + const perMinute = completed / (ms / 60000); + if (perMinute < 1) return `${(completed / (ms / 1000)).toFixed(1)}/s`; + return `${Math.round(perMinute)}/min`; +} + export const BatchProgress = memo(function BatchProgress({ progress, done, @@ -23,6 +48,7 @@ export const BatchProgress = memo(function BatchProgress({ cancelling, verification, failedClassnames, + startTime, onCancel, onDismiss, onRetry, @@ -39,10 +65,21 @@ export const BatchProgress = memo(function BatchProgress({ onDismiss?: () => void; onRetry?: (classnames: string[]) => void; }) { - const entries = [...progress.values()]; - const completed = entries.filter( - (item) => item.status === "done" || item.status === "failed", - ).length; + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (done) return; + const id = setInterval(() => setNow(Date.now()), 500); + return () => clearInterval(id); + }, [done]); + + const entries = useMemo(() => [...progress.values()], [progress]); + const completed = useMemo( + () => + entries.filter((i) => i.status === "done" || i.status === "failed") + .length, + [entries], + ); const total = entries.length; const percent = total ? Math.round((completed / total) * 100) : 0; const imported = done @@ -52,6 +89,8 @@ export const BatchProgress = memo(function BatchProgress({ ? failed : entries.filter((item) => item.status === "failed").length; const unfinished = Math.max(0, total - completed); + const elapsed = startTime ? now - startTime : 0; + const rate = formatRate(completed, elapsed); const heading = !done ? cancelling ? "Stopping import…" @@ -64,6 +103,7 @@ export const BatchProgress = memo(function BatchProgress({ const issues = verification ? verification.furniDataMissing + verification.furniDataConflicts : 0; + return (
{heading} -

- {imported} imported · {errors} failed - {unfinished > 0 && - ` · ${unfinished} ${done ? "not completed" : "remaining"}`} +

+ + + {imported} + {" "} + imported + + · + + 0 && "text-[var(--admin-error)]", + )} + > + {errors} + {" "} + failed + + {unfinished > 0 && ( + <> + · + + {unfinished} {done ? "not completed" : "remaining"} + + + )} + {!done && startTime ? ( + <> + · + {formatDuration(elapsed)} + {rate && ( + + {rate} + + )} + + ) : null}

@@ -127,16 +201,38 @@ export const BatchProgress = memo(function BatchProgress({ className="my-3 h-2 overflow-hidden rounded-full bg-[var(--admin-accent)]/10" >
{done && verification && ( -

- {issues - ? `${issues} catalog data issue(s) still need attention. Open Catalog audit to review them.` - : "Catalog data verification completed."} -

+
+

+ {issues + ? `${issues} catalog data issue(s) still need attention. Open Catalog audit to review them.` + : "Catalog data verification completed."} +

+ + + + + +
)}
@@ -146,40 +242,52 @@ export const BatchProgress = memo(function BatchProgress({ {entries.map((item) => (
-
- {item.status === "done" ? ( - - ) : item.status === "failed" ? ( - - ) : done || item.status === "pending" ? ( - - ) : ( - - )} - - {item.classname} - - - {done && item.status !== "done" && item.status !== "failed" - ? "Not completed" - : statusLabels[item.status]} - -
- {item.message && ( -

- {item.message} -

+ {item.status === "done" ? ( + + ) : item.status === "failed" ? ( + + ) : done || item.status === "pending" ? ( + + ) : ( + )} - {[...new Set(item.warnings ?? [])].map((warning) => ( -

- {warning} -

- ))} + + + + {item.classname} + + + {done && item.status !== "done" && item.status !== "failed" + ? "Not completed" + : statusLabels[item.status]} + + + {item.message && ( + + {item.message} + + )} + {[...new Set(item.warnings ?? [])].map((warning) => ( + + + {warning} + + ))} +
))}
@@ -187,3 +295,13 @@ export const BatchProgress = memo(function BatchProgress({
); }); + +function NodeChip({ count, label }: { count: number; label: string }) { + if (!count) return null; + return ( + + + {count} {label} + + ); +} diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx index e21978ce..dd290287 100644 --- a/src/components/admin/studio/studio-client.tsx +++ b/src/components/admin/studio/studio-client.tsx @@ -1,11 +1,12 @@ "use client"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { + ArrowDown, ArrowUpDown, Check, CircleAlert, CloudDownload, - Download, Globe, Languages, Layers, @@ -101,6 +102,11 @@ const ImportReview = lazy(() => })), ); +// Grid virtualization geometry (px) — matches the grid-card markup below. +const GRID_CARD_W = 150; +const GRID_GAP = 12; +const GRID_ROW_H = 176; + export function StudioClient({ source, initialTree, @@ -269,6 +275,39 @@ export function StudioClient({ [filteredItems, selected], ); + const hasMorePages = meta ? meta.currentPage < meta.lastPage : false; + + // ── Grid virtualization ───────────────────────────────────────── + const [gridCols, setGridCols] = useState(1); + useEffect(() => { + const el = listRef.current; + if (viewMode !== "grid" || !el) return; + const update = () => { + const width = Math.max(el.clientWidth - 24, 100); + const cols = Math.max( + 1, + Math.floor((width + GRID_GAP) / (GRID_CARD_W + GRID_GAP)), + ); + setGridCols((previous) => (previous === cols ? previous : cols)); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(el); + return () => observer.disconnect(); + }, [viewMode]); + + const gridRowCount = + viewMode === "grid" ? Math.ceil(filteredItems.length / gridCols) : 0; + const gridVirtualizer = useVirtualizer({ + count: gridRowCount, + getScrollElement: () => listRef.current, + estimateSize: () => GRID_ROW_H, + overscan: 5, + }); + + // Infinite scroll: auto-load the next page when the sentinel enters view. + const sentinelRef = useRef(null); + const fetchStats = useCallback(async () => { try { const res = await adminFetch("/api/admin/import/furni?action=stats"); @@ -335,6 +374,39 @@ export function StudioClient({ [], ); + // Infinite scroll: auto-load the next page when the sentinel enters view. + useEffect(() => { + const el = sentinelRef.current; + if (!el || !hasMorePages) return; + let cancelled = false; + const observer = new IntersectionObserver( + (entries) => { + if (cancelled) return; + if ( + entries.some((e) => e.isIntersecting) && + !loading && + !batchProgress + ) { + fetchItems(activeSearch, (meta?.currentPage ?? 1) + 1, activeSource); + } + }, + { root: listRef.current, rootMargin: "700px 0px" }, + ); + observer.observe(el); + return () => { + cancelled = true; + observer.disconnect(); + }; + }, [ + hasMorePages, + meta?.currentPage, + loading, + batchProgress, + activeSearch, + activeSource, + fetchItems, + ]); + const changeSource = (sourceId: string) => { sourceGeneration.current += 1; setConfirmDelete(false); @@ -1631,118 +1703,153 @@ export function StudioClient({ ) : ( <> {viewMode === "grid" ? ( -
- {filteredItems.map((item) => { - const isSel = selected.has(item.classname); +
+ {gridVirtualizer.getVirtualItems().map((vi) => { + const start = vi.index * gridCols; + const rowItems = filteredItems.slice( + start, + start + gridCols, + ); return ( -
- - + {rowItems.map((item) => { + const isSel = selected.has(item.classname); + return ( +
+ + +
+ ); + })} +
); })} @@ -1956,29 +2063,33 @@ export function StudioClient({
)} - {meta && meta.currentPage < meta.lastPage && ( -
- -
- )} +
+ {hasMorePages ? ( + loading ? ( + + + Loading more… + + ) : ( + + + Scroll for more + + ) + ) : ( + + + End of results + + + )} +
)} diff --git a/src/lib/services/furni-import-cache.ts b/src/lib/services/furni-import-cache.ts new file mode 100644 index 00000000..b09b8071 --- /dev/null +++ b/src/lib/services/furni-import-cache.ts @@ -0,0 +1,223 @@ +/** + * 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; +}