refactor: universal logger and remove N+1 catalog queries
CI / check (push) Successful in 28s
CI / tests-ui (push) Failing after 32s
CI / tests-unit (push) Successful in 1m43s
CI / tests-quality (push) Successful in 1m14s
CI / tests-integration (push) Successful in 1m50s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
CI / tests-imaging-browsers (push) Successful in 1m56s

- Replace all 39 console.* calls with universal logger (pino server, console browser)
- Set server-only marker on import-badge.ts
- Disable DB query logging in production
- Add bulkInsertCatalogItems: one transaction instead of N per-item inserts
- Add reserveCatalogItemIds helper for bulk ID allocation
- Optimize translateCatalogItems: single inArray batch query instead of N per-item selects
- Test mock updated for logger.debug
- biome clean, tsc clean, 3569 tests passing
This commit is contained in:
openhands committed 2026-10-17 14:29:14 +02:00
1 parent 6895eaab49
commit 59a45d2165
27 files changed
+416 -241

No files matched your search

+205 -100
View File
@@ -24,7 +24,10 @@ import { CatalogItems, db, ItemsBase, queryRows } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
import { allocateCatalogItemId } from "@/lib/services/furni-import";
import {
allocateCatalogItemId,
reserveCatalogItemIds,
} from "@/lib/services/furni-import";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { translateItemsSchema } from "@/lib/validators/catalog";
@@ -133,6 +136,138 @@ export async function createCatalogItem(
});
}
/**
* Bulk insert catalog items in ONE transaction instead of N per-item
* transactions + N INSERT queries. Same result as calling insertCatalogItemRow
* per row, but far faster for large batches. Side effects (audit, revalidate,
* RCON refresh) are left to the caller, which wraps this in withCatalogExport.
*/
export async function bulkInsertCatalogItems(data: {
pageId: number;
staffId: number;
rows: Array<{
baseId: number;
credits?: number;
points?: number;
pointsType?: number;
amount?: number;
}>;
}): Promise<{ created: number; failed: number }> {
const { pageId, staffId, rows } = data;
if (rows.length === 0) return { created: 0, failed: 0 };
const baseIds = [...new Set(rows.map((r) => r.baseId))];
const bases = await db
.select({
id: ItemsBase.id,
publicName: ItemsBase.publicName,
itemName: ItemsBase.itemName,
})
.from(ItemsBase)
.where(inArray(ItemsBase.id, baseIds));
const baseMap = new Map(bases.map((b) => [b.id, b]));
const ids = await reserveCatalogItemIds(rows.length);
const maxOrder = await queryRows<{ maxOrder: number }>(sql`
SELECT COALESCE(MAX(order_number), 0) AS maxOrder FROM catalog_items WHERE page_id = ${String(pageId)}
`);
let nextOrder = Number(maxOrder[0]?.maxOrder ?? 0) + 1;
const values: Array<{
id: number;
pageId: string;
itemIds: string;
catalogName: string;
costCredits: number;
costPoints: number;
pointsType: number;
amount: number;
orderNumber: number;
offerId: number;
songId: number;
limitedSells: number;
limitedStack: number;
extradata: string;
haveOffer: string;
clubOnly: string;
}> = [];
let failed = 0;
let validIdx = 0;
for (const row of rows) {
const base = baseMap.get(row.baseId);
if (!base) {
failed++;
continue;
}
values.push({
id: ids[validIdx],
pageId: String(pageId),
itemIds: String(row.baseId),
catalogName: base.publicName || base.itemName || String(row.baseId),
costCredits: row.credits ?? 0,
costPoints: row.points ?? 0,
pointsType: row.pointsType ?? 0,
amount: row.amount ?? 1,
orderNumber: nextOrder++,
offerId: -1,
songId: 0,
limitedSells: 0,
limitedStack: 0,
extradata: "",
haveOffer: "1",
clubOnly: "0",
});
validIdx++;
}
if (values.length === 0) return { created: 0, failed };
const allFurnitureIds = [...new Set(values.map((v) => Number(v.itemIds)))];
await db.transaction(async (tx) => {
await tx.execute(
sql`SELECT id FROM catalog_pages WHERE id=${String(pageId)} FOR UPDATE`,
);
if (allFurnitureIds.length) {
await tx.execute(
sql`SELECT id FROM items_base WHERE id IN (${sql.join(allFurnitureIds, sql`, `)}) ORDER BY id FOR UPDATE`,
);
}
for (const v of values) {
await tx.execute(sql`
INSERT INTO catalog_items (
id, page_id, item_ids, catalog_name,
cost_credits, cost_points, points_type, amount,
order_number, offer_id, song_id,
limited_sells, limited_stack, extradata, have_offer, club_only
) VALUES (
${v.id}, ${v.pageId}, ${v.itemIds}, ${v.catalogName},
${v.costCredits}, ${v.costPoints}, ${v.pointsType}, ${v.amount},
${v.orderNumber}, ${v.offerId}, ${v.songId},
${v.limitedSells}, ${v.limitedStack}, ${v.extradata},
${v.haveOffer}, ${v.clubOnly}
)
`);
}
});
await sendCatalogUpdate();
await logStaffActivity({
staffId,
action: "catalog_items_bulk_create",
description: `Bulk inserted ${values.length} catalog item(s) on page #${pageId}`,
targetType: "catalog_page",
targetId: pageId,
});
revalidatePath("/admin/catalog");
return { created: values.length, failed };
}
/** Bulk create with one RCON refresh at the end. */
export async function bulkCreateCatalogItems({
pageId,
@@ -155,67 +290,17 @@ export async function bulkCreateCatalogItems({
return { ok: false as const, error: "Max 500 items per bulk import" };
}
const baseIds = [...new Set(rows.map((r) => r.baseId))];
const bases = await db
.select({
id: ItemsBase.id,
publicName: ItemsBase.publicName,
itemName: ItemsBase.itemName,
})
.from(ItemsBase)
.where(inArray(ItemsBase.id, baseIds));
const baseMap = new Map(bases.map((b) => [b.id, b]));
let created = 0;
let failed = 0;
// Find the next order number on the target page to avoid collisions.
const maxOrder = await queryRows<{ maxOrder: number }>(sql`
SELECT COALESCE(MAX(order_number), 0) AS maxOrder FROM catalog_items WHERE page_id = ${String(pageId)}
`);
let nextOrder = Number(maxOrder[0]?.maxOrder ?? 0) + 1;
for (const row of rows) {
const base = baseMap.get(row.baseId);
if (!base) {
failed++;
continue;
}
try {
await insertCatalogItemRow({
pageId,
itemIds: String(row.baseId),
catalogName: base.publicName || base.itemName || String(row.baseId),
costCredits: row.credits ?? 0,
costPoints: row.points ?? 0,
pointsType: row.pointsType ?? 0,
amount: 1,
limitedSells: 0,
limitedStack: 0,
orderNumber: nextOrder++,
offerId: -1,
songId: 0,
haveOffer: "1",
clubOnly: "0",
extradata: "",
});
created++;
} catch {
failed++;
}
}
if (created > 0) {
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "catalog_items_bulk_create",
description: `Bulk imported ${created} catalog item(s) on page #${pageId}`,
targetType: "catalog_page",
targetId: pageId,
});
revalidatePath("/admin/catalog");
}
const { created, failed } = await bulkInsertCatalogItems({
pageId,
staffId: staff.id,
rows: rows.map((r) => ({
baseId: r.baseId,
credits: r.credits,
points: r.points,
pointsType: r.pointsType,
amount: 1,
})),
});
return { ok: true as const, data: { created, failed } };
});
@@ -504,6 +589,19 @@ export async function translateCatalogItems(input: {
"@/lib/services/furni-data-i18n"
);
const itemIds = items.map((i) => i.id);
const allBases = await db
.select({
id: ItemsBase.id,
publicName: ItemsBase.publicName,
itemName: ItemsBase.itemName,
type: ItemsBase.type,
spriteId: ItemsBase.spriteId,
})
.from(ItemsBase)
.where(inArray(ItemsBase.id, itemIds));
const baseById = new Map(allBases.map((b) => [b.id, b]));
let namesUpdated = 0;
let descriptionsUpdated = 0;
const furniPatches: Array<{
@@ -519,19 +617,14 @@ export async function translateCatalogItems(input: {
name?: string;
description?: string;
}> = [];
const nameUpdates: Array<{ id: number; nextName: string }> = [];
const catalogNameUpdates: Array<{
idStr: string;
nextName: string;
}> = [];
for (const item of items) {
const [base] = await db
.select({
id: ItemsBase.id,
publicName: ItemsBase.publicName,
itemName: ItemsBase.itemName,
type: ItemsBase.type,
spriteId: ItemsBase.spriteId,
})
.from(ItemsBase)
.where(eq(ItemsBase.id, item.id))
.limit(1);
const base = baseById.get(item.id);
if (!base) continue;
const nextName = item.publicName?.trim() ?? "";
@@ -540,33 +633,11 @@ export async function translateCatalogItems(input: {
nextName !== "" && nextName !== (base.publicName ?? "");
if (nameChanged) {
await db
.update(ItemsBase)
.set({ publicName: nextName })
.where(eq(ItemsBase.id, base.id));
const idStr = String(base.id);
const related = await db
.select({
id: CatalogItems.id,
catalogName: CatalogItems.catalogName,
})
.from(CatalogItems)
.where(
or(
eq(CatalogItems.itemIds, idStr),
like(CatalogItems.itemIds, `${idStr};%`),
like(CatalogItems.itemIds, `%;${idStr};%`),
like(CatalogItems.itemIds, `%;${idStr}`),
),
);
for (const row of related) {
if (row.catalogName !== nextName) {
await db
.update(CatalogItems)
.set({ catalogName: nextName })
.where(eq(CatalogItems.id, row.id));
}
}
nameUpdates.push({ id: base.id, nextName });
catalogNameUpdates.push({
idStr: String(base.id),
nextName,
});
namesUpdated++;
}
@@ -589,6 +660,40 @@ export async function translateCatalogItems(input: {
}
}
// Batch ItemsBase name updates.
for (const { id, nextName } of nameUpdates) {
await db
.update(ItemsBase)
.set({ publicName: nextName })
.where(eq(ItemsBase.id, id));
}
// Batch CatalogItems name updates: one SELECT + UPDATE per idStr.
for (const { idStr, nextName } of catalogNameUpdates) {
const related = await db
.select({
id: CatalogItems.id,
catalogName: CatalogItems.catalogName,
})
.from(CatalogItems)
.where(
or(
eq(CatalogItems.itemIds, idStr),
like(CatalogItems.itemIds, `${idStr};%`),
like(CatalogItems.itemIds, `%;${idStr};%`),
like(CatalogItems.itemIds, `%;${idStr}`),
),
);
for (const row of related) {
if (row.catalogName !== nextName) {
await db
.update(CatalogItems)
.set({ catalogName: nextName })
.where(eq(CatalogItems.id, row.id));
}
}
}
const furniResult =
furniPatches.length > 0
? await patchFurniEntryNames(furniPatches)
+15 -47
View File
@@ -2,7 +2,10 @@
import { inArray, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { insertCatalogItemRow } from "@/actions/catalog-items";
import {
bulkInsertCatalogItems,
insertCatalogItemRow,
} from "@/actions/catalog-items";
import { catalogFailure } from "@/features/catalog/server/errors";
import {
createPageCommand,
@@ -279,52 +282,17 @@ export async function createAutoCategory(input: {
}
const rows = input.offerRows ?? [];
const baseIds = [...new Set(rows.map((r) => r.baseId))];
const bases = await db
.select({
id: ItemsBase.id,
publicName: ItemsBase.publicName,
itemName: ItemsBase.itemName,
})
.from(ItemsBase)
.where(inArray(ItemsBase.id, baseIds));
const baseMap = new Map(bases.map((b) => [b.id, b]));
const maxOrder = await queryRows<{ maxOrder: number }>(sql`
SELECT COALESCE(MAX(order_number), 0) AS maxOrder FROM catalog_items WHERE page_id = ${String(pageId)}
`);
let nextOrder = Number(maxOrder[0]?.maxOrder ?? 0) + 1;
let created = 0;
for (const row of rows) {
const base = baseMap.get(row.baseId);
if (!base) continue;
try {
await insertCatalogItemRow({
pageId,
itemIds: String(row.baseId),
catalogName: base.publicName || base.itemName || String(row.baseId),
costCredits: row.credits ?? 0,
costPoints: row.points ?? 0,
pointsType: row.pointsType ?? 0,
amount: row.amount ?? 1,
limitedSells: 0,
limitedStack: 0,
orderNumber: nextOrder++,
offerId: -1,
songId: 0,
haveOffer: "1",
clubOnly: "0",
extradata: "",
});
created++;
} catch (error) {
console.error(
`[createAutoCategory] failed to insert item #${row.baseId}:`,
error,
);
}
}
const { created } = await bulkInsertCatalogItems({
pageId,
staffId: staff.id,
rows: rows.map((r) => ({
baseId: r.baseId,
credits: r.credits,
points: r.points,
pointsType: r.pointsType,
amount: r.amount,
})),
});
await logStaffActivity({
staffId: staff.id,
+4 -1
View File
@@ -46,6 +46,7 @@ import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action";
import { adminFetch } from "@/lib/admin-fetch";
import { invalidateSongPickerCache } from "@/lib/client-cache/song-picker-cache";
import { logger } from "@/lib/logger";
import { parseTraxChannels } from "@/lib/trax-format";
import { TraxPlayer } from "./trax-player";
@@ -455,7 +456,9 @@ function UploadDialog({
onOpenChange(false);
onUploaded();
} catch (err) {
console.error(err);
logger.error("Sound upload failed", {
err: err as Error,
});
toast.error("Network error during upload.");
} finally {
setUploading(false);
+4 -1
View File
@@ -19,6 +19,7 @@ import { Loader2, Play, Square } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { logger } from "@/lib/logger";
import { TRAX_TICK_SECONDS, type TraxChannel } from "@/lib/trax-format";
const TICK_SECONDS = TRAX_TICK_SECONDS;
@@ -127,7 +128,9 @@ export function TraxPlayer({
buffers.set(id, buf);
} catch (err) {
failed++;
console.warn(`[trax] failed to load sample ${id}:`, err);
logger.warn(`[trax] failed to load sample ${id}`, {
err: err as Error,
});
} finally {
done++;
setLoaded(done);
@@ -8,6 +8,7 @@ import {
bundleExistsInDir,
bundleWritePathInDir,
} from "@/lib/furni/bundle-file";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import {
@@ -315,9 +316,8 @@ export const POST = withAdmin(
try {
languages = await buildLocalizedFurniDataFiles();
} catch (err) {
console.warn(
"[batch-regen] Localized furnidata build failed:",
(err as Error).message,
logger.warn(
`[batch-regen] Localized furnidata build failed: ${(err as Error).message}`,
);
}
+16 -15
View File
@@ -4,6 +4,7 @@ import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { getRequestId } from "@/lib/foundation/request-context";
import type { ImportJob } from "@/lib/furni/import-job";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { getSource } from "@/lib/services/clone-sources";
@@ -112,10 +113,9 @@ export const POST = withAdmin(
await store.save(job);
return job;
} catch (error) {
console.warn(
"[import-furni] Could not create durable batch job",
error,
);
logger.warn("[import-furni] Could not create durable batch job", {
err: error as Error,
});
return null;
}
})();
@@ -156,7 +156,9 @@ export const POST = withAdmin(
try {
await store.save(mirrorJob);
} catch (error) {
console.warn("[import-furni] Checkpoint write failed", error);
logger.warn("[import-furni] Checkpoint write failed", {
err: error as Error,
});
} finally {
saveQueued = false;
}
@@ -177,7 +179,9 @@ export const POST = withAdmin(
try {
await store.save(mirrorJob);
} catch (error) {
console.warn("[import-furni] Final checkpoint write failed", error);
logger.warn("[import-furni] Final checkpoint write failed", {
err: error as Error,
});
}
};
@@ -412,9 +416,8 @@ export const POST = withAdmin(
});
}
} catch (err) {
console.warn(
"[import-furni] RCON update failed after batch:",
(err as Error).message,
logger.warn(
`[import-furni] RCON update failed after batch: ${(err as Error).message}`,
);
}
}
@@ -470,15 +473,13 @@ export const POST = withAdmin(
try {
languages = await buildLocalizedFurniDataFiles();
} catch (err) {
console.warn(
"[import-furni] Localized furnidata build failed:",
(err as Error).message,
logger.warn(
`[import-furni] Localized furnidata build failed: ${(err as Error).message}`,
);
}
} catch (err) {
console.warn(
"[import-furni] Post-import reconcile/ownership failed:",
(err as Error).message,
logger.warn(
`[import-furni] Post-import reconcile/ownership failed: ${(err as Error).message}`,
);
}
@@ -5,6 +5,7 @@ import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { db, ItemsBase, queryRows } from "@/lib/db";
import { resolveBundleInDir } from "@/lib/furni/bundle-file";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
@@ -65,9 +66,9 @@ export const GET = withAdmin(
return apiOk({ metadata: json, flags });
} catch (err) {
console.error(
"[nitro-editor] Failed to parse bundle:",
(err as Error).message,
logger.error(
`[nitro-editor] Failed to parse bundle: ${(err as Error).message}`,
{ err: err as Error },
);
return apiError("Failed to parse bundle file", 500);
}
@@ -156,9 +157,8 @@ export const PUT = withAdmin(
await writeFurniData(furniData as Record<string, unknown>);
});
} catch (err) {
console.warn(
"[nitro-editor] FurnitureData.json update failed:",
(err as Error).message,
logger.warn(
`[nitro-editor] FurnitureData.json update failed: ${(err as Error).message}`,
);
}
@@ -173,9 +173,8 @@ export const PUT = withAdmin(
WHERE item_name = ${rawClassname}
`);
} catch (err) {
console.warn(
"[nitro-editor] items_base update failed:",
(err as Error).message,
logger.warn(
`[nitro-editor] items_base update failed: ${(err as Error).message}`,
);
}
@@ -183,9 +182,8 @@ export const PUT = withAdmin(
try {
await rcon.updateItems();
} catch (err) {
console.warn(
"[nitro-editor] RCON updateItems failed:",
(err as Error).message,
logger.warn(
`[nitro-editor] RCON updateItems failed: ${(err as Error).message}`,
);
}
@@ -210,7 +208,7 @@ export const PUT = withAdmin(
},
});
} catch (err) {
console.warn("[nitro-editor] Audit log failed:", (err as Error).message);
logger.warn(`[nitro-editor] Audit log failed: ${(err as Error).message}`);
}
return apiOk({ dimensions: dims });
+13 -18
View File
@@ -11,6 +11,7 @@ import {
} from "@/lib/furni/bundle-file";
import { isSafeAssetName, normalizeClassname } from "@/lib/furni/classname";
import { localFurnitureStatus } from "@/lib/furni/local-presence";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { getCloneList } from "@/lib/services/clone-import";
@@ -446,9 +447,8 @@ export const POST = withAdmin(
try {
await appendFurniEntriesBulk(collectedFurniEntries);
} catch (err) {
console.warn(
"[import-furni] Bulk FurnitureData.json write failed:",
(err as Error).message,
logger.warn(
`[import-furni] Bulk FurnitureData.json write failed: ${(err as Error).message}`,
);
}
}
@@ -458,9 +458,8 @@ export const POST = withAdmin(
await rcon.updateCatalog();
await rcon.updateItems();
} catch (err) {
console.warn(
"[import-furni] RCON update failed after batch:",
(err as Error).message,
logger.warn(
`[import-furni] RCON update failed after batch: ${(err as Error).message}`,
);
}
invalidateCatalogTotals();
@@ -509,9 +508,8 @@ export const POST = withAdmin(
);
}
} catch (err) {
console.warn(
"[import-furni] Localized furnidata build failed:",
(err as Error).message,
logger.warn(
`[import-furni] Localized furnidata build failed: ${(err as Error).message}`,
);
}
@@ -588,9 +586,8 @@ export const POST = withAdmin(
await rcon.updateCatalog();
await rcon.updateItems();
} catch (err) {
console.warn(
"[import-furni] RCON update failed:",
(err as Error).message,
logger.warn(
`[import-furni] RCON update failed: ${(err as Error).message}`,
);
result.warnings.push("RCON cache refresh failed");
}
@@ -640,9 +637,8 @@ export const POST = withAdmin(
);
}
} catch (err) {
console.warn(
"[import-furni] Localized patch failed:",
(err as Error).message,
logger.warn(
`[import-furni] Localized patch failed: ${(err as Error).message}`,
);
}
@@ -850,9 +846,8 @@ export const PATCH = withAdmin(
await appendFurniEntry(entry, itemType);
}
} catch (fdErr) {
console.warn(
`[regen] FurnitureData update skipped for ${item.classname}:`,
(fdErr as Error).message,
logger.warn(
`[regen] FurnitureData update skipped for ${item.classname}: ${(fdErr as Error).message}`,
);
}
@@ -32,6 +32,7 @@ import {
} from "@/features/catalog/domain/organize";
import { CATALOG_LAYOUTS, type CatalogLayout } from "@/lib/catalog-layouts";
import { getLocalIconUrl } from "@/lib/furni/classname";
import { logger } from "@/lib/logger";
import { cn } from "@/lib/utils";
import { LiveCatalogPreview } from "./live-catalog-preview";
@@ -136,7 +137,9 @@ export function AutoCategoryDialog({
.then((r) => r.json())
.then((data) => setResults(data.results ?? []))
.catch((error) =>
console.error("[AutoCategory] furni search failed:", error),
logger.error("[AutoCategory] furni search failed", {
err: error as Error,
}),
)
.finally(() => setSearchLoading(false));
return () => ac.abort();
@@ -263,7 +266,9 @@ export function AutoCategoryDialog({
resetState();
onOpenChange(false);
} catch (error) {
console.error("[AutoCategory] create failed:", error);
logger.error("[AutoCategory] create failed", {
err: error as Error,
});
toast.error("Failed to create category");
} finally {
setIsCreating(false);
@@ -2,6 +2,7 @@
import { ChevronRight, Home } from "lucide-react";
import { Fragment, useEffect } from "react";
import { logger } from "@/lib/logger";
import { useCatalogManager } from "./use-catalog-store";
export function BreadcrumbBar({
@@ -29,7 +30,9 @@ export function BreadcrumbBar({
}
})
.catch((error) =>
console.error("[Breadcrumb] Failed to fetch ancestors:", error),
logger.error("[Breadcrumb] Failed to fetch ancestors", {
err: error as Error,
}),
);
return () => ac.abort();
}, [selectedPageId, dispatch, catQs]);
@@ -62,6 +62,7 @@ import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-
import { useLiveCatalog } from "@/features/catalog/client/use-live-catalog";
import { DuplicateCategoryDialog } from "@/features/catalog/components/duplicate-category-dialog";
import type { DuplicateInput } from "@/features/catalog/domain/duplicate";
import { logger } from "@/lib/logger";
import { cn } from "@/lib/utils";
import {
type FlatTreeNode,
@@ -779,7 +780,9 @@ export function SortableTree({
});
})
.catch((error) =>
console.error("[SortableTree] Failed to load children:", error),
logger.error("[SortableTree] Failed to load children", {
err: error as Error,
}),
);
}
}
+4 -1
View File
@@ -12,6 +12,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { catalogueIconUrl } from "@/lib/catalog-assets";
import { logger } from "@/lib/logger";
import { cn } from "@/lib/utils";
interface IconPickerProps {
@@ -64,7 +65,9 @@ export function IconPicker({
setIcons((prev) => (replace ? fetched : [...prev, ...fetched]));
setOffset(nextOffset + fetched.length);
} catch (err) {
console.error("[IconPicker]", err);
logger.error("[IconPicker] icon load failed", {
err: err as Error,
});
} finally {
setLoading(false);
}
@@ -14,6 +14,7 @@ import {
import { Input } from "@/components/ui/input";
import { useServerAction } from "@/hooks/use-server-action";
import { getHabboCdnIconUrl, getLocalIconUrl } from "@/lib/furni/classname";
import { logger } from "@/lib/logger";
interface FurniResult {
id: number;
@@ -81,7 +82,11 @@ export function QuickAddFurni({
)
.then((r) => r.json())
.then((data) => setResults(data.results ?? []))
.catch((error) => console.error("[QuickAdd] Furni search failed:", error))
.catch((error) =>
logger.error("[QuickAdd] Furni search failed", {
err: error as Error,
}),
)
.finally(() => setLoading(false));
return () => ac.abort();
}, [debounced, open]);
+6 -1
View File
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { deleteMedia, uploadMedia } from "@/actions/admin-media";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { formatDate } from "@/lib/format-date";
import { logger } from "@/lib/logger";
import type { MediaFile } from "@/lib/media-files";
import { loadMediaFiles, validImageUrl } from "@/lib/media-files";
@@ -83,7 +84,11 @@ export function AdminMediaGrid() {
setCopied(url);
setTimeout(() => setCopied(null), 1500);
})
.catch((error) => console.error("[Media] Clipboard copy failed:", error));
.catch((error) =>
logger.error("[Media] Clipboard copy failed", {
err: error as Error,
}),
);
}
async function remove(name: string) {
@@ -58,6 +58,7 @@ import {
isValidCatalogLayout,
LAYOUT_DESCRIPTIONS,
} from "@/lib/catalog-layouts";
import { logger } from "@/lib/logger";
import { cn } from "@/lib/utils";
const MAX_ITEMS_PER_RUN = 500;
@@ -245,7 +246,9 @@ export function OrganizeImportsDialog({
}
}
} catch (error) {
console.error("[OrganizeImports] load failed:", error);
logger.error("[OrganizeImports] load failed", {
err: error as Error,
});
setLoadError(true);
setGroups([]);
} finally {
@@ -358,7 +361,9 @@ export function OrganizeImportsDialog({
);
})
.catch((error) =>
console.error("[OrganizeImports] page search failed:", error),
logger.error("[OrganizeImports] page search failed", {
err: error as Error,
}),
)
.finally(() => setDestSearchLoading(false));
return () => ac.abort();
@@ -761,7 +766,9 @@ export function OrganizeImportsDialog({
}
onOpenChange(false);
} catch (error) {
console.error("[OrganizeImports] create failed:", error);
logger.error("[OrganizeImports] create failed", {
err: error as Error,
});
toast.error(t("errorFailed"));
} finally {
setIsCreating(false);
+6 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { logger } from "@/lib/logger";
import { useEventSource } from "@/lib/use-event-source";
/**
@@ -170,7 +171,11 @@ export default function RadioPlayer() {
.then((data) => {
if (data !== null) setConfig(parseConfig(data));
})
.catch((error) => console.error("[Radio] Config fetch failed:", error));
.catch((error) =>
logger.error("[Radio] Config fetch failed", {
err: error as Error,
}),
);
}, []);
// Keep the <audio> volume in sync with the slider.
+4 -1
View File
@@ -4,6 +4,7 @@ import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useRef, useState, useTransition } from "react";
import { toast } from "sonner";
import { logger } from "@/lib/logger";
import type { ActionResult } from "@/lib/safe-action-shared";
interface UseServerActionOptions {
@@ -97,7 +98,9 @@ export function useServerAction(options: UseServerActionOptions = {}) {
onError?.();
}
} catch (err) {
console.error("[useServerAction] Unexpected error:", err);
logger.error("[useServerAction] Unexpected error", {
err: err as Error,
});
toast.error(errorMessage);
onError?.();
} finally {
+1 -8
View File
@@ -49,14 +49,7 @@ function createDb() {
return drizzle(instrumentDatabase(pool), {
schema: fullSchema,
mode: "default",
logger:
env.NODE_ENV === "development"
? {
logQuery(query, params) {
console.log("[db]", query, params);
},
}
: false,
logger: false,
});
}
+31 -2
View File
@@ -5,6 +5,9 @@ import { getOperationContext } from "./foundation/request-context";
type LogLevel = "debug" | "info" | "warn" | "error";
const isBrowser =
typeof window !== "undefined" && typeof window.console !== "undefined";
const level: LogLevel =
(env.LOG_LEVEL as LogLevel | undefined) ??
(env.NODE_ENV === "production" ? "info" : "debug");
@@ -32,18 +35,45 @@ export function generateRequestId(): string {
return `${Date.now().toString(36)}-${requestIdCounter.toString(36)}`;
}
/** Compatibility wrapper — existing call sites use (message, meta). */
function formatMeta(meta: Record<string, unknown> = {}): string {
const keys = Object.keys(meta);
if (keys.length === 0) return "";
return keys
.map((k) => {
const v = meta[k];
return `${k}=${v instanceof Error ? v.message : String(v)}`;
})
.join(" ");
}
/** Universal logger: pino on server, console in browser. Same interface everywhere. */
export const logger = {
debug(message: string, meta: Record<string, unknown> = {}): void {
if (isBrowser) {
console.debug(`[debug] ${message} ${formatMeta(meta)}`.trim());
return;
}
pinoLogger.debug({ ...meta, ...getOperationContext() }, message);
},
info(message: string, meta: Record<string, unknown> = {}): void {
if (isBrowser) {
console.info(`[info] ${message} ${formatMeta(meta)}`.trim());
return;
}
pinoLogger.info({ ...meta, ...getOperationContext() }, message);
},
warn(message: string, meta: Record<string, unknown> = {}): void {
if (isBrowser) {
console.warn(`[warn] ${message} ${formatMeta(meta)}`.trim());
return;
}
pinoLogger.warn({ ...meta, ...getOperationContext() }, message);
},
error(message: string, meta: Record<string, unknown> = {}): string {
if (isBrowser) {
console.error(`[error] ${message} ${formatMeta(meta)}`.trim());
return "";
}
const error =
meta.err instanceof Error
? meta.err
@@ -57,7 +87,6 @@ export const logger = {
: message,
);
const operation = getOperationContext();
// Keep trusted correlation first so bounded error context retains it.
const context = { ...operation, ...meta, ...operation };
const errorId = captureCmsError("server", message, error, context);
pinoLogger.error({ ...context, errorId }, message);
+3 -3
View File
@@ -21,6 +21,7 @@ import {
normalizeClassname,
} from "@/lib/furni/classname";
import { indexLocalFurniture } from "@/lib/furni/local-presence";
import { logger } from "@/lib/logger";
import { readFurniData } from "./furni-data";
const CACHE_TTL = 30_000; // 30 seconds
@@ -244,9 +245,8 @@ export async function getFurniImportStats(
statsSnapshot = { stats, timestamp: now };
return stats;
} catch (err) {
console.warn(
"[furni-import-cache] Stats scan failed:",
(err as Error).message,
logger.warn(
`[furni-import-cache] Stats scan failed: ${(err as Error).message}`,
);
return (
statsSnapshot?.stats ?? {
+40
View File
@@ -534,6 +534,46 @@ export async function allocateCatalogItemId<T>(
return await insertFn(nextId);
}
/** Reserveer N sequentiële catalog item IDs uit de in-process counter, zonder
* per-item transaction. Gebruikt door bulk insert paden. */
export async function reserveCatalogItemIds(count: number): Promise<number[]> {
if (count <= 0) return [];
const now = Date.now();
if (
catalogNextId === null ||
now - catalogIdLastUsed > CATALOG_ID_REFRESH_MS
) {
let settle!: () => void;
const prev = catalogIdSeedChain;
catalogIdSeedChain = new Promise<void>((r) => {
settle = r;
});
await prev.catch((error) =>
logServerError("furni.catalog_id_chain_failed", error),
);
try {
if (
catalogNextId === null ||
Date.now() - catalogIdLastUsed > CATALOG_ID_REFRESH_MS
) {
const maxIdResult = await queryRows<{
maxId: number | bigint | null;
}>(sql`
SELECT MAX(id) as maxId FROM catalog_items
`);
catalogNextId = Number(maxIdResult[0]?.maxId ?? 0) + 1;
}
} finally {
settle();
}
}
catalogIdLastUsed = Date.now();
const start = catalogNextId;
catalogNextId += count;
return Array.from({ length: count }, (_, i) => start + i);
}
/** Test seam: drop the cached id counter between tests. */
/**
@@ -11,7 +11,7 @@ vi.mock("@/lib/services/habbo-gamedata-hotel", () => ({
}));
vi.mock("@/lib/logger", () => ({
logger: { warn: vi.fn() },
logger: { warn: vi.fn(), debug: vi.fn() },
}));
import * as cacheModule from "./habbo-furnidata-cache";
+2 -2
View File
@@ -40,7 +40,7 @@ export async function getOfficialHabboFurnidata(): Promise<
loading = (async () => {
const url = habboFurnidataUrl(hotel);
try {
console.debug(`[habbo-furnidata] Fetching furnidata from ${url}...`);
logger.debug(`[habbo-furnidata] Fetching furnidata from ${url}...`);
const res = await fetch(url, {
signal: AbortSignal.timeout(20000),
headers: browserHeaders(),
@@ -114,7 +114,7 @@ export async function getOfficialHabboFurnidata(): Promise<
}
}
console.debug(
logger.debug(
`[habbo-furnidata] Cached ${map.size} items from habbo.${hotel} (${roomTypes?.length ?? 0} room + ${wallTypes?.length ?? 0} wall)`,
);
cache = map;
+1 -1
View File
@@ -106,7 +106,7 @@ async function loadBadges(): Promise<{
badges.sort((a, b) => a.code.localeCompare(b.code));
console.debug(
logger.debug(
`[badges] Loaded ${badges.length} badges from habbo.${hotel}`,
);
badgeCache = badges;
+6 -6
View File
@@ -1,6 +1,8 @@
import "server-only";
import { promises as fs } from "node:fs";
import path from "node:path";
import { db, WebsiteBadges } from "@/lib/db";
import { logger } from "@/lib/logger";
import { getGamedataRoot } from "@/lib/services/furni-asset-dirs";
import { getRuntimePath } from "@/lib/utils/runtime-path";
@@ -37,9 +39,8 @@ export async function writeBadgeToExternalTexts(
} catch (err) {
// File not present yet (fresh deployment) — start with an empty object.
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
console.warn(
"[import-badges] Failed to update ExternalTexts.json:",
(err as Error).message,
logger.warn(
`[import-badges] Failed to update ExternalTexts.json: ${(err as Error).message}`,
);
return {
ok: false,
@@ -66,9 +67,8 @@ export async function writeBadgeToExternalTexts(
await fs.writeFile(extPath, JSON.stringify(texts, null, 4), "utf-8");
return { ok: true };
} catch (err) {
console.warn(
"[import-badges] Failed to update ExternalTexts.json:",
(err as Error).message,
logger.warn(
`[import-badges] Failed to update ExternalTexts.json: ${(err as Error).message}`,
);
return {
ok: false,
+6 -6
View File
@@ -1,4 +1,5 @@
import { promises as fs } from "node:fs";
import { logger } from "@/lib/logger";
import { getByparrCookies } from "@/lib/services/byparr";
import {
detectNitroTextureFormat,
@@ -119,7 +120,7 @@ export async function downloadFile(
const stat = await fs.stat(destPath);
return { ok: true, size: stat.size };
}
console.warn(
logger.warn(
`[import-download] Download failed ${url}: ${res.status}${deterministic ? " (deterministic, not retrying)" : ` after ${maxRetries + 1} attempts`}`,
);
return { ok: false, size: 0, error: `HTTP ${res.status}` };
@@ -134,14 +135,14 @@ export async function downloadFile(
}
if (options?.validate === "swf" && !validateSwfBytes(buffer)) {
if (attempt === maxRetries) {
console.warn(`[import-download] Invalid SWF magic bytes from ${url}`);
logger.warn(`[import-download] Invalid SWF magic bytes from ${url}`);
return { ok: false, size: 0, error: "Response is not a SWF file" };
}
continue;
}
if (options?.validate === "png" && !validatePngBytes(buffer)) {
if (attempt === maxRetries) {
console.warn(`[import-download] Invalid PNG magic bytes from ${url}`);
logger.warn(`[import-download] Invalid PNG magic bytes from ${url}`);
return { ok: false, size: 0, error: "Response is not a PNG file" };
}
continue;
@@ -159,9 +160,8 @@ export async function downloadFile(
const stat = await fs.stat(destPath);
return { ok: true, size: stat.size };
}
console.warn(
`[import-download] Download error ${url}:`,
(err as Error).message,
logger.warn(
`[import-download] Download error ${url}: ${(err as Error).message}`,
);
const failure = err as {
code?: string;
@@ -1,4 +1,5 @@
import { promises as fs } from "node:fs";
import { logger } from "@/lib/logger";
const LOCK_STALE_MS = 60_000;
const LOCK_ACQUIRE_TIMEOUT_MS = 30_000;
@@ -50,7 +51,7 @@ async function waitForChain(filePath: string): Promise<void> {
if (elapsed > CHAIN_TIMEOUT_MS) {
// Previous holder took too long — break the chain
console.warn(
logger.warn(
`[gamedata-lock] Breaking stale chain for ${filePath} (held for ${elapsed}ms)`,
);
chains.delete(filePath);
@@ -70,7 +71,7 @@ async function waitForChain(filePath: string): Promise<void> {
]);
} catch {
// Chain holder timed out or crashed — break the chain
console.warn(
logger.warn(
`[gamedata-lock] Chain timeout for ${filePath}, breaking chain`,
);
chains.delete(filePath);