From 28ce0f911c3f5c7b2d93f7e4ff44e54314c065af Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 30 Sep 2026 14:45:26 +0200 Subject: [PATCH] fix(catalog): keep the live catalog truthful after every import path The live catalog store only covered part of the import surface. A durable job settled, a sync queue drained, a .nitro upload or a clone run left the Studio rail and the stats bar showing pre-import numbers until the page was reloaded, and the Catalog Manager kept a second tree that never saw writes made elsewhere in the session. Every one of those paths now pulls the tree again, and the refresh carries the totals with it: importing writes catalog rows server-side, so the counts the store holds were stale for the rest of the session. - refreshCatalogTree shares one request between concurrent callers and queues a single follow-up read when a write lands mid-flight, so a burst of edits costs at most one extra read. - useFurnitureJobs treats its first payload as a baseline, so a page load no longer replays every past import as "just settled", and hands the settled jobs to the callback. - The Catalog Manager pushes its own mutations into the store and re-reads its active tab when the store changes. - The 30s unstable_cache on the admin totals is now tagged and invalidated from every catalog write, including the import worker, so it no longer survives an import even across a hard reload. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/actions/import-furni.ts | 15 +- src/app/admin/catalog/page.tsx | 44 +---- .../import/clone/import-clone-client.tsx | 4 + .../furni/upload/upload-furni-client.tsx | 4 + src/app/api/admin/catalog/tree/route.ts | 10 +- src/app/api/admin/import/clone/batch/route.ts | 2 + src/app/api/admin/import/furni/batch/route.ts | 4 + src/app/api/admin/import/furni/route.ts | 4 + .../api/admin/import/furni/upload/route.ts | 4 + .../catalog-manager-dialog.tsx | 13 +- .../admin/catalog-manager/sortable-tree.tsx | 16 ++ src/components/admin/operations-center.tsx | 4 +- .../admin/studio/furniture-sync-queue.tsx | 5 +- src/components/admin/studio/studio-client.tsx | 3 + .../admin/studio/use-furniture-jobs.ts | 29 +-- .../catalog/client/live-catalog-merge.test.ts | 39 ++++ .../catalog/client/live-catalog-merge.ts | 22 +++ .../catalog/client/use-live-catalog.test.ts | 169 ++++++++++++++++++ .../catalog/client/use-live-catalog.ts | 48 ++++- src/features/catalog/server/catalog-totals.ts | 67 +++++++ src/features/catalog/server/sync-status.ts | 4 + src/lib/services/furni-job-worker.ts | 5 + 22 files changed, 445 insertions(+), 70 deletions(-) create mode 100644 src/features/catalog/client/use-live-catalog.test.ts create mode 100644 src/features/catalog/server/catalog-totals.ts diff --git a/src/actions/import-furni.ts b/src/actions/import-furni.ts index cf84896e..0599d920 100644 --- a/src/actions/import-furni.ts +++ b/src/actions/import-furni.ts @@ -1,6 +1,7 @@ "use server"; import { z } from "zod"; +import { invalidateCatalogTotals } from "@/features/catalog/server/catalog-totals"; import { db, WebsiteSetting } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import { adminAction } from "@/lib/safe-action"; @@ -13,12 +14,14 @@ const deleteSchema = z.object({ classname: z.string().trim().min(1) }); export const deleteImportedFurni = adminAction( { permission: PERMS.ASSETS_IMPORT, schema: deleteSchema }, - async (ctx) => - actionOk( - (await withCatalogExport(() => - deleteImportedItem(ctx.data.classname), - )) as unknown as Record, - ), + async (ctx) => { + const result = (await withCatalogExport(() => + deleteImportedItem(ctx.data.classname), + )) as unknown as Record; + // Deleting an imported item can remove its offer and category page. + invalidateCatalogTotals(); + return actionOk(result); + }, ); const translateToggleSchema = z.object({ enabled: z.boolean() }); diff --git a/src/app/admin/catalog/page.tsx b/src/app/admin/catalog/page.tsx index 443f54a9..c5932d0d 100644 --- a/src/app/admin/catalog/page.tsx +++ b/src/app/admin/catalog/page.tsx @@ -1,6 +1,5 @@ -import { asc, count, eq, or } from "drizzle-orm"; +import { asc, eq, or } from "drizzle-orm"; import { Crown, Store, Wrench } from "lucide-react"; -import { unstable_cache } from "next/cache"; import Link from "next/link"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; @@ -8,47 +7,12 @@ import { AdminPageShell } from "@/components/admin/admin-page-shell"; import { OrganizeImportsLauncher } from "@/components/admin/studio/organize-imports-launcher"; import { CatalogSyncStatus } from "@/features/catalog/components/catalog-sync-status"; import { CatalogPackagesDialog } from "@/features/catalog/components/packages/catalog-packages-dialog"; -import { - CatalogItems, - CatalogItemsBc, - CatalogPages, - CatalogPagesBc, - db, -} from "@/lib/db"; +import { getCachedCatalogTotals } from "@/features/catalog/server/catalog-totals"; +import { CatalogPages, CatalogPagesBc, db } from "@/lib/db"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import type { RootTab } from "@/types/catalog"; import { CatalogManagerWorkspace } from "./catalog-manager-dialog-wrapper"; -interface Totals { - totalPages: number; - enabledPages: number; - totalItems: number; -} - -async function loadTotals(catalogType: "normal" | "bc"): Promise { - const pagesTable = catalogType === "bc" ? CatalogPagesBc : CatalogPages; - const itemsTable = catalogType === "bc" ? CatalogItemsBc : CatalogItems; - const [pages, items, enabled] = await Promise.all([ - db.select({ total: count() }).from(pagesTable), - db.select({ total: count() }).from(itemsTable), - db - .select({ total: count() }) - .from(pagesTable) - .where(eq(pagesTable.enabled, "1")), - ]); - return { - totalPages: Number(pages[0]?.total ?? 0), - totalItems: Number(items[0]?.total ?? 0), - enabledPages: Number(enabled[0]?.total ?? 0), - }; -} - -const getCachedTotals = unstable_cache( - async (catalogType: "normal" | "bc") => loadTotals(catalogType), - ["admin-catalog-totals"], - { revalidate: 30 }, -); - async function loadRootPages(catalogType: "normal" | "bc"): Promise { const pagesTable = catalogType === "bc" ? CatalogPagesBc : CatalogPages; const rootRaw = await db @@ -85,7 +49,7 @@ export default async function CatalogPagesPage({ const catalogType = sp.catalog === "bc" ? "bc" : "normal"; const [totals, rootPages] = await Promise.all([ - getCachedTotals(catalogType), + getCachedCatalogTotals(catalogType), loadRootPages(catalogType), ]); diff --git a/src/app/admin/import/clone/import-clone-client.tsx b/src/app/admin/import/clone/import-clone-client.tsx index 8bee2541..2c64595b 100644 --- a/src/app/admin/import/clone/import-clone-client.tsx +++ b/src/app/admin/import/clone/import-clone-client.tsx @@ -26,6 +26,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { refreshCatalogTree } from "@/features/catalog/client/use-live-catalog"; import { adminFetch } from "@/lib/admin-fetch"; import { HABBO_GAMEDATA_HOTELS } from "@/lib/habbo-gamedata-hotel"; import { runSseImport } from "@/lib/sse-client"; @@ -532,6 +533,8 @@ function FurniGrid({ source }: FurniGridProps) { if (succeeded > 0) toast.success(`${succeeded} furni cloned`); if (failed > 0) toast.error(`${failed} failed`); fetchItems(activeSearch, page, filter); + // Cloning writes catalog rows and category pages server-side. + if (succeeded > 0) void refreshCatalogTree(); }, abort.signal, () => { @@ -635,6 +638,7 @@ function FurniGrid({ source }: FurniGridProps) { } setSelected(new Set()); fetchItems(activeSearch, page, filter); + if (okCount > 0) void refreshCatalogTree(); } catch (err) { if ((err as Error)?.name === "AbortError") { toast.info("Clone cancelled"); diff --git a/src/app/admin/import/furni/upload/upload-furni-client.tsx b/src/app/admin/import/furni/upload/upload-furni-client.tsx index a5346496..84924ed5 100644 --- a/src/app/admin/import/furni/upload/upload-furni-client.tsx +++ b/src/app/admin/import/furni/upload/upload-furni-client.tsx @@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; +import { refreshCatalogTree } from "@/features/catalog/client/use-live-catalog"; import { adminFetch } from "@/lib/admin-fetch"; const CLASSNAME_RE = /^[a-zA-Z0-9_]+$/; @@ -127,6 +128,9 @@ export function UploadFurniClient() { } setResult(data); + // The upload creates a category page server-side, so the live tree and + // totals would stay pre-import without pulling them again. + void refreshCatalogTree(); toast.success(`${data.classname} imported successfully`); } catch { const msg = "Network error"; diff --git a/src/app/api/admin/catalog/tree/route.ts b/src/app/api/admin/catalog/tree/route.ts index 10e54c1b..45f32bd2 100644 --- a/src/app/api/admin/catalog/tree/route.ts +++ b/src/app/api/admin/catalog/tree/route.ts @@ -1,4 +1,5 @@ import { asc, count, eq, inArray, like, or } from "drizzle-orm"; +import { loadCatalogTotals } from "@/features/catalog/server/catalog-totals"; import { catalogFailure } from "@/features/catalog/server/errors"; import { deletePageCommand, @@ -348,8 +349,13 @@ export const GET = withAdmin( const isBc = sp.get("catalog") === "bc"; if (sp.get("mode") === "full") { - const pages = isBc ? await getBcTreeFlat() : await getTreeFlat(); - return apiOk({ pages }); + // Totals travel with the tree so one refresh updates both the rail and + // the stats bar. Read uncached — this response is already the fresh read. + const [pages, totals] = await Promise.all([ + isBc ? getBcTreeFlat() : getTreeFlat(), + loadCatalogTotals(isBc ? "bc" : "normal"), + ]); + return apiOk({ pages, totals }); } const pageIdRaw = sp.get("pageId"); diff --git a/src/app/api/admin/import/clone/batch/route.ts b/src/app/api/admin/import/clone/batch/route.ts index 9d284d3d..223f650e 100644 --- a/src/app/api/admin/import/clone/batch/route.ts +++ b/src/app/api/admin/import/clone/batch/route.ts @@ -1,3 +1,4 @@ +import { invalidateCatalogTotals } from "@/features/catalog/server/catalog-totals"; import { apiError } from "@/lib/api"; import { withAdmin } from "@/lib/api-handler"; import { PERMS } from "@/lib/permissions"; @@ -183,6 +184,7 @@ export const POST = withAdmin( // New items + files landed — drop caches so the Studio refresh is fresh. clearFurniImportCache(); + invalidateCatalogTotals(); }, }); }, diff --git a/src/app/api/admin/import/furni/batch/route.ts b/src/app/api/admin/import/furni/batch/route.ts index 5296d466..2dd709d6 100644 --- a/src/app/api/admin/import/furni/batch/route.ts +++ b/src/app/api/admin/import/furni/batch/route.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { invalidateCatalogTotals } from "@/features/catalog/server/catalog-totals"; import { apiError } from "@/lib/api"; import { withAdmin } from "@/lib/api-handler"; import { getRequestId } from "@/lib/foundation/request-context"; @@ -417,6 +418,9 @@ export const POST = withAdmin( ); } } + // The batch wrote offers and category pages whatever the emulator + // refresh did; the admin totals must not serve pre-batch numbers. + invalidateCatalogTotals(); if (!aborted) { send({ diff --git a/src/app/api/admin/import/furni/route.ts b/src/app/api/admin/import/furni/route.ts index c9ed2695..d13f785d 100644 --- a/src/app/api/admin/import/furni/route.ts +++ b/src/app/api/admin/import/furni/route.ts @@ -1,6 +1,7 @@ import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; import { eq, sql } from "drizzle-orm"; +import { invalidateCatalogTotals } from "@/features/catalog/server/catalog-totals"; import { apiError, apiOk } from "@/lib/api"; import { withAdmin } from "@/lib/api-handler"; import { db, ItemsBase, queryRows } from "@/lib/db"; @@ -101,6 +102,7 @@ export const DELETE = withAdmin( } catch (err) { errors.push(`RCON: ${(err as Error).message}`); } + invalidateCatalogTotals(); logAudit({ userId: ctx.session.user.id, @@ -457,6 +459,7 @@ export const POST = withAdmin( (err as Error).message, ); } + invalidateCatalogTotals(); // Force offer_id to mirror its catalog row id across the tree const reconciled = await reconcileImportedOfferIds(); @@ -587,6 +590,7 @@ export const POST = withAdmin( ); result.warnings.push("RCON cache refresh failed"); } + invalidateCatalogTotals(); // Force offer_id to mirror its catalog row id across the tree const reconciled = await reconcileImportedOfferIds(); diff --git a/src/app/api/admin/import/furni/upload/route.ts b/src/app/api/admin/import/furni/upload/route.ts index abd8c704..60f58a1b 100644 --- a/src/app/api/admin/import/furni/upload/route.ts +++ b/src/app/api/admin/import/furni/upload/route.ts @@ -1,3 +1,4 @@ +import { invalidateCatalogTotals } from "@/features/catalog/server/catalog-totals"; import { apiError, apiOk } from "@/lib/api"; import { withAdmin } from "@/lib/api-handler"; import { PERMS } from "@/lib/permissions"; @@ -69,6 +70,9 @@ export const POST = withAdmin( return apiError(result.error || "Upload failed", 400); } + // The upload can add an offer and its category page. + invalidateCatalogTotals(); + logAudit({ userId: ctx.session.user.id, action: "furni_upload", diff --git a/src/components/admin/catalog-manager/catalog-manager-dialog.tsx b/src/components/admin/catalog-manager/catalog-manager-dialog.tsx index bd2543f2..b536d01b 100644 --- a/src/components/admin/catalog-manager/catalog-manager-dialog.tsx +++ b/src/components/admin/catalog-manager/catalog-manager-dialog.tsx @@ -13,6 +13,7 @@ import type { CatalogSelection } from "@/features/catalog/client/catalog-selecti import { useCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation"; import { useLatestRequest } from "@/features/catalog/client/use-latest-request"; import { + refreshCatalogTree, seedCatalogTotals, useLiveCatalog, } from "@/features/catalog/client/use-live-catalog"; @@ -271,6 +272,16 @@ function CatalogManagerInner({ } }, [dispatch, catQs, rootRequests]); + /** + * A page created, moved or deleted here changes the tree the Studio rail and + * the stats bar read from the live store. Refreshing it here keeps both views + * truthful without a route re-render; the store coalesces bursts of edits. + */ + const onTreeMutation = useCallback(() => { + void loadRootPages(); + void refreshCatalogTree(); + }, [loadRootPages]); + // Seed once on opening, then refresh independently of category selection. useEffect(() => { if (!open) return; @@ -504,7 +515,7 @@ function CatalogManagerInner({ activeTabId={state.activeTabId} selectedPageId={state.selectedPageId} onSelectPage={handleSelectPage} - onTreeMutation={loadRootPages} + onTreeMutation={onTreeMutation} nodeUpdate={null} /> diff --git a/src/components/admin/catalog-manager/sortable-tree.tsx b/src/components/admin/catalog-manager/sortable-tree.tsx index 09adfd4b..aa56d142 100644 --- a/src/components/admin/catalog-manager/sortable-tree.tsx +++ b/src/components/admin/catalog-manager/sortable-tree.tsx @@ -59,6 +59,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation"; +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 { cn } from "@/lib/utils"; @@ -213,6 +214,21 @@ export function SortableTree({ void loadTabTree(); }, [activeTabId, loadTabTree, dispatch]); + /** + * This tree is loaded per root tab, so it cannot see writes that happen + * elsewhere in the session — an import settling, or a page edited through the + * Studio. The live store is where those land; when it changes, re-read this + * tab so the dialog never shows pre-import nodes. + */ + const liveTree = useLiveCatalog().tree; + const seenLiveTree = useRef(liveTree); + useEffect(() => { + if (liveTree === seenLiveTree.current) return; + seenLiveTree.current = liveTree; + if (activeTabId === null) return; + void loadTabTree(); + }, [liveTree, activeTabId, loadTabTree]); + // Ignore stale search completions when the query or catalog changes. useEffect(() => { const controller = new AbortController(); diff --git a/src/components/admin/operations-center.tsx b/src/components/admin/operations-center.tsx index 9f88bd25..3a08f77b 100644 --- a/src/components/admin/operations-center.tsx +++ b/src/components/admin/operations-center.tsx @@ -2,11 +2,13 @@ import { useTranslations } from "next-intl"; import { CatalogExportClient } from "@/app/admin/studio/sync/catalog-export-client"; import Link from "@/components/link"; +import { refreshCatalogTree } from "@/features/catalog/client/use-live-catalog"; import { FurnitureJobHistory } from "./studio/furniture-jobs"; import { useFurnitureJobs } from "./studio/use-furniture-jobs"; export function OperationsCenter() { const t = useTranslations("pages.admin.operationsCenter"); - const jobs = useFurnitureJobs(() => {}, true); + // Keep the live catalog in step with imports that finish while this tab is open. + const jobs = useFurnitureJobs(() => void refreshCatalogTree(), true); return (

{t("title")}

diff --git a/src/components/admin/studio/furniture-sync-queue.tsx b/src/components/admin/studio/furniture-sync-queue.tsx index 8c16b82c..5145f656 100644 --- a/src/components/admin/studio/furniture-sync-queue.tsx +++ b/src/components/admin/studio/furniture-sync-queue.tsx @@ -2,13 +2,16 @@ import { useTranslations } from "next-intl"; import { useRef, useState } from "react"; import { Button } from "@/components/ui/button"; +import { refreshCatalogTree } from "@/features/catalog/client/use-live-catalog"; import { adminFetch } from "@/lib/admin-fetch"; import { FurnitureJobHistory } from "./furniture-jobs"; import { useFurnitureJobs } from "./use-furniture-jobs"; export function FurnitureSyncQueue({ kind }: { kind: "official" | "clone" }) { const t = useTranslations("pages.admin.syncQueue"); - const history = useFurnitureJobs(() => {}, true); + // A sync job writes offers and pages server-side; the history list alone + // would leave the catalog rail and stats bar showing pre-sync numbers. + const history = useFurnitureJobs(() => void refreshCatalogTree(), true); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [queued, setQueued] = useState(false); diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx index 04ec7d38..bbc274f3 100644 --- a/src/components/admin/studio/studio-client.tsx +++ b/src/components/admin/studio/studio-client.tsx @@ -189,6 +189,9 @@ export function StudioClient({ () => { void fetchItems(activeSearch, 1, activeSource); void fetchStats(); + // A durable job derives its "Imported Furniture" pages server-side, so + // the rail cannot predict them either — pull the tree and totals. + void refreshCatalogTree(); }, false, actorId, diff --git a/src/components/admin/studio/use-furniture-jobs.ts b/src/components/admin/studio/use-furniture-jobs.ts index 66685801..9d87ea63 100644 --- a/src/components/admin/studio/use-furniture-jobs.ts +++ b/src/components/admin/studio/use-furniture-jobs.ts @@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { useVisiblePolling } from "@/hooks/use-visible-polling"; import { adminFetch } from "@/lib/admin-fetch"; -import type { ImportJobItem } from "@/lib/furni/import-job"; +import type { ImportJob, ImportJobItem } from "@/lib/furni/import-job"; import { createFurnitureImportRequests } from "./furniture-import-requests"; import { createFurnitureJobsClient, @@ -15,8 +15,10 @@ import { readFurnitureJobResponse, } from "./furniture-jobs-query"; +const SETTLED_STATES = ["completed", "interrupted", "cancelled"]; + export function useFurnitureJobs( - onComplete: () => void, + onComplete: (settled: ImportJob[]) => void, paginated = false, actorId?: number, ) { @@ -54,19 +56,24 @@ export function useFurnitureJobs( }, [client], ); + const baselined = useRef(false); useEffect(() => { if (!query.data || unauthorized) return; - let changed = false; + const settled: ImportJob[] = []; for (const job of query.data.jobs) { - if ( - ["completed", "interrupted", "cancelled"].includes(job.state) && - !completed.current.has(job.id) - ) { - completed.current.add(job.id); - changed = true; - } + if (!SETTLED_STATES.includes(job.state)) continue; + if (completed.current.has(job.id)) continue; + completed.current.add(job.id); + settled.push(job); } - if (changed) callback.current(); + // The first payload is history this component did not witness finishing. + // Recording it as a baseline keeps a page load from replaying every past + // import as "just settled", which would refetch the catalog on every visit. + if (!baselined.current) { + baselined.current = true; + return; + } + if (settled.length > 0) callback.current(settled); }, [query.data, unauthorized]); const refresh = useCallback(async () => { // fetchQuery shares an in-flight GET. Query owns data, errors and loading. diff --git a/src/features/catalog/client/live-catalog-merge.test.ts b/src/features/catalog/client/live-catalog-merge.test.ts index ab35ccc2..a127f945 100644 --- a/src/features/catalog/client/live-catalog-merge.test.ts +++ b/src/features/catalog/client/live-catalog-merge.test.ts @@ -7,6 +7,7 @@ import { type CreatedCatalogPage, EMPTY_CATALOG_DELTA, isEmptyCatalogDelta, + normalizeCatalogTotals, normalizeTreePages, recomputeDepth, } from "./live-catalog-merge"; @@ -238,3 +239,41 @@ describe("isEmptyCatalogDelta", () => { expect(isEmptyCatalogDelta(EMPTY_CATALOG_DELTA)).toBe(true); }); }); + +describe("normalizeCatalogTotals", () => { + it("accepts a totals payload from the tree route", () => { + expect( + normalizeCatalogTotals({ + totalPages: 12, + enabledPages: 9, + totalItems: 400, + }), + ).toEqual({ totalPages: 12, enabledPages: 9, totalItems: 400 }); + }); + + it("coerces string counts the way MariaDB drivers return them", () => { + expect( + normalizeCatalogTotals({ + totalPages: "12", + enabledPages: "9", + totalItems: "400", + }), + ).toEqual({ totalPages: 12, enabledPages: 9, totalItems: 400 }); + }); + + it("accepts an explicit zeroed payload for an empty catalog", () => { + expect( + normalizeCatalogTotals({ + totalPages: 0, + enabledPages: 0, + totalItems: 0, + }), + ).toEqual({ totalPages: 0, enabledPages: 0, totalItems: 0 }); + }); + + it("rejects a missing or partial payload so the store keeps its counts", () => { + expect(normalizeCatalogTotals(undefined)).toBeNull(); + expect(normalizeCatalogTotals("nope")).toBeNull(); + expect(normalizeCatalogTotals({ totalPages: 3 })).toBeNull(); + }); +}); diff --git a/src/features/catalog/client/live-catalog-merge.ts b/src/features/catalog/client/live-catalog-merge.ts index 87ca8efd..d5ec24c4 100644 --- a/src/features/catalog/client/live-catalog-merge.ts +++ b/src/features/catalog/client/live-catalog-merge.ts @@ -51,6 +51,28 @@ export interface CatalogTotals { totalItems: number; } +/** + * Coerce an API totals payload into CatalogTotals. An explicit all-zero payload + * is valid (an empty catalog); a missing or partial one returns null so callers + * keep whatever the store already holds. + */ +export function normalizeCatalogTotals(raw: unknown): CatalogTotals | null { + if (!raw || typeof raw !== "object") return null; + const row = raw as Record; + const { totalPages, totalItems, enabledPages } = row; + if ( + totalPages === undefined || + totalItems === undefined || + enabledPages === undefined + ) + return null; + return { + totalPages: toCount(totalPages), + totalItems: toCount(totalItems), + enabledPages: toCount(enabledPages), + }; +} + export function isEmptyCatalogDelta(delta: CatalogTreeDelta): boolean { return ( delta.pages.length === 0 && diff --git a/src/features/catalog/client/use-live-catalog.test.ts b/src/features/catalog/client/use-live-catalog.test.ts new file mode 100644 index 00000000..02b8c331 --- /dev/null +++ b/src/features/catalog/client/use-live-catalog.test.ts @@ -0,0 +1,169 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const adminFetch = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/admin-fetch", () => ({ adminFetch })); + +import { + applyCatalogDelta, + getLiveCatalogSnapshot, + refreshCatalogTree, + resetLiveCatalogForTests, + seedCatalogTotals, +} from "./use-live-catalog"; + +function ok(body: unknown) { + return { ok: true, json: async () => body } as unknown as Response; +} + +function page(id: number, over: Record = {}) { + return { + id, + caption: `page ${id}`, + parentId: -1, + depth: 0, + orderNum: 0, + enabled: "1", + visible: "1", + iconImage: 0, + iconColor: 0, + pageLayout: "default_3x3", + childCount: 0, + itemCount: 0, + ...over, + }; +} + +describe("refreshCatalogTree", () => { + beforeEach(() => { + resetLiveCatalogForTests(); + adminFetch.mockReset(); + }); + + it("adopts the tree and the totals the route reports", async () => { + seedCatalogTotals("normal", { + totalPages: 1, + enabledPages: 1, + totalItems: 1, + }); + adminFetch.mockResolvedValue( + ok({ + pages: [page(1), page(2, { parentId: 1, itemCount: 7 })], + totals: { totalPages: 2, enabledPages: 2, totalItems: 7 }, + }), + ); + + await refreshCatalogTree(); + + const snapshot = getLiveCatalogSnapshot(); + expect(snapshot.tree.map((n) => n.id)).toEqual([1, 2]); + expect(snapshot.totals.normal).toEqual({ + totalPages: 2, + enabledPages: 2, + totalItems: 7, + }); + expect(snapshot.treeLoaded).toBe(true); + }); + + it("replaces the counts an import delta had folded in", async () => { + seedCatalogTotals("normal", { + totalPages: 10, + enabledPages: 10, + totalItems: 100, + }); + applyCatalogDelta({ + pages: [ + { + id: 99, + caption: "Chairs", + parentId: -1, + pageLayout: "default_3x3", + iconImage: 0, + iconColor: 0, + orderNum: 0, + enabled: "1", + visible: "1", + }, + ], + addedItems: [{ pageId: 99, count: 4 }], + movedItems: [], + }); + adminFetch.mockResolvedValue( + ok({ + pages: [page(99)], + totals: { totalPages: 11, enabledPages: 11, totalItems: 104 }, + }), + ); + + await refreshCatalogTree(); + + expect(getLiveCatalogSnapshot().totals.normal).toEqual({ + totalPages: 11, + enabledPages: 11, + totalItems: 104, + }); + }); + + it("keeps the counts it holds when the route omits totals", async () => { + seedCatalogTotals("normal", { + totalPages: 3, + enabledPages: 2, + totalItems: 30, + }); + adminFetch.mockResolvedValue(ok({ pages: [page(1)] })); + + await refreshCatalogTree(); + + expect(getLiveCatalogSnapshot().totals.normal).toEqual({ + totalPages: 3, + enabledPages: 2, + totalItems: 30, + }); + }); + + it("collapses concurrent callers into one read plus at most one follow-up", async () => { + adminFetch.mockResolvedValue(ok({ pages: [] })); + await Promise.all([ + refreshCatalogTree(), + refreshCatalogTree(), + refreshCatalogTree(), + ]); + expect(adminFetch.mock.calls.length).toBeLessThanOrEqual(2); + }); + + it("re-reads once for writes that land while a read is in flight", async () => { + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + adminFetch + .mockImplementationOnce(async () => { + await gate; + return ok({ pages: [page(1)] }); + }) + .mockResolvedValueOnce(ok({ pages: [page(1), page(2)] })); + + const first = refreshCatalogTree(); + // A mutation lands while the first read is still open: its snapshot may + // predate the write, so this caller asks for a follow-up read. + const second = refreshCatalogTree(); + release?.(); + await Promise.all([first, second]); + + expect(adminFetch).toHaveBeenCalledTimes(2); + expect(getLiveCatalogSnapshot().tree.map((n) => n.id)).toEqual([1, 2]); + }); + + it("leaves the previous snapshot alone when the read fails", async () => { + adminFetch.mockResolvedValueOnce(ok({ pages: [page(1)] })); + await refreshCatalogTree(); + adminFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({}), + } as unknown as Response); + + await refreshCatalogTree(); + + expect(getLiveCatalogSnapshot().tree.map((n) => n.id)).toEqual([1]); + }); +}); diff --git a/src/features/catalog/client/use-live-catalog.ts b/src/features/catalog/client/use-live-catalog.ts index f97eebe7..6a36675a 100644 --- a/src/features/catalog/client/use-live-catalog.ts +++ b/src/features/catalog/client/use-live-catalog.ts @@ -9,6 +9,7 @@ import { type CatalogTotals, type CatalogTreeDelta, isEmptyCatalogDelta, + normalizeCatalogTotals, normalizeTreePages, } from "./live-catalog-merge"; @@ -51,6 +52,11 @@ export function useLiveCatalog(): LiveCatalogSnapshot { return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); } +/** Non-React read of the same snapshot `useLiveCatalog` subscribes to. */ +export function getLiveCatalogSnapshot(): LiveCatalogSnapshot { + return snapshot; +} + // ── Reads ────────────────────────────────────────────────────────────────── /** @@ -91,28 +97,53 @@ async function loadTree(): Promise { const res = await adminFetch("/api/admin/catalog/tree?mode=full"); if (!res.ok) throw new Error(`Catalog tree refresh failed (${res.status})`); const data = await res.json(); + // The route answers with live totals alongside the tree: importing writes + // catalog rows server-side, so the numbers the store holds would otherwise + // stay pre-import for the rest of the session. + const totals = normalizeCatalogTotals(data?.totals); emit({ ...snapshot, tree: normalizeTreePages(data?.pages), + totals: { + ...snapshot.totals, + normal: totals ?? snapshot.totals.normal, + }, treeLoaded: true, }); } let treeRequest: Promise | null = null; +let treeRequestQueued = false; /** - * Reload the whole tree over the API. Used where the server decides the shape of - * the result — the furniture importers derive their category pages from - * furnidata, so there is nothing for the client to predict. Still a plain + * Reload the whole tree and totals over the API. Used where the server decides + * the shape of the result — the furniture importers derive their category pages + * from furnidata, so there is nothing for the client to predict. Still a plain * in-place data update: no route re-render, no remount, no lost editor state. + * + * Concurrent calls share one request, and a call that lands while one is in + * flight queues a single follow-up read: the in-flight snapshot may predate the + * write that triggered it. That keeps a burst of catalog mutations to one + * extra read instead of one read each. */ export function refreshCatalogTree(): Promise { - if (treeRequest) return treeRequest; - treeRequest = loadTree() - .catch(() => undefined) - .finally(() => { + if (treeRequest) { + treeRequestQueued = true; + return treeRequest; + } + treeRequest = (async () => { + try { + do { + treeRequestQueued = false; + await loadTree(); + } while (treeRequestQueued); + } catch { + // A failed read keeps the previous snapshot; the next mount or + // mutation tries again. + } finally { treeRequest = null; - }); + } + })(); return treeRequest; } @@ -124,5 +155,6 @@ export function ensureCatalogTreeLoaded(): Promise { /** Test seam: drop all live catalog state between cases. */ export function resetLiveCatalogForTests(): void { treeRequest = null; + treeRequestQueued = false; emit(EMPTY_SNAPSHOT); } diff --git a/src/features/catalog/server/catalog-totals.ts b/src/features/catalog/server/catalog-totals.ts new file mode 100644 index 00000000..c8fb9a88 --- /dev/null +++ b/src/features/catalog/server/catalog-totals.ts @@ -0,0 +1,67 @@ +import "server-only"; +import { count, eq } from "drizzle-orm"; +import { revalidateTag, unstable_cache } from "next/cache"; +// Type-only import: the client merge module owns this shape, so the server +// keeps exactly one definition of it. +import type { CatalogTotals } from "@/features/catalog/client/live-catalog-merge"; +import { + CatalogItems, + CatalogItemsBc, + CatalogPages, + CatalogPagesBc, + db, +} from "@/lib/db"; + +export type { CatalogTotals }; + +/** + * Cache tag for the admin catalog totals. Any path that writes catalog pages or + * offers must invalidate it, otherwise the stats bar keeps serving the + * pre-import numbers for the length of the TTL — including after a hard reload. + */ +export const CATALOG_TOTALS_TAG = "admin-catalog-totals"; + +export async function loadCatalogTotals( + catalogType: "normal" | "bc", +): Promise { + const pagesTable = catalogType === "bc" ? CatalogPagesBc : CatalogPages; + const itemsTable = catalogType === "bc" ? CatalogItemsBc : CatalogItems; + const [pages, items, enabled] = await Promise.all([ + db.select({ total: count() }).from(pagesTable), + db.select({ total: count() }).from(itemsTable), + db + .select({ total: count() }) + .from(pagesTable) + .where(eq(pagesTable.enabled, "1")), + ]); + return { + totalPages: Number(pages[0]?.total ?? 0), + totalItems: Number(items[0]?.total ?? 0), + enabledPages: Number(enabled[0]?.total ?? 0), + }; +} + +const getCachedTotals = unstable_cache( + (catalogType: "normal" | "bc") => loadCatalogTotals(catalogType), + [CATALOG_TOTALS_TAG], + { tags: [CATALOG_TOTALS_TAG], revalidate: 30 }, +); + +export function getCachedCatalogTotals( + catalogType: "normal" | "bc", +): Promise { + return getCachedTotals(catalogType); +} + +/** + * Best-effort invalidation for import workers and API routes, which run outside + * a render. A failure here must never abort an import: the cache expires on its + * own after 30 seconds. + */ +export function invalidateCatalogTotals(): void { + try { + revalidateTag(CATALOG_TOTALS_TAG, { expire: 0 }); + } catch { + // Ignore — see above. + } +} diff --git a/src/features/catalog/server/sync-status.ts b/src/features/catalog/server/sync-status.ts index de5d45b8..f3efe6a7 100644 --- a/src/features/catalog/server/sync-status.ts +++ b/src/features/catalog/server/sync-status.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { logger } from "@/lib/logger"; import { catalogStateRoot } from "@/lib/services/catalog-git-config"; import { rcon } from "@/lib/services/rcon"; +import { invalidateCatalogTotals } from "./catalog-totals"; export interface CatalogHotelStatus { sent: boolean; checkedAt: string; @@ -40,6 +41,9 @@ export async function sendCatalogUpdate(): Promise { error, }); } + // Callers reach here only after writing catalog rows, so the admin stats + // totals must not keep serving the pre-write numbers from their 30s cache. + invalidateCatalogTotals(); const status = { sent, checkedAt: new Date().toISOString(), reference }; const root = catalogStateRoot(); const temp = path.join(root, `hotel-${randomUUID()}.tmp`); diff --git a/src/lib/services/furni-job-worker.ts b/src/lib/services/furni-job-worker.ts index 1c95d404..6332d0a3 100644 --- a/src/lib/services/furni-job-worker.ts +++ b/src/lib/services/furni-job-worker.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { invalidateCatalogTotals } from "@/features/catalog/server/catalog-totals"; import { createStore, runWithStore } from "@/lib/foundation/request-context"; import type { IpAddress, RequestId, UserId } from "@/lib/foundation/types"; import { IMPORT_PHASES, type ImportJob } from "@/lib/furni/import-job"; @@ -232,6 +233,10 @@ async function drain() { item.warnings ??= []; item.warnings.push("Game cache refresh failed"); } + } finally { + // Offers and category pages were written either way; drop the + // admin totals cache so the next read matches this job. + invalidateCatalogTotals(); } }), );