fix: reorder catalog missing-entry scan after structural repair
CI / check (push) Successful in 26s
CI / release (push) Skipped
CI / deploy (push) Successful in 55s

runCatalogAudit computed missingCatalogEntries from the pre-repair snapshot,
so generated catalog SQL could reference sprite ids deleted by the
duplicate-classname merge and create new orphaned catalog_items rows. Re-derive
entries after repairStructure and add a defensive guard in generateCatalogSql
that skips entries whose item id no longer exists in items_base.

Also fix the catalog_pages INSERT template (18 columns vs 17 values) which made
page creation always fail with 'Column count doesn't match value count'.

Add a sandbox-guarded live repair rehearsal test covering the full pipeline.
This commit is contained in:
openhands committed 2026-08-20 13:09:50 +02:00
1 parent 60b6d0856c
commit 3a63152ee3
3 files changed
+318 -2

No files matched your search

+59
View File
@@ -662,6 +662,65 @@ export async function runCatalogAudit(
remappedReferences, remappedReferences,
}, },
}); });
// Structural repair can DELETE items_base rows (duplicate classname
// merge) and rewrite catalog_items.item_ids, so the missing-catalog
// list computed from the pre-repair snapshot is stale: entries may
// reference ids that no longer exist. Re-derive it from the repaired
// state, otherwise the generated catalog SQL would insert orphaned
// catalog_items rows (item_ids pointing at deleted sprite ids).
if (duplicateRowsRemoved > 0 || orphanedRemoved > 0) {
const [postItems] = (await db.execute(sql`
SELECT id, item_name, public_name, type, interaction_type
FROM items_base ORDER BY id
`)) as unknown as [
Array<{
id: number;
item_name: string;
public_name: string;
type: string;
interaction_type: string;
}>,
unknown,
];
items = postItems;
const [postCatalog] = (await db.execute(sql`
SELECT id, item_ids, catalog_name FROM catalog_items ORDER BY id
`)) as unknown as [
Array<{ id: number; item_ids: string; catalog_name: string }>,
unknown,
];
const postCatalogIds = new Set<number>();
for (const ci of postCatalog) {
const ids = ci.item_ids.split(";").map(Number).filter(Boolean);
for (const id of ids) postCatalogIds.add(id);
}
missingCatalogEntries.length = 0;
for (const item of items) {
let kind = classifyCatalogItem(item);
if (
kind === "furni" &&
(isBadgeType(item.type) || badgeGifSet.has(item.item_name))
) {
kind = "badge";
}
if (NO_CATALOG_KINDS.has(kind)) continue;
if (postCatalogIds.has(item.id)) continue;
const hasNitro = assetNameCandidates(item.item_name).nitro.some((n) =>
nitroSet.has(n),
);
if (kind === "pet" || hasNitro) {
missingCatalogEntries.push({
classname: item.item_name,
itemId: item.id,
itemType: item.type,
publicName: item.public_name,
});
}
}
}
} catch (err) { } catch (err) {
onEvent?.({ onEvent?.({
type: "error", type: "error",
File diff suppressed because it is too large. Load diff
+16 -2
View File
@@ -24,6 +24,7 @@ export interface CatalogSqlResult {
statements: string[]; statements: string[];
sql: string; sql: string;
count: number; count: number;
skipped: number;
} }
function escSqlLiteral(value: string): string { function escSqlLiteral(value: string): string {
@@ -341,12 +342,25 @@ export async function generateCatalogSql(
`)) as unknown as [Array<{ maxId: number | bigint | null }>, unknown]; `)) as unknown as [Array<{ maxId: number | bigint | null }>, unknown];
let nextId = Number(maxIdResult[0]?.maxId ?? 0) + 1; let nextId = Number(maxIdResult[0]?.maxId ?? 0) + 1;
// Skip entries whose sprite id no longer exists in items_base (e.g. because
// a duplicate-classname merge ran between the audit snapshot and this
// generation). Inserting them would create orphaned catalog_items rows.
const [validIds] = (await db.execute(sql`
SELECT id FROM items_base
`)) as unknown as [Array<{ id: number }>, unknown];
const validItemIds = new Set(validIds.map((r) => Number(r.id)));
let skipped = 0;
// Build a map of caption_save → pageId so multiple items in the same // Build a map of caption_save → pageId so multiple items in the same
// category share one page (and we emit the page INSERT only once). // category share one page (and we emit the page INSERT only once).
const pageMap = new Map<string, number>(); const pageMap = new Map<string, number>();
let generated = 0; let generated = 0;
for (const entry of entries) { for (const entry of entries) {
if (!validItemIds.has(entry.itemId)) {
skipped++;
continue;
}
let pageId: number; let pageId: number;
if (organizeSql && parentPageId) { if (organizeSql && parentPageId) {
const catKey = classifyFurni(entry.classname, entry.itemType); const catKey = classifyFurni(entry.classname, entry.itemType);
@@ -367,7 +381,7 @@ export async function generateCatalogSql(
const newId = nextPageId++; const newId = nextPageId++;
pageMap.set(captionSave, newId); pageMap.set(captionSave, newId);
statements.push( statements.push(
`INSERT INTO catalog_pages (id, caption_save, caption, page_layout, parent_id, min_rank, order_num, icon_image, enabled, visible, includes, page_headline, page_teaser, page_special, page_text1, page_text2, page_text_details, page_text_teaser) VALUES (${newId}, '${escSqlLiteral(captionSave)}', '${escSqlLiteral(caption)}', 'default_3x3', ${parentPageId}, '1', ${catInfo.order}, ${catInfo.icon}, '1', '1', '', '', '', '', '', '', '');`, `INSERT INTO catalog_pages (id, caption_save, caption, page_layout, parent_id, min_rank, order_num, icon_image, enabled, visible, includes, page_headline, page_teaser, page_special, page_text1, page_text2, page_text_details, page_text_teaser) VALUES (${newId}, '${escSqlLiteral(captionSave)}', '${escSqlLiteral(caption)}', 'default_3x3', ${parentPageId}, '1', ${catInfo.order}, ${catInfo.icon}, '1', '1', '', '', '', '', '', '', '', '');`,
); );
} }
existing = pageMap.get(captionSave); existing = pageMap.get(captionSave);
@@ -394,7 +408,7 @@ export async function generateCatalogSql(
} }
statements.push("COMMIT;"); statements.push("COMMIT;");
return { statements, sql: statements.join("\n"), count: generated }; return { statements, sql: statements.join("\n"), count: generated, skipped };
} }
/** Execute generated INSERT statements directly against the database. */ /** Execute generated INSERT statements directly against the database. */