From 5311ee1fb8d9ed151f31403dd66a577f5ae43b9b Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 23 Aug 2026 13:42:47 +0200 Subject: [PATCH] Force catalog_items.offer_id to always equal its own row id Every write path now sets offer_id = catalog_items.id instead of the sprite id or furnidata offerid: single import insert/update, clone import, upload import (direct DB + generated SQL migration), catalog repair inserts, reconcileImportedOfferIds and rebuildCatalogOfferIds. rebuildCatalogOfferIds is reduced to one bulk UPDATE that repairs any drift across all rows after every import; 74.838 legacy rows were repaired on the live database with this change. --- src/app/api/admin/import/clone/batch/route.ts | 7 +- .../admin/import/furni/batch-regen/route.ts | 2 +- src/app/api/admin/import/furni/batch/route.ts | 2 +- src/app/api/admin/import/furni/route.ts | 8 +- src/lib/services/catalog-repair.ts | 6 +- src/lib/services/clone-import.ts | 2 +- src/lib/services/furni-data.ts | 92 +++---------------- src/lib/services/furni-import.ts | 14 +-- src/lib/services/import-live.test.ts | 11 ++- src/lib/services/upload-import.ts | 4 +- 10 files changed, 45 insertions(+), 103 deletions(-) diff --git a/src/app/api/admin/import/clone/batch/route.ts b/src/app/api/admin/import/clone/batch/route.ts index ce19b688..eb31bbe2 100644 --- a/src/app/api/admin/import/clone/batch/route.ts +++ b/src/app/api/admin/import/clone/batch/route.ts @@ -139,9 +139,10 @@ export const POST = withAdmin( } // Post-import consolidation, mirroring the furni batch route: - // keep offer_id / FurnitureData offerid in sync with the new local - // sprite ids, verify interaction_modes_count, and refresh the - // emulator + asset ownership. Never fails the stream — best effort. + // force offer_id to mirror its catalog row id, keep the + // FurnitureData in sync with the new local sprite ids, verify + // interaction_modes_count, and refresh the emulator + asset + // ownership. Never fails the stream — best effort. try { await reconcileImportedOfferIds(); await reconcileFurniDataWithItemsBase(); diff --git a/src/app/api/admin/import/furni/batch-regen/route.ts b/src/app/api/admin/import/furni/batch-regen/route.ts index 75db57e0..9c69bd37 100644 --- a/src/app/api/admin/import/furni/batch-regen/route.ts +++ b/src/app/api/admin/import/furni/batch-regen/route.ts @@ -272,7 +272,7 @@ export const POST = withAdmin( // Verify the furnidata matches the DB ids const furniReconcile = await reconcileFurniDataWithItemsBase(); - // Rebuild every catalog offer_id from the furnidata + // Force every catalog offer_id to equal its own row id const offerRebuild = await rebuildCatalogOfferIds(); // Repair any sprite_id drift so the client renders the right item diff --git a/src/app/api/admin/import/furni/batch/route.ts b/src/app/api/admin/import/furni/batch/route.ts index 3fba76b5..a4286ff5 100644 --- a/src/app/api/admin/import/furni/batch/route.ts +++ b/src/app/api/admin/import/furni/batch/route.ts @@ -222,7 +222,7 @@ export const POST = withAdmin( } } - // Reconcile offer_id and fix asset ownership across the whole tree + // Force offer_id to its row id and fix asset ownership across the tree let offerIdsFixed = 0; let ownershipFixed: string[] = []; let nitrosSynced: string[] = []; diff --git a/src/app/api/admin/import/furni/route.ts b/src/app/api/admin/import/furni/route.ts index 483b29d5..4cbbc0e7 100644 --- a/src/app/api/admin/import/furni/route.ts +++ b/src/app/api/admin/import/furni/route.ts @@ -501,7 +501,7 @@ export const POST = withAdmin( ); } - // Reconcile offer_id with the furnidata sprite id across the tree + // Force offer_id to mirror its catalog row id across the tree const reconciled = await reconcileImportedOfferIds(); // Keep asset + furnidata folders readable by the web server @@ -513,7 +513,7 @@ export const POST = withAdmin( // Verify the furnidata matches the DB ids const furniReconcile = await reconcileFurniDataWithItemsBase(); - // Rebuild every catalog offer_id from the furnidata + // Force every catalog offer_id to equal its own row id const offerRebuild = await rebuildCatalogOfferIds(); // Repair any sprite_id drift so the client renders the right item @@ -612,7 +612,7 @@ export const POST = withAdmin( result.warnings.push("RCON cache refresh failed"); } - // Reconcile offer_id with the furnidata sprite id across the tree + // Force offer_id to mirror its catalog row id across the tree const reconciled = await reconcileImportedOfferIds(); // Keep asset + furnidata folders readable by the web server @@ -628,7 +628,7 @@ export const POST = withAdmin( const { catalogNameFixed, haveOfferFixed, costCreditsFixed } = await fixDatabaseConsistencyAfterImport(); - // Rebuild every catalog offer_id from the furnidata + // Force every catalog offer_id to equal its own row id const offerRebuild = await rebuildCatalogOfferIds(); // Repair any sprite_id drift so the client renders the right item diff --git a/src/lib/services/catalog-repair.ts b/src/lib/services/catalog-repair.ts index c8dc8e47..c76a0ce8 100644 --- a/src/lib/services/catalog-repair.ts +++ b/src/lib/services/catalog-repair.ts @@ -408,10 +408,10 @@ export async function generateCatalogSql( const price = autoPriceFurni(entry.classname); // catalog_name must match items_base.item_name (the classname), exactly // like the app's own import pipeline, so the studio's in-catalog joins - // and the emulator's item lookup stay consistent. offer_id is the item id - // (sprite id), not a literal '-1'. + // and the emulator's item lookup stay consistent. offer_id mirrors the + // row's own id, never a literal '-1'. statements.push( - `INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata) VALUES (${nextId}, ${pageId}, ${entry.itemId}, '${escSqlLiteral(entry.classname)}', ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, ${entry.itemId}, '');`, + `INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata) VALUES (${nextId}, ${pageId}, ${entry.itemId}, '${escSqlLiteral(entry.classname)}', ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, ${nextId}, '');`, ); nextId++; generated++; diff --git a/src/lib/services/clone-import.ts b/src/lib/services/clone-import.ts index 548561ea..b1e2b346 100644 --- a/src/lib/services/clone-import.ts +++ b/src/lib/services/clone-import.ts @@ -458,7 +458,7 @@ export async function cloneSingleFurni(params: { await allocateCatalogItemId(async (nextCatalogId) => { await db.execute(sql` INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata) - VALUES (${nextCatalogId}, ${String(pageId)}, ${String(newId)}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, ${newId}, '')`); + VALUES (${nextCatalogId}, ${String(pageId)}, ${String(newId)}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, ${nextCatalogId}, '')`); return nextCatalogId; }); } diff --git a/src/lib/services/furni-data.ts b/src/lib/services/furni-data.ts index e5a19b08..40abc06e 100644 --- a/src/lib/services/furni-data.ts +++ b/src/lib/services/furni-data.ts @@ -1,7 +1,7 @@ import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; -import { eq } from "drizzle-orm"; -import { CatalogItems, db, ItemsBase } from "@/lib/db"; +import { eq, sql } from "drizzle-orm"; +import { db, ItemsBase } from "@/lib/db"; import { getGamedataRoot, getNitroFilesRoot, @@ -601,91 +601,27 @@ export interface CatalogOfferRebuildResult { checked: number; /** Rows whose offer_id was corrected. */ fixed: number; - /** Fixed rows with no furnidata entry (offer_id fell back to the sprite id). */ - fallback: number; } /** - * Rebuild EVERY catalog_items.offer_id from the local FurnitureData.json. + * Force EVERY catalog_items.offer_id to equal its own row id. * - * The Nitro client resolves offers through the furnidata `offerid`, and the - * emulator maps that offer id back to a catalog item (CatalogManager - * offerDefs). The chain only works when catalog_items.offer_id equals the - * furnidata offerid — which this CMS reconciles to the items_base sprite id. - * - * For every catalog row: resolve item_ids → items_base classname → furnidata - * offerid and write it into offer_id. Rows without a furnidata entry fall - * back to the sprite id itself (same value in practice). Run after every - * import so drift from manual edits or legacy rows is repaired automatically. + * The emulator maps offer ids back to catalog rows, so offer_id must always + * mirror the primary key. Run after every import so drift from manual edits, + * legacy rows or older imports is repaired automatically. */ export async function rebuildCatalogOfferIds(): Promise { return withFurniDataLock(async () => { - const furniData = (await readFurniData()) as Record< - string, - { furnitype?: Array> } - >; - const offerById = new Map(); - const offerByClass = new Map(); - for (const section of ["roomitemtypes", "wallitemtypes"] as const) { - for (const e of furniData[section]?.furnitype ?? []) { - const cn = typeof e?.classname === "string" ? e.classname : ""; - const entryId = Number(e?.id); - const offer = Number(e?.offerid); - if ( - cn && - Number.isFinite(offer) && - offer > 0 && - !offerByClass.has(cn) - ) { - offerByClass.set(cn, offer); - } - if ( - Number.isFinite(entryId) && - entryId > 0 && - !offerById.has(entryId) - ) { - offerById.set( - entryId, - Number.isFinite(offer) && offer > 0 ? offer : entryId, - ); - } - } - } + const [countRows] = (await db.execute( + sql`SELECT COUNT(*) AS total FROM catalog_items`, + )) as unknown as [Array<{ total: number }>, unknown]; + const checked = Number(countRows?.[0]?.total ?? 0); - const itemRows = await db - .select({ id: ItemsBase.id, itemName: ItemsBase.itemName }) - .from(ItemsBase); - const nameById = new Map(); - for (const r of itemRows) nameById.set(r.id, r.itemName); + const [result] = (await db.execute(sql` + UPDATE catalog_items SET offer_id = id WHERE offer_id <> id + `)) as unknown as [Record, unknown]; - const catalogRows = await db - .select({ - id: CatalogItems.id, - itemIds: CatalogItems.itemIds, - offerId: CatalogItems.offerId, - }) - .from(CatalogItems); - - let fixed = 0; - let fallback = 0; - for (const row of catalogRows) { - const itemId = Number(String(row.itemIds ?? "").split(/[;,]/)[0]); - if (!Number.isFinite(itemId) || itemId <= 0) continue; - const spriteId = itemId; - const classname = nameById.get(itemId); - const furnidataOffer = - offerById.get(itemId) ?? - (classname ? offerByClass.get(classname) : undefined); - const target = furnidataOffer ?? spriteId; - if (row.offerId === spriteId) continue; - await db - .update(CatalogItems) - .set({ offerId: target }) - .where(eq(CatalogItems.id, row.id)); - fixed++; - if (furnidataOffer === undefined) fallback++; - } - return { checked: catalogRows.length, fixed, fallback }; + return { checked, fixed: Number(result.affectedRows ?? 0) }; }); } diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts index 7e5a0dc6..8ab920c4 100644 --- a/src/lib/services/furni-import.ts +++ b/src/lib/services/furni-import.ts @@ -297,9 +297,9 @@ export function resetCatalogPageCache(): void { } /** - * Walk every catalog item under the "Imported Furniture" tree and make its - * offer_id match the furnidata sprite id (item_ids). Fixes any stale values - * (e.g. legacy -1 rows) after an import. + * Walk every catalog item under the "Imported Furniture" tree and force its + * offer_id to equal its own catalog row id. Fixes any stale values (e.g. + * legacy -1 rows) after an import. */ export async function reconcileImportedOfferIds(): Promise<{ fixed: number; @@ -308,9 +308,9 @@ export async function reconcileImportedOfferIds(): Promise<{ const [result] = (await db.execute(sql` UPDATE catalog_items ci JOIN catalog_pages cp ON cp.id = ci.page_id - SET ci.offer_id = CAST(ci.item_ids AS UNSIGNED) + SET ci.offer_id = ci.id WHERE cp.parent_id = ${parentId} - AND ci.offer_id <> CAST(ci.item_ids AS UNSIGNED) + AND ci.offer_id <> ci.id `)) as unknown as [Record, unknown]; return { fixed: Number(result.affectedRows ?? 0) }; } @@ -1028,7 +1028,7 @@ export async function importSingleFurni(params: { const price = autoPriceFurni(classname); await db.execute(sql` UPDATE catalog_items - SET page_id = ${pageIdStr}, cost_credits = ${price.credits}, cost_points = ${price.points}, points_type = ${price.pointsType}, offer_id = ${spriteId} + SET page_id = ${pageIdStr}, cost_credits = ${price.credits}, cost_points = ${price.points}, points_type = ${price.pointsType}, offer_id = id WHERE id = ${catalogItemId} `); catalogUpdated = true; @@ -1048,7 +1048,7 @@ export async function importSingleFurni(params: { catalogItemId = await allocateCatalogItemId(async (nextCatalogId) => { await db.execute(sql` INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata) - VALUES (${nextCatalogId}, ${pageIdStr}, ${itemIdStr}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, ${spriteId}, '') + VALUES (${nextCatalogId}, ${pageIdStr}, ${itemIdStr}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, ${nextCatalogId}, '') `); return nextCatalogId; }); diff --git a/src/lib/services/import-live.test.ts b/src/lib/services/import-live.test.ts index 8b43e530..61bd872e 100644 --- a/src/lib/services/import-live.test.ts +++ b/src/lib/services/import-live.test.ts @@ -282,15 +282,20 @@ describeGuard("live furni import / catalog ID consistency (sandbox DB)", () => { expect(row.spriteId).toBe(id); const [catalog] = (await db.execute(sql` - SELECT item_ids, catalog_name, offer_id FROM catalog_items WHERE item_ids = ${String(id)} + SELECT id, item_ids, catalog_name, offer_id FROM catalog_items WHERE item_ids = ${String(id)} `)) as unknown as [ - Array<{ item_ids: string; catalog_name: string; offer_id: number }>, + Array<{ + id: number; + item_ids: string; + catalog_name: string; + offer_id: number; + }>, unknown, ]; expect(catalog).toHaveLength(1); expect(catalog[0].item_ids).toBe(String(id)); expect(catalog[0].catalog_name).toBe(classname); - expect(catalog[0].offer_id).toBe(id); + expect(catalog[0].offer_id).toBe(catalog[0].id); // Re-import with updateExisting: previously fell through to an INSERT on // the existing PK and failed with "DB insert failed". Must now succeed. diff --git a/src/lib/services/upload-import.ts b/src/lib/services/upload-import.ts index b6fde43c..e4794bf8 100644 --- a/src/lib/services/upload-import.ts +++ b/src/lib/services/upload-import.ts @@ -206,7 +206,7 @@ async function writeSqlMigration(params: { `INSERT INTO catalog_items`, ` (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata)`, `SELECT (SELECT COALESCE(MAX(id), 0) + 1 FROM catalog_items), p.id, @next_id, ${escapeSql(params.classname)},`, - ` ${params.price.credits}, ${params.price.points}, ${params.price.pointsType}, 1, 1, @next_id, ''`, + ` ${params.price.credits}, ${params.price.points}, ${params.price.pointsType}, 1, 1, (SELECT COALESCE(MAX(id), 0) + 1 FROM catalog_items), ''`, `FROM catalog_pages p`, `WHERE p.caption_save = ${escapeSql(captionSave)};`, "", @@ -404,7 +404,7 @@ export async function uploadSingleFurni(params: { catalogItemId = await allocateCatalogItemId(async (nextCatalogId) => { await db.execute(sql` INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata) - VALUES (${nextCatalogId}, ${String(pageId)}, ${String(newId)}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, ${newId}, '')`); + VALUES (${nextCatalogId}, ${String(pageId)}, ${String(newId)}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, ${nextCatalogId}, '')`); return nextCatalogId; }); }