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
This commit is contained in:
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",
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user