fix: harden furni import pipeline for production
CI / check (push) Successful in 26s
CI / release (push) Skipped
CI / deploy (push) Successful in 57s

- importSingleFurni: actually update an existing item instead of failing on a primary key INSERT collision; refuse to reassign a spriteId owned by a different classname
- reconcileFurniDataWithItemsBase: also sync each entry's offerid to the DB sprite id, not just the id
- Move the full-table interaction_modes_count verification out of the per-item import hot path and run it once per batch/single import
- clone-import: apply the auto-detected interaction_type (was hardcoded 'default'), set FurnitureData offerid and catalog_items.offer_id to the new local sprite id (was the source hotel's id / -1)
- clone batch route: run the same post-import reconcile/verify/ownership/RCON consolidation as the furni batch route
- upload-import: set offer_id to the allocated id in both the direct insert and the generated SQL migration (was -1)
- generateCatalogSql: use the classname as catalog_name and the item id as offer_id so rows match the app-managed import convention
- stats + missing-nitro endpoints: match catalog membership via item_ids instead of the catalog_name join, so translated display names no longer break counts
- deleteImportedItem/rollback: delete catalog rows by the canonical item_ids link only
- classifyFurni: wall items always classify as 'walls' before prefix rules (rare_/val_/xmas_) can misfile them
This commit is contained in:
openhands committed 2026-08-20 11:48:51 +02:00
1 parent 2b9a015afb
commit 6021a91ef7
9 files changed
+162 -66

No files matched your search

+47 -46
View File
@@ -544,15 +544,22 @@ export async function importSingleFurni(params: {
.from(ItemsBase)
.where(eq(ItemsBase.itemName, classname))
.limit(1);
if (existsByName) {
if (!updateExisting) {
return {
ok: false,
warnings,
error: `Item "${classname}" already exists (id: ${existsByName.id})`,
};
}
// Update existing item - fall through to update logic
if (existsByName && !updateExisting) {
return {
ok: false,
warnings,
error: `Item "${classname}" already exists (id: ${existsByName.id})`,
};
}
if (existsByName && existsByName.id !== originalId) {
// The classname already occupies a different spriteId. Reassigning the
// id would orphan every reference (catalog item_ids, player items,
// furnidata), so this can never be an "update".
return {
ok: false,
warnings,
error: `Item "${classname}" already exists with id ${existsByName.id} — cannot reassign to spriteId ${originalId}`,
};
}
const [existsById] = await db
.select({ itemName: ItemsBase.itemName })
@@ -560,16 +567,17 @@ export async function importSingleFurni(params: {
.where(eq(ItemsBase.id, originalId))
.limit(1);
if (existsById && (!existsByName || existsById.itemName !== classname)) {
if (!updateExisting) {
return {
ok: false,
warnings,
error: `spriteId ${originalId} already used by "${existsById.itemName}"`,
};
}
// Update existing item - fall through to update logic
// The spriteId is owned by a different classname. Even with
// updateExisting this is a genuine conflict, not an update.
return {
ok: false,
warnings,
error: `spriteId ${originalId} already used by "${existsById.itemName}"`,
};
}
const isUpdate = !!existsByName;
// ── Check if spriteId already used in FurnitureData.json ──
const takenBy = await findFurniDataIdConflict(originalId, classname);
if (takenBy && !updateExisting) {
@@ -604,18 +612,25 @@ export async function importSingleFurni(params: {
// ── Insert into DB with enriched data ──────────────────────────────
onProgress?.("writing_db");
try {
// NOTE: live Arcturus `items_base` has no description/revision/rare columns —
// those metadata fields are persisted in FurnitureData.json (see entry below),
// not in the DB. Inserting them here throws "Unknown column 'description'".
await db.execute(sql`
INSERT INTO items_base (id, sprite_id, public_name, item_name, type)
VALUES (${spriteId}, ${spriteId}, ${bestName}, ${classname}, ${itemType})
`);
if (isUpdate) {
// NOTE: live Arcturus `items_base` has no description/revision/rare columns —
// those metadata fields are persisted in FurnitureData.json (see entry below),
// not in the DB. Only refresh the columns this import manages.
await db.execute(sql`
UPDATE items_base SET sprite_id = ${spriteId}, public_name = ${bestName}, item_name = ${classname}, type = ${itemType}
WHERE id = ${spriteId}
`);
} else {
await db.execute(sql`
INSERT INTO items_base (id, sprite_id, public_name, item_name, type)
VALUES (${spriteId}, ${spriteId}, ${bestName}, ${classname}, ${itemType})
`);
}
} catch (err) {
return {
ok: false,
warnings,
error: `DB insert failed: ${(err as Error).message}`,
error: `DB ${isUpdate ? "update" : "insert"} failed: ${(err as Error).message}`,
};
}
@@ -995,23 +1010,6 @@ export async function importSingleFurni(params: {
warnings.push("Catalog entry creation failed");
}
// Verify and fix interaction_modes_count for all furniture
try {
const verification = await verifyAndFixInteractionModesCount();
if (verification.fixed > 0) {
warnings.push(
`Fixed interaction_modes_count for ${verification.fixed} of ${verification.checked} items`,
);
}
for (const err of verification.errors) {
warnings.push(`Interaction modes verification: ${err}`);
}
} catch (err) {
warnings.push(
`Interaction modes verification failed: ${(err as Error).message}`,
);
}
return {
ok: true,
itemId: spriteId,
@@ -1193,11 +1191,13 @@ export async function deleteImportedItem(classname: string): Promise<{
starIdx !== -1 ? classname.substring(0, starIdx) : classname;
const iconFileName = classname.replace(/\*/g, "_");
// 1. Delete catalog_items
// 1. Delete catalog_items — match by the canonical item_ids link only.
// catalog_name may have been translated to a public name, so adding a
// name condition would silently leave an orphaned catalog row behind.
try {
const itemIdStr = String(spriteId);
await db.execute(
sql`DELETE FROM catalog_items WHERE item_ids = ${itemIdStr} AND catalog_name = ${classname}`,
sql`DELETE FROM catalog_items WHERE item_ids = ${itemIdStr}`,
);
} catch (err) {
errors.push(`catalog_items: ${(err as Error).message}`);
@@ -1361,10 +1361,11 @@ export async function rollbackFurniImport(
const errorsForItem: string[] = [];
// 1. Delete from catalog_items
// 1. Delete from catalog_items — match by the canonical item_ids link only
// (catalog_name may be a translated display name).
try {
await db.execute(
sql`DELETE FROM catalog_items WHERE item_ids = ${String(item.spriteId)} AND catalog_name = ${item.classname}`,
sql`DELETE FROM catalog_items WHERE item_ids = ${String(item.spriteId)}`,
);
} catch (err) {
errorsForItem.push(`catalog_items: ${(err as Error).message}`);