perf(studio): cache furni imports and virtualize the furniture grid
CI / check (push) Failing after 22s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

- 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
This commit is contained in:
openhands committed 2026-09-13 11:40:55 +02:00
1 parent 2920669362
commit d02aaa0d87
6 files changed
+672 -345

No files matched your search

@@ -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();
},
});
},
@@ -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",
+29 -164
View File
@@ -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<number>();
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<string>();
const catalogIdSet = new Set<number>();
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<string>();
const catalogIdSet = new Set<number>();
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 });
},
);