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.
This commit is contained in:
1 parent
acbe54fcb7
commit
cea88eaa57
3 files changed
+252
-5
No files matched your search
@@ -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<Record<string, unknown>>, 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<Record<string, unknown>>, 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<Record<string, unknown>>, unknown];
|
||||||
|
console.log("=== pages 353-358 ===");
|
||||||
|
console.log(JSON.stringify(pages, null, 1));
|
||||||
|
await db.$client?.end?.();
|
||||||
|
}
|
||||||
|
void main();
|
||||||
@@ -7,6 +7,8 @@ const state = vi.hoisted(() => ({
|
|||||||
pages: [] as Array<Record<string, unknown>>,
|
pages: [] as Array<Record<string, unknown>>,
|
||||||
bases: [] as Array<Record<string, unknown>>,
|
bases: [] as Array<Record<string, unknown>>,
|
||||||
orderRows: [] as Array<Record<string, unknown>>,
|
orderRows: [] as Array<Record<string, unknown>>,
|
||||||
|
cycleRows: [] as Array<Record<string, unknown>>,
|
||||||
|
childrenByParent: {} as Record<string, Array<Record<string, unknown>>>,
|
||||||
queries: [] as { sql: string; params: unknown[] }[],
|
queries: [] as { sql: string; params: unknown[] }[],
|
||||||
writes: 0,
|
writes: 0,
|
||||||
}));
|
}));
|
||||||
@@ -25,8 +27,24 @@ vi.mock("@/lib/db", () => ({
|
|||||||
execute: async (query: SQL) => {
|
execute: async (query: SQL) => {
|
||||||
const compiled = new MySqlDialect().sqlToQuery(query);
|
const compiled = new MySqlDialect().sqlToQuery(query);
|
||||||
state.queries.push(compiled);
|
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, []];
|
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++;
|
state.writes++;
|
||||||
return [{ affectedRows: 1 }, []];
|
return [{ affectedRows: 1 }, []];
|
||||||
},
|
},
|
||||||
@@ -53,9 +71,11 @@ const item = (id: number, overrides: Record<string, unknown> = {}) => ({
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
state.items = [];
|
state.items = [];
|
||||||
state.pages = [{ id: 10 }];
|
state.pages = [{ id: 10, parentId: 0, orderNum: 1 }];
|
||||||
state.bases = [{ id: 12 }];
|
state.bases = [{ id: 12 }];
|
||||||
state.orderRows = [];
|
state.orderRows = [];
|
||||||
|
state.cycleRows = [];
|
||||||
|
state.childrenByParent = {};
|
||||||
state.queries = [];
|
state.queries = [];
|
||||||
state.writes = 0;
|
state.writes = 0;
|
||||||
});
|
});
|
||||||
@@ -165,3 +185,70 @@ describe("fixEmulatorIssues", () => {
|
|||||||
expect(state.writes).toBe(0);
|
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,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -208,7 +208,9 @@ export interface EmulatorCatalogIssue {
|
|||||||
| "limited_stack_vs_sells"
|
| "limited_stack_vs_sells"
|
||||||
| "missing_page"
|
| "missing_page"
|
||||||
| "missing_item"
|
| "missing_item"
|
||||||
| "duplicate_order";
|
| "duplicate_order"
|
||||||
|
| "page_cycle"
|
||||||
|
| "sibling_page_order";
|
||||||
id: number;
|
id: number;
|
||||||
pageId?: number;
|
pageId?: number;
|
||||||
detail: string;
|
detail: string;
|
||||||
@@ -249,8 +251,11 @@ export async function listEmulatorIssues(): Promise<EmulatorCatalogIssue[]> {
|
|||||||
unknown,
|
unknown,
|
||||||
];
|
];
|
||||||
const [pages] = (await db.execute(
|
const [pages] = (await db.execute(
|
||||||
sql`SELECT id FROM catalog_pages`,
|
sql`SELECT id, parent_id AS parentId, order_num AS orderNum FROM catalog_pages`,
|
||||||
)) as unknown as [Array<{ id: number }>, unknown];
|
)) as unknown as [
|
||||||
|
Array<{ id: number; parentId: number | string; orderNum: number }>,
|
||||||
|
unknown,
|
||||||
|
];
|
||||||
const [bases] = (await db.execute(
|
const [bases] = (await db.execute(
|
||||||
sql`SELECT id FROM items_base`,
|
sql`SELECT id FROM items_base`,
|
||||||
)) as unknown as [Array<{ id: number }>, unknown];
|
)) as unknown as [Array<{ id: number }>, unknown];
|
||||||
@@ -343,6 +348,62 @@ export async function listEmulatorIssues(): Promise<EmulatorCatalogIssue[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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<number, number>();
|
||||||
|
for (const row of pages) parentOf.set(Number(row.id), Number(row.parentId));
|
||||||
|
const inCycle = new Set<number>();
|
||||||
|
const walked = new Set<number>();
|
||||||
|
for (const row of pages) {
|
||||||
|
const start = Number(row.id);
|
||||||
|
if (walked.has(start)) continue;
|
||||||
|
const path: number[] = [];
|
||||||
|
const at = new Map<number, number>();
|
||||||
|
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<string, number[]>();
|
||||||
|
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;
|
return issues;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,6 +529,73 @@ export async function fixEmulatorIssues(): Promise<FixEmulatorResult> {
|
|||||||
fixed += dupOrders.length;
|
fixed += dupOrders.length;
|
||||||
detail.push(`Re-numbered sibling order numbers on affected pages`);
|
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<number>();
|
||||||
|
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<number>();
|
||||||
|
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 };
|
return { fixed, removed, detail };
|
||||||
|
|||||||
Reference in new issue
Block a user