fix: harden furni import pipeline for production
- 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:
1 parent
2b9a015afb
commit
6021a91ef7
9 files changed
+162
-66
No files matched your search
@@ -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}`);
|
||||
|
||||
Reference in new issue
Block a user