diff --git a/src/app/api/admin/import/furni/route.ts b/src/app/api/admin/import/furni/route.ts index 0e9c6abff6..90b7e1901f 100644 --- a/src/app/api/admin/import/furni/route.ts +++ b/src/app/api/admin/import/furni/route.ts @@ -18,6 +18,7 @@ import { ensureDirectories, IMPORTED_PAGE_CAPTION_SAVE, importSingleFurni, + resetCatalogPageCache, } from "@/lib/services/furni-import"; import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache"; import { listFurniture } from "@/lib/services/habbofurni"; @@ -820,6 +821,8 @@ export const PUT = withAdmin( } if (!isDryRun) { + // Clear in-memory catalog page cache since we may have created/deleted pages + resetCatalogPageCache(); logAudit({ userId: ctx.session.user.id, action: "furni_reorganize", diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts index 4bfe833e2b..fbef82406c 100644 --- a/src/lib/services/furni-import.ts +++ b/src/lib/services/furni-import.ts @@ -182,14 +182,21 @@ export function buildItemsBaseEnrichmentUpdate(params: { `; } -/** Get or create the root "Imported Furniture" parent page (no items directly on this page). */ +/** Get or create the root "Imported Furniture" parent page (no items directly on this page). + * Caches the page ID in-memory for the process lifetime to avoid redundant DB reads + * during batch imports */ export async function getOrCreateImportedParentPage(): Promise { + if (parentIdCache !== null) return parentIdCache; + const [existing] = await db .select({ id: CatalogPages.id }) .from(CatalogPages) .where(eq(CatalogPages.captionSave, IMPORTED_PAGE_CAPTION_SAVE)) .limit(1); - if (existing) return existing.id; + if (existing) { + parentIdCache = existing.id; + return existing.id; + } // Use raw SQL to avoid ENUM mismatch (page_layout, visible, enabled) // All string columns must be '' (not NULL) or the Java emulator may skip the page @@ -207,6 +214,7 @@ export async function getOrCreateImportedParentPage(): Promise { 'Failed to create "Imported Furniture" parent catalog page', ); } + parentIdCache = created.id; return created.id; } @@ -281,6 +289,21 @@ let catalogIdLastUsed = 0; let catalogIdSeedChain: Promise = Promise.resolve(); const CATALOG_ID_REFRESH_MS = 60_000; +// In-memory cache for catalog page IDs — avoids N+1 DB lookups during batch imports +// where many items belong to the same category. +let parentIdCache: number | null = null; +let categoryPageCache: Map | null = null; +let categoryCacheTimestamp = 0; +const CATEGORY_CACHE_TTL_MS = 5 * 60_000; // 5 minutes + +/** Drop the in-process catalog page cache. Call after bulk operations + * (e.g. the PUT re-organize route) to force a fresh DB lookup. */ +export function resetCatalogPageCache(): void { + parentIdCache = null; + categoryPageCache = null; + categoryCacheTimestamp = 0; +} + /** Allocate a unique catalog_items id. Seeded once with MAX(id)+1, then handed * out from an in-process counter so concurrent imports don't serialize on a * SELECT MAX + INSERT round-trip per item. Re-seeds when idle so rows added @@ -326,7 +349,10 @@ export function __resetCatalogIdAllocatorForTests(): void { catalogIdSeedChain = Promise.resolve(); } -/** Get or create a category sub-page under the imported parent page. */ +/** Get or create a category sub-page under the imported parent page. + * Caches category page IDs in-memory for CATEGORY_CACHE_TTL_MS to avoid + * redundant DB lookups during batch imports where many items map to the same + * category. */ export async function getOrCreateCategoryPage( classname: string, itemType: string, @@ -334,6 +360,17 @@ export async function getOrCreateCategoryPage( const parentId = await getOrCreateImportedParentPage(); const catKey = classifyFurni(classname, itemType); const captionSave = `imp_${catKey}`; + + // Check in-memory cache (TTL based) + const now = Date.now(); + if ( + categoryPageCache && + now - categoryCacheTimestamp < CATEGORY_CACHE_TTL_MS + ) { + const cached = categoryPageCache.get(captionSave); + if (cached !== undefined) return cached; + } + const catInfo = CATEGORY_PAGE[catKey] || CATEGORY_PAGE.other; const [existing] = await db @@ -346,7 +383,11 @@ export async function getOrCreateCategoryPage( ), ) .limit(1); - if (existing) return existing.id; + if (existing) { + if (!categoryPageCache) categoryPageCache = new Map(); + categoryPageCache.set(captionSave, existing.id); + return existing.id; + } await db.execute(sql` INSERT INTO catalog_pages (caption_save, caption, page_layout, parent_id, min_rank, order_num, icon_image, enabled, visible, includes, page_headline, page_teaser, page_special, page_text1, page_text2, page_text_details, page_text_teaser) @@ -368,6 +409,8 @@ export async function getOrCreateCategoryPage( `Failed to create catalog sub-page "${captionSave}" under parent ${parentId}`, ); } + if (!categoryPageCache) categoryPageCache = new Map(); + categoryPageCache.set(captionSave, created.id); return created.id; } @@ -625,11 +668,24 @@ export async function importSingleFurni(params: { } // ── Rollback: if conversion/download failed AND no .nitro exists, remove DB record ── - if ( - !existsSync(/*turbopackIgnore: true*/ nitroPath) && - !conversionResult && - !nitroDownloadOk - ) { + // Also validate that the .nitro file is non-trivial (≥ 128 bytes — a bare + // Nitro bundle header is larger than that, anything smaller is corrupt/empty). + const nitroStat = await fs + .stat(/*turbopackIgnore: true*/ nitroPath) + .catch(() => null); + const nitroValid = nitroStat && nitroStat.size >= 128; + + if (!nitroValid && !conversionResult) { + if (nitroStat) { + warnings.push( + `Nitro file too small/corrupt (${nitroStat.size} bytes) — removing`, + ); + try { + await fs.unlink(nitroPath); + } catch { + /* ignore */ + } + } try { await db.execute( sql`DELETE FROM items_base WHERE id = ${spriteId} AND item_name = ${classname}`, diff --git a/src/lib/services/habbofurni.ts b/src/lib/services/habbofurni.ts index 441c10a2f4..b9ed74f880 100644 --- a/src/lib/services/habbofurni.ts +++ b/src/lib/services/habbofurni.ts @@ -154,11 +154,14 @@ export async function batchLookupByClassnames( const result = new Map(); if (classnames.length === 0) return result; - for (const cn of classnames) { - const item = await getFurnitureByClassname(cn); - if (item?.name) { - result.set(cn, item); - } + // Parallel lookup — all share the same in-memory furnidata cache + const lookedUp = await Promise.all( + classnames.map((cn) => getFurnitureByClassname(cn)), + ); + + for (let i = 0; i < classnames.length; i++) { + const item = lookedUp[i]; + if (item?.name) result.set(classnames[i], item); } return result;