From cea88eaa57f89c2b7ac0b17bb8c524a3c443cc36 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 10 Sep 2026 11:50:27 +0200 Subject: [PATCH] feat(catalog): repair page hierarchy cycles and duplicate sibling page order The Arcturus errors "page hierarchy contains a cycle page 354 and 357" and "sibling order 1 is used more than once (111 problems)" come from catalog_pages, not catalog_items: pages 354/357 point at themselves, and many parents have child pages sharing the same order_num. Extend the emulator catalog scan + fix to detect both: pages whose parent chain loops back get detached (parent_id = 0 on the highest cycle member) and every affected parent's children are renumbered sequentially, preserving their current relative order. Add scripts/diag-emulator.ts to inspect the live catalog state. --- scripts/diag-emulator.ts | 32 +++++ src/lib/services/furni-maintenance.test.ts | 91 +++++++++++++- src/lib/services/furni-maintenance.ts | 134 ++++++++++++++++++++- 3 files changed, 252 insertions(+), 5 deletions(-) create mode 100644 scripts/diag-emulator.ts diff --git a/scripts/diag-emulator.ts b/scripts/diag-emulator.ts new file mode 100644 index 00000000..6b342e9e --- /dev/null +++ b/scripts/diag-emulator.ts @@ -0,0 +1,32 @@ +import "./load-env"; +import { sql } from "drizzle-orm"; +import { db } from "@/lib/db"; + +async function main() { + const [dp] = (await db.execute(sql` + SELECT CAST(parent_id AS CHAR) parentId, CAST(order_num AS CHAR) orderNum, COUNT(*) c, GROUP_CONCAT(id) ids + FROM catalog_pages + GROUP BY parent_id, order_num + HAVING COUNT(*) > 1 + `)) as unknown as [Array>, unknown]; + console.log("=== catalog_pages duplicate (parent, order_num) groups ==="); + console.log(JSON.stringify(dp)); + + const [children] = (await db.execute(sql` + SELECT CAST(parent_id AS CHAR) parentId, COUNT(*) c + FROM catalog_pages + WHERE parent_id <> 0 + GROUP BY parent_id ORDER BY c DESC LIMIT 8 + `)) as unknown as [Array>, unknown]; + console.log("=== most children per parent ==="); + console.log(JSON.stringify(children)); + + const [pages] = (await db.execute(sql` + SELECT CAST(id AS CHAR) id, CAST(parent_id AS CHAR) parentId, CAST(order_num AS CHAR) orderNum, caption_save caption, enabled + FROM catalog_pages WHERE id IN (353,354,355,356,357,358) + `)) as unknown as [Array>, unknown]; + console.log("=== pages 353-358 ==="); + console.log(JSON.stringify(pages, null, 1)); + await db.$client?.end?.(); +} +void main(); diff --git a/src/lib/services/furni-maintenance.test.ts b/src/lib/services/furni-maintenance.test.ts index 872a82bc..43619588 100644 --- a/src/lib/services/furni-maintenance.test.ts +++ b/src/lib/services/furni-maintenance.test.ts @@ -7,6 +7,8 @@ const state = vi.hoisted(() => ({ pages: [] as Array>, bases: [] as Array>, orderRows: [] as Array>, + cycleRows: [] as Array>, + childrenByParent: {} as Record>>, queries: [] as { sql: string; params: unknown[] }[], writes: 0, })); @@ -25,8 +27,24 @@ vi.mock("@/lib/db", () => ({ execute: async (query: SQL) => { const compiled = new MySqlDialect().sqlToQuery(query); state.queries.push(compiled); - if (compiled.sql.trimStart().startsWith("SELECT")) + const sqlText = compiled.sql.replace(/\s+/g, " ").trim(); + if (sqlText.includes("SELECT id, page_id AS pageId, order_number")) return [state.orderRows, []]; + if ( + sqlText.includes( + "SELECT id, parent_id AS parentId FROM catalog_pages", + ) + ) + return [state.cycleRows, []]; + if ( + sqlText.includes( + "SELECT id, order_num AS orderNum FROM catalog_pages", + ) + ) { + const parent = String(compiled.params[0]); + return [state.childrenByParent[parent] ?? [], []]; + } + if (sqlText.startsWith("SELECT")) return [state.orderRows, []]; state.writes++; return [{ affectedRows: 1 }, []]; }, @@ -53,9 +71,11 @@ const item = (id: number, overrides: Record = {}) => ({ beforeEach(() => { state.items = []; - state.pages = [{ id: 10 }]; + state.pages = [{ id: 10, parentId: 0, orderNum: 1 }]; state.bases = [{ id: 12 }]; state.orderRows = []; + state.cycleRows = []; + state.childrenByParent = {}; state.queries = []; state.writes = 0; }); @@ -165,3 +185,70 @@ describe("fixEmulatorIssues", () => { expect(state.writes).toBe(0); }); }); + +describe("page hierarchy checks", () => { + it("flags pages whose parent chain forms a cycle", async () => { + state.pages = [ + { id: 354, parentId: 354, orderNum: 4 }, + { id: 357, parentId: 357, orderNum: 7 }, + ]; + const issues = await listEmulatorIssues(); + const cycles = issues.filter((i) => i.type === "page_cycle"); + expect(cycles.map((i) => i.id).sort()).toEqual([354, 357]); + }); + + it("does not flag pages with a valid parent chain", async () => { + state.pages = [{ id: 354, parentId: 10, orderNum: 4 }]; + const issues = await listEmulatorIssues(); + expect(issues.filter((i) => i.type === "page_cycle")).toEqual([]); + }); + + it("flags sibling pages that share an order_num under the same parent", async () => { + state.pages = [ + { id: 110, parentId: 49, orderNum: 1 }, + { id: 111, parentId: 49, orderNum: 1 }, + { id: 112, parentId: 49, orderNum: 2 }, + { id: 120, parentId: 0, orderNum: 1 }, + ]; + const issues = await listEmulatorIssues(); + const flags = issues.filter((i) => i.type === "sibling_page_order"); + expect(flags.map((i) => i.id).sort()).toEqual([110, 111]); + expect(flags.every((i) => i.pageId === 49)).toBe(true); + }); + + it("breaks cycles and re-numbers sibling pages in one transaction", async () => { + state.pages = [ + { id: 10, parentId: 0, orderNum: 1 }, + { id: 354, parentId: 354, orderNum: 4 }, + { id: 357, parentId: 357, orderNum: 7 }, + { id: 110, parentId: 49, orderNum: 1 }, + { id: 111, parentId: 49, orderNum: 1 }, + { id: 112, parentId: 49, orderNum: 2 }, + ]; + state.cycleRows = [ + { id: 354, parentId: 354 }, + { id: 357, parentId: 357 }, + ]; + state.childrenByParent["49"] = [ + { id: 110, orderNum: 1 }, + { id: 111, orderNum: 1 }, + { id: 112, orderNum: 2 }, + ]; + + await fixEmulatorIssues(); + + const bySql = (needle: string) => + state.queries.filter((q) => + q.sql.replace(/\s+/g, " ").trimStart().includes(needle), + ); + const detach = bySql("UPDATE catalog_pages SET parent_id = 0 WHERE id"); + expect(detach.length).toBe(2); + expect([...detach[0].params, ...detach[1].params]).toEqual([354, 357]); + + const reorder = bySql("UPDATE catalog_pages SET order_num ="); + expect(reorder.length).toBe(2); + expect([...reorder[0].params, ...reorder[1].params]).toEqual([ + 2, 111, 3, 112, + ]); + }); +}); diff --git a/src/lib/services/furni-maintenance.ts b/src/lib/services/furni-maintenance.ts index b60958df..09135d3b 100644 --- a/src/lib/services/furni-maintenance.ts +++ b/src/lib/services/furni-maintenance.ts @@ -208,7 +208,9 @@ export interface EmulatorCatalogIssue { | "limited_stack_vs_sells" | "missing_page" | "missing_item" - | "duplicate_order"; + | "duplicate_order" + | "page_cycle" + | "sibling_page_order"; id: number; pageId?: number; detail: string; @@ -249,8 +251,11 @@ export async function listEmulatorIssues(): Promise { unknown, ]; const [pages] = (await db.execute( - sql`SELECT id FROM catalog_pages`, - )) as unknown as [Array<{ id: number }>, unknown]; + sql`SELECT id, parent_id AS parentId, order_num AS orderNum FROM catalog_pages`, + )) as unknown as [ + Array<{ id: number; parentId: number | string; orderNum: number }>, + unknown, + ]; const [bases] = (await db.execute( sql`SELECT id FROM items_base`, )) as unknown as [Array<{ id: number }>, unknown]; @@ -343,6 +348,62 @@ export async function listEmulatorIssues(): Promise { } } + // Page hierarchy cycles: a page (or a chain of pages) whose parent chain + // loops back on itself breaks the catalog tree ("page hierarchy contains a + // cycle"). Flag every page that is a member of such a cycle. + const parentOf = new Map(); + for (const row of pages) parentOf.set(Number(row.id), Number(row.parentId)); + const inCycle = new Set(); + const walked = new Set(); + for (const row of pages) { + const start = Number(row.id); + if (walked.has(start)) continue; + const path: number[] = []; + const at = new Map(); + let cur = start; + while (cur !== 0 && !at.has(cur)) { + at.set(cur, path.length); + path.push(cur); + cur = parentOf.get(cur) ?? 0; + } + for (const node of path) walked.add(node); + if (at.has(cur)) { + for (let i = at.get(cur) ?? path.length; i < path.length; i++) + inCycle.add(path[i]); + } + } + for (const id of [...inCycle].sort((a, b) => a - b)) { + issues.push({ + type: "page_cycle", + id, + detail: `catalog page #${id} (parent ${parentOf.get(id)}) is part of a parent hierarchy cycle`, + }); + } + + // Sibling page order: child pages of the same parent must have a unique + // order_num ("sibling order N is used more than once"). Flag every page + // whose (parent_id, order_num) is shared with at least one sibling. + const pageOrderGroups = new Map(); + for (const row of pages) { + const parentId = Number(row.parentId); + const order = Number(row.orderNum); + const key = `${parentId}:${order}`; + const arr = pageOrderGroups.get(key) ?? []; + arr.push(Number(row.id)); + pageOrderGroups.set(key, arr); + } + for (const [, ids] of pageOrderGroups) { + if (ids.length <= 1) continue; + for (const id of ids) { + issues.push({ + type: "sibling_page_order", + id, + pageId: parentOf.get(id) ?? 0, + detail: `catalog page #${id} shares order_num with a sibling under the same parent`, + }); + } + } + return issues; } @@ -468,6 +529,73 @@ export async function fixEmulatorIssues(): Promise { fixed += dupOrders.length; detail.push(`Re-numbered sibling order numbers on affected pages`); } + + // Page hierarchy cycles -> detach the highest member of each cycle so + // the parent chain can never loop back on itself. + const cyclePages = issues.filter((i) => i.type === "page_cycle"); + if (cyclePages.length > 0) { + const cycleIds = cyclePages.map((i) => i.id); + const [cycleRows] = (await tx.execute(sql` + SELECT id, parent_id AS parentId FROM catalog_pages WHERE id IN (${sql.join(cycleIds, sql`, `)}) + `)) as unknown as [ + Array<{ id: number | string; parentId: number | string }>, + unknown, + ]; + const parentOf = new Map( + cycleRows.map((r) => [Number(r.id), Number(r.parentId)]), + ); + const broken = new Set(); + for (const id of cycleIds) { + if (broken.has(id)) continue; + // Walk the cycle's parent chain to collect every member. + let cur = id; + const chain = new Set(); + while (cur !== 0 && !chain.has(cur)) { + chain.add(cur); + cur = parentOf.get(cur) ?? 0; + } + const member = Math.max(...chain); + await tx.execute( + sql`UPDATE catalog_pages SET parent_id = 0 WHERE id = ${member}`, + ); + for (const node of chain) broken.add(node); + } + fixed += cyclePages.length; + detail.push( + `Detached ${new Set([...broken]).size} page(s) from parent hierarchy cycles`, + ); + } + + // Sibling page order -> re-number children sequentially under each + // affected parent, preserving the existing relative order. + const pageOrderIssues = issues.filter( + (i) => i.type === "sibling_page_order", + ); + if (pageOrderIssues.length > 0) { + const parents = [...new Set(pageOrderIssues.map((i) => i.pageId))]; + for (const parent of parents) { + const [children] = (await tx.execute(sql` + SELECT id, order_num AS orderNum FROM catalog_pages + WHERE parent_id = ${String(parent)} ORDER BY order_num, id + `)) as unknown as [ + Array<{ id: number | string; orderNum: number }>, + unknown, + ]; + let next = 1; + for (const child of children) { + const newOrder = next++; + if (newOrder !== Number(child.orderNum)) { + await tx.execute( + sql`UPDATE catalog_pages SET order_num = ${newOrder} WHERE id = ${Number(child.id)}`, + ); + } + } + } + fixed += pageOrderIssues.length; + detail.push( + `Re-numbered sibling page order under ${parents.length} parent(s)`, + ); + } }); return { fixed, removed, detail };